From ebe4975aa331776bcc5b9870c7fbbdbb7a9431c3 Mon Sep 17 00:00:00 2001 From: jnsdls Date: Sun, 15 Sep 2024 00:25:25 +0000 Subject: [PATCH] permissions in v5 (#4588) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## PR-Codex overview The focus of this PR is to refactor permissions handling in the dashboard. ### Detailed summary - Refactored permissions handling to use `useReadContract` - Removed `isPrebuilt` checks in `ContractPermission` - Updated `getAllRoleMembers` and `createSetAllRoleMembersTx` - Removed unnecessary imports and variables > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` --- .../src/contract-ui/hooks/permissions.ts | 125 ++++++++++++++++++ .../overview/components/PermissionsTable.tsx | 8 +- .../components/contract-permission.tsx | 11 +- .../tabs/permissions/components/index.tsx | 78 ++++++----- 4 files changed, 172 insertions(+), 50 deletions(-) create mode 100644 apps/dashboard/src/contract-ui/hooks/permissions.ts diff --git a/apps/dashboard/src/contract-ui/hooks/permissions.ts b/apps/dashboard/src/contract-ui/hooks/permissions.ts new file mode 100644 index 000000000..17bc7a072 --- /dev/null +++ b/apps/dashboard/src/contract-ui/hooks/permissions.ts @@ -0,0 +1,125 @@ +import { + type BaseTransactionOptions, + type Hex, + type ThirdwebContract, + encode, + getAddress, +} from "thirdweb"; +import { multicall } from "thirdweb/extensions/common"; +import { resolveAddress } from "thirdweb/extensions/ens"; +import * as PermissionsExt from "thirdweb/extensions/permissions"; +import type { Account } from "thirdweb/wallets"; + +export async function getAllRoleMembers(options: BaseTransactionOptions) { + return Object.fromEntries( + await Promise.all( + Object.keys(PermissionsExt.roleMap).map( + async (role) => + [ + role, + await PermissionsExt.getAllRoleMembers({ ...options, role }).catch( + () => [], + ), + ] as const, + ), + ), + ); +} + +export function createSetAllRoleMembersTx(options: { + account: Account; + contract: ThirdwebContract; + roleMemberMap: Awaited>; +}) { + return multicall({ + contract: options.contract, + asyncParams: async () => { + const connectedWalletAddress = getAddress(options.account.address); + // if we are removing multiple roles, we need to allways remove the connected wallet address *last* + // this is so we don't renounce (i.e.) admin role first and then try to revoke someone else's (i.e.) admin role after (which will revert the entire txn because we are no longer an admin) + const roles = Object.keys(options.roleMemberMap); + if (roles.length === 0) { + throw new Error("No roles to set"); + } + // get the current role members + const currentRoles = await getAllRoleMembers(options); + const encoded: Hex[] = []; + // add / remove admin role at the end so we don't revoke admin then grant + const sortedRoles = roles.sort((role) => (role === "admin" ? 1 : -1)); + for (let i = 0; i < sortedRoles.length; i++) { + const role = sortedRoles[i]; + const [addresses, currentAddresses] = await Promise.all([ + Promise.all( + options.roleMemberMap[role]?.map((addressOrEns) => + resolveAddress({ + client: options.contract.client, + name: addressOrEns, + }), + ) || [], + ), + Promise.all( + currentRoles[role]?.map((addressOrEns) => + resolveAddress({ + client: options.contract.client, + name: addressOrEns, + }), + ) || [], + ), + ]); + const toAdd = addresses.filter( + (address) => !currentAddresses.includes(address), + ); + const toRemove = currentAddresses.filter( + (address) => !addresses.includes(address), + ); + + // if we're removing more than one address we have to make sure we always remove the *connected* (acting) wallet address first + // otherwise we'll revoke the connected wallet address and then try to revoke someone else's address which will revert the entire txn + if (toRemove.length > 1) { + const index = toRemove.indexOf(connectedWalletAddress); + if (index > -1) { + toRemove.splice(index, 1); + toRemove.push(connectedWalletAddress); + } + } + if (toAdd.length) { + const encodedToAdd = await Promise.all( + toAdd.map((addr) => + encode( + PermissionsExt.grantRole({ + contract: options.contract, + role, + targetAccountAddress: addr, + }), + ), + ), + ); + encoded.push(...encodedToAdd); + } + if (toRemove.length) { + const encodedToRemove = await Promise.all( + toRemove.map((address) => { + const tx = + getAddress(address) === connectedWalletAddress + ? PermissionsExt.renounceRole({ + contract: options.contract, + role, + targetAccountAddress: address, + }) + : PermissionsExt.revokeRole({ + contract: options.contract, + role, + targetAccountAddress: address, + }); + return encode(tx); + }), + ); + encoded.push(...encodedToRemove); + } + } + return { + data: encoded, + }; + }, + }); +} diff --git a/apps/dashboard/src/contract-ui/tabs/overview/components/PermissionsTable.tsx b/apps/dashboard/src/contract-ui/tabs/overview/components/PermissionsTable.tsx index ba7780782..fdb62cb8a 100644 --- a/apps/dashboard/src/contract-ui/tabs/overview/components/PermissionsTable.tsx +++ b/apps/dashboard/src/contract-ui/tabs/overview/components/PermissionsTable.tsx @@ -11,12 +11,12 @@ import { useClipboard, useToast, } from "@chakra-ui/react"; -import { useAllRoleMembers, useContract } from "@thirdweb-dev/react"; import { useTabHref } from "contract-ui/utils"; import { AnimatePresence, motion } from "framer-motion"; import { useMemo } from "react"; import { FiCopy } from "react-icons/fi"; import { type ThirdwebContract, ZERO_ADDRESS } from "thirdweb"; +import { useReadContract } from "thirdweb/react"; import { Button, Card, @@ -26,6 +26,7 @@ import { type TrackedLinkProps, } from "tw-components"; import { shortenIfAddress } from "utils/usedapp-external"; +import { getAllRoleMembers } from "../../../hooks/permissions"; interface PermissionsTableProps { contract: ThirdwebContract; @@ -36,8 +37,9 @@ export const PermissionsTable: React.FC = ({ contract, trackingCategory, }) => { - const contractQuery = useContract(contract.address); - const allRoleMembers = useAllRoleMembers(contractQuery.contract); + const allRoleMembers = useReadContract(getAllRoleMembers, { + contract, + }); const permissionsHref = useTabHref("permissions"); const members = useMemo(() => { diff --git a/apps/dashboard/src/contract-ui/tabs/permissions/components/contract-permission.tsx b/apps/dashboard/src/contract-ui/tabs/permissions/components/contract-permission.tsx index f0b2a8093..4c32cdb82 100644 --- a/apps/dashboard/src/contract-ui/tabs/permissions/components/contract-permission.tsx +++ b/apps/dashboard/src/contract-ui/tabs/permissions/components/contract-permission.tsx @@ -10,7 +10,6 @@ interface ContractPermissionProps { role: string; description: string; isLoading: boolean; - isPrebuilt: boolean; contract: ThirdwebContract; } @@ -18,7 +17,7 @@ export const ContractPermission: React.FC = ({ role, description, isLoading, - isPrebuilt, + contract, }) => { const { @@ -80,7 +79,7 @@ export const ContractPermission: React.FC = ({ )} - {role === "lister" && isPrebuilt && ( + {role === "lister" && ( {isLoading || isSubmitting ? ( @@ -115,7 +114,7 @@ export const ContractPermission: React.FC = ({ )} - {role === "asset" && isPrebuilt && ( + {role === "asset" && ( {isLoading || isSubmitting ? ( @@ -191,7 +190,7 @@ export const ContractPermission: React.FC = ({ )} - {role === "lister" && isPrebuilt && ( + {role === "lister" && ( = ({ )} - {role === "asset" && isPrebuilt && ( + {role === "asset" && ( = { - [role in RolesForContract]: string[]; +type PermissionFormContext = { + [role: string]: string[]; }; -export const Permissions = ({ +export function Permissions({ contract, }: { contract: ThirdwebContract; -}) => { +}) { const trackEvent = useTrack(); - const { contract: contractV4 } = useContract(contract.address); - const allRoleMembers = useAllRoleMembers(contractV4); - const setAllRoleMembers = useSetAllRoleMembers(contractV4 as TContract); + const account = useActiveAccount(); + const allRoleMembers = useReadContract(getAllRoleMembers, { + contract, + }); + const sendTx = useSendAndConfirmTransaction(); const transformedQueryData = useMemo(() => { if (!allRoleMembers.data) { return {}; } - return allRoleMembers.data as PermissionFormContext; + return allRoleMembers.data satisfies PermissionFormContext; }, [allRoleMembers.data]); const form = useForm({ @@ -44,27 +46,14 @@ export const Permissions = ({ values: transformedQueryData, }); - const { data: contractType } = useContractType(contract.address); - const contractData = - BuiltinContractMap[contractType as keyof typeof BuiltinContractMap]; - const { onSuccess, onError } = useTxNotifications( "Permissions updated", "Failed to update permissions", ); const roles = useMemo(() => { - return Object.keys(allRoleMembers.data || ROLE_DESCRIPTION_MAP).filter( - (role) => - contractData && contractData.contractType !== "custom" - ? contractData.roles?.includes(role as keyof typeof roleMap) - : true, - ); - }, [allRoleMembers.data, contractData]); - - const isPrebuilt = - BuiltinContractMap[contractType as keyof typeof BuiltinContractMap] - .contractType !== "custom"; + return Object.keys(allRoleMembers.data || ROLE_DESCRIPTION_MAP); + }, [allRoleMembers.data]); return ( @@ -73,20 +62,28 @@ export const Permissions = ({ direction="column" as="form" onSubmit={form.handleSubmit((d) => { + if (!account) { + onError(new Error("Wallet not connected!")); + return; + } trackEvent({ category: "permissions", action: "set-permissions", label: "attempt", }); - // if we switch back to mutateAsync then *need* to catch errors - setAllRoleMembers.mutate(d as PermissionFormContext, { - onSuccess: (_data, variables) => { + const tx = createSetAllRoleMembersTx({ + account, + contract, + roleMemberMap: d, + }); + sendTx.mutate(tx, { + onSuccess: () => { trackEvent({ category: "permissions", action: "set-permissions", label: "success", }); - form.reset(variables); + form.reset(d); onSuccess(); }, onError: (error) => { @@ -108,7 +105,6 @@ export const Permissions = ({ key={role} role={role} description={ROLE_DESCRIPTION_MAP[role] || ""} - isPrebuilt={isPrebuilt} contract={contract} /> ); @@ -118,7 +114,7 @@ export const Permissions = ({ borderRadius="md" isDisabled={ !allRoleMembers.data || - setAllRoleMembers.isLoading || + sendTx.isPending || !form.formState.isDirty } onClick={() => form.reset(allRoleMembers.data)} @@ -130,7 +126,7 @@ export const Permissions = ({ transactionCount={1} isDisabled={!form.formState.isDirty} type="submit" - isLoading={setAllRoleMembers.isLoading} + isLoading={sendTx.isPending} loadingText="Saving permissions ..." > Update permissions @@ -139,4 +135,4 @@ export const Permissions = ({ ); -}; +}