11"use client" ;
22
33import { useState } from "react" ;
4- import { Bot , Plus , Trash2 , Copy , Loader2 } from "lucide-react" ;
4+ import { Bot , Plus , Trash2 , Copy , Loader2 , Pencil } from "lucide-react" ;
55import CardUI from "@/components/ui/card-content" ;
66import RowLabelInfo from "@/components/ui/row-label-info" ;
77import { Button } from "@/components/ui/button" ;
@@ -21,6 +21,9 @@ import { Input } from "@/components/ui/input";
2121import { Label } from "@/components/ui/label" ;
2222import { Checkbox } from "@/components/ui/checkbox" ;
2323import { 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
2528export 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 >
0 commit comments