Skip to content

Commit a5dc57b

Browse files
committed
feat: add edit functionality for bot key scopes in BotManagementCard
- Introduced editing capabilities for bot key scopes, allowing users to update API scopes directly within the BotManagementCard component. - Added new state management for editing bot keys and integrated validation for required scopes. - Enhanced API router to support updating bot key scopes with appropriate error handling. - Updated UI components to reflect changes and provide user feedback during the editing process.
1 parent dc6b6c4 commit a5dc57b

2 files changed

Lines changed: 211 additions & 56 deletions

File tree

src/components/pages/user/BotManagementCard.tsx

Lines changed: 184 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

33
import { useState } from "react";
4-
import { Bot, Plus, Trash2, Copy, Loader2 } from "lucide-react";
4+
import { Bot, Plus, Trash2, Copy, Loader2, Pencil } from "lucide-react";
55
import CardUI from "@/components/ui/card-content";
66
import RowLabelInfo from "@/components/ui/row-label-info";
77
import { Button } from "@/components/ui/button";
@@ -21,6 +21,9 @@ import { Input } from "@/components/ui/input";
2121
import { Label } from "@/components/ui/label";
2222
import { Checkbox } from "@/components/ui/checkbox";
2323
import { BOT_SCOPES, type BotScope } from "@/lib/auth/botKey";
24+
import { Badge } from "@/components/ui/badge";
25+
26+
const READ_SCOPE = "multisig:read" as const;
2427

2528
export default function BotManagementCard() {
2629
const { toast } = useToast();
@@ -29,9 +32,28 @@ export default function BotManagementCard() {
2932
const [newScopes, setNewScopes] = useState<BotScope[]>([]);
3033
const [createdSecret, setCreatedSecret] = useState<string | null>(null);
3134
const [createdBotKeyId, setCreatedBotKeyId] = useState<string | null>(null);
35+
const [editOpen, setEditOpen] = useState(false);
36+
const [editingBotKeyId, setEditingBotKeyId] = useState<string | null>(null);
37+
const [editScopes, setEditScopes] = useState<BotScope[]>([]);
3238

3339
const { data: botKeys, isLoading } = api.bot.listBotKeys.useQuery({});
40+
const editingBotKey = botKeys?.find((key) => key.id === editingBotKeyId) ?? null;
3441
const utils = api.useUtils();
42+
43+
const handleCloseCreate = () => {
44+
setCreateOpen(false);
45+
setNewName("");
46+
setNewScopes([]);
47+
setCreatedSecret(null);
48+
setCreatedBotKeyId(null);
49+
};
50+
51+
const handleCloseEdit = () => {
52+
setEditOpen(false);
53+
setEditingBotKeyId(null);
54+
setEditScopes([]);
55+
};
56+
3557
const createBotKey = api.bot.createBotKey.useMutation({
3658
onSuccess: (data) => {
3759
setCreatedSecret(data.secret);
@@ -54,7 +76,21 @@ export default function BotManagementCard() {
5476
const revokeBotKey = api.bot.revokeBotKey.useMutation({
5577
onSuccess: () => {
5678
toast({ title: "Bot revoked" });
57-
void api.useUtils().bot.listBotKeys.invalidate();
79+
void utils.bot.listBotKeys.invalidate();
80+
},
81+
onError: (err) => {
82+
toast({
83+
title: "Error",
84+
description: err.message,
85+
variant: "destructive",
86+
});
87+
},
88+
});
89+
const updateBotKeyScopes = api.bot.updateBotKeyScopes.useMutation({
90+
onSuccess: () => {
91+
toast({ title: "Scopes updated" });
92+
handleCloseEdit();
93+
void utils.bot.listBotKeys.invalidate();
5894
},
5995
onError: (err) => {
6096
toast({
@@ -94,12 +130,15 @@ export default function BotManagementCard() {
94130
createBotKey.mutate({ name: newName.trim(), scope: newScopes });
95131
};
96132

97-
const handleCloseCreate = () => {
98-
setCreateOpen(false);
99-
setNewName("");
100-
setNewScopes([]);
101-
setCreatedSecret(null);
102-
setCreatedBotKeyId(null);
133+
const openEditDialog = (botKeyId: string, scopes: readonly BotScope[]) => {
134+
setEditingBotKeyId(botKeyId);
135+
setEditScopes([...scopes]);
136+
setEditOpen(true);
137+
};
138+
139+
const handleSaveScopes = () => {
140+
if (!editingBotKeyId || editScopes.length === 0) return;
141+
updateBotKeyScopes.mutate({ botKeyId: editingBotKeyId, scope: editScopes });
103142
};
104143

105144
const toggleScope = (scope: BotScope) => {
@@ -108,6 +147,15 @@ export default function BotManagementCard() {
108147
);
109148
};
110149

150+
const toggleEditScope = (scope: BotScope) => {
151+
setEditScopes((prev) =>
152+
prev.includes(scope) ? prev.filter((s) => s !== scope) : [...prev, scope],
153+
);
154+
};
155+
156+
const missingReadScopeInCreate = newScopes.length > 0 && !newScopes.includes(READ_SCOPE);
157+
const missingReadScopeInEdit = editScopes.length > 0 && !editScopes.includes(READ_SCOPE);
158+
111159
return (
112160
<CardUI
113161
title="Bot accounts"
@@ -186,16 +234,21 @@ export default function BotManagementCard() {
186234
{BOT_SCOPES.map((scope) => (
187235
<div key={scope} className="flex items-center space-x-2">
188236
<Checkbox
189-
id={scope}
237+
id={`create-scope-${scope}`}
190238
checked={newScopes.includes(scope)}
191239
onCheckedChange={() => toggleScope(scope)}
192240
/>
193-
<label htmlFor={scope} className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
241+
<label htmlFor={`create-scope-${scope}`} className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
194242
{scope}
195243
</label>
196244
</div>
197245
))}
198246
</div>
247+
{missingReadScopeInCreate && (
248+
<p className="text-xs text-amber-600">
249+
Warning: without <code className="rounded bg-muted px-1">multisig:read</code>, <code className="rounded bg-muted px-1">POST /api/v1/botAuth</code> authentication will fail for this bot key.
250+
</p>
251+
)}
199252
</div>
200253
<DialogFooter>
201254
<Button
@@ -210,6 +263,57 @@ export default function BotManagementCard() {
210263
</DialogContent>
211264
</Dialog>
212265
</div>
266+
<Dialog
267+
open={editOpen}
268+
onOpenChange={(open) => {
269+
if (!open) handleCloseEdit();
270+
}}
271+
>
272+
<DialogContent className="sm:max-w-md max-h-[90vh] overflow-y-auto">
273+
<DialogHeader>
274+
<DialogTitle className="flex items-center gap-2">
275+
<Pencil className="h-4 w-4" />
276+
Edit scopes
277+
</DialogTitle>
278+
<DialogDescription>
279+
Update API scopes for {editingBotKey?.name ?? "this bot"}.
280+
</DialogDescription>
281+
</DialogHeader>
282+
<div className="space-y-2">
283+
<Label>Scopes</Label>
284+
<div className="flex flex-col gap-2">
285+
{BOT_SCOPES.map((scope) => (
286+
<div key={scope} className="flex items-center space-x-2">
287+
<Checkbox
288+
id={`edit-scope-${scope}`}
289+
checked={editScopes.includes(scope)}
290+
onCheckedChange={() => toggleEditScope(scope)}
291+
/>
292+
<label htmlFor={`edit-scope-${scope}`} className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
293+
{scope}
294+
</label>
295+
</div>
296+
))}
297+
</div>
298+
{missingReadScopeInEdit && (
299+
<p className="text-xs text-amber-600">
300+
Warning: without <code className="rounded bg-muted px-1">multisig:read</code>, <code className="rounded bg-muted px-1">POST /api/v1/botAuth</code> authentication will fail for this bot key.
301+
</p>
302+
)}
303+
</div>
304+
<DialogFooter>
305+
<Button variant="outline" onClick={handleCloseEdit}>
306+
Cancel
307+
</Button>
308+
<Button
309+
onClick={handleSaveScopes}
310+
disabled={updateBotKeyScopes.isPending || !editingBotKeyId || editScopes.length === 0}
311+
>
312+
{updateBotKeyScopes.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : "Save"}
313+
</Button>
314+
</DialogFooter>
315+
</DialogContent>
316+
</Dialog>
213317

214318
{isLoading ? (
215319
<div className="flex items-center gap-2 text-sm text-muted-foreground">
@@ -220,51 +324,77 @@ export default function BotManagementCard() {
220324
<p className="text-sm text-muted-foreground">No bots yet. Create one to allow API access with a bot key or wallet sign-in.</p>
221325
) : (
222326
<ul className="space-y-3 max-h-[280px] overflow-y-auto">
223-
{botKeys.map((key) => (
224-
<li
225-
key={key.id}
226-
className="flex flex-col gap-1 rounded-md border p-3 text-sm"
227-
>
228-
<div className="flex items-center justify-between">
229-
<span className="font-medium">{key.name}</span>
230-
<Button
231-
variant="ghost"
232-
size="sm"
233-
className="text-destructive hover:text-destructive"
234-
onClick={() => {
235-
if (confirm("Revoke this bot? The bot will no longer be able to authenticate.")) {
236-
revokeBotKey.mutate({ botKeyId: key.id });
237-
}
238-
}}
239-
disabled={revokeBotKey.isPending}
240-
>
241-
<Trash2 className="h-4 w-4" />
242-
</Button>
243-
</div>
244-
<RowLabelInfo
245-
label="Key ID"
246-
value={getFirstAndLast(key.id, 10, 8)}
247-
copyString={key.id}
248-
/>
249-
{key.botUser ? (
250-
<>
251-
<RowLabelInfo
252-
label="Bot address"
253-
value={getFirstAndLast(key.botUser.paymentAddress, 12, 8)}
254-
copyString={key.botUser.paymentAddress}
255-
/>
256-
{key.botUser.displayName && (
257-
<RowLabelInfo label="Display name" value={key.botUser.displayName} />
327+
{botKeys.map((key) => {
328+
const scopes = key.scopes ?? [];
329+
return (
330+
<li
331+
key={key.id}
332+
className="flex flex-col gap-1 rounded-md border p-3 text-sm"
333+
>
334+
<div className="flex items-center justify-between">
335+
<span className="font-medium">{key.name}</span>
336+
<div className="flex items-center gap-2">
337+
<Button
338+
variant="outline"
339+
size="sm"
340+
onClick={() => openEditDialog(key.id, scopes)}
341+
>
342+
Edit scopes
343+
</Button>
344+
<Button
345+
variant="ghost"
346+
size="sm"
347+
className="text-destructive hover:text-destructive"
348+
onClick={() => {
349+
if (confirm("Revoke this bot? The bot will no longer be able to authenticate.")) {
350+
revokeBotKey.mutate({ botKeyId: key.id });
351+
}
352+
}}
353+
disabled={revokeBotKey.isPending}
354+
>
355+
<Trash2 className="h-4 w-4" />
356+
</Button>
357+
</div>
358+
</div>
359+
<RowLabelInfo
360+
label="Key ID"
361+
value={getFirstAndLast(key.id, 10, 8)}
362+
copyString={key.id}
363+
/>
364+
<div className="flex items-start gap-2">
365+
<span className="min-w-20 text-sm font-medium text-muted-foreground">Scopes</span>
366+
{scopes.length > 0 ? (
367+
<div className="flex flex-wrap gap-1">
368+
{scopes.map((scope) => (
369+
<Badge key={scope} variant="secondary">
370+
{scope}
371+
</Badge>
372+
))}
373+
</div>
374+
) : (
375+
<span className="text-xs text-muted-foreground">No valid scopes configured.</span>
258376
)}
259-
</>
260-
) : (
261-
<p className="text-xs text-muted-foreground">Not registered yet. Use botAuth with this key to register the bot wallet.</p>
262-
)}
263-
<p className="text-xs text-muted-foreground">
264-
Created {new Date(key.createdAt).toLocaleDateString()}
265-
</p>
266-
</li>
267-
))}
377+
</div>
378+
{key.botUser ? (
379+
<>
380+
<RowLabelInfo
381+
label="Bot address"
382+
value={getFirstAndLast(key.botUser.paymentAddress, 12, 8)}
383+
copyString={key.botUser.paymentAddress}
384+
/>
385+
{key.botUser.displayName && (
386+
<RowLabelInfo label="Display name" value={key.botUser.displayName} />
387+
)}
388+
</>
389+
) : (
390+
<p className="text-xs text-muted-foreground">Not registered yet. Use botAuth with this key to register the bot wallet.</p>
391+
)}
392+
<p className="text-xs text-muted-foreground">
393+
Created {new Date(key.createdAt).toLocaleDateString()}
394+
</p>
395+
</li>
396+
);
397+
})}
268398
</ul>
269399
)}
270400
</div>

src/server/api/routers/bot.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { TRPCError } from "@trpc/server";
22
import { z } from "zod";
33
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
4-
import { hashBotKeySecret, generateBotKeySecret, BOT_SCOPES } from "@/lib/auth/botKey";
4+
import { hashBotKeySecret, generateBotKeySecret, BOT_SCOPES, parseScope } from "@/lib/auth/botKey";
55
import { BotWalletRole } from "@prisma/client";
66

77
function requireSessionAddress(ctx: unknown): string {
@@ -39,13 +39,38 @@ export const botRouter = createTRPCRouter({
3939

4040
listBotKeys: protectedProcedure.input(z.object({})).query(async ({ ctx }) => {
4141
const ownerAddress = requireSessionAddress(ctx);
42-
return ctx.db.botKey.findMany({
42+
const botKeys = await ctx.db.botKey.findMany({
4343
where: { ownerAddress },
4444
include: { botUser: true },
4545
orderBy: { createdAt: "desc" },
4646
});
47+
return botKeys.map((botKey) => ({
48+
...botKey,
49+
scopes: parseScope(botKey.scope),
50+
}));
4751
}),
4852

53+
updateBotKeyScopes: protectedProcedure
54+
.input(
55+
z.object({
56+
botKeyId: z.string(),
57+
scope: z.array(z.enum(BOT_SCOPES as unknown as [string, ...string[]])).min(1),
58+
}),
59+
)
60+
.mutation(async ({ ctx, input }) => {
61+
const ownerAddress = requireSessionAddress(ctx);
62+
const botKey = await ctx.db.botKey.findUnique({ where: { id: input.botKeyId } });
63+
if (!botKey) throw new TRPCError({ code: "NOT_FOUND", message: "Bot key not found" });
64+
if (botKey.ownerAddress !== ownerAddress) {
65+
throw new TRPCError({ code: "FORBIDDEN", message: "Not the owner of this bot key" });
66+
}
67+
await ctx.db.botKey.update({
68+
where: { id: input.botKeyId },
69+
data: { scope: JSON.stringify(input.scope) },
70+
});
71+
return { ok: true };
72+
}),
73+
4974
revokeBotKey: protectedProcedure
5075
.input(z.object({ botKeyId: z.string() }))
5176
.mutation(async ({ ctx, input }) => {

0 commit comments

Comments
 (0)