mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-16 17:48:34 +02:00
feat: Add relay admin command with NIP-86 support
Implement `relay admin <url>` command for relay management using NIP-86 Relay Management API: - Add NIP-98 HTTP Auth signing utility for API authentication - Add NIP-86 client with all standard methods - Add RelayAdminViewer component with accordion-based sections: - Metadata section (edit relay name, description, icon) - Moderation section (ban/allow pubkeys, event moderation queue) - Kind filtering section (allow/disallow event kinds) - IP blocking section (block/unblock IPs) - Add batch operations with selection checkboxes - Add confirmation dialogs for destructive actions - Show relay metadata (NIP-11) for non-authenticated users - Gate admin features behind account sign-in The viewer dynamically shows sections based on relay's supported methods, discovered via `supportedmethods` API call.
This commit is contained in:
313
src/components/RelayAdminViewer.tsx
Normal file
313
src/components/RelayAdminViewer.tsx
Normal file
@@ -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<string[] | null>(
|
||||
null,
|
||||
);
|
||||
const [methodsLoading, setMethodsLoading] = useState(false);
|
||||
const [methodsError, setMethodsError] = useState<string | null>(null);
|
||||
|
||||
// Create signer function from active account
|
||||
const getSigner = useCallback(() => {
|
||||
const account = accountManager.active;
|
||||
if (!account?.signer) return null;
|
||||
|
||||
return async (event: EventTemplate): Promise<NostrEvent> => {
|
||||
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 (
|
||||
<div className="flex flex-col gap-6 p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-2xl font-bold">
|
||||
{info?.name || "Unknown Relay"}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 text-xs font-mono text-muted-foreground">
|
||||
{url}
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
className="size-4 text-muted-foreground"
|
||||
onClick={() => copy(url)}
|
||||
>
|
||||
{copied ? (
|
||||
<CopyCheck className="size-3" />
|
||||
) : (
|
||||
<Copy className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{info?.description && (
|
||||
<p className="text-sm mt-2 text-muted-foreground">
|
||||
{info.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NIP-86 Support Status */}
|
||||
{!supportsNip86 && info && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 p-3 rounded-md">
|
||||
<AlertCircle className="size-4" />
|
||||
<span>
|
||||
This relay does not advertise NIP-86 support. Admin features may not
|
||||
be available.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Account Warning */}
|
||||
{!hasAccount && (
|
||||
<div className="flex items-center gap-2 text-sm text-yellow-500 bg-yellow-500/10 p-3 rounded-md">
|
||||
<Shield className="size-4" />
|
||||
<span>
|
||||
Sign in with an account to access admin features. Only relay
|
||||
metadata is shown.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Supported NIPs */}
|
||||
{info?.supported_nips && info.supported_nips.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 font-semibold text-sm">Supported NIPs</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{info.supported_nips.map((num: number) => (
|
||||
<NIPBadge
|
||||
key={num}
|
||||
nipNumber={String(num).padStart(2, "0")}
|
||||
showName={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admin Sections */}
|
||||
{hasAccount && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-sm">Admin Controls</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={fetchSupportedMethods}
|
||||
disabled={methodsLoading}
|
||||
>
|
||||
{methodsLoading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-4" />
|
||||
)}
|
||||
<span className="ml-2">Refresh</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{methodsLoading && !supportedMethods && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>Checking admin access...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{methodsError && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive bg-destructive/10 p-3 rounded-md">
|
||||
<AlertCircle className="size-4" />
|
||||
<span>{methodsError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admin Sections Accordion */}
|
||||
{supportedMethods && supportedMethods.length > 0 && (
|
||||
<Accordion type="multiple" className="w-full">
|
||||
{/* Metadata Section */}
|
||||
{categoryHasMethods("metadata", supportedMethods) && (
|
||||
<AccordionItem value="metadata">
|
||||
<AccordionTrigger>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="size-4" />
|
||||
<span>Relay Metadata</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<MetadataSection
|
||||
url={url}
|
||||
getClient={getClient}
|
||||
supportedMethods={supportedMethods}
|
||||
currentInfo={info}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
||||
{/* Moderation Section */}
|
||||
{categoryHasMethods("moderation", supportedMethods) && (
|
||||
<AccordionItem value="moderation">
|
||||
<AccordionTrigger>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="size-4" />
|
||||
<span>Moderation</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<ModerationSection
|
||||
url={url}
|
||||
getClient={getClient}
|
||||
supportedMethods={supportedMethods}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
||||
{/* Kind Filtering Section */}
|
||||
{categoryHasMethods("kindFiltering", supportedMethods) && (
|
||||
<AccordionItem value="kinds">
|
||||
<AccordionTrigger>
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="size-4" />
|
||||
<span>Kind Filtering</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<KindFilterSection
|
||||
url={url}
|
||||
getClient={getClient}
|
||||
supportedMethods={supportedMethods}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
||||
{/* IP Blocking Section */}
|
||||
{categoryHasMethods("ipBlocking", supportedMethods) && (
|
||||
<AccordionItem value="ips">
|
||||
<AccordionTrigger>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="size-4" />
|
||||
<span>IP Blocking</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<IpBlockingSection
|
||||
url={url}
|
||||
getClient={getClient}
|
||||
supportedMethods={supportedMethods}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
{/* No Methods Available */}
|
||||
{supportedMethods && supportedMethods.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No admin methods available on this relay.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 = <RelayViewer url={window.props.url} />;
|
||||
break;
|
||||
case "relay-admin":
|
||||
content = <RelayAdminViewer url={window.props.url} />;
|
||||
break;
|
||||
case "debug":
|
||||
content = <DebugViewer />;
|
||||
break;
|
||||
|
||||
147
src/components/relay-admin/ConfirmActionDialog.tsx
Normal file
147
src/components/relay-admin/ConfirmActionDialog.tsx
Normal file
@@ -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<void>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{destructive && (
|
||||
<AlertTriangle className="size-5 text-destructive" />
|
||||
)}
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{showReasonInput && (
|
||||
<div className="py-4">
|
||||
<Input
|
||||
placeholder="Reason (optional)"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel} disabled={loading}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button
|
||||
variant={destructive ? "destructive" : "default"}
|
||||
onClick={handleConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && <Loader2 className="size-4 mr-2 animate-spin" />}
|
||||
{confirmText}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<ConfirmActionDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={title}
|
||||
description={description}
|
||||
confirmText={`${actionText} ${itemCount} ${itemType}${itemCount === 1 ? "" : "s"}`}
|
||||
destructive={destructive}
|
||||
showReasonInput={showReasonInput}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
318
src/components/relay-admin/IpBlockingSection.tsx
Normal file
318
src/components/relay-admin/IpBlockingSection.tsx
Normal file
@@ -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<IpEntry[]>([]);
|
||||
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<Set<string>>(new Set());
|
||||
const [showBatchDialog, setShowBatchDialog] = useState(false);
|
||||
|
||||
// Single unblock confirmation
|
||||
const [ipToUnblock, setIpToUnblock] = useState<string | null>(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 (
|
||||
<div className="space-y-4">
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{canListIps && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchIps}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-4" />
|
||||
)}
|
||||
<span className="ml-2">{loaded ? "Refresh" : "Load IPs"}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canUnblockIp && selectedIps.size > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowBatchDialog(true)}
|
||||
>
|
||||
<Trash2 className="size-4 mr-2" />
|
||||
Unblock {selectedIps.size} selected
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add IP Form */}
|
||||
{canBlockIp && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Block IP Address</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newIp}
|
||||
onChange={(e) => setNewIp(e.target.value)}
|
||||
placeholder="192.168.1.1 or 2001:db8::1"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={newReason}
|
||||
onChange={(e) => setNewReason(e.target.value)}
|
||||
placeholder="Reason (optional)"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleBlockIp}
|
||||
disabled={addingIp || !newIp.trim() || !isValidIp(newIp.trim())}
|
||||
>
|
||||
{addingIp ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{newIp && !isValidIp(newIp) && (
|
||||
<p className="text-xs text-destructive">
|
||||
Invalid IP address format
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* IPs List */}
|
||||
{loaded && ips.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">
|
||||
Blocked IPs ({ips.length})
|
||||
</label>
|
||||
{canUnblockIp && ips.length > 1 && (
|
||||
<Button variant="ghost" size="sm" onClick={toggleAllSelection}>
|
||||
{selectedIps.size === ips.length
|
||||
? "Deselect All"
|
||||
: "Select All"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||
{ips.map((entry) => (
|
||||
<div
|
||||
key={entry.ip}
|
||||
className="flex items-center gap-2 p-2 rounded-md hover:bg-muted/50"
|
||||
>
|
||||
{canUnblockIp && (
|
||||
<Checkbox
|
||||
checked={selectedIps.has(entry.ip)}
|
||||
onCheckedChange={() => toggleIpSelection(entry.ip)}
|
||||
/>
|
||||
)}
|
||||
<Globe className="size-4 text-muted-foreground" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-mono text-sm">{entry.ip}</div>
|
||||
{entry.reason && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{entry.reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canUnblockIp && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6"
|
||||
onClick={() => setIpToUnblock(entry.ip)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{loaded && ips.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground py-4 text-center">
|
||||
No blocked IP addresses.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Single Unblock Confirmation */}
|
||||
<ConfirmActionDialog
|
||||
open={!!ipToUnblock}
|
||||
onOpenChange={(open) => !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 */}
|
||||
<BatchConfirmDialog
|
||||
open={showBatchDialog}
|
||||
onOpenChange={setShowBatchDialog}
|
||||
title="Unblock IP Addresses"
|
||||
itemCount={selectedIps.size}
|
||||
itemType="IP address"
|
||||
actionText="Unblock"
|
||||
onConfirm={handleBatchUnblock}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
252
src/components/relay-admin/KindFilterSection.tsx
Normal file
252
src/components/relay-admin/KindFilterSection.tsx
Normal file
@@ -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<number[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
// Selection state for batch operations
|
||||
const [selectedKinds, setSelectedKinds] = useState<Set<number>>(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 (
|
||||
<div className="space-y-4">
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{canListKinds && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchKinds}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-4" />
|
||||
)}
|
||||
<span className="ml-2">{loaded ? "Refresh" : "Load Kinds"}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canDisallowKind && selectedKinds.size > 0 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setShowBatchDialog(true)}
|
||||
>
|
||||
<Trash2 className="size-4 mr-2" />
|
||||
Remove {selectedKinds.size} selected
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Kind */}
|
||||
{canAllowKind && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Add Kind</label>
|
||||
<KindSelector onSelect={handleAddKind} exclude={kinds} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Kinds List */}
|
||||
{loaded && kinds.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">
|
||||
Allowed Kinds ({kinds.length})
|
||||
</label>
|
||||
{canDisallowKind && kinds.length > 1 && (
|
||||
<Button variant="ghost" size="sm" onClick={toggleAllSelection}>
|
||||
{selectedKinds.size === kinds.length
|
||||
? "Deselect All"
|
||||
: "Select All"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 max-h-[300px] overflow-y-auto">
|
||||
{kinds.map((kind) => (
|
||||
<div
|
||||
key={kind}
|
||||
className="flex items-center gap-2 p-2 rounded-md hover:bg-muted/50"
|
||||
>
|
||||
{canDisallowKind && (
|
||||
<Checkbox
|
||||
checked={selectedKinds.has(kind)}
|
||||
onCheckedChange={() => toggleKindSelection(kind)}
|
||||
/>
|
||||
)}
|
||||
<KindBadge kind={kind} />
|
||||
{canDisallowKind && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 ml-auto"
|
||||
onClick={() => handleRemoveKind(kind)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{loaded && kinds.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground py-4 text-center">
|
||||
No allowed kinds configured. This relay may accept all kinds.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Batch Remove Dialog */}
|
||||
<BatchConfirmDialog
|
||||
open={showBatchDialog}
|
||||
onOpenChange={setShowBatchDialog}
|
||||
title="Remove Kinds"
|
||||
itemCount={selectedKinds.size}
|
||||
itemType="kind"
|
||||
actionText="Remove"
|
||||
destructive
|
||||
onConfirm={async () => {
|
||||
await handleBatchRemove();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
189
src/components/relay-admin/MetadataSection.tsx
Normal file
189
src/components/relay-admin/MetadataSection.tsx
Normal file
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
{/* Name */}
|
||||
{canChangeName && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Name</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Relay name"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveName}
|
||||
disabled={savingName || !name.trim()}
|
||||
>
|
||||
{savingName ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{canChangeDescription && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Description</label>
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Relay description"
|
||||
className="flex-1 min-h-[80px]"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveDescription}
|
||||
disabled={savingDescription}
|
||||
>
|
||||
{savingDescription ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Icon URL */}
|
||||
{canChangeIcon && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Icon URL</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={iconUrl}
|
||||
onChange={(e) => setIconUrl(e.target.value)}
|
||||
placeholder="https://example.com/icon.png"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveIcon}
|
||||
disabled={savingIcon || !iconUrl.trim()}
|
||||
>
|
||||
{savingIcon ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{iconUrl && (
|
||||
<img
|
||||
src={iconUrl}
|
||||
alt="Relay icon preview"
|
||||
className="size-16 rounded-md object-cover"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
856
src/components/relay-admin/ModerationSection.tsx
Normal file
856
src/components/relay-admin/ModerationSection.tsx
Normal file
@@ -0,0 +1,856 @@
|
||||
/**
|
||||
* Moderation Section
|
||||
*
|
||||
* Manage pubkey bans/allows and event moderation.
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
Trash2,
|
||||
UserX,
|
||||
UserCheck,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
ShieldX,
|
||||
FileX,
|
||||
} from "lucide-react";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { UserName } from "@/components/nostr/UserName";
|
||||
import { KindBadge } from "@/components/KindBadge";
|
||||
import type {
|
||||
Nip86Client,
|
||||
PubkeyEntry,
|
||||
EventEntry,
|
||||
ModerationQueueEntry,
|
||||
} from "@/lib/nip86-client";
|
||||
import { BatchConfirmDialog } from "./ConfirmActionDialog";
|
||||
import eventStore from "@/services/event-store";
|
||||
import { eventLoader } from "@/services/loaders";
|
||||
import type { NostrEvent } from "nostr-tools/core";
|
||||
|
||||
interface ModerationSectionProps {
|
||||
url: string;
|
||||
getClient: () => Nip86Client | null;
|
||||
supportedMethods: string[];
|
||||
}
|
||||
|
||||
export function ModerationSection({
|
||||
url,
|
||||
getClient,
|
||||
supportedMethods,
|
||||
}: ModerationSectionProps) {
|
||||
// Capability checks
|
||||
const canBanPubkey = supportedMethods.includes("banpubkey");
|
||||
const canListBannedPubkeys = supportedMethods.includes("listbannedpubkeys");
|
||||
const canAllowPubkey = supportedMethods.includes("allowpubkey");
|
||||
const canListAllowedPubkeys = supportedMethods.includes("listallowedpubkeys");
|
||||
const canListModQueue = supportedMethods.includes(
|
||||
"listeventsneedingmoderation",
|
||||
);
|
||||
const canAllowEvent = supportedMethods.includes("allowevent");
|
||||
const canBanEvent = supportedMethods.includes("banevent");
|
||||
const canListBannedEvents = supportedMethods.includes("listbannedevents");
|
||||
|
||||
// State for each list
|
||||
const [bannedPubkeys, setBannedPubkeys] = useState<PubkeyEntry[]>([]);
|
||||
const [allowedPubkeys, setAllowedPubkeys] = useState<PubkeyEntry[]>([]);
|
||||
const [moderationQueue, setModerationQueue] = useState<
|
||||
ModerationQueueEntry[]
|
||||
>([]);
|
||||
const [bannedEvents, setBannedEvents] = useState<EventEntry[]>([]);
|
||||
|
||||
// Event cache for previews
|
||||
const [eventCache, setEventCache] = useState<Record<string, NostrEvent>>({});
|
||||
|
||||
// Loading states
|
||||
const [loadingBanned, setLoadingBanned] = useState(false);
|
||||
const [loadingAllowed, setLoadingAllowed] = useState(false);
|
||||
const [loadingQueue, setLoadingQueue] = useState(false);
|
||||
const [loadingBannedEvents, setLoadingBannedEvents] = useState(false);
|
||||
|
||||
// Loaded flags
|
||||
const [loadedBanned, setLoadedBanned] = useState(false);
|
||||
const [loadedAllowed, setLoadedAllowed] = useState(false);
|
||||
const [loadedQueue, setLoadedQueue] = useState(false);
|
||||
const [loadedBannedEvents, setLoadedBannedEvents] = useState(false);
|
||||
|
||||
// Add form state
|
||||
const [newPubkey, setNewPubkey] = useState("");
|
||||
const [newReason, setNewReason] = useState("");
|
||||
const [addingPubkey, setAddingPubkey] = useState(false);
|
||||
|
||||
// Selection states
|
||||
const [selectedBannedPubkeys, setSelectedBannedPubkeys] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
const [_selectedAllowedPubkeys, setSelectedAllowedPubkeys] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
const [selectedQueueEvents, setSelectedQueueEvents] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [_selectedBannedEvents, setSelectedBannedEvents] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
|
||||
// Dialog states
|
||||
const [_showBanDialog, _setShowBanDialog] = useState(false);
|
||||
const [showUnbanDialog, setShowUnbanDialog] = useState(false);
|
||||
const [showApproveDialog, setShowApproveDialog] = useState(false);
|
||||
const [showRejectDialog, setShowRejectDialog] = useState(false);
|
||||
const [_pubkeyToAction, _setPubkeyToAction] = useState<{
|
||||
pubkey: string;
|
||||
action: "ban" | "unban" | "remove";
|
||||
} | null>(null);
|
||||
const [_eventToAction, _setEventToAction] = useState<{
|
||||
id: string;
|
||||
action: "approve" | "ban" | "unban";
|
||||
} | null>(null);
|
||||
|
||||
// Parse pubkey input (supports npub, hex, nprofile)
|
||||
const parsePubkeyInput = (input: string): string | null => {
|
||||
const trimmed = input.trim();
|
||||
|
||||
// Try hex
|
||||
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
// Try bech32
|
||||
try {
|
||||
const decoded = nip19.decode(trimmed);
|
||||
if (decoded.type === "npub") {
|
||||
return decoded.data;
|
||||
}
|
||||
if (decoded.type === "nprofile") {
|
||||
return decoded.data.pubkey;
|
||||
}
|
||||
} catch {
|
||||
// Not a valid bech32
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Fetch events for preview
|
||||
const fetchEventPreviews = useCallback(
|
||||
async (eventIds: string[]) => {
|
||||
const newCache: Record<string, NostrEvent> = { ...eventCache };
|
||||
|
||||
for (const id of eventIds) {
|
||||
if (newCache[id]) continue;
|
||||
|
||||
// First check if already in store
|
||||
const existing = eventStore.getEvent(id);
|
||||
if (existing) {
|
||||
newCache[id] = existing;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to fetch from the relay we're administering
|
||||
try {
|
||||
eventLoader({ id, relays: [url] }).subscribe({
|
||||
next: (event) => {
|
||||
if (event) {
|
||||
setEventCache((prev) => ({ ...prev, [event.id]: event }));
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Ignore fetch errors
|
||||
}
|
||||
}
|
||||
|
||||
setEventCache(newCache);
|
||||
},
|
||||
[eventCache, url],
|
||||
);
|
||||
|
||||
// Fetch functions
|
||||
const fetchBannedPubkeys = useCallback(async () => {
|
||||
const client = getClient();
|
||||
if (!client || !canListBannedPubkeys) return;
|
||||
|
||||
setLoadingBanned(true);
|
||||
try {
|
||||
const result = await client.listBannedPubkeys();
|
||||
setBannedPubkeys(result);
|
||||
setLoadedBanned(true);
|
||||
setSelectedBannedPubkeys(new Set());
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to fetch banned pubkeys",
|
||||
);
|
||||
} finally {
|
||||
setLoadingBanned(false);
|
||||
}
|
||||
}, [getClient, canListBannedPubkeys]);
|
||||
|
||||
const fetchAllowedPubkeys = useCallback(async () => {
|
||||
const client = getClient();
|
||||
if (!client || !canListAllowedPubkeys) return;
|
||||
|
||||
setLoadingAllowed(true);
|
||||
try {
|
||||
const result = await client.listAllowedPubkeys();
|
||||
setAllowedPubkeys(result);
|
||||
setLoadedAllowed(true);
|
||||
setSelectedAllowedPubkeys(new Set());
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to fetch allowed pubkeys",
|
||||
);
|
||||
} finally {
|
||||
setLoadingAllowed(false);
|
||||
}
|
||||
}, [getClient, canListAllowedPubkeys]);
|
||||
|
||||
const fetchModerationQueue = useCallback(async () => {
|
||||
const client = getClient();
|
||||
if (!client || !canListModQueue) return;
|
||||
|
||||
setLoadingQueue(true);
|
||||
try {
|
||||
const result = await client.listEventsNeedingModeration();
|
||||
setModerationQueue(result);
|
||||
setLoadedQueue(true);
|
||||
setSelectedQueueEvents(new Set());
|
||||
|
||||
// Fetch event previews
|
||||
if (result.length > 0) {
|
||||
fetchEventPreviews(result.map((e) => e.id));
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to fetch moderation queue",
|
||||
);
|
||||
} finally {
|
||||
setLoadingQueue(false);
|
||||
}
|
||||
}, [getClient, canListModQueue, fetchEventPreviews]);
|
||||
|
||||
const fetchBannedEvents = useCallback(async () => {
|
||||
const client = getClient();
|
||||
if (!client || !canListBannedEvents) return;
|
||||
|
||||
setLoadingBannedEvents(true);
|
||||
try {
|
||||
const result = await client.listBannedEvents();
|
||||
setBannedEvents(result);
|
||||
setLoadedBannedEvents(true);
|
||||
setSelectedBannedEvents(new Set());
|
||||
|
||||
// Fetch event previews
|
||||
if (result.length > 0) {
|
||||
fetchEventPreviews(result.map((e) => e.id));
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to fetch banned events",
|
||||
);
|
||||
} finally {
|
||||
setLoadingBannedEvents(false);
|
||||
}
|
||||
}, [getClient, canListBannedEvents, fetchEventPreviews]);
|
||||
|
||||
// Action handlers
|
||||
const handleBanPubkey = async () => {
|
||||
const client = getClient();
|
||||
const pubkey = parsePubkeyInput(newPubkey);
|
||||
if (!client || !canBanPubkey || !pubkey) return;
|
||||
|
||||
setAddingPubkey(true);
|
||||
try {
|
||||
await client.banPubkey(pubkey, newReason.trim() || undefined);
|
||||
toast.success("Pubkey banned");
|
||||
setNewPubkey("");
|
||||
setNewReason("");
|
||||
if (loadedBanned) await fetchBannedPubkeys();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to ban pubkey",
|
||||
);
|
||||
} finally {
|
||||
setAddingPubkey(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAllowPubkey = async () => {
|
||||
const client = getClient();
|
||||
const pubkey = parsePubkeyInput(newPubkey);
|
||||
if (!client || !canAllowPubkey || !pubkey) return;
|
||||
|
||||
setAddingPubkey(true);
|
||||
try {
|
||||
await client.allowPubkey(pubkey, newReason.trim() || undefined);
|
||||
toast.success("Pubkey allowed");
|
||||
setNewPubkey("");
|
||||
setNewReason("");
|
||||
if (loadedAllowed) await fetchAllowedPubkeys();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to allow pubkey",
|
||||
);
|
||||
} finally {
|
||||
setAddingPubkey(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnbanPubkey = async (pubkey: string) => {
|
||||
const client = getClient();
|
||||
if (!client || !canAllowPubkey) return;
|
||||
|
||||
try {
|
||||
await client.allowPubkey(pubkey);
|
||||
toast.success("Pubkey unbanned");
|
||||
setBannedPubkeys((prev) => prev.filter((p) => p.pubkey !== pubkey));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to unban pubkey",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchUnban = async () => {
|
||||
const client = getClient();
|
||||
if (!client || !canAllowPubkey) return;
|
||||
|
||||
let successCount = 0;
|
||||
for (const pubkey of selectedBannedPubkeys) {
|
||||
try {
|
||||
await client.allowPubkey(pubkey);
|
||||
successCount++;
|
||||
} catch {
|
||||
// Continue with others
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
toast.success(`Unbanned ${successCount} pubkey(s)`);
|
||||
await fetchBannedPubkeys();
|
||||
}
|
||||
};
|
||||
|
||||
const handleApproveEvent = async (eventId: string) => {
|
||||
const client = getClient();
|
||||
if (!client || !canAllowEvent) return;
|
||||
|
||||
try {
|
||||
await client.allowEvent(eventId);
|
||||
toast.success("Event approved");
|
||||
setModerationQueue((prev) => prev.filter((e) => e.id !== eventId));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to approve event",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBanEventFromQueue = async (eventId: string) => {
|
||||
const client = getClient();
|
||||
if (!client || !canBanEvent) return;
|
||||
|
||||
try {
|
||||
await client.banEvent(eventId);
|
||||
toast.success("Event banned");
|
||||
setModerationQueue((prev) => prev.filter((e) => e.id !== eventId));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to ban event",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchApprove = async () => {
|
||||
const client = getClient();
|
||||
if (!client || !canAllowEvent) return;
|
||||
|
||||
let successCount = 0;
|
||||
for (const eventId of selectedQueueEvents) {
|
||||
try {
|
||||
await client.allowEvent(eventId);
|
||||
successCount++;
|
||||
} catch {
|
||||
// Continue
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
toast.success(`Approved ${successCount} event(s)`);
|
||||
await fetchModerationQueue();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchReject = async () => {
|
||||
const client = getClient();
|
||||
if (!client || !canBanEvent) return;
|
||||
|
||||
let successCount = 0;
|
||||
for (const eventId of selectedQueueEvents) {
|
||||
try {
|
||||
await client.banEvent(eventId);
|
||||
successCount++;
|
||||
} catch {
|
||||
// Continue
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
toast.success(`Rejected ${successCount} event(s)`);
|
||||
await fetchModerationQueue();
|
||||
}
|
||||
};
|
||||
|
||||
// Render event preview
|
||||
const renderEventPreview = (eventId: string) => {
|
||||
const event = eventCache[eventId];
|
||||
if (!event) {
|
||||
return (
|
||||
<span className="font-mono text-xs text-muted-foreground truncate">
|
||||
{eventId.slice(0, 8)}...{eventId.slice(-8)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<KindBadge kind={event.kind} variant="compact" className="shrink-0" />
|
||||
<UserName pubkey={event.pubkey} className="shrink-0 text-xs" />
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{event.content.slice(0, 50)}
|
||||
{event.content.length > 50 ? "..." : ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Determine which tabs to show
|
||||
const showPubkeyTab =
|
||||
canBanPubkey ||
|
||||
canListBannedPubkeys ||
|
||||
canAllowPubkey ||
|
||||
canListAllowedPubkeys;
|
||||
const showEventsTab =
|
||||
canListModQueue || canAllowEvent || canBanEvent || canListBannedEvents;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Tabs defaultValue={showPubkeyTab ? "pubkeys" : "events"}>
|
||||
<TabsList>
|
||||
{showPubkeyTab && <TabsTrigger value="pubkeys">Pubkeys</TabsTrigger>}
|
||||
{showEventsTab && <TabsTrigger value="events">Events</TabsTrigger>}
|
||||
</TabsList>
|
||||
|
||||
{/* Pubkeys Tab */}
|
||||
{showPubkeyTab && (
|
||||
<TabsContent value="pubkeys" className="space-y-4">
|
||||
{/* Add Pubkey Form */}
|
||||
{(canBanPubkey || canAllowPubkey) && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
Add Pubkey to Ban/Allow List
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newPubkey}
|
||||
onChange={(e) => setNewPubkey(e.target.value)}
|
||||
placeholder="npub or hex pubkey"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={newReason}
|
||||
onChange={(e) => setNewReason(e.target.value)}
|
||||
placeholder="Reason (optional)"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{canBanPubkey && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleBanPubkey}
|
||||
disabled={addingPubkey || !parsePubkeyInput(newPubkey)}
|
||||
>
|
||||
{addingPubkey ? (
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<UserX className="size-4 mr-2" />
|
||||
)}
|
||||
Ban
|
||||
</Button>
|
||||
)}
|
||||
{canAllowPubkey && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleAllowPubkey}
|
||||
disabled={addingPubkey || !parsePubkeyInput(newPubkey)}
|
||||
>
|
||||
{addingPubkey ? (
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<UserCheck className="size-4 mr-2" />
|
||||
)}
|
||||
Allow
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Banned Pubkeys */}
|
||||
{canListBannedPubkeys && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium flex items-center gap-2">
|
||||
<UserX className="size-4 text-destructive" />
|
||||
Banned Pubkeys
|
||||
{loadedBanned && ` (${bannedPubkeys.length})`}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{selectedBannedPubkeys.size > 0 && canAllowPubkey && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowUnbanDialog(true)}
|
||||
>
|
||||
Unban {selectedBannedPubkeys.size} selected
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchBannedPubkeys}
|
||||
disabled={loadingBanned}
|
||||
>
|
||||
{loadingBanned ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadedBanned && bannedPubkeys.length > 0 && (
|
||||
<div className="space-y-1 max-h-[200px] overflow-y-auto">
|
||||
{bannedPubkeys.map((entry) => (
|
||||
<div
|
||||
key={entry.pubkey}
|
||||
className="flex items-center gap-2 p-2 rounded hover:bg-muted/50"
|
||||
>
|
||||
{canAllowPubkey && (
|
||||
<Checkbox
|
||||
checked={selectedBannedPubkeys.has(entry.pubkey)}
|
||||
onCheckedChange={() => {
|
||||
setSelectedBannedPubkeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(entry.pubkey)) {
|
||||
next.delete(entry.pubkey);
|
||||
} else {
|
||||
next.add(entry.pubkey);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<UserName
|
||||
pubkey={entry.pubkey}
|
||||
className="flex-1 truncate"
|
||||
/>
|
||||
{entry.reason && (
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[150px]">
|
||||
{entry.reason}
|
||||
</span>
|
||||
)}
|
||||
{canAllowPubkey && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6"
|
||||
onClick={() => handleUnbanPubkey(entry.pubkey)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadedBanned && bannedPubkeys.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground py-2">
|
||||
No banned pubkeys
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Allowed Pubkeys */}
|
||||
{canListAllowedPubkeys && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium flex items-center gap-2">
|
||||
<UserCheck className="size-4 text-green-500" />
|
||||
Allowed Pubkeys
|
||||
{loadedAllowed && ` (${allowedPubkeys.length})`}
|
||||
</label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchAllowedPubkeys}
|
||||
disabled={loadingAllowed}
|
||||
>
|
||||
{loadingAllowed ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loadedAllowed && allowedPubkeys.length > 0 && (
|
||||
<div className="space-y-1 max-h-[200px] overflow-y-auto">
|
||||
{allowedPubkeys.map((entry) => (
|
||||
<div
|
||||
key={entry.pubkey}
|
||||
className="flex items-center gap-2 p-2 rounded hover:bg-muted/50"
|
||||
>
|
||||
<UserName
|
||||
pubkey={entry.pubkey}
|
||||
className="flex-1 truncate"
|
||||
/>
|
||||
{entry.reason && (
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[150px]">
|
||||
{entry.reason}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadedAllowed && allowedPubkeys.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground py-2">
|
||||
No allowed pubkeys (relay may be public)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Events Tab */}
|
||||
{showEventsTab && (
|
||||
<TabsContent value="events" className="space-y-4">
|
||||
{/* Moderation Queue */}
|
||||
{canListModQueue && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium flex items-center gap-2">
|
||||
<ShieldAlert className="size-4 text-yellow-500" />
|
||||
Moderation Queue
|
||||
{loadedQueue && ` (${moderationQueue.length})`}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{selectedQueueEvents.size > 0 && (
|
||||
<>
|
||||
{canAllowEvent && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowApproveDialog(true)}
|
||||
>
|
||||
<ShieldCheck className="size-4 mr-1" />
|
||||
Approve {selectedQueueEvents.size}
|
||||
</Button>
|
||||
)}
|
||||
{canBanEvent && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setShowRejectDialog(true)}
|
||||
>
|
||||
<ShieldX className="size-4 mr-1" />
|
||||
Reject {selectedQueueEvents.size}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchModerationQueue}
|
||||
disabled={loadingQueue}
|
||||
>
|
||||
{loadingQueue ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadedQueue && moderationQueue.length > 0 && (
|
||||
<div className="space-y-1 max-h-[250px] overflow-y-auto">
|
||||
{moderationQueue.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="flex items-center gap-2 p-2 rounded hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedQueueEvents.has(entry.id)}
|
||||
onCheckedChange={() => {
|
||||
setSelectedQueueEvents((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(entry.id)) {
|
||||
next.delete(entry.id);
|
||||
} else {
|
||||
next.add(entry.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
{renderEventPreview(entry.id)}
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{canAllowEvent && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6"
|
||||
onClick={() => handleApproveEvent(entry.id)}
|
||||
>
|
||||
<ShieldCheck className="size-3 text-green-500" />
|
||||
</Button>
|
||||
)}
|
||||
{canBanEvent && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6"
|
||||
onClick={() => handleBanEventFromQueue(entry.id)}
|
||||
>
|
||||
<ShieldX className="size-3 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadedQueue && moderationQueue.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground py-2">
|
||||
No events pending moderation
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Banned Events */}
|
||||
{canListBannedEvents && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium flex items-center gap-2">
|
||||
<FileX className="size-4 text-destructive" />
|
||||
Banned Events
|
||||
{loadedBannedEvents && ` (${bannedEvents.length})`}
|
||||
</label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchBannedEvents}
|
||||
disabled={loadingBannedEvents}
|
||||
>
|
||||
{loadingBannedEvents ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loadedBannedEvents && bannedEvents.length > 0 && (
|
||||
<div className="space-y-1 max-h-[200px] overflow-y-auto">
|
||||
{bannedEvents.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="flex items-center gap-2 p-2 rounded hover:bg-muted/50"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
{renderEventPreview(entry.id)}
|
||||
</div>
|
||||
{entry.reason && (
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[100px]">
|
||||
{entry.reason}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadedBannedEvents && bannedEvents.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground py-2">
|
||||
No banned events
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
{/* Batch Dialogs */}
|
||||
<BatchConfirmDialog
|
||||
open={showUnbanDialog}
|
||||
onOpenChange={setShowUnbanDialog}
|
||||
title="Unban Pubkeys"
|
||||
itemCount={selectedBannedPubkeys.size}
|
||||
itemType="pubkey"
|
||||
actionText="Unban"
|
||||
onConfirm={handleBatchUnban}
|
||||
/>
|
||||
|
||||
<BatchConfirmDialog
|
||||
open={showApproveDialog}
|
||||
onOpenChange={setShowApproveDialog}
|
||||
title="Approve Events"
|
||||
itemCount={selectedQueueEvents.size}
|
||||
itemType="event"
|
||||
actionText="Approve"
|
||||
onConfirm={handleBatchApprove}
|
||||
/>
|
||||
|
||||
<BatchConfirmDialog
|
||||
open={showRejectDialog}
|
||||
onOpenChange={setShowRejectDialog}
|
||||
title="Reject Events"
|
||||
itemCount={selectedQueueEvents.size}
|
||||
itemType="event"
|
||||
actionText="Reject"
|
||||
destructive
|
||||
onConfirm={handleBatchReject}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
310
src/lib/nip86-client.ts
Normal file
310
src/lib/nip86-client.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* NIP-86 Relay Management API Client
|
||||
*
|
||||
* JSON-RPC-like API for relay administration.
|
||||
* Uses NIP-98 HTTP Auth for authorization.
|
||||
*
|
||||
* @see https://github.com/nostr-protocol/nips/blob/master/86.md
|
||||
*/
|
||||
|
||||
import { createAuthHeader, type SignerFn } from "./nip98";
|
||||
|
||||
/** NIP-86 Content-Type header */
|
||||
const CONTENT_TYPE = "application/nostr+json+rpc";
|
||||
|
||||
/** Banned/allowed pubkey entry */
|
||||
export interface PubkeyEntry {
|
||||
pubkey: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Banned event entry */
|
||||
export interface EventEntry {
|
||||
id: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Event needing moderation */
|
||||
export interface ModerationQueueEntry {
|
||||
id: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Blocked IP entry */
|
||||
export interface IpEntry {
|
||||
ip: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** NIP-86 API error */
|
||||
export class Nip86Error extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "Nip86Error";
|
||||
}
|
||||
}
|
||||
|
||||
/** NIP-86 authorization error (401) */
|
||||
export class Nip86AuthError extends Nip86Error {
|
||||
constructor(message: string = "Unauthorized") {
|
||||
super(message);
|
||||
this.name = "Nip86AuthError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-86 Relay Management API Client
|
||||
*
|
||||
* Provides methods to manage relay settings, moderation, and access control.
|
||||
*/
|
||||
export class Nip86Client {
|
||||
private httpUrl: string;
|
||||
|
||||
/**
|
||||
* Create a NIP-86 client
|
||||
*
|
||||
* @param relayUrl - WebSocket relay URL (wss://...)
|
||||
* @param sign - Signer function for NIP-98 auth
|
||||
*/
|
||||
constructor(
|
||||
public readonly relayUrl: string,
|
||||
private sign: SignerFn,
|
||||
) {
|
||||
// Convert ws(s):// to http(s)://
|
||||
this.httpUrl = relayUrl.replace(/^ws/, "http");
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a NIP-86 API call
|
||||
*
|
||||
* @param method - Method name
|
||||
* @param params - Method parameters
|
||||
* @returns Result from relay
|
||||
*/
|
||||
async call<T>(method: string, params: unknown[] = []): Promise<T> {
|
||||
const body = JSON.stringify({ method, params });
|
||||
|
||||
const authHeader = await createAuthHeader(
|
||||
{
|
||||
url: this.httpUrl,
|
||||
method: "POST",
|
||||
payload: body,
|
||||
},
|
||||
this.sign,
|
||||
);
|
||||
|
||||
const response = await fetch(this.httpUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": CONTENT_TYPE,
|
||||
Authorization: authHeader,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new Nip86AuthError();
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Nip86Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.error) {
|
||||
throw new Nip86Error(result.error);
|
||||
}
|
||||
|
||||
return result.result;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Discovery
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get list of supported methods
|
||||
*/
|
||||
async supportedMethods(): Promise<string[]> {
|
||||
return this.call<string[]>("supportedmethods");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Pubkey Management
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ban a pubkey from the relay
|
||||
*/
|
||||
async banPubkey(pubkey: string, reason?: string): Promise<boolean> {
|
||||
const params: unknown[] = [pubkey];
|
||||
if (reason) params.push(reason);
|
||||
return this.call<boolean>("banpubkey", params);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all banned pubkeys
|
||||
*/
|
||||
async listBannedPubkeys(): Promise<PubkeyEntry[]> {
|
||||
return this.call<PubkeyEntry[]>("listbannedpubkeys");
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow a pubkey (for private relays or unban)
|
||||
*/
|
||||
async allowPubkey(pubkey: string, reason?: string): Promise<boolean> {
|
||||
const params: unknown[] = [pubkey];
|
||||
if (reason) params.push(reason);
|
||||
return this.call<boolean>("allowpubkey", params);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all allowed pubkeys
|
||||
*/
|
||||
async listAllowedPubkeys(): Promise<PubkeyEntry[]> {
|
||||
return this.call<PubkeyEntry[]>("listallowedpubkeys");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Event Moderation
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* List events waiting for moderation approval
|
||||
*/
|
||||
async listEventsNeedingModeration(): Promise<ModerationQueueEntry[]> {
|
||||
return this.call<ModerationQueueEntry[]>("listeventsneedingmoderation");
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve an event
|
||||
*/
|
||||
async allowEvent(eventId: string, reason?: string): Promise<boolean> {
|
||||
const params: unknown[] = [eventId];
|
||||
if (reason) params.push(reason);
|
||||
return this.call<boolean>("allowevent", params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ban an event
|
||||
*/
|
||||
async banEvent(eventId: string, reason?: string): Promise<boolean> {
|
||||
const params: unknown[] = [eventId];
|
||||
if (reason) params.push(reason);
|
||||
return this.call<boolean>("banevent", params);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all banned events
|
||||
*/
|
||||
async listBannedEvents(): Promise<EventEntry[]> {
|
||||
return this.call<EventEntry[]>("listbannedevents");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Relay Metadata
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Change relay name
|
||||
*/
|
||||
async changeRelayName(name: string): Promise<boolean> {
|
||||
return this.call<boolean>("changerelayname", [name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Change relay description
|
||||
*/
|
||||
async changeRelayDescription(description: string): Promise<boolean> {
|
||||
return this.call<boolean>("changerelaydescription", [description]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Change relay icon URL
|
||||
*/
|
||||
async changeRelayIcon(iconUrl: string): Promise<boolean> {
|
||||
return this.call<boolean>("changerelayicon", [iconUrl]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Kind Filtering
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Allow a kind on the relay
|
||||
*/
|
||||
async allowKind(kind: number): Promise<boolean> {
|
||||
return this.call<boolean>("allowkind", [kind]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disallow a kind on the relay
|
||||
*/
|
||||
async disallowKind(kind: number): Promise<boolean> {
|
||||
return this.call<boolean>("disallowkind", [kind]);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all allowed kinds
|
||||
*/
|
||||
async listAllowedKinds(): Promise<number[]> {
|
||||
return this.call<number[]>("listallowedkinds");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// IP Blocking
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Block an IP address
|
||||
*/
|
||||
async blockIp(ip: string, reason?: string): Promise<boolean> {
|
||||
const params: unknown[] = [ip];
|
||||
if (reason) params.push(reason);
|
||||
return this.call<boolean>("blockip", params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unblock an IP address
|
||||
*/
|
||||
async unblockIp(ip: string): Promise<boolean> {
|
||||
return this.call<boolean>("unblockip", [ip]);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all blocked IPs
|
||||
*/
|
||||
async listBlockedIps(): Promise<IpEntry[]> {
|
||||
return this.call<IpEntry[]>("listblockedips");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Method categories for UI organization
|
||||
*/
|
||||
export const METHOD_CATEGORIES = {
|
||||
metadata: ["changerelayname", "changerelaydescription", "changerelayicon"],
|
||||
moderation: [
|
||||
"banpubkey",
|
||||
"listbannedpubkeys",
|
||||
"allowpubkey",
|
||||
"listallowedpubkeys",
|
||||
"listeventsneedingmoderation",
|
||||
"allowevent",
|
||||
"banevent",
|
||||
"listbannedevents",
|
||||
],
|
||||
kindFiltering: ["allowkind", "disallowkind", "listallowedkinds"],
|
||||
ipBlocking: ["blockip", "unblockip", "listblockedips"],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Check if a category has any supported methods
|
||||
*/
|
||||
export function categoryHasMethods(
|
||||
category: keyof typeof METHOD_CATEGORIES,
|
||||
supportedMethods: string[],
|
||||
): boolean {
|
||||
return METHOD_CATEGORIES[category].some((m) => supportedMethods.includes(m));
|
||||
}
|
||||
94
src/lib/nip98.ts
Normal file
94
src/lib/nip98.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* NIP-98 HTTP Auth
|
||||
*
|
||||
* Creates signed authorization events for HTTP requests.
|
||||
* Used by NIP-86 Relay Management API.
|
||||
*
|
||||
* @see https://github.com/nostr-protocol/nips/blob/master/98.md
|
||||
*/
|
||||
|
||||
import type { EventTemplate, NostrEvent } from "nostr-tools/core";
|
||||
|
||||
/** HTTP Auth event kind (NIP-98) */
|
||||
export const HTTP_AUTH_KIND = 27235;
|
||||
|
||||
/** Options for creating a NIP-98 auth event */
|
||||
export interface Nip98Options {
|
||||
/** The URL being accessed */
|
||||
url: string;
|
||||
/** HTTP method (GET, POST, PUT, DELETE) */
|
||||
method: "GET" | "POST" | "PUT" | "DELETE";
|
||||
/** Request body (required for NIP-86) - will be hashed */
|
||||
payload?: string;
|
||||
}
|
||||
|
||||
/** Signer function type */
|
||||
export type SignerFn = (event: EventTemplate) => Promise<NostrEvent>;
|
||||
|
||||
/**
|
||||
* Compute SHA-256 hash of a string and return as hex
|
||||
*/
|
||||
async function sha256Hex(data: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const buffer = encoder.encode(data);
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", buffer);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a NIP-98 HTTP Auth event
|
||||
*
|
||||
* @param options - URL, method, and optional payload
|
||||
* @param sign - Signer function
|
||||
* @returns Signed auth event
|
||||
*/
|
||||
export async function createNip98Event(
|
||||
options: Nip98Options,
|
||||
sign: SignerFn,
|
||||
): Promise<NostrEvent> {
|
||||
const tags: string[][] = [
|
||||
["u", options.url],
|
||||
["method", options.method],
|
||||
];
|
||||
|
||||
// Add payload hash if provided (required for NIP-86)
|
||||
if (options.payload) {
|
||||
const hash = await sha256Hex(options.payload);
|
||||
tags.push(["payload", hash]);
|
||||
}
|
||||
|
||||
const template: EventTemplate = {
|
||||
kind: HTTP_AUTH_KIND,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
content: "",
|
||||
};
|
||||
|
||||
return sign(template);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a NIP-98 event as an Authorization header value
|
||||
*
|
||||
* @param event - Signed NIP-98 event
|
||||
* @returns Authorization header value (Nostr base64(event))
|
||||
*/
|
||||
export function formatAuthHeader(event: NostrEvent): string {
|
||||
return `Nostr ${btoa(JSON.stringify(event))}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Authorization header for an HTTP request
|
||||
*
|
||||
* @param options - URL, method, and optional payload
|
||||
* @param sign - Signer function
|
||||
* @returns Authorization header value
|
||||
*/
|
||||
export async function createAuthHeader(
|
||||
options: Nip98Options,
|
||||
sign: SignerFn,
|
||||
): Promise<string> {
|
||||
const event = await createNip98Event(options, sign);
|
||||
return formatAuthHeader(event);
|
||||
}
|
||||
49
src/lib/relay-admin-parser.ts
Normal file
49
src/lib/relay-admin-parser.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Parser for RELAY ADMIN command
|
||||
*
|
||||
* Usage: relay admin <url>
|
||||
*
|
||||
* Examples:
|
||||
* relay admin wss://relay.damus.io
|
||||
* relay admin relay.primal.net
|
||||
*/
|
||||
|
||||
import { normalizeRelayURL } from "./relay-url";
|
||||
|
||||
export interface ParsedRelayAdminCommand {
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse RELAY ADMIN command arguments
|
||||
*
|
||||
* @param args - Command arguments (URL only)
|
||||
* @returns Parsed command with normalized relay URL
|
||||
* @throws Error if URL is missing or invalid
|
||||
*/
|
||||
export function parseRelayAdminCommand(
|
||||
args: string[],
|
||||
): ParsedRelayAdminCommand {
|
||||
if (args.length < 1) {
|
||||
throw new Error("Usage: relay admin <url>");
|
||||
}
|
||||
|
||||
let url = args[0];
|
||||
|
||||
// Auto-add wss:// protocol if not present
|
||||
if (!url.startsWith("ws://") && !url.startsWith("wss://")) {
|
||||
url = `wss://${url}`;
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
if (!parsedUrl.protocol.startsWith("ws")) {
|
||||
throw new Error("Relay must be a WebSocket URL (ws:// or wss://)");
|
||||
}
|
||||
} catch {
|
||||
throw new Error(`Invalid relay URL: ${url}`);
|
||||
}
|
||||
|
||||
return { url: normalizeRelayURL(url) };
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export type AppId =
|
||||
| "encode"
|
||||
| "decode"
|
||||
| "relay"
|
||||
| "relay-admin"
|
||||
| "debug"
|
||||
| "conn"
|
||||
| "chat"
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { AppId } from "./app";
|
||||
import { parseOpenCommand } from "@/lib/open-parser";
|
||||
import { parseProfileCommand } from "@/lib/profile-parser";
|
||||
import { parseRelayCommand } from "@/lib/relay-parser";
|
||||
import { parseRelayAdminCommand } from "@/lib/relay-admin-parser";
|
||||
import { resolveNip05Batch } from "@/lib/nip05";
|
||||
import { parseChatCommand } from "@/lib/chat-parser";
|
||||
import { parseBlossomCommand } from "@/lib/blossom-parser";
|
||||
@@ -484,7 +485,7 @@ export const manPages: Record<string, ManPageEntry> = {
|
||||
"relay wss://relay.damus.io View relay information",
|
||||
"relay nos.lol View relay capabilities",
|
||||
],
|
||||
seeAlso: ["req", "profile"],
|
||||
seeAlso: ["req", "profile", "relay admin"],
|
||||
appId: "relay",
|
||||
category: "Nostr",
|
||||
argParser: (args: string[]) => {
|
||||
@@ -492,6 +493,32 @@ export const manPages: Record<string, ManPageEntry> = {
|
||||
return parsed;
|
||||
},
|
||||
},
|
||||
"relay admin": {
|
||||
name: "relay admin",
|
||||
section: "1",
|
||||
synopsis: "relay admin <url>",
|
||||
description:
|
||||
"Manage a Nostr relay using the NIP-86 Relay Management API. Allows administrators to configure relay metadata, moderate pubkeys and events, manage kind filtering, and block IPs. Requires an active account for admin features (NIP-98 auth). Non-authenticated users can still view relay metadata.",
|
||||
options: [
|
||||
{
|
||||
flag: "<url>",
|
||||
description:
|
||||
"Relay WebSocket URL (wss:// or ws://) or domain (auto-adds wss://)",
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
"relay admin wss://relay.damus.io Open relay admin panel",
|
||||
"relay admin nos.lol Manage relay settings",
|
||||
"relay admin my-relay.com Administer your own relay",
|
||||
],
|
||||
seeAlso: ["relay", "conn"],
|
||||
appId: "relay-admin",
|
||||
category: "Nostr",
|
||||
argParser: (args: string[]) => {
|
||||
const parsed = parseRelayAdminCommand(args);
|
||||
return parsed;
|
||||
},
|
||||
},
|
||||
conn: {
|
||||
name: "conn",
|
||||
section: "1",
|
||||
|
||||
Reference in New Issue
Block a user