Skip to content

Commit 76a5035

Browse files
added access token flow
1 parent 26df73f commit 76a5035

File tree

7 files changed

+459
-74
lines changed

7 files changed

+459
-74
lines changed
Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
"use client";
2+
3+
import { CopyTextButton } from "@/components/ui/CopyTextButton";
4+
import { Spinner } from "@/components/ui/Spinner/Spinner";
5+
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
6+
import { Button } from "@/components/ui/button";
7+
import { CheckboxWithLabel } from "@/components/ui/checkbox";
8+
import { Checkbox } from "@/components/ui/checkbox";
9+
import {
10+
Dialog,
11+
DialogContent,
12+
DialogHeader,
13+
DialogTitle,
14+
} from "@/components/ui/dialog";
15+
import { Input } from "@/components/ui/input";
16+
import { THIRDWEB_VAULT_URL } from "@/constants/env";
17+
import { cn } from "@/lib/utils";
18+
import { useMutation } from "@tanstack/react-query";
19+
import { createAccessToken, createVaultClient } from "@thirdweb-dev/vault-sdk";
20+
import { Loader2 } from "lucide-react";
21+
import { useState } from "react";
22+
import { toast } from "sonner";
23+
24+
export default function CreateAccessToken(props: {
25+
projectId: string;
26+
teamId: string;
27+
}) {
28+
const [modalOpen, setModalOpen] = useState(false);
29+
const [keysConfirmed, setKeysConfirmed] = useState(false);
30+
const [adminKey, setAdminKey] = useState("");
31+
// TODO allow passing permissions to the access token
32+
const createAccessTokenMutation = useMutation({
33+
mutationFn: async (args: {
34+
adminKey: string;
35+
}) => {
36+
const vaultClient = await createVaultClient({
37+
baseUrl: THIRDWEB_VAULT_URL,
38+
});
39+
40+
const userAccessTokenRes = await createAccessToken({
41+
client: vaultClient,
42+
request: {
43+
options: {
44+
expiresAt: new Date(
45+
Date.now() + 60 * 60 * 1000 * 24 * 365 * 1000,
46+
).toISOString(), // 100 years from now
47+
policies: [
48+
{
49+
type: "eoa:read",
50+
metadataPatterns: [
51+
{
52+
key: "projectId",
53+
rule: {
54+
pattern: props.projectId,
55+
},
56+
},
57+
{
58+
key: "teamId",
59+
rule: {
60+
pattern: props.teamId,
61+
},
62+
},
63+
{
64+
key: "type",
65+
rule: {
66+
pattern: "server-wallet",
67+
},
68+
},
69+
],
70+
},
71+
{
72+
type: "eoa:create",
73+
requiredMetadataPatterns: [
74+
{
75+
key: "projectId",
76+
rule: {
77+
pattern: props.projectId,
78+
},
79+
},
80+
{
81+
key: "teamId",
82+
rule: {
83+
pattern: props.teamId,
84+
},
85+
},
86+
{
87+
key: "type",
88+
rule: {
89+
pattern: "server-wallet",
90+
},
91+
},
92+
],
93+
},
94+
{
95+
type: "eoa:signMessage",
96+
metadataPatterns: [
97+
{
98+
key: "projectId",
99+
rule: {
100+
pattern: props.projectId,
101+
},
102+
},
103+
{
104+
key: "teamId",
105+
rule: {
106+
pattern: props.teamId,
107+
},
108+
},
109+
{
110+
key: "type",
111+
rule: {
112+
pattern: "server-wallet",
113+
},
114+
},
115+
],
116+
},
117+
{
118+
type: "eoa:signTransaction",
119+
payloadPatterns: {},
120+
metadataPatterns: [
121+
{
122+
key: "projectId",
123+
rule: {
124+
pattern: props.projectId,
125+
},
126+
},
127+
{
128+
key: "teamId",
129+
rule: {
130+
pattern: props.teamId,
131+
},
132+
},
133+
{
134+
key: "type",
135+
rule: {
136+
pattern: "server-wallet",
137+
},
138+
},
139+
],
140+
},
141+
{
142+
type: "eoa:signTypedData",
143+
metadataPatterns: [
144+
{
145+
key: "projectId",
146+
rule: {
147+
pattern: props.projectId,
148+
},
149+
},
150+
{
151+
key: "teamId",
152+
rule: {
153+
pattern: props.teamId,
154+
},
155+
},
156+
{
157+
key: "type",
158+
rule: {
159+
pattern: "server-wallet",
160+
},
161+
},
162+
],
163+
},
164+
],
165+
metadata: {
166+
projectId: props.projectId,
167+
teamId: props.teamId,
168+
purpose: "Thirdweb Project Server Wallet Access Token",
169+
},
170+
},
171+
auth: {
172+
adminKey: args.adminKey,
173+
},
174+
},
175+
});
176+
177+
if (!userAccessTokenRes.success) {
178+
throw new Error(
179+
`Failed to create access token: ${userAccessTokenRes.error.message}`,
180+
);
181+
}
182+
183+
return {
184+
userAccessToken: userAccessTokenRes.data,
185+
};
186+
},
187+
onError: (error) => {
188+
toast.error(error.message);
189+
},
190+
});
191+
192+
const handleCloseModal = () => {
193+
if (!keysConfirmed) {
194+
return;
195+
}
196+
setModalOpen(false);
197+
setKeysConfirmed(false);
198+
};
199+
200+
const isLoading = createAccessTokenMutation.isPending;
201+
202+
return (
203+
<>
204+
<Button
205+
variant={"secondary"}
206+
onClick={() => setModalOpen(true)}
207+
disabled={isLoading}
208+
className="flex flex-row items-center gap-2"
209+
>
210+
{isLoading && <Loader2 className="animate-spin" />}
211+
Create Access Token
212+
</Button>
213+
214+
<Dialog open={modalOpen} onOpenChange={handleCloseModal} modal={true}>
215+
<DialogContent
216+
className="overflow-hidden p-0"
217+
dialogCloseClassName={cn(!keysConfirmed && "hidden")}
218+
>
219+
<DialogHeader className="p-6">
220+
<DialogTitle>Create Wallet Access Token</DialogTitle>
221+
</DialogHeader>
222+
{createAccessTokenMutation.isPending ? (
223+
<div className="flex flex-col items-center justify-center gap-4 p-10">
224+
<Spinner className="size-8" />
225+
<DialogTitle>Generating your access token</DialogTitle>
226+
</div>
227+
) : createAccessTokenMutation.data ? (
228+
<div>
229+
<div className="space-y-6 p-6 pt-0">
230+
<div className="space-y-4">
231+
<div>
232+
<h3 className="mb-2 font-medium text-sm">
233+
Wallet Access Token
234+
</h3>
235+
<div className="flex flex-col gap-2">
236+
<CopyTextButton
237+
textToCopy={
238+
createAccessTokenMutation.data.userAccessToken
239+
.accessToken
240+
}
241+
className="!h-auto w-full justify-between bg-background px-3 py-3 font-mono text-xs"
242+
textToShow={maskSecret(
243+
createAccessTokenMutation.data.userAccessToken
244+
.accessToken,
245+
)}
246+
copyIconPosition="right"
247+
tooltip="Copy Wallet Access Token"
248+
/>
249+
<p className="text-muted-foreground text-xs">
250+
This access token is used to send transactions to the
251+
blockchain from your backend. Can be revoked and
252+
recreated with your admin key.
253+
</p>
254+
</div>
255+
</div>
256+
</div>
257+
<Alert variant="destructive">
258+
<AlertTitle>Secure your keys</AlertTitle>
259+
<AlertDescription>
260+
These keys will not be displayed again. Store them securely
261+
as they provide access to your server wallets.
262+
</AlertDescription>
263+
<div className="h-4" />
264+
<CheckboxWithLabel className="text-foreground">
265+
<Checkbox
266+
checked={keysConfirmed}
267+
onCheckedChange={(v) => setKeysConfirmed(!!v)}
268+
/>
269+
I confirm that I've securely stored these keys
270+
</CheckboxWithLabel>
271+
</Alert>
272+
</div>
273+
274+
<div className="flex justify-end gap-3 border-t bg-card px-6 py-4">
275+
<Button
276+
onClick={handleCloseModal}
277+
disabled={!keysConfirmed}
278+
variant={"primary"}
279+
>
280+
Close
281+
</Button>
282+
</div>
283+
</div>
284+
) : (
285+
<div className="px-6 pb-6">
286+
<div className="flex flex-col gap-4">
287+
<p className="text-sm text-warning-text">
288+
This action requries your admin key.
289+
</p>
290+
<Input
291+
type="password"
292+
placeholder="Enter your admin key"
293+
value={adminKey}
294+
onChange={(e) => setAdminKey(e.target.value)}
295+
/>
296+
<div className="flex justify-end gap-3">
297+
<Button
298+
variant={"outline"}
299+
onClick={() => {
300+
setAdminKey("");
301+
setModalOpen(false);
302+
}}
303+
>
304+
Cancel
305+
</Button>
306+
<Button
307+
variant={"primary"}
308+
onClick={() =>
309+
createAccessTokenMutation.mutate({ adminKey })
310+
}
311+
disabled={!adminKey || createAccessTokenMutation.isPending}
312+
>
313+
{createAccessTokenMutation.isPending ? (
314+
<>
315+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
316+
Creating...
317+
</>
318+
) : (
319+
"Create"
320+
)}
321+
</Button>
322+
</div>
323+
</div>
324+
</div>
325+
)}
326+
</DialogContent>
327+
</Dialog>
328+
</>
329+
);
330+
}
331+
332+
function maskSecret(secret: string) {
333+
return `${secret.substring(0, 10)}...${secret.substring(secret.length - 10)}`;
334+
}

0 commit comments

Comments
 (0)