ManagedACLTab.tsx raw
1 import { useCallback, useEffect, useState } from 'react'
2 import relayAdmin from '@/services/relay-admin.service'
3 import { Button } from '@/components/ui/button'
4 import { Input } from '@/components/ui/input'
5 import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
6 import { toast } from 'sonner'
7
8 // ---------------------------------------------------------------------------
9 // Types
10 // ---------------------------------------------------------------------------
11
12 interface PubkeyEntry {
13 pubkey: string
14 reason?: string
15 }
16
17 interface EventEntry {
18 id: string
19 reason?: string
20 }
21
22 interface IPEntry {
23 ip: string
24 reason?: string
25 }
26
27 interface RelayConfig {
28 relay_name: string
29 relay_description: string
30 relay_icon: string
31 }
32
33 // ---------------------------------------------------------------------------
34 // Helpers
35 // ---------------------------------------------------------------------------
36
37 async function nip86(method: string, params: unknown[] = []): Promise<unknown> {
38 const res = await relayAdmin.nip86Request(method, params)
39 if ((res as { error?: string }).error) {
40 throw new Error((res as { error: string }).error)
41 }
42 return (res as { result?: unknown }).result
43 }
44
45 // ---------------------------------------------------------------------------
46 // Sub-components
47 // ---------------------------------------------------------------------------
48
49 function ListShell({
50 children,
51 empty
52 }: {
53 children: React.ReactNode
54 empty: string
55 }) {
56 const hasChildren = Array.isArray(children) ? children.length > 0 : !!children
57 return (
58 <div className="rounded-lg border border-border bg-card max-h-72 overflow-y-auto">
59 {hasChildren ? children : (
60 <div className="py-8 text-center text-sm text-muted-foreground italic">{empty}</div>
61 )}
62 </div>
63 )
64 }
65
66 function ListRow({ children }: { children: React.ReactNode }) {
67 return (
68 <div className="flex items-center gap-3 border-b border-border px-3 py-2 last:border-b-0 text-sm">
69 {children}
70 </div>
71 )
72 }
73
74 // ---------------------------------------------------------------------------
75 // Banned Pubkeys
76 // ---------------------------------------------------------------------------
77
78 function BannedPubkeysSection() {
79 const [items, setItems] = useState<PubkeyEntry[]>([])
80 const [pubkey, setPubkey] = useState('')
81 const [reason, setReason] = useState('')
82 const [busy, setBusy] = useState(false)
83
84 const load = useCallback(async () => {
85 try {
86 const r = await nip86('listbannedpubkeys')
87 setItems(Array.isArray(r) ? r : [])
88 } catch (e) {
89 toast.error(`Load banned pubkeys: ${e instanceof Error ? e.message : String(e)}`)
90 }
91 }, [])
92
93 useEffect(() => { load() }, [load])
94
95 const ban = async () => {
96 if (!pubkey.trim()) return
97 setBusy(true)
98 try {
99 await nip86('banpubkey', [pubkey.trim(), reason.trim()])
100 toast.success('Pubkey banned')
101 setPubkey('')
102 setReason('')
103 await load()
104 } catch (e) {
105 toast.error(`Ban failed: ${e instanceof Error ? e.message : String(e)}`)
106 } finally {
107 setBusy(false)
108 }
109 }
110
111 return (
112 <div className="space-y-3">
113 <h3 className="text-base font-semibold">Banned Pubkeys</h3>
114 <div className="flex flex-wrap gap-2">
115 <Input className="flex-1 min-w-[200px]" placeholder="Pubkey (64 hex chars)" value={pubkey} onChange={(e) => setPubkey(e.target.value)} />
116 <Input className="flex-1 min-w-[140px]" placeholder="Reason (optional)" value={reason} onChange={(e) => setReason(e.target.value)} />
117 <Button size="sm" onClick={ban} disabled={busy}>Ban Pubkey</Button>
118 </div>
119 <ListShell empty="No banned pubkeys.">
120 {items.map((it, i) => (
121 <ListRow key={i}>
122 <span className="font-mono text-xs break-all flex-1">{it.pubkey}</span>
123 {it.reason && <span className="text-muted-foreground italic text-xs">{it.reason}</span>}
124 </ListRow>
125 ))}
126 </ListShell>
127 </div>
128 )
129 }
130
131 // ---------------------------------------------------------------------------
132 // Allowed Pubkeys
133 // ---------------------------------------------------------------------------
134
135 function AllowedPubkeysSection() {
136 const [items, setItems] = useState<PubkeyEntry[]>([])
137 const [pubkey, setPubkey] = useState('')
138 const [reason, setReason] = useState('')
139 const [busy, setBusy] = useState(false)
140
141 const load = useCallback(async () => {
142 try {
143 const r = await nip86('listallowedpubkeys')
144 setItems(Array.isArray(r) ? r : [])
145 } catch (e) {
146 toast.error(`Load allowed pubkeys: ${e instanceof Error ? e.message : String(e)}`)
147 }
148 }, [])
149
150 useEffect(() => { load() }, [load])
151
152 const allow = async () => {
153 if (!pubkey.trim()) return
154 setBusy(true)
155 try {
156 await nip86('allowpubkey', [pubkey.trim(), reason.trim()])
157 toast.success('Pubkey allowed')
158 setPubkey('')
159 setReason('')
160 await load()
161 } catch (e) {
162 toast.error(`Allow failed: ${e instanceof Error ? e.message : String(e)}`)
163 } finally {
164 setBusy(false)
165 }
166 }
167
168 return (
169 <div className="space-y-3">
170 <h3 className="text-base font-semibold">Allowed Pubkeys</h3>
171 <div className="flex flex-wrap gap-2">
172 <Input className="flex-1 min-w-[200px]" placeholder="Pubkey (64 hex chars)" value={pubkey} onChange={(e) => setPubkey(e.target.value)} />
173 <Input className="flex-1 min-w-[140px]" placeholder="Reason (optional)" value={reason} onChange={(e) => setReason(e.target.value)} />
174 <Button size="sm" onClick={allow} disabled={busy}>Allow Pubkey</Button>
175 </div>
176 <ListShell empty="No allowed pubkeys.">
177 {items.map((it, i) => (
178 <ListRow key={i}>
179 <span className="font-mono text-xs break-all flex-1">{it.pubkey}</span>
180 {it.reason && <span className="text-muted-foreground italic text-xs">{it.reason}</span>}
181 </ListRow>
182 ))}
183 </ListShell>
184 </div>
185 )
186 }
187
188 // ---------------------------------------------------------------------------
189 // Banned Events
190 // ---------------------------------------------------------------------------
191
192 function BannedEventsSection() {
193 const [items, setItems] = useState<EventEntry[]>([])
194 const [eventId, setEventId] = useState('')
195 const [reason, setReason] = useState('')
196 const [busy, setBusy] = useState(false)
197
198 const load = useCallback(async () => {
199 try {
200 const r = await nip86('listbannedevents')
201 setItems(Array.isArray(r) ? r : [])
202 } catch (e) {
203 toast.error(`Load banned events: ${e instanceof Error ? e.message : String(e)}`)
204 }
205 }, [])
206
207 useEffect(() => { load() }, [load])
208
209 const ban = async () => {
210 if (!eventId.trim()) return
211 setBusy(true)
212 try {
213 await nip86('banevent', [eventId.trim(), reason.trim()])
214 toast.success('Event banned')
215 setEventId('')
216 setReason('')
217 await load()
218 } catch (e) {
219 toast.error(`Ban failed: ${e instanceof Error ? e.message : String(e)}`)
220 } finally {
221 setBusy(false)
222 }
223 }
224
225 return (
226 <div className="space-y-3">
227 <h3 className="text-base font-semibold">Banned Events</h3>
228 <div className="flex flex-wrap gap-2">
229 <Input className="flex-1 min-w-[200px]" placeholder="Event ID (64 hex chars)" value={eventId} onChange={(e) => setEventId(e.target.value)} />
230 <Input className="flex-1 min-w-[140px]" placeholder="Reason (optional)" value={reason} onChange={(e) => setReason(e.target.value)} />
231 <Button size="sm" onClick={ban} disabled={busy}>Ban Event</Button>
232 </div>
233 <ListShell empty="No banned events.">
234 {items.map((it, i) => (
235 <ListRow key={i}>
236 <span className="font-mono text-xs break-all flex-1">{it.id}</span>
237 {it.reason && <span className="text-muted-foreground italic text-xs">{it.reason}</span>}
238 </ListRow>
239 ))}
240 </ListShell>
241 </div>
242 )
243 }
244
245 // ---------------------------------------------------------------------------
246 // Allowed Events (allow only, no list endpoint)
247 // ---------------------------------------------------------------------------
248
249 function AllowedEventsSection() {
250 const [eventId, setEventId] = useState('')
251 const [reason, setReason] = useState('')
252 const [busy, setBusy] = useState(false)
253
254 const allow = async () => {
255 if (!eventId.trim()) return
256 setBusy(true)
257 try {
258 await nip86('allowevent', [eventId.trim(), reason.trim()])
259 toast.success('Event allowed')
260 setEventId('')
261 setReason('')
262 } catch (e) {
263 toast.error(`Allow failed: ${e instanceof Error ? e.message : String(e)}`)
264 } finally {
265 setBusy(false)
266 }
267 }
268
269 return (
270 <div className="space-y-3">
271 <h3 className="text-base font-semibold">Allow Event</h3>
272 <div className="flex flex-wrap gap-2">
273 <Input className="flex-1 min-w-[200px]" placeholder="Event ID (64 hex chars)" value={eventId} onChange={(e) => setEventId(e.target.value)} />
274 <Input className="flex-1 min-w-[140px]" placeholder="Reason (optional)" value={reason} onChange={(e) => setReason(e.target.value)} />
275 <Button size="sm" onClick={allow} disabled={busy}>Allow Event</Button>
276 </div>
277 </div>
278 )
279 }
280
281 // ---------------------------------------------------------------------------
282 // Allowed Kinds
283 // ---------------------------------------------------------------------------
284
285 function AllowedKindsSection() {
286 const [items, setItems] = useState<number[]>([])
287 const [kindInput, setKindInput] = useState('')
288 const [busy, setBusy] = useState(false)
289
290 const load = useCallback(async () => {
291 try {
292 const r = await nip86('listallowedkinds')
293 setItems(Array.isArray(r) ? r : [])
294 } catch (e) {
295 toast.error(`Load allowed kinds: ${e instanceof Error ? e.message : String(e)}`)
296 }
297 }, [])
298
299 useEffect(() => { load() }, [load])
300
301 const addKind = async () => {
302 const num = parseInt(kindInput, 10)
303 if (isNaN(num)) {
304 toast.error('Invalid kind number')
305 return
306 }
307 setBusy(true)
308 try {
309 await nip86('allowkind', [num])
310 toast.success(`Kind ${num} allowed`)
311 setKindInput('')
312 await load()
313 } catch (e) {
314 toast.error(`Allow kind failed: ${e instanceof Error ? e.message : String(e)}`)
315 } finally {
316 setBusy(false)
317 }
318 }
319
320 const removeKind = async (kind: number) => {
321 setBusy(true)
322 try {
323 await nip86('disallowkind', [kind])
324 toast.success(`Kind ${kind} disallowed`)
325 await load()
326 } catch (e) {
327 toast.error(`Disallow kind failed: ${e instanceof Error ? e.message : String(e)}`)
328 } finally {
329 setBusy(false)
330 }
331 }
332
333 return (
334 <div className="space-y-3">
335 <h3 className="text-base font-semibold">Allowed Event Kinds</h3>
336 <div className="flex flex-wrap gap-2">
337 <Input className="w-40" type="number" placeholder="Kind number" value={kindInput} onChange={(e) => setKindInput(e.target.value)} />
338 <Button size="sm" onClick={addKind} disabled={busy}>Allow Kind</Button>
339 </div>
340 <ListShell empty="No allowed kinds configured. All kinds are allowed by default.">
341 {items.map((kind) => (
342 <ListRow key={kind}>
343 <span className="font-mono text-xs flex-1">Kind {kind}</span>
344 <Button variant="ghost-destructive" size="sm" onClick={() => removeKind(kind)} disabled={busy}>Remove</Button>
345 </ListRow>
346 ))}
347 </ListShell>
348 </div>
349 )
350 }
351
352 // ---------------------------------------------------------------------------
353 // Blocked IPs
354 // ---------------------------------------------------------------------------
355
356 function BlockedIPsSection() {
357 const [items, setItems] = useState<IPEntry[]>([])
358 const [ip, setIp] = useState('')
359 const [reason, setReason] = useState('')
360 const [busy, setBusy] = useState(false)
361
362 const load = useCallback(async () => {
363 try {
364 const r = await nip86('listblockedips')
365 setItems(Array.isArray(r) ? r : [])
366 } catch (e) {
367 toast.error(`Load blocked IPs: ${e instanceof Error ? e.message : String(e)}`)
368 }
369 }, [])
370
371 useEffect(() => { load() }, [load])
372
373 const block = async () => {
374 if (!ip.trim()) return
375 setBusy(true)
376 try {
377 await nip86('blockip', [ip.trim(), reason.trim()])
378 toast.success('IP blocked')
379 setIp('')
380 setReason('')
381 await load()
382 } catch (e) {
383 toast.error(`Block failed: ${e instanceof Error ? e.message : String(e)}`)
384 } finally {
385 setBusy(false)
386 }
387 }
388
389 const unblock = async (addr: string) => {
390 setBusy(true)
391 try {
392 await nip86('unblockip', [addr])
393 toast.success('IP unblocked')
394 await load()
395 } catch (e) {
396 toast.error(`Unblock failed: ${e instanceof Error ? e.message : String(e)}`)
397 } finally {
398 setBusy(false)
399 }
400 }
401
402 return (
403 <div className="space-y-3">
404 <h3 className="text-base font-semibold">Blocked IPs</h3>
405 <div className="flex flex-wrap gap-2">
406 <Input className="flex-1 min-w-[160px]" placeholder="IP address" value={ip} onChange={(e) => setIp(e.target.value)} />
407 <Input className="flex-1 min-w-[140px]" placeholder="Reason (optional)" value={reason} onChange={(e) => setReason(e.target.value)} />
408 <Button size="sm" onClick={block} disabled={busy}>Block IP</Button>
409 </div>
410 <ListShell empty="No blocked IPs.">
411 {items.map((it, i) => (
412 <ListRow key={i}>
413 <span className="font-mono text-xs flex-1">{it.ip}</span>
414 {it.reason && <span className="text-muted-foreground italic text-xs">{it.reason}</span>}
415 <Button variant="ghost-destructive" size="sm" onClick={() => unblock(it.ip)} disabled={busy}>Unblock</Button>
416 </ListRow>
417 ))}
418 </ListShell>
419 </div>
420 )
421 }
422
423 // ---------------------------------------------------------------------------
424 // Events Needing Moderation
425 // ---------------------------------------------------------------------------
426
427 function ModerationSection() {
428 const [items, setItems] = useState<EventEntry[]>([])
429 const [busy, setBusy] = useState(false)
430
431 const load = useCallback(async () => {
432 setBusy(true)
433 try {
434 const r = await nip86('listeventsneedingmoderation')
435 setItems(Array.isArray(r) ? r : [])
436 } catch (e) {
437 toast.error(`Load moderation queue: ${e instanceof Error ? e.message : String(e)}`)
438 setItems([])
439 } finally {
440 setBusy(false)
441 }
442 }, [])
443
444 useEffect(() => { load() }, [load])
445
446 const approve = async (id: string) => {
447 setBusy(true)
448 try {
449 await nip86('allowevent', [id, 'Approved from moderation queue'])
450 toast.success('Event approved')
451 await load()
452 } catch (e) {
453 toast.error(`Approve failed: ${e instanceof Error ? e.message : String(e)}`)
454 } finally {
455 setBusy(false)
456 }
457 }
458
459 const reject = async (id: string) => {
460 setBusy(true)
461 try {
462 await nip86('banevent', [id, 'Rejected from moderation queue'])
463 toast.success('Event rejected')
464 await load()
465 } catch (e) {
466 toast.error(`Reject failed: ${e instanceof Error ? e.message : String(e)}`)
467 } finally {
468 setBusy(false)
469 }
470 }
471
472 return (
473 <div className="space-y-3">
474 <div className="flex items-center justify-between">
475 <h3 className="text-base font-semibold">Events Needing Moderation</h3>
476 <Button variant="outline" size="sm" onClick={load} disabled={busy}>
477 {busy ? 'Loading...' : 'Refresh'}
478 </Button>
479 </div>
480 <ListShell empty="No events need moderation.">
481 {items.map((it, i) => (
482 <ListRow key={i}>
483 <span className="font-mono text-xs break-all flex-1">{it.id}</span>
484 {it.reason && <span className="text-muted-foreground italic text-xs">{it.reason}</span>}
485 <div className="flex gap-1 shrink-0">
486 <Button size="sm" onClick={() => approve(it.id)} disabled={busy}>Allow</Button>
487 <Button variant="destructive" size="sm" onClick={() => reject(it.id)} disabled={busy}>Ban</Button>
488 </div>
489 </ListRow>
490 ))}
491 </ListShell>
492 </div>
493 )
494 }
495
496 // ---------------------------------------------------------------------------
497 // Relay Config
498 // ---------------------------------------------------------------------------
499
500 function RelayConfigSection() {
501 const [config, setConfig] = useState<RelayConfig>({
502 relay_name: '',
503 relay_description: '',
504 relay_icon: ''
505 })
506 const [busy, setBusy] = useState(false)
507
508 const fetchInfo = useCallback(async () => {
509 setBusy(true)
510 try {
511 const info = await relayAdmin.fetchRelayInfo()
512 if (info) {
513 setConfig({
514 relay_name: (info.name as string) || '',
515 relay_description: (info.description as string) || '',
516 relay_icon: (info.icon as string) || ''
517 })
518 }
519 } catch (e) {
520 toast.error(`Fetch relay info: ${e instanceof Error ? e.message : String(e)}`)
521 } finally {
522 setBusy(false)
523 }
524 }, [])
525
526 useEffect(() => { fetchInfo() }, [fetchInfo])
527
528 const save = async () => {
529 setBusy(true)
530 try {
531 const updates: Promise<unknown>[] = []
532 if (config.relay_name) updates.push(nip86('changerelayname', [config.relay_name]))
533 if (config.relay_description) updates.push(nip86('changerelaydescription', [config.relay_description]))
534 if (config.relay_icon) updates.push(nip86('changerelayicon', [config.relay_icon]))
535
536 if (updates.length === 0) {
537 toast.info('No changes to update')
538 return
539 }
540
541 await Promise.all(updates)
542 toast.success('Relay configuration updated')
543 await fetchInfo()
544 } catch (e) {
545 toast.error(`Update failed: ${e instanceof Error ? e.message : String(e)}`)
546 } finally {
547 setBusy(false)
548 }
549 }
550
551 return (
552 <div className="space-y-4">
553 <div className="flex items-center justify-between">
554 <h3 className="text-base font-semibold">Relay Configuration</h3>
555 <Button variant="outline" size="sm" onClick={fetchInfo} disabled={busy}>Refresh</Button>
556 </div>
557 <div className="space-y-3">
558 <div className="space-y-1">
559 <label className="text-sm font-medium" htmlFor="acl-relay-name">Relay Name</label>
560 <Input
561 id="acl-relay-name"
562 placeholder="Enter relay name"
563 value={config.relay_name}
564 onChange={(e) => setConfig((c) => ({ ...c, relay_name: e.target.value }))}
565 />
566 </div>
567 <div className="space-y-1">
568 <label className="text-sm font-medium" htmlFor="acl-relay-desc">Relay Description</label>
569 <textarea
570 id="acl-relay-desc"
571 className="flex w-full rounded-lg border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring min-h-[80px] resize-y"
572 placeholder="Enter relay description"
573 value={config.relay_description}
574 onChange={(e) => setConfig((c) => ({ ...c, relay_description: e.target.value }))}
575 />
576 </div>
577 <div className="space-y-1">
578 <label className="text-sm font-medium" htmlFor="acl-relay-icon">Relay Icon URL</label>
579 <Input
580 id="acl-relay-icon"
581 type="url"
582 placeholder="Enter icon URL"
583 value={config.relay_icon}
584 onChange={(e) => setConfig((c) => ({ ...c, relay_icon: e.target.value }))}
585 />
586 </div>
587 </div>
588 <Button onClick={save} disabled={busy}>
589 {busy ? 'Saving...' : 'Save Configuration'}
590 </Button>
591 </div>
592 )
593 }
594
595 // ---------------------------------------------------------------------------
596 // Main component
597 // ---------------------------------------------------------------------------
598
599 export default function ManagedACLTab() {
600 return (
601 <div className="p-4 space-y-4 w-full">
602 <div>
603 <h2 className="text-lg font-semibold">Managed ACL Configuration</h2>
604 <p className="text-sm text-muted-foreground">NIP-86 relay management</p>
605 <div className="mt-2 rounded-md bg-yellow-500/10 border border-yellow-500/30 px-3 py-2 text-sm">
606 <span className="font-semibold">Owner only</span> -- this interface is restricted to relay owners.
607 </div>
608 </div>
609
610 <Tabs defaultValue="pubkeys">
611 <TabsList className="flex flex-wrap h-auto gap-1">
612 <TabsTrigger value="pubkeys">Pubkeys</TabsTrigger>
613 <TabsTrigger value="events">Events</TabsTrigger>
614 <TabsTrigger value="ips">IPs</TabsTrigger>
615 <TabsTrigger value="kinds">Kinds</TabsTrigger>
616 <TabsTrigger value="moderation">Moderation</TabsTrigger>
617 <TabsTrigger value="relay">Relay Config</TabsTrigger>
618 </TabsList>
619
620 <TabsContent value="pubkeys" className="space-y-6">
621 <BannedPubkeysSection />
622 <AllowedPubkeysSection />
623 </TabsContent>
624
625 <TabsContent value="events" className="space-y-6">
626 <BannedEventsSection />
627 <AllowedEventsSection />
628 </TabsContent>
629
630 <TabsContent value="ips">
631 <BlockedIPsSection />
632 </TabsContent>
633
634 <TabsContent value="kinds">
635 <AllowedKindsSection />
636 </TabsContent>
637
638 <TabsContent value="moderation">
639 <ModerationSection />
640 </TabsContent>
641
642 <TabsContent value="relay">
643 <RelayConfigSection />
644 </TabsContent>
645 </Tabs>
646 </div>
647 )
648 }
649