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 diff --git a/docs/slots/_metrics.md b/docs/slots/_metrics.md index f56e7f7a83..d61cfbe6db 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 | - | 3 | - | - | 3 | ## 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/e2e/helpers/pages/daoSettingsPage/daoSettingsPage.ts b/e2e/helpers/pages/daoSettingsPage/daoSettingsPage.ts index a77cd2a78e..d8c64984c0 100644 --- a/e2e/helpers/pages/daoSettingsPage/daoSettingsPage.ts +++ b/e2e/helpers/pages/daoSettingsPage/daoSettingsPage.ts @@ -16,7 +16,7 @@ export class DaoSettingsPage extends DaoPage { readonly daoInfoSection = () => this.page.getByRole('main'); readonly governanceProcesses = () => - this.page.getByRole('main').locator('a[href*="/settings/"]'); + this.page.locator('#governance').locator('a[href*="/settings/"]'); readonly firstProcess = () => this.governanceProcesses().first(); diff --git a/package.json b/package.json index ae23d6c45d..577dad4968 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "dependencies": { "@aragon/aragon-domain": "^0.3.1", "@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", @@ -51,6 +52,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 9340866d67..ecc93e96aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,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) @@ -77,6 +80,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 @@ -675,6 +681,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==} @@ -3395,6 +3407,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==} @@ -3407,6 +3422,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==} @@ -3416,6 +3434,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==} @@ -3933,6 +3957,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'} @@ -4389,6 +4429,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==} @@ -4546,6 +4589,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'} @@ -4566,6 +4617,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'} @@ -4582,6 +4637,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'} @@ -7767,6 +7832,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'} @@ -8415,6 +8495,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 @@ -11674,6 +11760,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': @@ -11686,6 +11776,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 @@ -11694,6 +11786,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': {} @@ -12776,6 +12877,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 @@ -13188,6 +13314,8 @@ snapshots: cjs-module-lexer@2.2.0: {} + classcat@5.0.5: {} + classnames@2.5.1: {} cli-cursor@5.0.0: @@ -13323,6 +13451,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: {} @@ -13341,6 +13476,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 @@ -13355,6 +13492,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: @@ -16881,6 +17035,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/public/patitoDao.png b/public/patitoDao.png new file mode 100644 index 0000000000..334011d2ca Binary files /dev/null and b/public/patitoDao.png differ diff --git a/public/patitoDeveloperDao.png b/public/patitoDeveloperDao.png new file mode 100644 index 0000000000..1ffcb790a2 Binary files /dev/null and b/public/patitoDeveloperDao.png differ 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) { 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 bc2949895c..f4c0ae557e 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -3512,6 +3512,85 @@ "info": "This policy can't be dispatched once it's uninstalled." } }, + "daoPermissionsPage": { + "header": { + "breadcrumb": { + "settings": "Settings", + "permissions": "Permissions" + }, + "title": "Permissions", + "description": "All permissions configured in this DAO — who has access, to which contract, and under what conditions." + }, + "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": { + "header": { + "who": "Who", + "where": "Where", + "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": { + "heading": "No permissions", + "description": "This account has no indexed permissions." + } + }, + "noConditionSlot": { + "heading": "No condition", + "description": "Functions guarded by this permission can be called by the granted address directly." + }, + "votingPowerConditionSlot": { + "token": "Token", + "minVotingPower": "Minimum voting power" + }, + "executeSelectorConditionSlot": { + "description": "The ExecuteSelectorCondition authorizes the caller to execute actions only on explicitly authorized contract functions.", + "allowedActions": "Allowed actions", + "noActions": "No allowed actions" + }, + "membershipConditionSlot": { + "memberOfMultisig": "Member of multisig", + "true": "True", + "false": "False" + }, "daoPolicyDetailsPage": { "error": { "action": "Explore settings", @@ -3564,6 +3643,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", 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/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/daoHierarchy/daoHierarchy.tsx b/src/modules/settings/components/daoHierarchy/daoHierarchy.tsx index 40de8838fa..bc52c36dbf 100644 --- a/src/modules/settings/components/daoHierarchy/daoHierarchy.tsx +++ b/src/modules/settings/components/daoHierarchy/daoHierarchy.tsx @@ -12,8 +12,10 @@ import { } from '@aragon/gov-ui-kit'; import type { IDao, ILinkedAccountSummary } from '@/shared/api/daoService'; import { DaoTypeTag } from '@/shared/components/daoTypeTag'; +import { useFeatureFlags } from '@/shared/components/featureFlagsProvider'; 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 +34,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 +140,17 @@ const DaoInfo: React.FC = ({ dao }) => { )} + {permissionsHref != null && ( + + {t('app.settings.daoSettingsInfo.permissionsLink')} + + )} ); }; @@ -140,6 +158,8 @@ const DaoInfo: React.FC = ({ dao }) => { export const DaoHierarchy: React.FC = (props) => { const { dao, currentDaoId } = props; + const { isEnabled } = useFeatureFlags(); + const isViewingMainDao = dao.id === currentDaoId; const hasLinkedAccounts = dao.linkedAccounts != null && dao.linkedAccounts.length > 0; @@ -147,6 +167,10 @@ export const DaoHierarchy: React.FC = (props) => { const getDaoAvatar = (d: IDao | ILinkedAccountSummary) => ipfsUtils.cidToSrc(d.avatar); + const permissionsHref = isEnabled('permissionsPage') + ? daoUtils.getDaoUrl(dao, 'settings/permissions') + : undefined; + // If viewing main DAO with linked accounts, show accordion structure if (isViewingMainDao && hasLinkedAccounts) { return ( @@ -168,7 +192,7 @@ export const DaoHierarchy: React.FC = (props) => { - + {dao.linkedAccounts?.map((linkedAccount) => ( @@ -203,7 +227,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..66ad0579e2 100644 --- a/src/modules/settings/components/daoSettingsInfo/daoSettingsInfo.test.tsx +++ b/src/modules/settings/components/daoSettingsInfo/daoSettingsInfo.test.tsx @@ -2,6 +2,7 @@ import type * as GovUiKit from '@aragon/gov-ui-kit'; import { GukModulesProvider } from '@aragon/gov-ui-kit'; import { render, screen } from '@testing-library/react'; import { Network } from '@/shared/api/daoService'; +import * as featureFlagsProvider from '@/shared/components/featureFlagsProvider'; import { generateDao } from '@/shared/testUtils'; import { ipfsUtils } from '@/shared/utils/ipfsUtils'; import { DaoSettingsInfo, type IDaoSettingsInfoProps } from './daoSettingsInfo'; @@ -14,6 +15,25 @@ jest.mock('@aragon/gov-ui-kit', () => ({ })); describe(' component', () => { + const useFeatureFlagsSpy = jest.spyOn( + featureFlagsProvider, + 'useFeatureFlags', + ); + + const setPermissionsPageEnabled = (enabled: boolean) => { + useFeatureFlagsSpy.mockReturnValue({ + isEnabled: (key) => key === 'permissionsPage' && enabled, + } as ReturnType); + }; + + beforeEach(() => { + setPermissionsPageEnabled(true); + }); + + afterEach(() => { + useFeatureFlagsSpy.mockReset(); + }); + const createTestComponent = (props?: Partial) => { const completeProps: IDaoSettingsInfoProps = { dao: generateDao(), @@ -58,6 +78,36 @@ 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('does not render the permissions link when the flag is disabled', () => { + setPermissionsPageEnabled(false); + render(createTestComponent()); + + expect( + screen.queryByText(/daoSettingsInfo.permissionsLink/), + ).not.toBeInTheDocument(); + }); + 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..feb28d0869 100644 --- a/src/modules/settings/components/daoSettingsInfo/daoSettingsInfo.tsx +++ b/src/modules/settings/components/daoSettingsInfo/daoSettingsInfo.tsx @@ -8,6 +8,7 @@ import { Tag, } from '@aragon/gov-ui-kit'; import type { IDao } from '@/shared/api/daoService'; +import { useFeatureFlags } from '@/shared/components/featureFlagsProvider'; import { useTranslations } from '@/shared/components/translationsProvider'; import { networkDefinitions } from '@/shared/constants/networkDefinitions'; import { useDaoChain } from '@/shared/hooks/useDaoChain'; @@ -24,6 +25,7 @@ export interface IDaoSettingsInfoProps { export const DaoSettingsInfo: React.FC = (props) => { const { dao } = props; const { t } = useTranslations(); + const { isEnabled } = useFeatureFlags(); const daoAvatar = ipfsUtils.cidToSrc(dao.avatar); @@ -111,6 +113,23 @@ export const DaoSettingsInfo: React.FC = (props) => { )} + {isEnabled('permissionsPage') && ( + + {t('app.settings.daoSettingsInfo.permissionsLink')} + + )} ); 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..5f018eb8d2 --- /dev/null +++ b/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.test.tsx @@ -0,0 +1,65 @@ +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 description and the selectors mapped to their truncated targets', () => { + render( + createTestComponent({ + selectors: ['0xa9059cbb', '0x23b872dd'], + targets: [ + '0x0bA45A8b5d5575935B8158a88C631E9F9C95a2e5', + '0xDe0B295669a9FD93d5F28D9Ec85E40f4cb697BAe', + ], + }), + ); + + 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', () => { + 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(); + }); + + 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 new file mode 100644 index 0000000000..7f9e9afe42 --- /dev/null +++ b/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { addressUtils, DefinitionList } from '@aragon/gov-ui-kit'; +import type { IConditionData } from '@/modules/settings/types'; +import { useTranslations } from '@/shared/components/translationsProvider'; +import { stringUtils } from '@/shared/utils/stringUtils'; + +const EMPTY_VALUE = '—'; + +interface IAllowedAction { + selector: string; + target: string; +} + +const toStringList = (value: unknown): string[] => + Array.isArray(value) ? value.filter(stringUtils.isNonEmptyString) : []; + +const toAllowedActions = ( + selectors: unknown, + targets: unknown, +): IAllowedAction[] => { + const selectorList = toStringList(selectors); + const targetList = toStringList(targets); + + return selectorList.map((selector, index) => ({ + selector, + target: targetList[index] ?? EMPTY_VALUE, + })); +}; + +export const ExecuteSelectorConditionSlot: React.FC = ( + props, +) => { + const { selectors, targets } = props; + const { t } = useTranslations(); + + const allowedActions = toAllowedActions(selectors, targets); + const hasAllowedActions = allowedActions.length > 0; + + return ( +
+

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

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

+ {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/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/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..5890410b76 --- /dev/null +++ b/src/modules/settings/components/noConditionSlot/noConditionSlot.tsx @@ -0,0 +1,19 @@ +'use client'; + +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(); + + return ( + + ); +}; 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..096fb88295 --- /dev/null +++ b/src/modules/settings/components/permissionsGraph/permissionDetailNode.tsx @@ -0,0 +1,180 @@ +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, ANY_ADDR } 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 isWhoAnyAddress = addressUtils.isAddressEqual( + row.whoAddress, + ANY_ADDR, + ); + const isWhereAnyAddress = addressUtils.isAddressEqual( + row.whereAddress, + ANY_ADDR, + ); + + const whoTruncated = addressUtils.truncateAddress(row.whoAddress); + const whereTruncated = addressUtils.truncateAddress(row.whereAddress); + // Only show the resolved name as the description when it adds information — + // unresolved actors are labelled by their own truncated address, which would + // otherwise just repeat the heading. + const whoName = whoLabel === whoTruncated ? undefined : whoLabel; + const whereName = whereLabel === whereTruncated ? undefined : whereLabel; + + 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' ? ( + + + {isWhoAnyAddress ? whoLabel : whoTruncated} + + + {isWhereAnyAddress ? whereLabel : whereTruncated} + + + {addressUtils.truncateHash(row.permissionId)} + + + ) : ( + + )} +
+ + +
+ ); +}; diff --git a/src/modules/settings/components/permissionsGraph/permissionGraphEdge.tsx b/src/modules/settings/components/permissionsGraph/permissionGraphEdge.tsx new file mode 100644 index 0000000000..4643664695 --- /dev/null +++ b/src/modules/settings/components/permissionsGraph/permissionGraphEdge.tsx @@ -0,0 +1,131 @@ +import { + BaseEdge, + type Edge, + EdgeLabelRenderer, + type EdgeProps, + getSmoothStepPath, +} from '@xyflow/react'; +import classNames from 'classnames'; +import { useTranslations } from '@/shared/components/translationsProvider'; + +/** + * A single permission carried by an edge label. Multiple permissions between the + * same two nodes are stacked vertically so every one stays legible. + */ +export interface IPermissionEdgeEntry { + /** + * Id of the underlying graph edge, passed back to `onSelect`. + */ + edgeId: string; + permissionName: string; + conditionLabel?: string; +} + +/** + * Data carried by a permission-graph edge: the permissions granted along it and + * the selection callback invoked when one of their labels is clicked. + */ +export interface IPermissionEdgeData { + permissions: IPermissionEdgeEntry[]; + /** + * Whether the labels are dimmed because another edge is selected. + */ + dimmed?: boolean; + /** + * Selects the given permission (invoked when its label is clicked). + */ + onSelect?: (edgeId: string) => void; + [key: string]: unknown; +} + +/** + * React Flow edge carrying resolved permission-edge data. + */ +export type IPermissionFlowEdge = Edge; + +export const PermissionGraphEdge: React.FC> = ({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + markerStart, + markerEnd, + style, + data, +}) => { + const { t } = useTranslations(); + + const [edgePath, labelX, labelY] = getSmoothStepPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + }); + + const permissions = data?.permissions ?? []; + + return ( + <> + + {permissions.length > 0 && ( + +
+ {permissions.map((permission) => { + const isDimmed = data?.dimmed === true; + + return ( + + ); + })} +
+
+ )} + + ); +}; diff --git a/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx b/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx new file mode 100644 index 0000000000..56c37d4323 --- /dev/null +++ b/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx @@ -0,0 +1,104 @@ +import { Avatar, DaoAvatar, Tag } from '@aragon/gov-ui-kit'; +import { Handle, type Node, type NodeProps, Position } from '@xyflow/react'; +import classNames from 'classnames'; +import { useTranslations } from '@/shared/components/translationsProvider'; +import type { IPermissionGraphNode, PermissionNodeKind } from '../../types'; + +/** + * Role a node plays in the currently selected permission edge. + */ +export type PermissionNodeSelectionRole = 'who' | 'where'; + +/** + * Permission-graph node data plus its selection state, widened to satisfy React + * Flow's `Record` node-data constraint. + */ +export interface IPermissionNodeData extends IPermissionGraphNode { + /** + * Set when this node is the `who` / `where` of the selected edge. + */ + selectionRole?: PermissionNodeSelectionRole; + /** + * Whether the node is dimmed because another edge is selected. + */ + dimmed?: boolean; + [key: string]: unknown; +} + +/** + * React Flow node carrying a resolved permission-graph node as its data. + */ +export type IPermissionFlowNode = Node; + +const SUBTITLE_KEY: Record = { + dao: 'app.settings.daoPermissionsPage.graphView.node.primaryDao', + linkedDao: 'app.settings.daoPermissionsPage.graphView.node.linkedDao', + plugin: 'app.settings.daoPermissionsPage.graphView.node.plugin', + actor: 'app.settings.daoPermissionsPage.graphView.node.actor', +}; + +const SELECTION_LABEL_KEY: Record = { + who: 'app.settings.daoPermissionsPage.graphView.node.who', + where: 'app.settings.daoPermissionsPage.graphView.node.where', +}; + +export const PermissionGraphNode: React.FC> = ({ + data, +}) => { + const { t } = useTranslations(); + const { kind, label, tag, avatarSrc, selectionRole, dimmed } = data; + + const isDaoKind = kind === 'dao' || kind === 'linkedDao'; + const isSelected = selectionRole != null; + + return ( +
+ {isSelected && ( + + {t(SELECTION_LABEL_KEY[selectionRole])} + + )} +
+ +
+ {label} + + {t(SUBTITLE_KEY[kind])} + +
+ {isDaoKind && ( + + )} + {kind === 'plugin' && tag != null && ( + + )} + {kind === 'actor' && } + +
+
+ ); +}; diff --git a/src/modules/settings/components/permissionsGraph/permissionsGraph.tsx b/src/modules/settings/components/permissionsGraph/permissionsGraph.tsx new file mode 100644 index 0000000000..5f4afc08ee --- /dev/null +++ b/src/modules/settings/components/permissionsGraph/permissionsGraph.tsx @@ -0,0 +1,91 @@ +'use client'; + +import '@xyflow/react/dist/style.css'; +import { CardEmptyState, StateSkeletonBar } from '@aragon/gov-ui-kit'; +import { ReactFlowProvider } from '@xyflow/react'; +import { useMemo } from 'react'; +import type { IDao, IDaoPlugin } from '@/shared/api/daoService'; +import type { IFilterComponentPlugin } from '@/shared/components/pluginFilterComponent'; +import { useTranslations } from '@/shared/components/translationsProvider'; +import type { IPermissionRow } from '../../types'; +import { buildPermissionGraph } from '../../utils/buildPermissionGraph'; +import { PermissionsGraphCanvas } from './permissionsGraphCanvas'; + +export interface IPermissionsGraphProps { + /** + * Permission rows to visualize (the same data the list view consumes). + */ + rows: IPermissionRow[]; + /** + * The DAO the permissions belong to, used to classify DAO / linked-DAO nodes. + */ + dao?: IDao; + /** + * Installed DAO plugins used to classify and label plugin nodes. + */ + daoPlugins?: IFilterComponentPlugin[]; + /** + * Whether the permissions are still loading. + */ + isLoading: boolean; + /** + * Active account id — re-fits the canvas when the selected account changes. + */ + activeAccountId?: string; +} + +/** + * Body of the permissions graph view. Receives already-resolved data from the + * shared `usePermissionsData` hook so it visualizes exactly what the list shows; + * the account selector and view toggle live in the page shell. + */ +export const PermissionsGraph: React.FC = (props) => { + const { rows, dao, daoPlugins, isLoading, activeAccountId } = props; + + const { t } = useTranslations(); + + const graph = useMemo(() => { + if (dao == null) { + return { nodes: [], edges: [] }; + } + + return buildPermissionGraph({ rows, dao, daoPlugins }); + }, [rows, dao, daoPlugins]); + + if (isLoading || dao == null) { + return ; + } + + if (graph.nodes.length === 0) { + return ( + + ); + } + + return ( +
+ + + +
+ ); +}; + +const PermissionsGraphSkeleton: React.FC = () => ( +
+ + + +
+); diff --git a/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx b/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx new file mode 100644 index 0000000000..177f40aee0 --- /dev/null +++ b/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx @@ -0,0 +1,541 @@ +'use client'; + +import { + Background, + Controls, + type Edge, + MarkerType, + type Node, + ReactFlow, + useEdgesState, + useNodesInitialized, + useNodesState, + useReactFlow, +} from '@xyflow/react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { IPermissionGraph } from '../../types'; +import { getLayoutedElements } from '../../utils/permissionGraphLayout'; +import { PermissionDetailNode } from './permissionDetailNode'; +import { + type IPermissionEdgeData, + type IPermissionEdgeEntry, + PermissionGraphEdge, +} from './permissionGraphEdge'; +import { PermissionGraphNode } from './permissionGraphNode'; + +const nodeTypes = { + permission: PermissionGraphNode, + permissionDetail: PermissionDetailNode, +}; +const edgeTypes = { permission: PermissionGraphEdge }; + +const MIN_ZOOM = 0.2; +const FIT_VIEW_OPTIONS = { padding: 0.2, maxZoom: 1, duration: 300 }; +const UNPOSITIONED = { x: 0, y: 0 }; + +// Every connector shows control-flow direction in every state: a dot where it +// leaves its source (who) and an arrow where it enters its target (where). The +// selected flow is drawn in primary. React Flow wraps bare dot marker ids as +// url('#') (see the in the canvas); the arrow uses a built-in marker. +const EDGE_NEUTRAL = { + style: { stroke: 'var(--color-neutral-300)', strokeWidth: 1.5 }, + markerStart: 'permission-dot-neutral', + markerEnd: { + type: MarkerType.ArrowClosed, + color: 'var(--color-neutral-300)', + width: 18, + height: 18, + }, +}; +// The selected flow is elevated above the dimmed connectors so where they +// converge on a shared node handle the primary line/arrow always paints on top +// (otherwise a dimmed grey edge later in the array covers the blue at the node). +const SELECTED_EDGE_Z_INDEX = 10; +const EDGE_SELECTED_IN = { + style: { stroke: 'var(--color-primary-400)', strokeWidth: 1.5 }, + markerStart: 'permission-dot-primary', + markerEnd: 'permission-dot-primary', + zIndex: SELECTED_EDGE_Z_INDEX, +}; +const EDGE_SELECTED_OUT = { + style: { stroke: 'var(--color-primary-400)', strokeWidth: 1.5 }, + markerStart: 'permission-dot-primary', + markerEnd: { + type: MarkerType.ArrowClosed, + color: 'var(--color-primary-400)', + width: 18, + height: 18, + }, + zIndex: SELECTED_EDGE_Z_INDEX, +}; + +// Vertical gap between the expanded detail card and the who/where nodes it +// connects, so the inline card does not touch either node. +const DETAIL_VERTICAL_GAP = 48; + +interface IBuildFlowParams { + graph: IPermissionGraph; + selectedEdgeId: string | null; + onSelectEdge: (edgeId: string) => void; + onClose: () => void; +} + +const buildFlowElements = ({ + graph, + selectedEdgeId, + onSelectEdge, + onClose, +}: IBuildFlowParams): { nodes: Node[]; edges: Edge[] } => { + const selectedEdge = + selectedEdgeId != null + ? (graph.edges.find((edge) => edge.id === selectedEdgeId) ?? null) + : null; + + const nodesById = new Map(graph.nodes.map((node) => [node.id, node])); + + const permissionNodes: Node[] = graph.nodes.map((node) => { + let selectionRole: 'who' | 'where' | undefined; + + if (selectedEdge?.source === node.id) { + selectionRole = 'who'; + } else if (selectedEdge?.target === node.id) { + selectionRole = 'where'; + } + + return { + id: node.id, + type: 'permission', + position: UNPOSITIONED, + data: { + ...node, + selectionRole, + dimmed: selectedEdge != null && selectionRole == null, + }, + }; + }); + + // Group permissions by the node pair they connect so parallel permissions + // between the same two nodes share one connector and stack their labels. + const pairKey = (source: string, target: string) => `${source} ${target}`; + const groups = new Map< + string, + { source: string; target: string; entries: IPermissionEdgeEntry[] } + >(); + for (const edge of graph.edges) { + const key = pairKey(edge.source, edge.target); + const group = groups.get(key) ?? { + source: edge.source, + target: edge.target, + entries: [], + }; + group.entries.push({ + edgeId: edge.id, + permissionName: edge.permissionName, + conditionLabel: edge.conditionLabel, + }); + groups.set(key, group); + } + + const detailNodes: Node[] = []; + const flowEdges: Edge[] = []; + + if (selectedEdge == null) { + for (const group of groups.values()) { + flowEdges.push({ + id: pairKey(group.source, group.target), + source: group.source, + target: group.target, + type: 'permission', + ...EDGE_NEUTRAL, + data: { + permissions: group.entries, + onSelect: onSelectEdge, + } satisfies IPermissionEdgeData, + }); + } + } else { + const detailId = `detail-${selectedEdge.id}`; + const selectedKey = pairKey(selectedEdge.source, selectedEdge.target); + + detailNodes.push({ + id: detailId, + type: 'permissionDetail', + position: UNPOSITIONED, + draggable: false, + data: { + edge: selectedEdge, + whoLabel: nodesById.get(selectedEdge.source)?.label ?? '', + whereLabel: nodesById.get(selectedEdge.target)?.label ?? '', + onClose, + }, + }); + + for (const group of groups.values()) { + const key = pairKey(group.source, group.target); + + if (key !== selectedKey) { + // Other relationships stay visible but dimmed, labels intact. + flowEdges.push({ + id: key, + source: group.source, + target: group.target, + type: 'permission', + ...EDGE_NEUTRAL, + data: { + permissions: group.entries, + dimmed: true, + onSelect: onSelectEdge, + } satisfies IPermissionEdgeData, + }); + continue; + } + + // Selected relationship: route through the detail card in primary. + // Sibling permissions on the same pair ride the who→detail connector, + // dimmed, so they stay visible without overlapping the card. + const siblings = group.entries.filter( + (entry) => entry.edgeId !== selectedEdge.id, + ); + flowEdges.push( + siblings.length > 0 + ? { + id: `${selectedEdge.id}-in`, + source: selectedEdge.source, + target: detailId, + type: 'permission', + ...EDGE_SELECTED_IN, + data: { + permissions: siblings, + dimmed: true, + onSelect: onSelectEdge, + } satisfies IPermissionEdgeData, + } + : { + id: `${selectedEdge.id}-in`, + source: selectedEdge.source, + target: detailId, + type: 'smoothstep', + ...EDGE_SELECTED_IN, + }, + ); + flowEdges.push({ + id: `${selectedEdge.id}-out`, + source: detailId, + target: selectedEdge.target, + type: 'smoothstep', + ...EDGE_SELECTED_OUT, + }); + } + } + + return { nodes: [...permissionNodes, ...detailNodes], edges: flowEdges }; +}; + +export interface IPermissionsGraphCanvasProps { + /** + * Resolved permission graph to render. + */ + graph: IPermissionGraph; +} + +export const PermissionsGraphCanvas: React.FC = ({ + graph, +}) => { + const [selectedEdgeId, setSelectedEdgeId] = useState(null); + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const { fitView, getNodes } = useReactFlow(); + const nodesInitialized = useNodesInitialized(); + + // Positions the board returns to when the detail card closes. Captured live + // at the moment of selection, so any manual node drags before opening are + // the "last known" arrangement we restore to — not the initial auto-layout. + const restorePositions = useRef( + new Map(), + ); + // Whether a permission is currently expanded, tracked across renders so the + // effect can tell the open/close transitions apart from steady states. + const wasSelected = useRef(false); + // Measured permission-node set the dagre auto-layout last ran for. Auto + // layout only re-runs when this changes (sizes settling), so it never + // clobbers manual drags, which leave the signature untouched. + const autoLayoutSignature = useRef(''); + + const selectEdge = useCallback((edgeId: string) => { + setSelectedEdgeId((current) => (current === edgeId ? null : edgeId)); + }, []); + const clearSelection = useCallback(() => setSelectedEdgeId(null), []); + + // Rebuild the node/edge set when the graph or selection changes, preserving + // known positions so surviving nodes do not jump before re-layout. + useEffect(() => { + const previousPositions = new Map( + getNodes().map((node) => [node.id, node.position]), + ); + + const { nodes: nextNodes, edges: nextEdges } = buildFlowElements({ + graph, + selectedEdgeId, + onSelectEdge: selectEdge, + onClose: clearSelection, + }); + + setNodes( + nextNodes.map((node) => ({ + ...node, + position: previousPositions.get(node.id) ?? node.position, + })), + ); + setEdges(nextEdges); + }, [ + graph, + selectedEdgeId, + selectEdge, + clearSelection, + getNodes, + setNodes, + setEdges, + ]); + + // Once React Flow has measured the current node set, lay it out with the + // real measured dimensions. The signature includes each node's measured size + // so the layout re-runs when a node settles to its final size. + const layoutSignature = nodes + .map( + (node) => + `${node.id}:${Math.round(node.measured?.width ?? 0)}x${Math.round(node.measured?.height ?? 0)}`, + ) + .join('|'); + const [layoutVersion, setLayoutVersion] = useState(0); + + useEffect(() => { + if (!nodesInitialized || layoutSignature === '') { + return; + } + + const currentNodes = getNodes(); + const permissionNodes = currentNodes.filter( + (node) => node.type === 'permission', + ); + + if (permissionNodes.length === 0) { + return; + } + + // Signature of just the permission nodes: the auto-layout only depends + // on these, so the detail card settling to its size never re-flows the + // board, and manual drags (which don't change sizes) are preserved. + const permissionSignature = permissionNodes + .map( + (node) => + `${node.id}:${Math.round(node.measured?.width ?? 0)}x${Math.round(node.measured?.height ?? 0)}`, + ) + .join('|'); + + // Selection state is read from the node set the sibling effect produced, + // NOT from `selectedEdgeId`. This keeps the layout a pure function of the + // committed nodes: it runs one commit after selection changes (when the + // detail node is already in the store) and can never clobber it. + const detailNode = currentNodes.find( + (node) => node.type === 'permissionDetail', + ); + const whoNode = permissionNodes.find( + (node) => node.data.selectionRole === 'who', + ); + const whereNode = permissionNodes.find( + (node) => node.data.selectionRole === 'where', + ); + const isSelected = + detailNode != null && whoNode != null && whereNode != null; + + // Auto-layout: run dagre once per size change while unselected. It never + // runs during a selection (would fight the frozen board) and never after + // sizes settle (would clobber drags), so the restore baseline follows + // the user's own arrangement. + if ( + !isSelected && + autoLayoutSignature.current !== permissionSignature + ) { + const baseTopology = graph.edges.map((edge) => ({ + id: edge.id, + source: edge.source, + target: edge.target, + })); + const { nodes: baseNodes } = getLayoutedElements( + permissionNodes, + baseTopology, + { direction: 'BT' }, + ); + const laid = new Map( + baseNodes.map((node) => [node.id, node.position]), + ); + autoLayoutSignature.current = permissionSignature; + restorePositions.current = laid; + wasSelected.current = false; + setNodes( + currentNodes.map((node) => ({ + ...node, + position: laid.get(node.id) ?? node.position, + })), + ); + setLayoutVersion((version) => version + 1); + return; + } + + let laidNodes: Node[]; + + if (whoNode == null || whereNode == null || detailNode == null) { + if (!wasSelected.current) { + // Steady unselected state: leave positions (respect drags). + return; + } + // Card just closed: return every node to its last-known position. + wasSelected.current = false; + const restore = restorePositions.current; + laidNodes = currentNodes.map((node) => ({ + ...node, + position: restore.get(node.id) ?? node.position, + })); + } else { + // Card is open. On the opening frame, snapshot the live board (dagre + // layout plus any drags) so closing returns to exactly this. + if (!wasSelected.current) { + restorePositions.current = new Map( + permissionNodes.map((node) => [node.id, node.position]), + ); + wasSelected.current = true; + } + const frozen = restorePositions.current; + const whoBase = frozen.get(whoNode.id); + const whereBase = frozen.get(whereNode.id); + + const whoWidth = whoNode.measured?.width ?? 0; + const whereWidth = whereNode.measured?.width ?? 0; + const whereHeight = whereNode.measured?.height ?? 0; + const detailWidth = detailNode.measured?.width ?? 0; + const detailHeight = detailNode.measured?.height ?? 0; + + const columnCenterX = + whoBase != null && whereBase != null + ? (whoBase.x + + whoWidth / 2 + + whereBase.x + + whereWidth / 2) / + 2 + : (whoBase?.x ?? whereBase?.x ?? 0); + + const detailY = + (whereBase?.y ?? 0) + whereHeight + DETAIL_VERTICAL_GAP; + const detailPosition = { + x: columnCenterX - detailWidth / 2, + y: detailY, + }; + const whoPosition = { + x: whoBase?.x ?? columnCenterX, + y: detailY + detailHeight + DETAIL_VERTICAL_GAP, + }; + + // Amount the who node slides down to clear the inline card. Every + // node below the where node shifts by the same delta so the whole + // lower half opens up rigidly around the card — nodes not in the + // relationship relocate too, instead of being overlapped. + const shiftDelta = whoPosition.y - (whoBase?.y ?? 0); + const whereBaseY = whereBase?.y ?? 0; + + laidNodes = currentNodes.map((node) => { + if (node.id === detailNode.id) { + return { ...node, position: detailPosition }; + } + if (node.id === whoNode.id) { + return { ...node, position: whoPosition }; + } + + const base = frozen.get(node.id); + if (base == null) { + return node; + } + // Nodes above/at the where rank stay; everything below slides + // down to make room for the expanded card. + const position = + base.y > whereBaseY + ? { x: base.x, y: base.y + shiftDelta } + : base; + return { ...node, position }; + }); + } + + setNodes(laidNodes); + setLayoutVersion((version) => version + 1); + }, [nodesInitialized, layoutSignature, graph, getNodes, setNodes]); + + // Fit the view after the laid-out positions have committed. + useEffect(() => { + if (layoutVersion === 0) { + return; + } + + const frame = requestAnimationFrame(() => { + void fitView(FIT_VIEW_OPTIONS); + }); + + return () => cancelAnimationFrame(frame); + }, [layoutVersion, fitView]); + + return ( + + + + + + ); +}; diff --git a/src/modules/settings/components/permissionsList/index.ts b/src/modules/settings/components/permissionsList/index.ts new file mode 100644 index 0000000000..cc5f1e5f0b --- /dev/null +++ b/src/modules/settings/components/permissionsList/index.ts @@ -0,0 +1,5 @@ +export { + getPermissionRowKey, + 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..bc66593abc --- /dev/null +++ b/src/modules/settings/components/permissionsList/permissionsList.test.tsx @@ -0,0 +1,169 @@ +import { GukModulesProvider } from '@aragon/gov-ui-kit'; +import { render, screen } from '@testing-library/react'; +import { ALLOW_FLAG, ANY_ADDR } from '../../constants/permissionSentinels'; +import { initialiseConditionRegistry } from '../../initConditionRegistry'; +import type { IPermissionRow } from '../../types'; +import { + getPermissionRowKey, + type IPermissionsListProps, + PermissionsList, +} from './permissionsList'; + +const ROOT_PERMISSION_ID = + '0x815fe80e4b37c8582a3b773d1d7071f983eacfd56b5965db654f3087c25ada33'; +const EXECUTE_PERMISSION_ID = + '0xbf04b4486c9663d805744005c3da000eda93de6e3308a4a7a812eb565327b78d'; + +describe(' component', () => { + beforeAll(() => { + initialiseConditionRegistry(); + }); + + const createTestComponent = (props?: Partial) => { + const completeProps: IPermissionsListProps = { + rows: [], + accountRefs: [], + daoPlugins: [], + chainId: undefined, + isLoading: false, + expandedRows: [], + onExpandedRowsChange: jest.fn(), + ...props, + }; + + return ( + + + + ); + }; + + it('renders a skeleton while the permissions are loading', () => { + render(createTestComponent({ isLoading: true })); + + expect( + screen.getByTestId('permissions-list-skeleton'), + ).toBeInTheDocument(); + expect( + screen.queryByText(/permissionsList.empty.heading/), + ).not.toBeInTheDocument(); + }); + + it('renders the empty state when there are no permissions', () => { + render(createTestComponent({ rows: [] })); + + 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' }, + }, + ]; + + render(createTestComponent({ rows })); + + 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( + screen.getByText(/permissionsList.header.condition/), + ).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' }, + }, + ]; + + render(createTestComponent({ rows })); + + expect(screen.getByText('VotingPower')).toBeInTheDocument(); + expect(screen.getByText('-')).toBeInTheDocument(); + }); + + it('renders both the Details and Condition lists for an expanded row', async () => { + const rows: IPermissionRow[] = [ + { + permissionId: EXECUTE_PERMISSION_ID, + whoAddress: ANY_ADDR, + whereAddress: ALLOW_FLAG, + conditionAddress: '0xC0Ffee254729296a45a3885639AC7E10F9d54979', + condition: { + conditionType: 'voting-power', + token: '0x0bA45A8b5d5575935B8158a88C631E9F9C95a2e5', + minVotingPower: '1000000000000000000', + }, + }, + ]; + + render( + createTestComponent({ + rows, + expandedRows: [getPermissionRowKey(rows[0])], + }), + ); + + expect( + screen.getByText(/permissionsList.details.heading/), + ).toBeInTheDocument(); + expect( + screen.getByText(/permissionsList.condition.heading/), + ).toBeInTheDocument(); + // The condition slot is a lazy (dynamic) component — await its load. + expect( + await screen.findByText(/votingPowerConditionSlot.token/), + ).toBeInTheDocument(); + // 1e18 base units formatted with the default 18 decimals. + expect(await screen.findByText('1')).toBeInTheDocument(); + }); + + it('routes the condition cell to the fallback slot for an expanded row', () => { + const rows: IPermissionRow[] = [ + { + permissionId: ROOT_PERMISSION_ID, + whoAddress: ANY_ADDR, + whereAddress: ALLOW_FLAG, + conditionAddress: ALLOW_FLAG, + }, + ]; + + render( + createTestComponent({ + rows, + expandedRows: [getPermissionRowKey(rows[0])], + }), + ); + + expect(screen.getByText(/noConditionSlot.heading/)).toBeInTheDocument(); + }); +}); diff --git a/src/modules/settings/components/permissionsList/permissionsList.tsx b/src/modules/settings/components/permissionsList/permissionsList.tsx new file mode 100644 index 0000000000..ea1f5d90dd --- /dev/null +++ b/src/modules/settings/components/permissionsList/permissionsList.tsx @@ -0,0 +1,402 @@ +'use client'; + +import { + Accordion, + addressUtils, + CardEmptyState, + ChainEntityType, + DaoAvatar, + DefinitionList, + Link, + StateSkeletonBar, + Tag, + useBlockExplorer, +} from '@aragon/gov-ui-kit'; +import type { IDaoPlugin } from '@/shared/api/daoService'; +import type { IFilterComponentPlugin } from '@/shared/components/pluginFilterComponent'; +import { PluginSingleComponent } from '@/shared/components/pluginSingleComponent'; +import { useTranslations } from '@/shared/components/translationsProvider'; +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 { + type IPermissionAccountRef, + type IPermissionEntity, + permissionEntityUtils, +} from '../../utils/permissionEntityUtils'; +import { NoConditionSlot } from '../noConditionSlot'; + +type DaoPlugins = IFilterComponentPlugin[] | undefined; + +export interface IPermissionsListProps { + /** + * Permission rows to render. + */ + rows: IPermissionRow[]; + /** + * Account references used to resolve `who` / `where` entities. + */ + accountRefs: IPermissionAccountRef[]; + /** + * Installed DAO plugins used to classify plugin entities. + */ + daoPlugins?: DaoPlugins; + /** + * Chain id for block-explorer links. + */ + chainId?: number; + /** + * Whether the permissions are still loading. + */ + isLoading: boolean; + /** + * Row keys currently expanded. + */ + expandedRows: string[]; + /** + * Called when the set of expanded rows changes. + */ + onExpandedRowsChange: (rows: string[]) => void; +} + +const SKELETON_ROW_KEYS = [ + 'skeleton-1', + 'skeleton-2', + 'skeleton-3', + 'skeleton-4', +]; + +export const getPermissionRowKey = (row: IPermissionRow): string => + `${row.permissionId}-${row.whoAddress}-${row.whereAddress}`; + +/** + * Body of the permissions list view. Receives already-resolved data from the + * shared `usePermissionsData` hook; the account selector, view toggle and + * expand-all control live in the page shell so the list and graph share them. + */ +export const PermissionsList: React.FC = (props) => { + const { + rows, + accountRefs, + daoPlugins, + chainId, + isLoading, + expandedRows, + onExpandedRowsChange, + } = props; + + const { t } = useTranslations(); + + if (isLoading) { + return ; + } + + if (rows.length === 0) { + return ( + + ); + } + + return ( +
+ + onExpandedRowsChange(value ?? [])} + value={expandedRows} + > + {rows.map((row) => ( + + ))} + +
+ ); +}; + +interface IPermissionsListRowProps { + row: IPermissionRow; + rowKey: string; + daoPlugins: DaoPlugins; + accounts: IPermissionAccountRef[]; + chainId?: number; +} + +interface IPermissionEntityCellProps { + entity: IPermissionEntity; +} + +const PermissionEntityCell: React.FC = ({ + entity, +}) => ( + + {entity.label} + {entity.type === 'dao' && ( + + )} + {entity.type === 'plugin' && entity.tag != null && ( + + )} + {entity.type === 'sentinel' && ( + +); + +interface IPermissionEntityDetailProps { + entity: IPermissionEntity; + chainId?: number; +} + +const PermissionEntityDetail: React.FC = ({ + entity, + chainId, +}) => { + const { buildEntityUrl } = useBlockExplorer({ chainId }); + + if (entity.isSentinel) { + return ( +
+ {entity.label} + + {addressUtils.truncateAddress(entity.address)} + +
+ ); + } + + const explorerUrl = buildEntityUrl({ + type: ChainEntityType.ADDRESS, + id: entity.address, + }); + + return ( +
+ + {addressUtils.truncateAddress(entity.address)} + + {entity.detailName != null && ( + + {entity.detailName} + + )} +
+ ); +}; + +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, accounts, chainId } = props; + + const { t } = useTranslations(); + + const resolveOptions = { daoPlugins, accounts }; + const who = permissionEntityUtils.resolvePermissionEntity( + row.whoAddress, + resolveOptions, + ); + const where = permissionEntityUtils.resolvePermissionEntity( + row.whereAddress, + resolveOptions, + ); + const permissionName = permissionNameUtils.getPermissionName( + row.permissionId, + ); + const conditionType = conditionTypeUtils.resolveConditionType( + 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 ( + + +
+ + + + {permissionName} + + + {hasCondition ? ( + + ) : ( + + {conditionLabel} + + )} + +
+
+ +
+
+

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

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

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

+ +
+
+
+
+ ); +}; + +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/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..a1a72f06b1 --- /dev/null +++ b/src/modules/settings/components/votingPowerConditionSlot/votingPowerConditionSlot.test.tsx @@ -0,0 +1,49 @@ +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 truncated token address and the formatted minimum voting power', () => { + render( + createTestComponent({ + token: '0x0bA45A8b5d5575935B8158a88C631E9F9C95a2e5', + minVotingPower: '1000000000000000000', + }), + ); + + expect( + screen.getByText(/votingPowerConditionSlot.token/), + ).toBeInTheDocument(); + expect(screen.getByText('0x0bA4…a2e5')).toBeInTheDocument(); + expect( + screen.getByText(/votingPowerConditionSlot.minVotingPower/), + ).toBeInTheDocument(); + // 1e18 base units formatted with the default 18 decimals. + expect(screen.getByText('1')).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 new file mode 100644 index 0000000000..e4b3ca65b2 --- /dev/null +++ b/src/modules/settings/components/votingPowerConditionSlot/votingPowerConditionSlot.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { + addressUtils, + DefinitionList, + formatterUtils, +} from '@aragon/gov-ui-kit'; +import { formatUnits } from 'viem'; +import type { IConditionData } from '@/modules/settings/types'; +import { useTranslations } from '@/shared/components/translationsProvider'; +import { stringUtils } from '@/shared/utils/stringUtils'; + +const EMPTY_VALUE = '—'; + +// TODO(APP-953): source decimals from the token metadata (useToken) once the +// condition slot receives the token's chainId. Governance tokens are 18-decimal +// by convention, so default to that for now. +const DEFAULT_TOKEN_DECIMALS = 18; + +const formatMinVotingPower = (value: unknown): string => { + if ( + typeof value !== 'string' && + typeof value !== 'number' && + typeof value !== 'bigint' + ) { + return EMPTY_VALUE; + } + + if (typeof value === 'string' && value.length === 0) { + return EMPTY_VALUE; + } + + try { + const amount = formatUnits(BigInt(value), DEFAULT_TOKEN_DECIMALS); + + return formatterUtils.formatNumber(amount) ?? amount; + } catch { + return EMPTY_VALUE; + } +}; + +export const VotingPowerConditionSlot: React.FC = (props) => { + const { token, minVotingPower } = props; + const { t } = useTranslations(); + + const tokenLabel = stringUtils.isNonEmptyString(token) + ? addressUtils.truncateAddress(token) + : EMPTY_VALUE; + const minVotingPowerLabel = formatMinVotingPower(minVotingPower); + + return ( + + + {tokenLabel} + + + {minVotingPowerLabel} + + + ); +}; 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', } 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/constants/permissionsMocks.ts b/src/modules/settings/constants/permissionsMocks.ts new file mode 100644 index 0000000000..c866ec55b2 --- /dev/null +++ b/src/modules/settings/constants/permissionsMocks.ts @@ -0,0 +1,118 @@ +import type { IPaginatedResponse } from '@/shared/api/aragonBackendService'; +import type { IDaoPermission } from '@/shared/api/daoService'; +import type { IBackendApiMock } from '@/shared/types'; +import { permissionNameUtils } from '@/shared/utils/permissionNameUtils'; +import type { IPermissionRow } from '../types'; +import { ALLOW_FLAG, ANY_ADDR } from './permissionSentinels'; +import { PermissionsPreviewRef } from './permissionsPreviewRefs'; + +// Permission ids are derived from their names so the dictionary stays in a single +// place ({@link permissionNameUtils}) instead of duplicating raw keccak256 hashes. +const ROOT_PERMISSION_ID = + permissionNameUtils.getPermissionId('ROOT_PERMISSION'); +const EXECUTE_PERMISSION_ID = + permissionNameUtils.getPermissionId('EXECUTE_PERMISSION'); +const CREATE_PROPOSAL_PERMISSION_ID = permissionNameUtils.getPermissionId( + 'CREATE_PROPOSAL_PERMISSION', +); + +const tokenAddress = '0x0bA45A8b5d5575935B8158a88C631E9F9C95a2e5'; +const gaugeVoterAddress = '0x1234567890AbcdEF1234567890aBcdef12345678'; +const votingConditionAddress = '0xC0Ffee254729296a45a3885639AC7E10F9d54979'; +const selectorConditionAddress = '0xDe0B295669a9FD93d5F28D9Ec85E40f4cb697BAe'; +const membershipConditionAddress = '0xaB5801a7D398351b8bE11C439e05C5B3259aeC9B'; + +// `who` / `where` reference the viewed DAO ({@link PermissionsPreviewRef.self}), +// its first linked DAO and its installed plugins so the sample rows resolve to +// real names, tags and avatars for whichever DAO is previewed (see +// {@link PermissionsPreviewRef}). The scenarios mirror the permissions-page +// Figma (self→self root, self→linked root, plugin execute, anyone-creates). +const permissions: Array = [ + { + // The DAO holds root permission over itself, unconditionally. + permissionId: ROOT_PERMISSION_ID, + whoAddress: PermissionsPreviewRef.self, + whereAddress: PermissionsPreviewRef.self, + conditionAddress: ALLOW_FLAG, + }, + { + // The DAO holds root permission over its linked DAO, unconditionally. + permissionId: ROOT_PERMISSION_ID, + whoAddress: PermissionsPreviewRef.self, + whereAddress: PermissionsPreviewRef.linked, + conditionAddress: ALLOW_FLAG, + }, + { + // The first plugin may execute on the DAO, gated by an execute-selector + // condition over a handful of functions. + permissionId: EXECUTE_PERMISSION_ID, + whoAddress: PermissionsPreviewRef.plugin0, + whereAddress: PermissionsPreviewRef.self, + conditionAddress: selectorConditionAddress, + condition: { + conditionType: 'execute-selector', + selectors: ['0x3f4ba83f', '0x40e58ee5', '0x8456cb59'], + targets: [gaugeVoterAddress, gaugeVoterAddress, gaugeVoterAddress], + }, + }, + { + // The second plugin may execute on the DAO, unconditionally. + permissionId: EXECUTE_PERMISSION_ID, + whoAddress: PermissionsPreviewRef.plugin1, + whereAddress: PermissionsPreviewRef.self, + conditionAddress: ALLOW_FLAG, + }, + { + // Anyone may create a proposal on the first plugin, gated by voting power. + permissionId: CREATE_PROPOSAL_PERMISSION_ID, + whoAddress: ANY_ADDR, + whereAddress: PermissionsPreviewRef.plugin0, + conditionAddress: votingConditionAddress, + condition: { + conditionType: 'voting-power', + token: tokenAddress, + // Raw base-unit value; renders formatted once token decimals are wired. + minVotingPower: '1000000000000000000', + }, + }, + { + // Anyone may create a proposal on the second plugin, gated by multisig + // membership. + permissionId: CREATE_PROPOSAL_PERMISSION_ID, + whoAddress: ANY_ADDR, + whereAddress: PermissionsPreviewRef.plugin1, + conditionAddress: membershipConditionAddress, + condition: { + conditionType: 'membership', + onlyListed: true, + }, + }, +]; + +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 the + * condition scenarios shown in the permissions-page Figma (no-condition, + * execute-selector, voting-power, membership) so the permissions UI can be + * exercised without a live backend. + */ +export const permissionsMocks: IBackendApiMock[] = [ + { + // Scoped to the exact `/permissions/:network/:daoAddress` endpoint so + // the interceptor can't replace unrelated requests that merely contain + // the word "permissions" in their path. + url: /\/permissions\/[\w-]+\/0x[a-fA-F0-9]{40}(?:$|[/?])/, + type: 'replace', + data: permissionsResponse, + }, +]; diff --git a/src/modules/settings/constants/permissionsPreviewData.ts b/src/modules/settings/constants/permissionsPreviewData.ts new file mode 100644 index 0000000000..e71a6f2205 --- /dev/null +++ b/src/modules/settings/constants/permissionsPreviewData.ts @@ -0,0 +1,103 @@ +import { + type IDao, + type IDaoPlugin, + Network, + PluginInterfaceType, +} from '@/shared/api/daoService'; +import type { IFilterComponentPlugin } from '@/shared/components/pluginFilterComponent'; + +/** + * Self-contained "Patito DAO" preview identity used alongside + * {@link permissionsMocks} when the `useMocks` flag is on, so the permissions + * page (list AND graph) renders the exact scenario from the Figma design (Patito + * DAO + a linked Patito Developer DAO, with `Core` (SPP) and `Founders` + * (MULTISIG) plugins) regardless of which DAO is opened. Ignored for live data. + */ + +/** + * Minimal account shape the permissions views need to build the account selector + * and resolve `who` / `where` entities. + */ +export interface IPermissionsPreviewAccount { + id: string; + name: string; + network: Network; + daoAddress: string; + avatarSrc?: string; +} + +const patitoDaoAddress = '0xf204245b0B05E9A0780761E326552A569c1D6ceb'; +const patitoDeveloperDaoAddress = '0x71C7656EC7ab88b098defB751B7401B5f6d8976F'; +const coreAddress = '0x1eC50000000000000000000000000000000e8145'; +const foundersAddress = '0xf00d00000000000000000000000000000000e845'; + +const patitoDaoAvatar = '/patitoDao.png'; +const patitoDeveloperDaoAvatar = '/patitoDeveloperDao.png'; + +export const permissionsPreviewAccounts: IPermissionsPreviewAccount[] = [ + { + id: 'patito-dao', + name: 'Patito DAO', + network: Network.ETHEREUM_MAINNET, + daoAddress: patitoDaoAddress, + avatarSrc: patitoDaoAvatar, + }, + { + id: 'patito-developer-dao', + name: 'Patito Developer DAO', + network: Network.ETHEREUM_MAINNET, + daoAddress: patitoDeveloperDaoAddress, + avatarSrc: patitoDeveloperDaoAvatar, + }, +]; + +/** + * Patito `IDao` consumed by the graph's `buildPermissionGraph`, which classifies + * nodes from `dao.address` / `dao.linkedAccounts`. Only the fields the graph and + * account derivation read are meaningful; the rest is cast for preview use. + */ +export const permissionsPreviewDao = { + id: 'patito-dao', + name: 'Patito DAO', + network: Network.ETHEREUM_MAINNET, + address: patitoDaoAddress, + avatar: patitoDaoAvatar, + linkedAccounts: [ + { + id: 'patito-developer-dao', + name: 'Patito Developer DAO', + network: Network.ETHEREUM_MAINNET, + address: patitoDeveloperDaoAddress, + avatar: patitoDeveloperDaoAvatar, + }, + ], +} as unknown as IDao; + +export const permissionsPreviewPlugins: IFilterComponentPlugin[] = [ + { + id: 'core', + uniqueId: `${coreAddress}-spp`, + label: 'Core', + meta: { + address: coreAddress, + name: 'Core', + interfaceType: PluginInterfaceType.SPP, + release: '1', + build: '3', + } as IDaoPlugin, + props: {}, + }, + { + id: 'founders', + uniqueId: `${foundersAddress}-multisig`, + label: 'Founders', + meta: { + address: foundersAddress, + name: 'Founders', + interfaceType: PluginInterfaceType.MULTISIG, + release: '1', + build: '4', + } as IDaoPlugin, + props: {}, + }, +]; diff --git a/src/modules/settings/constants/permissionsPreviewRefs.ts b/src/modules/settings/constants/permissionsPreviewRefs.ts new file mode 100644 index 0000000000..beb7ab9b02 --- /dev/null +++ b/src/modules/settings/constants/permissionsPreviewRefs.ts @@ -0,0 +1,21 @@ +/** + * Preview-only address markers used by {@link permissionsMocks}. + * + * The permissions list swaps these markers for the viewed DAO's own address and + * its installed plugin addresses at render time, so the sample rows resolve to + * real names, tags and avatars for whichever DAO is being previewed instead of + * hard-coding a specific DAO. Real backend responses never contain these values, + * so the swap is a no-op for live data. + */ +const previewRef = (suffix: string): string => `0x${suffix.padStart(40, '0')}`; + +export const PermissionsPreviewRef = { + /** Resolves to the active account's DAO address. */ + self: previewRef('5e1f'), + /** Resolves to the active account's first linked DAO. */ + linked: previewRef('11a0'), + /** Resolves to the first installed plugin of the active account. */ + plugin0: previewRef('9100'), + /** Resolves to the second installed plugin of the active account. */ + plugin1: previewRef('9101'), +} as const; diff --git a/src/modules/settings/hooks/usePermissionsData/index.ts b/src/modules/settings/hooks/usePermissionsData/index.ts new file mode 100644 index 0000000000..e344ad57da --- /dev/null +++ b/src/modules/settings/hooks/usePermissionsData/index.ts @@ -0,0 +1,6 @@ +export { + type IPermissionsDataAccount, + type IUsePermissionsDataParams, + type IUsePermissionsDataResult, + usePermissionsData, +} from './usePermissionsData'; diff --git a/src/modules/settings/hooks/usePermissionsData/usePermissionsData.test.ts b/src/modules/settings/hooks/usePermissionsData/usePermissionsData.test.ts new file mode 100644 index 0000000000..b3c8060b96 --- /dev/null +++ b/src/modules/settings/hooks/usePermissionsData/usePermissionsData.test.ts @@ -0,0 +1,120 @@ +import { act, renderHook } from '@testing-library/react'; +import * as daoService from '@/shared/api/daoService'; +import { type IDao, 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 { permissionsPreviewAccounts } from '../../constants/permissionsPreviewData'; +import { usePermissionsData } from './usePermissionsData'; + +describe('usePermissionsData hook', () => { + const useDaoSpy = jest.spyOn(daoService, 'useDao'); + const useAllDaoPermissionsSpy = jest.spyOn( + daoService, + 'useAllDaoPermissions', + ); + const useDaoPluginsSpy = jest.spyOn(useDaoPluginsModule, 'useDaoPlugins'); + const useFeatureFlagsSpy = jest.spyOn( + featureFlagsProvider, + 'useFeatureFlags', + ); + + const setFeatureFlags = (enabled: Record) => { + useFeatureFlagsSpy.mockReturnValue({ + isEnabled: (key: string) => enabled[key] ?? false, + } as ReturnType); + }; + + const setDao = (dao?: Partial) => { + useDaoSpy.mockReturnValue( + generateReactQueryResultSuccess({ + data: generateDao(dao), + }) as ReturnType, + ); + }; + + beforeEach(() => { + setFeatureFlags({}); + setDao(); + useAllDaoPermissionsSpy.mockReturnValue({ + data: [], + isLoading: false, + error: null, + refetch: jest.fn(), + } as ReturnType); + useDaoPluginsSpy.mockReturnValue([]); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('re-queries permissions with the selected account params on switch', () => { + setFeatureFlags({ linkedAccount: true }); + setDao({ + id: 'main-dao', + address: '0xMainAddress', + network: Network.ETHEREUM_MAINNET, + name: 'Main DAO', + linkedAccounts: [ + { + 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: '', + }, + ], + }); + + const { result } = renderHook(() => + usePermissionsData({ daoId: 'main-dao' }), + ); + + expect(useAllDaoPermissionsSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + urlParams: { + network: Network.ETHEREUM_MAINNET, + daoAddress: '0xMainAddress', + }, + }), + expect.anything(), + ); + + act(() => { + result.current.setSelectedAccountId('linked-1'); + }); + + expect(useAllDaoPermissionsSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + urlParams: { + network: Network.POLYGON_MAINNET, + daoAddress: '0xLinkedAddress', + }, + }), + expect.anything(), + ); + }); + + it('uses the Patito preview identity when the mocks flag is on', () => { + setFeatureFlags({ useMocks: true }); + + const { result } = renderHook(() => + usePermissionsData({ daoId: 'any-dao' }), + ); + + expect(result.current.accounts).toEqual(permissionsPreviewAccounts); + expect(result.current.dao?.name).toBe('Patito DAO'); + }); +}); diff --git a/src/modules/settings/hooks/usePermissionsData/usePermissionsData.ts b/src/modules/settings/hooks/usePermissionsData/usePermissionsData.ts new file mode 100644 index 0000000000..1d2f059891 --- /dev/null +++ b/src/modules/settings/hooks/usePermissionsData/usePermissionsData.ts @@ -0,0 +1,225 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { + type IDao, + type IDaoPlugin, + type Network, + useAllDaoPermissions, + useDao, +} from '@/shared/api/daoService'; +import { useFeatureFlags } from '@/shared/components/featureFlagsProvider'; +import type { IFilterComponentPlugin } from '@/shared/components/pluginFilterComponent'; +import { networkDefinitions } from '@/shared/constants/networkDefinitions'; +import { useDaoPlugins } from '@/shared/hooks/useDaoPlugins'; +import { ipfsUtils } from '@/shared/utils/ipfsUtils'; +import { + permissionsPreviewAccounts, + permissionsPreviewDao, + permissionsPreviewPlugins, +} from '../../constants/permissionsPreviewData'; +import { PermissionsPreviewRef } from '../../constants/permissionsPreviewRefs'; +import type { IPermissionRow } from '../../types'; +import type { IPermissionAccountRef } from '../../utils/permissionEntityUtils'; + +/** + * Account shown in the permissions account selector and used to scope the + * fetched permissions. + */ +export interface IPermissionsDataAccount { + id: string; + name: string; + network: Network; + daoAddress: string; + avatarSrc?: string; +} + +export interface IUsePermissionsDataParams { + /** + * ID of the DAO to load permissions for. + */ + daoId: string; +} + +export interface IUsePermissionsDataResult { + /** + * The DAO the permissions belong to (Patito DAO under the preview flag), + * consumed by the graph view. + */ + dao?: IDao; + /** + * The DAO and its linked accounts, driving the account selector. + */ + accounts: IPermissionsDataAccount[]; + /** + * Currently selected account id. + */ + activeAccountId?: string; + /** + * Selects an account by id. + */ + setSelectedAccountId: (id: string) => void; + /** + * The resolved active account. + */ + activeAccount?: IPermissionsDataAccount; + /** + * Account references (address + display data) used to resolve `who` / `where` + * entities in the list view. + */ + accountRefs: IPermissionAccountRef[]; + /** + * Installed DAO plugins used to classify plugin entities. + */ + daoPlugins?: IFilterComponentPlugin[]; + /** + * Permission rows for the active account (preview markers already resolved). + */ + rows: IPermissionRow[]; + /** + * Chain id of the active account, for block-explorer links. + */ + chainId?: number; + /** + * Whether the permissions are still loading. + */ + isLoading: boolean; +} + +/** + * Single source of truth for the permissions views: fetches the DAO, its + * plugins and permissions, owns the account selection, and applies the preview + * identity + marker swap when `useMocks` is on. Both the list and graph render + * from this so a view toggle never changes the underlying data. + */ +export const usePermissionsData = ( + params: IUsePermissionsDataParams, +): IUsePermissionsDataResult => { + const { daoId } = params; + + const { isEnabled } = useFeatureFlags(); + + // The `useMocks` flag drives both the preview permission rows and the + // self-contained "Patito DAO" identity they resolve against. + const isPreview = isEnabled('useMocks'); + + const { data: realDao } = useDao({ urlParams: { id: daoId } }); + const realDaoPlugins = useDaoPlugins({ + daoId, + includeLinkedAccounts: true, + }); + + const dao = isPreview ? permissionsPreviewDao : realDao; + const daoPlugins = isPreview ? permissionsPreviewPlugins : realDaoPlugins; + + const realAccounts = useMemo(() => { + if (realDao == null) { + return []; + } + + const mainAccount: IPermissionsDataAccount = { + id: realDao.id, + name: realDao.name, + network: realDao.network, + daoAddress: realDao.address, + avatarSrc: ipfsUtils.cidToSrc(realDao.avatar), + }; + + const linkedAccounts = realDao.linkedAccounts ?? []; + const showLinkedAccounts = + isEnabled('linkedAccount') && linkedAccounts.length > 0; + + if (!showLinkedAccounts) { + return [mainAccount]; + } + + return [ + mainAccount, + ...linkedAccounts.map((account) => ({ + id: account.id, + name: account.name, + network: account.network, + daoAddress: account.address, + avatarSrc: ipfsUtils.cidToSrc(account.avatar), + })), + ]; + }, [realDao, isEnabled]); + + const accounts = isPreview ? permissionsPreviewAccounts : realAccounts; + + const [selectedAccountId, setSelectedAccountId] = useState(); + const activeAccountId = selectedAccountId ?? accounts[0]?.id; + const activeAccount = + accounts.find((account) => account.id === activeAccountId) ?? + accounts[0]; + + const accountRefs = useMemo( + () => + accounts.map((account) => ({ + address: account.daoAddress, + name: account.name, + avatarSrc: account.avatarSrc, + })), + [accounts], + ); + + const { data, isLoading } = 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 = useMemo(() => { + const rawRows = (data ?? []) as IPermissionRow[]; + const pluginAddresses = (daoPlugins ?? []).map( + (plugin) => plugin.meta.address, + ); + + const linkedAddress = accounts.find( + (account) => account.id !== activeAccount?.id, + )?.daoAddress; + + // Swap preview markers for the viewed DAO's real addresses so the sample + // rows resolve to names/tags/avatars. No-op for real backend data. + const refMap = new Map([ + [ + PermissionsPreviewRef.self.toLowerCase(), + activeAccount?.daoAddress, + ], + [PermissionsPreviewRef.linked.toLowerCase(), linkedAddress], + [PermissionsPreviewRef.plugin0.toLowerCase(), pluginAddresses[0]], + [PermissionsPreviewRef.plugin1.toLowerCase(), pluginAddresses[1]], + ]); + const resolveRef = (address: string): string => + refMap.get(address.toLowerCase()) ?? address; + + return rawRows.map((row) => ({ + ...row, + whoAddress: resolveRef(row.whoAddress), + whereAddress: resolveRef(row.whereAddress), + })); + }, [data, daoPlugins, accounts, activeAccount]); + + const chainId = activeAccount + ? networkDefinitions[activeAccount.network].id + : undefined; + + return { + dao, + accounts, + activeAccountId, + setSelectedAccountId, + activeAccount, + accountRefs, + daoPlugins, + rows, + chainId, + isLoading, + }; +}; diff --git a/src/modules/settings/initConditionRegistry.test.ts b/src/modules/settings/initConditionRegistry.test.ts new file mode 100644 index 0000000000..5edae60a45 --- /dev/null +++ b/src/modules/settings/initConditionRegistry.test.ts @@ -0,0 +1,47 @@ +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'; + +describe('initialiseConditionRegistry', () => { + beforeEach(() => { + initialiseConditionRegistry(); + }); + + it.each([ + { pluginId: 'voting-power', component: VotingPowerConditionSlot }, + { + pluginId: 'execute-selector', + component: ExecuteSelectorConditionSlot, + }, + { pluginId: 'membership', component: MembershipConditionSlot }, + ])('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..798409b7c8 --- /dev/null +++ b/src/modules/settings/initConditionRegistry.ts @@ -0,0 +1,34 @@ +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'; + +/** + * 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, + }) + .registerSlotComponent({ + slotId: SettingsSlotId.PERMISSION_CONDITION, + pluginId: 'membership', + component: MembershipConditionSlot, + }); +}; diff --git a/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPage.tsx b/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPage.tsx new file mode 100644 index 0000000000..c5bbb707e6 --- /dev/null +++ b/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPage.tsx @@ -0,0 +1,38 @@ +import { notFound } from 'next/navigation-original'; +import { Page } from '@/shared/components/page'; +import { featureFlags } from '@/shared/featureFlags'; +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; + } + + if (!(await featureFlags.isEnabled('permissionsPage'))) { + notFound(); + } + + 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..f630bb1246 --- /dev/null +++ b/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx @@ -0,0 +1,187 @@ +'use client'; + +import { Button, 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 { useFilterUrlParam } from '@/shared/hooks/useFilterUrlParam'; +import { daoUtils } from '@/shared/utils/daoUtils'; +import { PermissionsGraph } from '../../components/permissionsGraph'; +import { + getPermissionRowKey, + PermissionsList, +} from '../../components/permissionsList'; +import { usePermissionsData } from '../../hooks/usePermissionsData'; + +export interface IDaoPermissionsPageClientProps { + /** + * ID of the DAO. + */ + daoId: string; +} + +export const permissionsViewParam = 'permissionsview'; + +enum PermissionsView { + LIST = 'list', + GRAPH = 'graph', +} + +const permissionsViews = Object.values(PermissionsView); + +export const DaoPermissionsPageClient: React.FC< + IDaoPermissionsPageClientProps +> = (props) => { + const { daoId } = props; + + const { t } = useTranslations(); + + const { data: dao } = useDao({ urlParams: { id: daoId } }); + + const { + dao: permissionsDao, + accounts, + activeAccountId, + setSelectedAccountId, + accountRefs, + daoPlugins, + rows, + chainId, + isLoading, + } = usePermissionsData({ daoId }); + + const [view, setView] = useFilterUrlParam({ + name: permissionsViewParam, + fallbackValue: PermissionsView.LIST, + validValues: permissionsViews, + enableUrlUpdate: true, + }); + + const [expandedRows, setExpandedRows] = useState([]); + + const handleViewChange = (value?: string | string[]) => { + if (typeof value === 'string' && value) { + setView(value); + } + }; + + const handleAccountChange = (value?: string | string[]) => { + if (typeof value === 'string' && value) { + setSelectedAccountId(value); + } + }; + + const allExpanded = rows.length > 0 && expandedRows.length === rows.length; + + const handleToggleAll = () => { + setExpandedRows(allExpanded ? [] : rows.map(getPermissionRowKey)); + }; + + const pageBreadcrumbs = [ + { + href: daoUtils.getDaoUrl(dao, 'settings'), + label: t( + 'app.settings.daoPermissionsPage.header.breadcrumb.settings', + ), + }, + { + label: t( + 'app.settings.daoPermissionsPage.header.breadcrumb.permissions', + ), + }, + ]; + + const isListView = view === PermissionsView.LIST; + const showAccountSelector = accounts.length > 1; + const showExpandAll = isListView && !isLoading && rows.length > 0; + + return ( + <> + + + +
+
+ {showAccountSelector && ( + + {accounts.map((account) => ( + + ))} + + )} +
+ {showExpandAll && ( + + )} + + + + +
+
+ {isListView ? ( + + ) : ( + + )} +
+
+
+ + ); +}; 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'; diff --git a/src/modules/settings/types/index.ts b/src/modules/settings/types/index.ts index 176224a0ff..8e50dbe6e7 100644 --- a/src/modules/settings/types/index.ts +++ b/src/modules/settings/types/index.ts @@ -2,5 +2,12 @@ export type { IBuildPreparePluginUpdateDataParams } from './buildPreparePluginUp export type { IDaoPolicyDetailsPageParams } from './daoPolicyDetailsPageParams'; export type { IDaoProcessDetailsPageParams } from './daoProcessDetailsPageParams'; export type { IGetUninstallHelpersParams } from './getUninstallHelpersParams'; +export type { + IPermissionGraph, + IPermissionGraphEdge, + IPermissionGraphNode, + PermissionNodeKind, +} from './permissionGraph'; +export type { IConditionData, IPermissionRow } from './permissionRow'; export type { IPluginToFormDataParams } from './pluginToFormDataParams'; export type { IUseGovernanceSettingsParams } from './useGovernanceSettingsParams'; diff --git a/src/modules/settings/types/permissionGraph.ts b/src/modules/settings/types/permissionGraph.ts new file mode 100644 index 0000000000..270bcec213 --- /dev/null +++ b/src/modules/settings/types/permissionGraph.ts @@ -0,0 +1,89 @@ +import type { IPermissionRow } from './permissionRow'; + +/** + * Discriminator for a permission-graph node, driving both layout grouping and + * the node component rendered for it. + * + * - `dao` — the primary DAO the permissions belong to. + * - `linkedDao` — a DAO linked to the primary DAO (see `IDao.linkedAccounts`). + * - `plugin` — an installed DAO plugin (e.g. a Multisig / SPP process). + * - `actor` — any other grantee/target: a sentinel (`Anyone` / `Any Address`) + * or a plain externally-owned address. + */ +export type PermissionNodeKind = 'dao' | 'linkedDao' | 'plugin' | 'actor'; + +/** + * A single node in the permission graph. One node per unique `who` / `where` + * address across all permission rows. + */ +export interface IPermissionGraphNode { + /** + * Stable node id — the lowercased address the node represents. + */ + id: string; + /** + * Classified node kind used to pick the node component and layout treatment. + */ + kind: PermissionNodeKind; + /** + * Human-readable label (DAO / plugin / sentinel name, or truncated address). + */ + label: string; + /** + * Short type tag for plugin nodes (e.g. `MULTISIG` / `SPP`). Undefined for + * every other kind. + */ + tag?: string; + /** + * Avatar image source for DAO / linked DAO nodes. Undefined for plugins and + * actors, and may be null when the DAO has no avatar set. + */ + avatarSrc?: string | null; + /** + * The original, unmodified address the node represents. + */ + address: string; +} + +/** + * A single directed edge in the permission graph: `who` (source) is granted a + * permission over `where` (target). + */ +export interface IPermissionGraphEdge { + /** + * Stable edge id, unique per `(permission, who, where)` triple. + */ + id: string; + /** + * Source node id (the `who` address, lowercased). + */ + source: string; + /** + * Target node id (the `where` address, lowercased). + */ + target: string; + /** + * Resolved permission name (e.g. `EXECUTE_PERMISSION`), or a truncated hash + * when the permission id is unknown. + */ + permissionName: string; + /** + * Resolved condition label rendered as an `if