Skip to content

Commit 79a45a8

Browse files
committed
[TOOL-4972] Dashboard: Add field to configure admins in NFT asset creation
1 parent 67c3d86 commit 79a45a8

File tree

12 files changed

+300
-30
lines changed

12 files changed

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

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: 15 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,13 @@ export type CreateNFTCollectionAllValues = {
3735
};
3836

3937
export type CreateNFTCollectionFunctions = {
38+
setAdmins: (values: {
39+
contractAddress: string;
40+
admins: {
41+
address: string;
42+
}[];
43+
chain: string;
44+
}) => Promise<void>;
4045
erc721: {
4146
deployContract: (values: CreateNFTCollectionAllValues) => Promise<{
4247
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: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
import { useId } from "react";
44
import type { UseFormReturn } from "react-hook-form";
55
import type { ThirdwebClient } from "thirdweb";
6+
import { useActiveAccount } from "thirdweb/react";
67
import { ClientOnly } from "@/components/blocks/client-only";
78
import { FileInput } from "@/components/blocks/FileInput";
89
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
910
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
1011
import { Form } from "@/components/ui/form";
1112
import { Input } from "@/components/ui/input";
1213
import { Textarea } from "@/components/ui/textarea";
14+
import { AdminAddressesFieldset } from "../../_common/admin-addresses-fieldset";
1315
import { SocialUrlsFieldset } from "../../_common/SocialUrls";
1416
import { StepCard } from "../../_common/step-card";
1517
import type { NFTCollectionInfoFormValues } from "../_common/form";
@@ -24,6 +26,7 @@ export function NFTCollectionInfoFieldset(props: {
2426
const nameId = useId();
2527
const symbolId = useId();
2628
const descriptionId = useId();
29+
const account = useActiveAccount();
2730

2831
return (
2932
<Form {...form}>
@@ -126,6 +129,11 @@ export function NFTCollectionInfoFieldset(props: {
126129
</div>
127130

128131
<SocialUrlsFieldset form={form} />
132+
133+
<AdminAddressesFieldset
134+
connectedAddress={account?.address ?? ""}
135+
form={form}
136+
/>
129137
</StepCard>
130138
</form>
131139
</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: 55 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,59 @@ export function CreateNFTPage(props: {
332333
}
333334
}
334335

336+
async function handleSetAdmins(params: {
337+
contractAddress: string;
338+
admins: {
339+
address: string;
340+
}[];
341+
chain: string;
342+
}) {
343+
const { contract, activeAccount } = getContractAndAccount({
344+
chain: params.chain,
345+
});
346+
347+
// remove the current account from the list - its already an admin, don't have to add it again
348+
const adminsToAdd = params.admins.filter(
349+
(admin) => admin.address !== activeAccount.address,
350+
);
351+
352+
const encodedTxs = await Promise.all(
353+
adminsToAdd.map((admin) => {
354+
const tx = grantRole({
355+
contract,
356+
role: "admin",
357+
targetAccountAddress: admin.address,
358+
});
359+
360+
return encode(tx);
361+
}),
362+
);
363+
364+
const tx = multicall({
365+
contract,
366+
data: encodedTxs,
367+
});
368+
369+
try {
370+
await sendAndConfirmTransaction({
371+
account: activeAccount,
372+
transaction: tx,
373+
});
374+
} catch (e) {
375+
const errorMessage = parseError(e);
376+
console.error(errorMessage);
377+
378+
reportAssetCreationFailed({
379+
assetType: "nft",
380+
contractType: "DropERC721",
381+
error: errorMessage,
382+
step: "set-admins",
383+
});
384+
385+
throw e;
386+
}
387+
}
388+
335389
return (
336390
<CreateNFTPageUI
337391
{...props}
@@ -349,7 +403,6 @@ export function CreateNFTPage(props: {
349403
formValues,
350404
});
351405
},
352-
353406
setClaimConditions: async (formValues) => {
354407
return handleSetClaimConditionsERC721({
355408
formValues,
@@ -373,6 +426,7 @@ export function CreateNFTPage(props: {
373426
return handleSetClaimConditionsERC1155(params);
374427
},
375428
},
429+
setAdmins: handleSetAdmins,
376430
}}
377431
onLaunchSuccess={() => {
378432
revalidatePathAction(

0 commit comments

Comments
 (0)