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