diff --git a/src/components/RelayAdminViewer.tsx b/src/components/RelayAdminViewer.tsx new file mode 100644 index 0000000..9283d8d --- /dev/null +++ b/src/components/RelayAdminViewer.tsx @@ -0,0 +1,313 @@ +/** + * Relay Admin Viewer + * + * NIP-86 Relay Management API interface. + * Shows relay metadata (NIP-11) and admin controls for supported methods. + */ + +import { useState, useEffect, useCallback } from "react"; +import { + Copy, + CopyCheck, + RefreshCw, + Shield, + Settings, + Filter, + Globe, + AlertCircle, + Loader2, +} from "lucide-react"; +import { useRelayInfo } from "@/hooks/useRelayInfo"; +import { useCopy } from "@/hooks/useCopy"; +import { useGrimoire } from "@/core/state"; +import { Button } from "./ui/button"; +import { NIPBadge } from "./NIPBadge"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "./ui/accordion"; +import { + Nip86Client, + Nip86AuthError, + categoryHasMethods, +} from "@/lib/nip86-client"; +import accountManager from "@/services/accounts"; +import type { EventTemplate, NostrEvent } from "nostr-tools/core"; +import { MetadataSection } from "./relay-admin/MetadataSection"; +import { ModerationSection } from "./relay-admin/ModerationSection"; +import { KindFilterSection } from "./relay-admin/KindFilterSection"; +import { IpBlockingSection } from "./relay-admin/IpBlockingSection"; + +export interface RelayAdminViewerProps { + url: string; +} + +export function RelayAdminViewer({ url }: RelayAdminViewerProps) { + const info = useRelayInfo(url); + const { copy, copied } = useCopy(); + const { state } = useGrimoire(); + const hasAccount = !!state.activeAccount?.pubkey; + + // NIP-86 state + const [supportedMethods, setSupportedMethods] = useState( + null, + ); + const [methodsLoading, setMethodsLoading] = useState(false); + const [methodsError, setMethodsError] = useState(null); + + // Create signer function from active account + const getSigner = useCallback(() => { + const account = accountManager.active; + if (!account?.signer) return null; + + return async (event: EventTemplate): Promise => { + const signed = await account.signer!.signEvent(event); + return signed as NostrEvent; + }; + }, []); + + // Create NIP-86 client + const getClient = useCallback(() => { + const signer = getSigner(); + if (!signer) return null; + return new Nip86Client(url, signer); + }, [url, getSigner]); + + // Fetch supported methods + const fetchSupportedMethods = useCallback(async () => { + const client = getClient(); + if (!client) { + setMethodsError("No active account"); + return; + } + + setMethodsLoading(true); + setMethodsError(null); + + try { + const methods = await client.supportedMethods(); + setSupportedMethods(methods); + } catch (error) { + if (error instanceof Nip86AuthError) { + setMethodsError("Unauthorized - you may not have admin access"); + } else { + setMethodsError( + error instanceof Error ? error.message : "Failed to fetch methods", + ); + } + setSupportedMethods(null); + } finally { + setMethodsLoading(false); + } + }, [getClient]); + + // Fetch methods on mount if account is available + useEffect(() => { + if (hasAccount) { + fetchSupportedMethods(); + } + }, [hasAccount, fetchSupportedMethods]); + + // Check if relay supports NIP-86 + const supportsNip86 = info?.supported_nips?.includes(86); + + return ( +
+ {/* Header */} +
+
+

+ {info?.name || "Unknown Relay"} +

+
+ {url} + +
+ {info?.description && ( +

+ {info.description} +

+ )} +
+
+ + {/* NIP-86 Support Status */} + {!supportsNip86 && info && ( +
+ + + This relay does not advertise NIP-86 support. Admin features may not + be available. + +
+ )} + + {/* No Account Warning */} + {!hasAccount && ( +
+ + + Sign in with an account to access admin features. Only relay + metadata is shown. + +
+ )} + + {/* Supported NIPs */} + {info?.supported_nips && info.supported_nips.length > 0 && ( +
+

Supported NIPs

+
+ {info.supported_nips.map((num: number) => ( + + ))} +
+
+ )} + + {/* Admin Sections */} + {hasAccount && ( +
+
+

Admin Controls

+ +
+ + {/* Loading State */} + {methodsLoading && !supportedMethods && ( +
+ + Checking admin access... +
+ )} + + {/* Error State */} + {methodsError && ( +
+ + {methodsError} +
+ )} + + {/* Admin Sections Accordion */} + {supportedMethods && supportedMethods.length > 0 && ( + + {/* Metadata Section */} + {categoryHasMethods("metadata", supportedMethods) && ( + + +
+ + Relay Metadata +
+
+ + + +
+ )} + + {/* Moderation Section */} + {categoryHasMethods("moderation", supportedMethods) && ( + + +
+ + Moderation +
+
+ + + +
+ )} + + {/* Kind Filtering Section */} + {categoryHasMethods("kindFiltering", supportedMethods) && ( + + +
+ + Kind Filtering +
+
+ + + +
+ )} + + {/* IP Blocking Section */} + {categoryHasMethods("ipBlocking", supportedMethods) && ( + + +
+ + IP Blocking +
+
+ + + +
+ )} +
+ )} + + {/* No Methods Available */} + {supportedMethods && supportedMethods.length === 0 && ( +
+ No admin methods available on this relay. +
+ )} +
+ )} +
+ ); +} diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx index 84d0ffa..345c5e0 100644 --- a/src/components/WindowRenderer.tsx +++ b/src/components/WindowRenderer.tsx @@ -20,6 +20,9 @@ const DecodeViewer = lazy(() => import("./DecodeViewer")); const RelayViewer = lazy(() => import("./RelayViewer").then((m) => ({ default: m.RelayViewer })), ); +const RelayAdminViewer = lazy(() => + import("./RelayAdminViewer").then((m) => ({ default: m.RelayAdminViewer })), +); const KindRenderer = lazy(() => import("./KindRenderer")); const KindsViewer = lazy(() => import("./KindsViewer")); const NipsViewer = lazy(() => import("./NipsViewer")); @@ -172,6 +175,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) { case "relay": content = ; break; + case "relay-admin": + content = ; + break; case "debug": content = ; break; diff --git a/src/components/relay-admin/ConfirmActionDialog.tsx b/src/components/relay-admin/ConfirmActionDialog.tsx new file mode 100644 index 0000000..d7a72f9 --- /dev/null +++ b/src/components/relay-admin/ConfirmActionDialog.tsx @@ -0,0 +1,147 @@ +/** + * Confirm Action Dialog + * + * Reusable confirmation dialog for destructive actions. + */ + +import { useState } from "react"; +import { AlertTriangle, Loader2 } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +interface ConfirmActionDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description: string; + confirmText?: string; + cancelText?: string; + destructive?: boolean; + showReasonInput?: boolean; + onConfirm: (reason?: string) => Promise; +} + +export function ConfirmActionDialog({ + open, + onOpenChange, + title, + description, + confirmText = "Confirm", + cancelText = "Cancel", + destructive = false, + showReasonInput = false, + onConfirm, +}: ConfirmActionDialogProps) { + const [loading, setLoading] = useState(false); + const [reason, setReason] = useState(""); + + const handleConfirm = async () => { + setLoading(true); + try { + await onConfirm(reason || undefined); + onOpenChange(false); + setReason(""); + } catch { + // Error is handled by caller via toast + } finally { + setLoading(false); + } + }; + + const handleCancel = () => { + onOpenChange(false); + setReason(""); + }; + + return ( + + + + + {destructive && ( + + )} + {title} + + {description} + + + {showReasonInput && ( +
+ setReason(e.target.value)} + /> +
+ )} + + + + + +
+
+ ); +} + +/** + * Batch Confirm Dialog + * + * For confirming actions on multiple items. + */ +interface BatchConfirmDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + itemCount: number; + itemType: string; + actionText: string; + destructive?: boolean; + showReasonInput?: boolean; + onConfirm: (reason?: string) => Promise; +} + +export function BatchConfirmDialog({ + open, + onOpenChange, + title, + itemCount, + itemType, + actionText, + destructive = false, + showReasonInput = false, + onConfirm, +}: BatchConfirmDialogProps) { + const description = `You are about to ${actionText.toLowerCase()} ${itemCount} ${itemType}${itemCount === 1 ? "" : "s"}. This action cannot be undone.`; + + return ( + + ); +} diff --git a/src/components/relay-admin/IpBlockingSection.tsx b/src/components/relay-admin/IpBlockingSection.tsx new file mode 100644 index 0000000..f278373 --- /dev/null +++ b/src/components/relay-admin/IpBlockingSection.tsx @@ -0,0 +1,318 @@ +/** + * IP Blocking Section + * + * Manage blocked IP addresses on the relay. + */ + +import { useState, useCallback } from "react"; +import { toast } from "sonner"; +import { RefreshCw, Loader2, Trash2, Plus, Globe } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import type { Nip86Client, IpEntry } from "@/lib/nip86-client"; +import { BatchConfirmDialog, ConfirmActionDialog } from "./ConfirmActionDialog"; + +interface IpBlockingSectionProps { + url: string; + getClient: () => Nip86Client | null; + supportedMethods: string[]; +} + +export function IpBlockingSection({ + getClient, + supportedMethods, +}: IpBlockingSectionProps) { + const canListIps = supportedMethods.includes("listblockedips"); + const canBlockIp = supportedMethods.includes("blockip"); + const canUnblockIp = supportedMethods.includes("unblockip"); + + const [ips, setIps] = useState([]); + const [loading, setLoading] = useState(false); + const [loaded, setLoaded] = useState(false); + + // Add IP form + const [newIp, setNewIp] = useState(""); + const [newReason, setNewReason] = useState(""); + const [addingIp, setAddingIp] = useState(false); + + // Selection state for batch operations + const [selectedIps, setSelectedIps] = useState>(new Set()); + const [showBatchDialog, setShowBatchDialog] = useState(false); + + // Single unblock confirmation + const [ipToUnblock, setIpToUnblock] = useState(null); + + const fetchIps = useCallback(async () => { + const client = getClient(); + if (!client || !canListIps) return; + + setLoading(true); + try { + const result = await client.listBlockedIps(); + setIps(result); + setLoaded(true); + setSelectedIps(new Set()); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to fetch blocked IPs", + ); + } finally { + setLoading(false); + } + }, [getClient, canListIps]); + + const handleBlockIp = async () => { + const client = getClient(); + if (!client || !canBlockIp || !newIp.trim()) return; + + setAddingIp(true); + try { + await client.blockIp(newIp.trim(), newReason.trim() || undefined); + toast.success(`IP ${newIp} blocked`); + setNewIp(""); + setNewReason(""); + // Refresh list + await fetchIps(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to block IP", + ); + } finally { + setAddingIp(false); + } + }; + + const handleUnblockIp = async (ip: string) => { + const client = getClient(); + if (!client || !canUnblockIp) return; + + try { + await client.unblockIp(ip); + toast.success(`IP ${ip} unblocked`); + setIps((prev) => prev.filter((entry) => entry.ip !== ip)); + setSelectedIps((prev) => { + const next = new Set(prev); + next.delete(ip); + return next; + }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to unblock IP", + ); + } + }; + + const handleBatchUnblock = async () => { + const client = getClient(); + if (!client || !canUnblockIp || selectedIps.size === 0) return; + + let successCount = 0; + let failCount = 0; + + for (const ip of selectedIps) { + try { + await client.unblockIp(ip); + successCount++; + } catch { + failCount++; + } + } + + if (successCount > 0) { + toast.success(`Unblocked ${successCount} IP(s)`); + } + if (failCount > 0) { + toast.error(`Failed to unblock ${failCount} IP(s)`); + } + + // Refresh list + await fetchIps(); + }; + + const toggleIpSelection = (ip: string) => { + setSelectedIps((prev) => { + const next = new Set(prev); + if (next.has(ip)) { + next.delete(ip); + } else { + next.add(ip); + } + return next; + }); + }; + + const toggleAllSelection = () => { + if (selectedIps.size === ips.length) { + setSelectedIps(new Set()); + } else { + setSelectedIps(new Set(ips.map((entry) => entry.ip))); + } + }; + + // Validate IP address format (basic validation) + const isValidIp = (ip: string): boolean => { + // IPv4 + const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; + // IPv6 (simplified) + const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/; + return ipv4Regex.test(ip) || ipv6Regex.test(ip); + }; + + return ( +
+ {/* Controls */} +
+ {canListIps && ( + + )} + + {canUnblockIp && selectedIps.size > 0 && ( + + )} +
+ + {/* Add IP Form */} + {canBlockIp && ( +
+ +
+ setNewIp(e.target.value)} + placeholder="192.168.1.1 or 2001:db8::1" + className="flex-1" + /> + setNewReason(e.target.value)} + placeholder="Reason (optional)" + className="flex-1" + /> + +
+ {newIp && !isValidIp(newIp) && ( +

+ Invalid IP address format +

+ )} +
+ )} + + {/* IPs List */} + {loaded && ips.length > 0 && ( +
+
+ + {canUnblockIp && ips.length > 1 && ( + + )} +
+ +
+ {ips.map((entry) => ( +
+ {canUnblockIp && ( + toggleIpSelection(entry.ip)} + /> + )} + +
+
{entry.ip}
+ {entry.reason && ( +
+ {entry.reason} +
+ )} +
+ {canUnblockIp && ( + + )} +
+ ))} +
+
+ )} + + {/* Empty State */} + {loaded && ips.length === 0 && ( +
+ No blocked IP addresses. +
+ )} + + {/* Single Unblock Confirmation */} + !open && setIpToUnblock(null)} + title="Unblock IP Address" + description={`Are you sure you want to unblock ${ipToUnblock}?`} + confirmText="Unblock" + onConfirm={async () => { + if (ipToUnblock) { + await handleUnblockIp(ipToUnblock); + setIpToUnblock(null); + } + }} + /> + + {/* Batch Unblock Dialog */} + +
+ ); +} diff --git a/src/components/relay-admin/KindFilterSection.tsx b/src/components/relay-admin/KindFilterSection.tsx new file mode 100644 index 0000000..97d78fe --- /dev/null +++ b/src/components/relay-admin/KindFilterSection.tsx @@ -0,0 +1,252 @@ +/** + * Kind Filter Section + * + * Manage allowed/disallowed event kinds on the relay. + */ + +import { useState, useCallback } from "react"; +import { toast } from "sonner"; +import { RefreshCw, Loader2, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { KindSelector } from "@/components/KindSelector"; +import { KindBadge } from "@/components/KindBadge"; +import type { Nip86Client } from "@/lib/nip86-client"; +import { BatchConfirmDialog } from "./ConfirmActionDialog"; + +interface KindFilterSectionProps { + url: string; + getClient: () => Nip86Client | null; + supportedMethods: string[]; +} + +export function KindFilterSection({ + getClient, + supportedMethods, +}: KindFilterSectionProps) { + const canListKinds = supportedMethods.includes("listallowedkinds"); + const canAllowKind = supportedMethods.includes("allowkind"); + const canDisallowKind = supportedMethods.includes("disallowkind"); + + const [kinds, setKinds] = useState([]); + const [loading, setLoading] = useState(false); + const [loaded, setLoaded] = useState(false); + + // Selection state for batch operations + const [selectedKinds, setSelectedKinds] = useState>(new Set()); + const [showBatchDialog, setShowBatchDialog] = useState(false); + const [_batchLoading, setBatchLoading] = useState(false); + + const fetchKinds = useCallback(async () => { + const client = getClient(); + if (!client || !canListKinds) return; + + setLoading(true); + try { + const result = await client.listAllowedKinds(); + setKinds(result.sort((a, b) => a - b)); + setLoaded(true); + setSelectedKinds(new Set()); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to fetch kinds", + ); + } finally { + setLoading(false); + } + }, [getClient, canListKinds]); + + const handleAddKind = async (kind: number) => { + const client = getClient(); + if (!client || !canAllowKind) return; + + try { + await client.allowKind(kind); + toast.success(`Kind ${kind} allowed`); + // Refresh list + await fetchKinds(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to allow kind", + ); + } + }; + + const handleRemoveKind = async (kind: number) => { + const client = getClient(); + if (!client || !canDisallowKind) return; + + try { + await client.disallowKind(kind); + toast.success(`Kind ${kind} disallowed`); + setKinds((prev) => prev.filter((k) => k !== kind)); + setSelectedKinds((prev) => { + const next = new Set(prev); + next.delete(kind); + return next; + }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to disallow kind", + ); + } + }; + + const handleBatchRemove = async () => { + const client = getClient(); + if (!client || !canDisallowKind || selectedKinds.size === 0) return; + + setBatchLoading(true); + let successCount = 0; + let failCount = 0; + + for (const kind of selectedKinds) { + try { + await client.disallowKind(kind); + successCount++; + } catch { + failCount++; + } + } + + setBatchLoading(false); + + if (successCount > 0) { + toast.success(`Disallowed ${successCount} kind(s)`); + } + if (failCount > 0) { + toast.error(`Failed to disallow ${failCount} kind(s)`); + } + + // Refresh list + await fetchKinds(); + }; + + const toggleKindSelection = (kind: number) => { + setSelectedKinds((prev) => { + const next = new Set(prev); + if (next.has(kind)) { + next.delete(kind); + } else { + next.add(kind); + } + return next; + }); + }; + + const toggleAllSelection = () => { + if (selectedKinds.size === kinds.length) { + setSelectedKinds(new Set()); + } else { + setSelectedKinds(new Set(kinds)); + } + }; + + return ( +
+ {/* Controls */} +
+ {canListKinds && ( + + )} + + {canDisallowKind && selectedKinds.size > 0 && ( + + )} +
+ + {/* Add Kind */} + {canAllowKind && ( +
+ + +
+ )} + + {/* Kinds List */} + {loaded && kinds.length > 0 && ( +
+
+ + {canDisallowKind && kinds.length > 1 && ( + + )} +
+ +
+ {kinds.map((kind) => ( +
+ {canDisallowKind && ( + toggleKindSelection(kind)} + /> + )} + + {canDisallowKind && ( + + )} +
+ ))} +
+
+ )} + + {/* Empty State */} + {loaded && kinds.length === 0 && ( +
+ No allowed kinds configured. This relay may accept all kinds. +
+ )} + + {/* Batch Remove Dialog */} + { + await handleBatchRemove(); + }} + /> +
+ ); +} diff --git a/src/components/relay-admin/MetadataSection.tsx b/src/components/relay-admin/MetadataSection.tsx new file mode 100644 index 0000000..2bd1462 --- /dev/null +++ b/src/components/relay-admin/MetadataSection.tsx @@ -0,0 +1,189 @@ +/** + * Metadata Section + * + * Edit relay name, description, and icon. + */ + +import { useState } from "react"; +import { toast } from "sonner"; +import { Save, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import type { Nip86Client } from "@/lib/nip86-client"; + +interface MetadataSectionProps { + url: string; + getClient: () => Nip86Client | null; + supportedMethods: string[]; + currentInfo?: { + name?: string; + description?: string; + icon?: string; + } | null; +} + +export function MetadataSection({ + getClient, + supportedMethods, + currentInfo, +}: MetadataSectionProps) { + const canChangeName = supportedMethods.includes("changerelayname"); + const canChangeDescription = supportedMethods.includes( + "changerelaydescription", + ); + const canChangeIcon = supportedMethods.includes("changerelayicon"); + + const [name, setName] = useState(currentInfo?.name || ""); + const [description, setDescription] = useState( + currentInfo?.description || "", + ); + const [iconUrl, setIconUrl] = useState(currentInfo?.icon || ""); + + const [savingName, setSavingName] = useState(false); + const [savingDescription, setSavingDescription] = useState(false); + const [savingIcon, setSavingIcon] = useState(false); + + const handleSaveName = async () => { + const client = getClient(); + if (!client || !name.trim()) return; + + setSavingName(true); + try { + await client.changeRelayName(name.trim()); + toast.success("Relay name updated"); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to update name", + ); + } finally { + setSavingName(false); + } + }; + + const handleSaveDescription = async () => { + const client = getClient(); + if (!client) return; + + setSavingDescription(true); + try { + await client.changeRelayDescription(description); + toast.success("Relay description updated"); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to update description", + ); + } finally { + setSavingDescription(false); + } + }; + + const handleSaveIcon = async () => { + const client = getClient(); + if (!client || !iconUrl.trim()) return; + + setSavingIcon(true); + try { + await client.changeRelayIcon(iconUrl.trim()); + toast.success("Relay icon updated"); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to update icon", + ); + } finally { + setSavingIcon(false); + } + }; + + return ( +
+ {/* Name */} + {canChangeName && ( +
+ +
+ setName(e.target.value)} + placeholder="Relay name" + className="flex-1" + /> + +
+
+ )} + + {/* Description */} + {canChangeDescription && ( +
+ +
+