permissions in v5 (#4588)

<!-- start pr-codex -->

## 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}`

<!-- end pr-codex -->
This commit is contained in:
jnsdls
2024-09-15 00:25:26 +00:00
parent 9c8beac7fe
commit ebe4975aa3
4 changed files with 172 additions and 50 deletions
@@ -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<ReturnType<typeof getAllRoleMembers>>;
}) {
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,
};
},
});
}
@@ -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<PermissionsTableProps> = ({
contract,
trackingCategory,
}) => {
const contractQuery = useContract(contract.address);
const allRoleMembers = useAllRoleMembers(contractQuery.contract);
const allRoleMembers = useReadContract(getAllRoleMembers, {
contract,
});
const permissionsHref = useTabHref("permissions");
const members = useMemo(() => {
@@ -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<ContractPermissionProps> = ({
role,
description,
isLoading,
isPrebuilt,
contract,
}) => {
const {
@@ -80,7 +79,7 @@ export const ContractPermission: React.FC<ContractPermissionProps> = ({
</Flex>
)}
{role === "lister" && isPrebuilt && (
{role === "lister" && (
<Flex align="center" justify="center" flexGrow={0} flexShrink={0}>
{isLoading || isSubmitting ? (
<Flex align="center" gap={2} px={2}>
@@ -115,7 +114,7 @@ export const ContractPermission: React.FC<ContractPermissionProps> = ({
</Flex>
)}
{role === "asset" && isPrebuilt && (
{role === "asset" && (
<Flex align="center" justify="center" flexGrow={0} flexShrink={0}>
{isLoading || isSubmitting ? (
<Flex align="center" gap={2} px={2}>
@@ -191,7 +190,7 @@ export const ContractPermission: React.FC<ContractPermissionProps> = ({
</Flex>
)}
{role === "lister" && isPrebuilt && (
{role === "lister" && (
<Flex
direction="row"
borderRadius="md"
@@ -225,7 +224,7 @@ export const ContractPermission: React.FC<ContractPermissionProps> = ({
</Flex>
)}
{role === "asset" && isPrebuilt && (
{role === "asset" && (
<Flex
direction="row"
borderRadius="md"
@@ -1,42 +1,44 @@
import { ButtonGroup, Flex } from "@chakra-ui/react";
import {
type ContractWithRoles,
type RolesForContract,
useAllRoleMembers,
useContract,
useContractType,
useSetAllRoleMembers,
} from "@thirdweb-dev/react";
import { TransactionButton } from "components/buttons/TransactionButton";
import { BuiltinContractMap, ROLE_DESCRIPTION_MAP } from "constants/mappings";
import { ROLE_DESCRIPTION_MAP } from "constants/mappings";
import { useTrack } from "hooks/analytics/useTrack";
import { useTxNotifications } from "hooks/useTxNotifications";
import { useMemo } from "react";
import { FormProvider, useForm } from "react-hook-form";
import type { ThirdwebContract } from "thirdweb";
import type { roleMap } from "thirdweb/extensions/permissions";
import {
useActiveAccount,
useReadContract,
useSendAndConfirmTransaction,
} from "thirdweb/react";
import { Button } from "tw-components";
import {
createSetAllRoleMembersTx,
getAllRoleMembers,
} from "../../../hooks/permissions";
import { ContractPermission } from "./contract-permission";
type PermissionFormContext<TContract extends ContractWithRoles> = {
[role in RolesForContract<TContract>]: string[];
type PermissionFormContext = {
[role: string]: string[];
};
export const Permissions = <TContract extends ContractWithRoles>({
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<TContract>;
return allRoleMembers.data satisfies PermissionFormContext;
}, [allRoleMembers.data]);
const form = useForm({
@@ -44,27 +46,14 @@ export const Permissions = <TContract extends ContractWithRoles>({
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 (
<FormProvider {...form}>
@@ -73,20 +62,28 @@ export const Permissions = <TContract extends ContractWithRoles>({
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<TContract>, {
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 = <TContract extends ContractWithRoles>({
key={role}
role={role}
description={ROLE_DESCRIPTION_MAP[role] || ""}
isPrebuilt={isPrebuilt}
contract={contract}
/>
);
@@ -118,7 +114,7 @@ export const Permissions = <TContract extends ContractWithRoles>({
borderRadius="md"
isDisabled={
!allRoleMembers.data ||
setAllRoleMembers.isLoading ||
sendTx.isPending ||
!form.formState.isDirty
}
onClick={() => form.reset(allRoleMembers.data)}
@@ -130,7 +126,7 @@ export const Permissions = <TContract extends ContractWithRoles>({
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 = <TContract extends ContractWithRoles>({
</Flex>
</FormProvider>
);
};
}