Skip to content

Commit 028f871

Browse files
committed
[TOOL-4972] Dashboard: Add field to configure admins in NFT asset creation (#7542)
<!-- ## title your PR with this format: "[SDK/Dashboard/Portal] Feature/Fix: Concise title for the changes" If you did not copy the branch name from Linear, paste the issue tag here (format is TEAM-0000): ## Notes for the reviewer Anything important to call out? Be sure to also clarify these in your comments. ## How to test Unit tests, playground, etc. --> <!-- start pr-codex --> --- ## PR-Codex overview This PR focuses on enhancing the NFT creation process by adding the ability to set admin addresses and refining various components for better user experience and validation. ### Detailed summary - Added `"set-admins"` step to the NFT asset type. - Introduced `AdminAddressesFieldset` for managing admin addresses. - Updated forms to include admin validation. - Improved UI components with consistent styling. - Enhanced error handling during admin setting. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added the ability to specify multiple admin wallet addresses when creating an NFT collection, with dynamic form fields and validation. * NFT launch workflow now includes a step to assign admin roles to specified addresses. * NFT collection info and launch dialogs now display the list of assigned admins. * **Improvements** * The NFT and token creation forms now automatically set the default blockchain network based on the active wallet chain. * NFT upload form includes default values for price and supply fields. * Visual spacing adjustments in social URL and token launch dialogs for improved UI consistency. * **Bug Fixes** * Ensured at least one admin address is required when creating an NFT collection. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 2821b4e commit 028f871

File tree

12 files changed

+299
-30
lines changed

12 files changed

+299
-30
lines changed

apps/dashboard/src/@/analytics/report.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,11 @@ export function reportAssetCreationFailed(
362362
properties: { contractType: AssetContractType; error: string } & (
363363
| {
364364
assetType: "nft";
365-
step: "deploy-contract" | "mint-nfts" | "set-claim-conditions";
365+
step:
366+
| "deploy-contract"
367+
| "mint-nfts"
368+
| "set-claim-conditions"
369+
| "set-admins";
366370
}
367371
| {
368372
assetType: "coin";

apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/SocialUrls.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export function SocialUrlsFieldset<T extends WithSocialUrls>(props: {
3434
<h2 className="mb-2 font-medium text-sm">Social URLs</h2>
3535

3636
{fields.length > 0 && (
37-
<div className="mb-5 space-y-4">
37+
<div className="mb-4 space-y-3">
3838
{fields.map((field, index) => (
3939
<div
4040
className="flex gap-3 max-sm:mb-6 max-sm:border-b max-sm:border-dashed max-sm:pb-6"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"use client";
2+
3+
import { PlusIcon, Trash2Icon } from "lucide-react";
4+
import { type UseFormReturn, useFieldArray } from "react-hook-form";
5+
import { useActiveAccount } from "thirdweb/react";
6+
import { Button } from "@/components/ui/button";
7+
import {
8+
FormControl,
9+
FormField,
10+
FormItem,
11+
FormMessage,
12+
} from "@/components/ui/form";
13+
import { Input } from "@/components/ui/input";
14+
15+
type WithAdmins = {
16+
admins: {
17+
address: string;
18+
}[];
19+
};
20+
21+
export function AdminAddressesFieldset<T extends WithAdmins>(props: {
22+
form: UseFormReturn<T>;
23+
}) {
24+
// T contains all properties of WithAdmins, so this is ok
25+
const form = props.form as unknown as UseFormReturn<WithAdmins>;
26+
const account = useActiveAccount();
27+
28+
const { fields, append, remove } = useFieldArray({
29+
control: form.control,
30+
name: "admins",
31+
});
32+
33+
const handleAddAddress = () => {
34+
append({ address: "" });
35+
};
36+
37+
const handleRemoveAddress = (index: number) => {
38+
const field = fields[index];
39+
if (field?.address === account?.address) {
40+
return; // Don't allow removing the connected address
41+
}
42+
remove(index);
43+
};
44+
45+
return (
46+
<div className="border-t border-dashed px-4 py-6 lg:px-6">
47+
<div className="mb-3">
48+
<h2 className="mb-1 font-medium text-sm">Admins</h2>
49+
<p className="text-sm text-muted-foreground">
50+
These wallets will have authority on the token
51+
</p>
52+
</div>
53+
54+
{fields.length > 0 && (
55+
<div className="mb-4 space-y-3">
56+
{fields.map((field, index) => (
57+
<div
58+
className="flex gap-3 max-sm:mb-6 max-sm:border-b max-sm:border-dashed max-sm:pb-6"
59+
key={field.id}
60+
>
61+
<div className="flex flex-1 flex-col gap-3 lg:flex-row">
62+
<FormField
63+
control={form.control}
64+
name={`admins.${index}.address`}
65+
render={({ field }) => (
66+
<FormItem className="flex-1">
67+
<FormControl>
68+
<Input
69+
{...field}
70+
aria-label="Admin Address"
71+
disabled={field.value === account?.address}
72+
placeholder="0x..."
73+
/>
74+
</FormControl>
75+
<FormMessage />
76+
</FormItem>
77+
)}
78+
/>
79+
</div>
80+
81+
<Button
82+
className="rounded-full"
83+
disabled={field.address === account?.address}
84+
onClick={() => handleRemoveAddress(index)}
85+
size="icon"
86+
type="button"
87+
variant="outline"
88+
>
89+
<Trash2Icon className="h-4 w-4" />
90+
<span className="sr-only">Remove</span>
91+
</Button>
92+
</div>
93+
))}
94+
</div>
95+
)}
96+
97+
<Button
98+
className="h-auto gap-1.5 rounded-full px-3 py-1.5 text-xs"
99+
onClick={handleAddAddress}
100+
size="sm"
101+
type="button"
102+
variant="outline"
103+
>
104+
<PlusIcon className="size-3.5" />
105+
Add Admin
106+
</Button>
107+
108+
{form.watch("admins").length === 0 && (
109+
<p className="text-sm text-destructive mt-2">
110+
At least one admin address is required
111+
</p>
112+
)}
113+
</div>
114+
);
115+
}

apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/schema.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,22 @@ export const socialUrlsSchema = z.array(
2222
}),
2323
);
2424

25+
export const addressArraySchema = z.array(
26+
z.object({
27+
address: z.string().refine(
28+
(value) => {
29+
if (isAddress(value)) {
30+
return true;
31+
}
32+
return false;
33+
},
34+
{
35+
message: "Invalid address",
36+
},
37+
),
38+
}),
39+
);
40+
2541
export const addressSchema = z.string().refine(
2642
(value) => {
2743
if (isAddress(value)) {

apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/_common/form.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1-
import { isAddress } from "thirdweb";
21
import * as z from "zod";
3-
import { socialUrlsSchema } from "../../_common/schema";
2+
import {
3+
addressArraySchema,
4+
addressSchema,
5+
socialUrlsSchema,
6+
} from "../../_common/schema";
47
import type { NFTMetadataWithPrice } from "../upload-nfts/batch-upload/process-files";
58

69
export const nftCollectionInfoFormSchema = z.object({
10+
admins: addressArraySchema.refine((addresses) => addresses.length > 0, {
11+
message: "At least one admin is required",
12+
}),
713
chain: z.string().min(1, "Chain is required"),
814
description: z.string().optional(),
915
image: z.instanceof(File).optional(),
@@ -12,14 +18,6 @@ export const nftCollectionInfoFormSchema = z.object({
1218
symbol: z.string(),
1319
});
1420

15-
const addressSchema = z.string().refine((value) => {
16-
if (isAddress(value)) {
17-
return true;
18-
}
19-
20-
return false;
21-
});
22-
2321
export const nftSalesSettingsFormSchema = z.object({
2422
primarySaleRecipient: addressSchema,
2523
royaltyBps: z.coerce.number().min(0).max(10000),
@@ -37,6 +35,14 @@ export type CreateNFTCollectionAllValues = {
3735
};
3836

3937
export type CreateNFTCollectionFunctions = {
38+
setAdmins: (values: {
39+
contractAddress: string;
40+
contractType: "DropERC721" | "DropERC1155";
41+
admins: {
42+
address: string;
43+
}[];
44+
chain: string;
45+
}) => Promise<void>;
4046
erc721: {
4147
deployContract: (values: CreateNFTCollectionAllValues) => Promise<{
4248
contractAddress: string;

apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/collection-info/nft-collection-info-fieldset.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
1010
import { Form } from "@/components/ui/form";
1111
import { Input } from "@/components/ui/input";
1212
import { Textarea } from "@/components/ui/textarea";
13+
import { AdminAddressesFieldset } from "../../_common/admin-addresses-fieldset";
1314
import { SocialUrlsFieldset } from "../../_common/SocialUrls";
1415
import { StepCard } from "../../_common/step-card";
1516
import type { NFTCollectionInfoFormValues } from "../_common/form";
@@ -126,6 +127,8 @@ export function NFTCollectionInfoFieldset(props: {
126127
</div>
127128

128129
<SocialUrlsFieldset form={form} />
130+
131+
<AdminAddressesFieldset form={form} />
129132
</StepCard>
130133
</form>
131134
</Form>

apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page-ui.tsx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
NATIVE_TOKEN_ADDRESS,
99
type ThirdwebClient,
1010
} from "thirdweb";
11-
import { useActiveAccount } from "thirdweb/react";
11+
import { useActiveAccount, useActiveWalletChain } from "thirdweb/react";
1212
import { reportAssetCreationStepConfigured } from "@/analytics/report";
1313
import type { Team } from "@/api/team";
1414
import {
@@ -163,11 +163,16 @@ export function CreateNFTPageUI(props: {
163163
}
164164

165165
function useNFTCollectionInfoForm() {
166+
const chain = useActiveWalletChain();
167+
const account = useActiveAccount();
166168
return useForm<NFTCollectionInfoFormValues>({
167-
resolver: zodResolver(nftCollectionInfoFormSchema),
168-
reValidateMode: "onChange",
169-
values: {
170-
chain: "1",
169+
defaultValues: {
170+
admins: [
171+
{
172+
address: account?.address || "",
173+
},
174+
],
175+
chain: chain?.id.toString() || "1",
171176
description: "",
172177
image: undefined,
173178
name: "",
@@ -183,5 +188,7 @@ function useNFTCollectionInfoForm() {
183188
],
184189
symbol: "",
185190
},
191+
resolver: zodResolver(nftCollectionInfoFormSchema),
192+
reValidateMode: "onChange",
186193
});
187194
}

apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/create-nft-page.tsx

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
lazyMint as lazyMint1155,
1919
setClaimConditions as setClaimConditions1155,
2020
} from "thirdweb/extensions/erc1155";
21+
import { grantRole } from "thirdweb/extensions/permissions";
2122
import { useActiveAccount } from "thirdweb/react";
2223
import { maxUint256 } from "thirdweb/utils";
2324
import { revalidatePathAction } from "@/actions/revalidate";
@@ -332,6 +333,60 @@ export function CreateNFTPage(props: {
332333
}
333334
}
334335

336+
async function handleSetAdmins(params: {
337+
contractAddress: string;
338+
contractType: "DropERC721" | "DropERC1155";
339+
admins: {
340+
address: string;
341+
}[];
342+
chain: string;
343+
}) {
344+
const { contract, activeAccount } = getContractAndAccount({
345+
chain: params.chain,
346+
});
347+
348+
// remove the current account from the list - its already an admin, don't have to add it again
349+
const adminsToAdd = params.admins.filter(
350+
(admin) => admin.address !== activeAccount.address,
351+
);
352+
353+
const encodedTxs = await Promise.all(
354+
adminsToAdd.map((admin) => {
355+
const tx = grantRole({
356+
contract,
357+
role: "admin",
358+
targetAccountAddress: admin.address,
359+
});
360+
361+
return encode(tx);
362+
}),
363+
);
364+
365+
const tx = multicall({
366+
contract,
367+
data: encodedTxs,
368+
});
369+
370+
try {
371+
await sendAndConfirmTransaction({
372+
account: activeAccount,
373+
transaction: tx,
374+
});
375+
} catch (e) {
376+
const errorMessage = parseError(e);
377+
console.error(errorMessage);
378+
379+
reportAssetCreationFailed({
380+
assetType: "nft",
381+
contractType: params.contractType,
382+
error: errorMessage,
383+
step: "set-admins",
384+
});
385+
386+
throw e;
387+
}
388+
}
389+
335390
return (
336391
<CreateNFTPageUI
337392
{...props}
@@ -349,7 +404,6 @@ export function CreateNFTPage(props: {
349404
formValues,
350405
});
351406
},
352-
353407
setClaimConditions: async (formValues) => {
354408
return handleSetClaimConditionsERC721({
355409
formValues,
@@ -373,6 +427,7 @@ export function CreateNFTPage(props: {
373427
return handleSetClaimConditionsERC1155(params);
374428
},
375429
},
430+
setAdmins: handleSetAdmins,
376431
}}
377432
onLaunchSuccess={() => {
378433
revalidatePathAction(

0 commit comments

Comments
 (0)