Add NIP-86 relay management console

This commit is contained in:
2026-06-11 09:58:06 +02:00
parent 4acdb31b8a
commit 66f86db782
5 changed files with 470 additions and 2 deletions

View File

@@ -6,6 +6,7 @@ import { Explore } from "./pages/Explore";
import { Dashboard } from "./pages/Dashboard";
import { DashboardEvents } from "./pages/DashboardEvents";
import { DashboardExport } from "./pages/DashboardExport";
import { RelayManagement } from "./pages/RelayManagement";
import { NIP19Page } from "./pages/NIP19Page";
import { Terms } from "./pages/Terms";
import { Privacy } from "./pages/Privacy";
@@ -21,6 +22,7 @@ export function AppRouter() {
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/dashboard/events" element={<DashboardEvents />} />
<Route path="/dashboard/export" element={<DashboardExport />} />
<Route path="/dashboard/relays" element={<RelayManagement />} />
<Route path="/terms" element={<Terms />} />
<Route path="/privacy" element={<Privacy />} />
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */}
@@ -31,4 +33,4 @@ export function AppRouter() {
</BrowserRouter>
);
}
export default AppRouter;
export default AppRouter;

View File

@@ -1,4 +1,4 @@
import { Home, LayoutDashboard, FileText, Download } from 'lucide-react';
import { Home, LayoutDashboard, FileText, Download, RadioTower } from 'lucide-react';
import { Link, useLocation } from 'react-router-dom';
import {
Sidebar,
@@ -34,6 +34,11 @@ const navigationItems = [
url: '/dashboard/export',
icon: Download,
},
{
title: 'Relay Management',
url: '/dashboard/relays',
icon: RadioTower,
},
];
export function AppSidebar() {

29
src/lib/nip86.test.ts Normal file
View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { isHex64, normalizeRelayManagementUrl } from './nip86';
describe('normalizeRelayManagementUrl', () => {
it('adds secure relay protocols to bare hosts', () => {
expect(normalizeRelayManagementUrl('relay.example.com')).toEqual({
websocket: 'wss://relay.example.com/',
http: 'https://relay.example.com/',
});
});
it('preserves paths while converting websocket URLs for HTTP management', () => {
expect(normalizeRelayManagementUrl('ws://localhost:7777/nostr')).toEqual({
websocket: 'ws://localhost:7777/nostr',
http: 'http://localhost:7777/nostr',
});
});
it('rejects unsupported protocols', () => {
expect(() => normalizeRelayManagementUrl('ftp://relay.example.com')).toThrow();
});
});
describe('isHex64', () => {
it('accepts Nostr pubkeys and event ids in hex form', () => {
expect(isHex64('a'.repeat(64))).toBe(true);
expect(isHex64('npub1nothex')).toBe(false);
});
});

123
src/lib/nip86.ts Normal file
View File

@@ -0,0 +1,123 @@
import type { NUser } from '@nostrify/react/login';
export const NIP86_METHODS = [
'supportedmethods',
'banpubkey',
'unbanpubkey',
'listbannedpubkeys',
'allowpubkey',
'unallowpubkey',
'listallowedpubkeys',
'listeventsneedingmoderation',
'allowevent',
'banevent',
'listbannedevents',
'changerelayname',
'changerelaydescription',
'changerelayicon',
'allowkind',
'disallowkind',
'listallowedkinds',
'blockip',
'unblockip',
'listblockedips',
] as const;
export type Nip86Method = (typeof NIP86_METHODS)[number];
interface Nip86Response<T> {
result?: T;
error?: string;
}
export interface RelayManagementUrls {
websocket: string;
http: string;
}
export function normalizeRelayManagementUrl(value: string): RelayManagementUrls {
const input = value.trim();
if (!input) throw new Error('Enter a relay URL.');
const withProtocol = /^[a-z]+:\/\//i.test(input) ? input : `wss://${input}`;
const url = new URL(withProtocol);
if (!['ws:', 'wss:', 'http:', 'https:'].includes(url.protocol)) {
throw new Error('Relay URLs must use ws, wss, http, or https.');
}
url.hash = '';
const websocket = new URL(url);
websocket.protocol = url.protocol === 'http:' ? 'ws:' : url.protocol === 'https:' ? 'wss:' : url.protocol;
const http = new URL(url);
http.protocol = url.protocol === 'ws:' ? 'http:' : url.protocol === 'wss:' ? 'https:' : url.protocol;
return { websocket: websocket.toString(), http: http.toString() };
}
export function isHex64(value: string): boolean {
return /^[0-9a-f]{64}$/i.test(value.trim());
}
async function sha256Hex(value: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
}
export async function callNip86<T>(
user: NUser,
relayUrl: string,
method: Nip86Method,
params: unknown[] = [],
): Promise<T> {
const { http } = normalizeRelayManagementUrl(relayUrl);
const body = JSON.stringify({ method, params });
const payload = await sha256Hex(body);
const event = await user.signer.signEvent({
kind: 27235,
content: '',
created_at: Math.floor(Date.now() / 1000),
tags: [
['u', http],
['method', 'POST'],
['payload', payload],
],
});
let response: Response;
try {
response = await fetch(http, {
method: 'POST',
headers: {
Accept: 'application/nostr+json+rpc, application/json',
Authorization: `Nostr ${btoa(JSON.stringify(event))}`,
'Content-Type': 'application/nostr+json+rpc',
},
body,
signal: AbortSignal.timeout(10_000),
});
} catch (error) {
if (error instanceof DOMException && error.name === 'TimeoutError') {
throw new Error('The relay management endpoint timed out.');
}
throw new Error('Could not reach the management endpoint. The relay may not support NIP-86 or browser CORS requests.');
}
if (response.status === 401) throw new Error('The relay rejected the NIP-98 authorization.');
if (!response.ok) throw new Error(`Relay returned HTTP ${response.status} ${response.statusText}.`);
let data: Nip86Response<T>;
try {
data = await response.json() as Nip86Response<T>;
} catch {
throw new Error('The relay returned an invalid NIP-86 response.');
}
if (data.error) throw new Error(data.error);
if (!Object.prototype.hasOwnProperty.call(data, 'result')) {
throw new Error('The relay response did not include a result.');
}
return data.result as T;
}

View File

@@ -0,0 +1,309 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Activity,
Ban,
Check,
CircleAlert,
FileWarning,
Globe2,
KeyRound,
Loader2,
Network,
RefreshCw,
Save,
ServerCog,
ShieldX,
Tags,
Wifi,
X,
} from 'lucide-react';
import { AppSidebar } from '@/components/navigation/AppSidebar';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
import { callNip86, isHex64, type Nip86Method, normalizeRelayManagementUrl } from '@/lib/nip86';
interface ReasonEntry { reason?: string }
interface PubkeyEntry extends ReasonEntry { pubkey: string }
interface EventEntry extends ReasonEntry { id: string }
interface IpEntry extends ReasonEntry { ip: string }
const STORAGE_KEY = 'nostr:nip86-relay';
function MethodBadge({ method, supported }: { method: Nip86Method; supported: Set<string> }) {
const available = supported.has(method);
return (
<Badge variant={available ? 'default' : 'outline'} className={available ? 'bg-emerald-600 hover:bg-emerald-600' : 'text-muted-foreground'}>
{available ? <Check className="mr-1 h-3 w-3" /> : <X className="mr-1 h-3 w-3" />}
{method}
</Badge>
);
}
function EmptyState({ children }: { children: React.ReactNode }) {
return <div className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">{children}</div>;
}
function EntryList<T extends ReasonEntry>({
entries,
value,
actionLabel,
onAction,
disabled,
}: {
entries: T[];
value: (entry: T) => string;
actionLabel: string;
onAction: (entry: T) => void;
disabled?: boolean;
}) {
if (!entries.length) return <EmptyState>No entries returned by the relay.</EmptyState>;
return (
<div className="divide-y rounded-md border">
{entries.map((entry) => {
const entryValue = value(entry);
return (
<div key={entryValue} className="flex flex-col gap-3 p-3 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1">
<p className="truncate font-mono text-xs" title={entryValue}>{entryValue}</p>
<p className="mt-1 text-xs text-muted-foreground">{entry.reason || 'No reason provided'}</p>
</div>
<Button size="sm" variant="outline" disabled={disabled} onClick={() => onAction(entry)}>{actionLabel}</Button>
</div>
);
})}
</div>
);
}
export function RelayManagement() {
const { user } = useCurrentUser();
const { toast } = useToast();
const [relayInput, setRelayInput] = useState(() => localStorage.getItem(STORAGE_KEY) || 'wss://');
const [relayUrl, setRelayUrl] = useState('');
const [supportedMethods, setSupportedMethods] = useState<string[]>([]);
const [error, setError] = useState('');
const [busy, setBusy] = useState('');
const [pubkey, setPubkey] = useState('');
const [pubkeyReason, setPubkeyReason] = useState('');
const [pubkeyAction, setPubkeyAction] = useState<'banpubkey' | 'allowpubkey'>('banpubkey');
const [bannedPubkeys, setBannedPubkeys] = useState<PubkeyEntry[]>([]);
const [allowedPubkeys, setAllowedPubkeys] = useState<PubkeyEntry[]>([]);
const [eventId, setEventId] = useState('');
const [eventReason, setEventReason] = useState('');
const [eventAction, setEventAction] = useState<'banevent' | 'allowevent'>('banevent');
const [moderationEvents, setModerationEvents] = useState<EventEntry[]>([]);
const [bannedEvents, setBannedEvents] = useState<EventEntry[]>([]);
const [kind, setKind] = useState('');
const [allowedKinds, setAllowedKinds] = useState<number[]>([]);
const [ip, setIp] = useState('');
const [ipReason, setIpReason] = useState('');
const [blockedIps, setBlockedIps] = useState<IpEntry[]>([]);
const [relayName, setRelayName] = useState('');
const [relayDescription, setRelayDescription] = useState('');
const [relayIcon, setRelayIcon] = useState('');
const supported = useMemo(() => new Set(supportedMethods), [supportedMethods]);
const invoke = useCallback(async <T,>(method: Nip86Method, params: unknown[] = []): Promise<T> => {
if (!user) throw new Error('Log in with a Nostr account first.');
if (!relayUrl) throw new Error('Connect to a relay first.');
setBusy(method);
setError('');
try {
return await callNip86<T>(user, relayUrl, method, params);
} catch (caught) {
const message = caught instanceof Error ? caught.message : 'Relay management request failed.';
setError(message);
throw caught;
} finally {
setBusy('');
}
}, [relayUrl, user]);
const refreshPubkeys = useCallback(async () => {
const requests: Promise<void>[] = [];
if (supported.has('listbannedpubkeys')) requests.push(invoke<PubkeyEntry[]>('listbannedpubkeys').then(setBannedPubkeys));
if (supported.has('listallowedpubkeys')) requests.push(invoke<PubkeyEntry[]>('listallowedpubkeys').then(setAllowedPubkeys));
await Promise.all(requests);
}, [invoke, supported]);
const refreshEvents = useCallback(async () => {
const requests: Promise<void>[] = [];
if (supported.has('listeventsneedingmoderation')) requests.push(invoke<EventEntry[]>('listeventsneedingmoderation').then(setModerationEvents));
if (supported.has('listbannedevents')) requests.push(invoke<EventEntry[]>('listbannedevents').then(setBannedEvents));
await Promise.all(requests);
}, [invoke, supported]);
const refreshKinds = useCallback(async () => {
if (supported.has('listallowedkinds')) setAllowedKinds(await invoke<number[]>('listallowedkinds'));
}, [invoke, supported]);
const refreshIps = useCallback(async () => {
if (supported.has('listblockedips')) setBlockedIps(await invoke<IpEntry[]>('listblockedips'));
}, [invoke, supported]);
const connect = async () => {
if (!user) return;
try {
const normalized = normalizeRelayManagementUrl(relayInput);
setBusy('supportedmethods');
setError('');
const methods = await callNip86<string[]>(user, normalized.websocket, 'supportedmethods');
setRelayUrl(normalized.websocket);
setSupportedMethods(methods);
localStorage.setItem(STORAGE_KEY, normalized.websocket);
toast({ title: 'Relay connected', description: `${methods.length} management methods detected.` });
} catch (caught) {
setRelayUrl('');
setSupportedMethods([]);
setError(caught instanceof Error ? caught.message : 'Could not connect to the relay.');
} finally {
setBusy('');
}
};
useEffect(() => {
setBannedPubkeys([]);
setAllowedPubkeys([]);
setModerationEvents([]);
setBannedEvents([]);
setAllowedKinds([]);
setBlockedIps([]);
}, [relayUrl]);
const runAction = async (method: Nip86Method, params: unknown[], success: string, refresh?: () => Promise<void>) => {
try {
await invoke(method, params);
toast({ title: success });
await refresh?.();
} catch {
// The shared request handler exposes the relay error in the page alert.
}
};
const submitPubkey = async () => {
if (!isHex64(pubkey)) return setError('Pubkeys must be 64-character hexadecimal values.');
await runAction(pubkeyAction, [pubkey.trim(), pubkeyReason.trim()], pubkeyAction === 'banpubkey' ? 'Pubkey banned' : 'Pubkey allowed', refreshPubkeys);
setPubkey('');
setPubkeyReason('');
};
const submitEvent = async () => {
if (!isHex64(eventId)) return setError('Event IDs must be 64-character hexadecimal values.');
await runAction(eventAction, [eventId.trim(), eventReason.trim()], eventAction === 'banevent' ? 'Event banned' : 'Event allowed', refreshEvents);
setEventId('');
setEventReason('');
};
const connectedHost = relayUrl ? new URL(relayUrl).host : '';
return (
<SidebarProvider>
<div className="flex min-h-screen w-full overflow-x-hidden bg-muted/20">
<AppSidebar />
<main className="min-w-0 flex-1">
<div className="sticky top-0 z-20 flex h-14 items-center gap-4 border-b bg-background/95 px-4 backdrop-blur lg:h-[60px] lg:px-6">
<SidebarTrigger />
<h1 className="truncate text-lg font-semibold md:text-xl">Relay Management</h1>
{relayUrl && <Badge variant="outline" className="ml-auto hidden gap-1.5 sm:flex"><span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />{connectedHost}</Badge>}
</div>
<div className="mx-auto max-w-7xl space-y-6 p-4 md:p-6 lg:p-8">
<section className="relative overflow-hidden rounded-xl border bg-zinc-950 px-5 py-8 text-zinc-50 shadow-xl md:px-8">
<div className="absolute inset-0 opacity-30 [background-image:linear-gradient(rgba(245,158,11,.16)_1px,transparent_1px),linear-gradient(90deg,rgba(245,158,11,.16)_1px,transparent_1px)] [background-size:32px_32px]" />
<div className="relative grid gap-8 lg:grid-cols-[1fr_1.1fr] lg:items-end">
<div className="space-y-4">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.22em] text-amber-400"><Network className="h-4 w-4" />NIP-86 control plane</div>
<h2 className="max-w-xl text-3xl font-bold tracking-tight md:text-5xl">Operate your relay from one console.</h2>
<p className="max-w-xl text-sm leading-6 text-zinc-400 md:text-base">Inspect supported methods, moderate events, manage access rules, and update relay settings through signed NIP-98 requests.</p>
</div>
<div className="rounded-lg border border-white/10 bg-white/[0.06] p-4 backdrop-blur">
<Label htmlFor="relay-url" className="text-xs uppercase tracking-wider text-zinc-400">Relay endpoint</Label>
<div className="mt-2 flex flex-col gap-2 sm:flex-row">
<Input id="relay-url" value={relayInput} onChange={(event) => setRelayInput(event.target.value)} onKeyDown={(event) => event.key === 'Enter' && void connect()} placeholder="wss://relay.example.com" className="border-white/15 bg-black/40 font-mono text-zinc-100 placeholder:text-zinc-600" />
<Button onClick={() => void connect()} disabled={!user || !!busy} className="shrink-0 bg-amber-500 text-black hover:bg-amber-400">
{busy === 'supportedmethods' ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Wifi className="mr-2 h-4 w-4" />}
Connect
</Button>
</div>
<p className="mt-2 text-xs text-zinc-500">The WebSocket URL is converted to HTTP(S) for NIP-86 requests.</p>
</div>
</div>
</section>
{!user && <Alert><KeyRound className="h-4 w-4" /><AlertTitle>Nostr login required</AlertTitle><AlertDescription>Use the account control in the sidebar to sign NIP-98 authorization events.</AlertDescription></Alert>}
{error && <Alert variant="destructive"><CircleAlert className="h-4 w-4" /><AlertTitle>Management request failed</AlertTitle><AlertDescription>{error}</AlertDescription></Alert>}
{!relayUrl ? (
<Card className="border-dashed bg-background/70"><CardContent className="flex flex-col items-center py-16 text-center"><ServerCog className="mb-4 h-10 w-10 text-muted-foreground" /><h3 className="font-semibold">Connect a NIP-86 relay</h3><p className="mt-2 max-w-md text-sm text-muted-foreground">The console first calls <code className="font-mono">supportedmethods</code> and only enables controls advertised by that relay.</p></CardContent></Card>
) : (
<>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{[
{ label: 'Methods', value: supportedMethods.length, icon: Activity },
{ label: 'Banned pubkeys', value: bannedPubkeys.length, icon: ShieldX },
{ label: 'Moderation queue', value: moderationEvents.length, icon: FileWarning },
{ label: 'Allowed kinds', value: allowedKinds.length, icon: Tags },
].map((stat) => <Card key={stat.label}><CardContent className="flex items-center justify-between p-5"><div><p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">{stat.label}</p><p className="mt-1 text-3xl font-bold tabular-nums">{stat.value}</p></div><div className="rounded-md bg-primary/10 p-2.5 text-primary"><stat.icon className="h-5 w-5" /></div></CardContent></Card>)}
</div>
<Tabs defaultValue="access" className="space-y-4">
<TabsList className="grid h-auto w-full grid-cols-2 md:grid-cols-5">
<TabsTrigger value="access">Access</TabsTrigger><TabsTrigger value="events">Events</TabsTrigger><TabsTrigger value="kinds">Kinds</TabsTrigger><TabsTrigger value="network">Network</TabsTrigger><TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabsContent value="access" className="grid gap-4 lg:grid-cols-[0.85fr_1.15fr]">
<Card><CardHeader><CardTitle>Pubkey rule</CardTitle><CardDescription>Ban a key or add it to the relay allowlist.</CardDescription></CardHeader><CardContent className="space-y-4">
<div className="space-y-2"><Label>Action</Label><Select value={pubkeyAction} onValueChange={(value) => setPubkeyAction(value as typeof pubkeyAction)}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="banpubkey" disabled={!supported.has('banpubkey')}>Ban pubkey</SelectItem><SelectItem value="allowpubkey" disabled={!supported.has('allowpubkey')}>Allow pubkey</SelectItem></SelectContent></Select></div>
<div className="space-y-2"><Label htmlFor="pubkey">Pubkey hex</Label><Input id="pubkey" className="font-mono" maxLength={64} value={pubkey} onChange={(event) => setPubkey(event.target.value)} placeholder="64-character public key" /></div>
<div className="space-y-2"><Label htmlFor="pubkey-reason">Reason</Label><Input id="pubkey-reason" value={pubkeyReason} onChange={(event) => setPubkeyReason(event.target.value)} placeholder="Optional operator note" /></div>
<Button className="w-full" disabled={!!busy || !supported.has(pubkeyAction)} onClick={() => void submitPubkey()}>{busy === pubkeyAction && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}Apply rule</Button>
</CardContent></Card>
<Card><CardHeader className="flex-row items-start justify-between space-y-0"><div><CardTitle>Access lists</CardTitle><CardDescription>Current relay-level pubkey controls.</CardDescription></div><Button variant="outline" size="icon" disabled={!!busy} onClick={() => void refreshPubkeys()}><RefreshCw className="h-4 w-4" /></Button></CardHeader><CardContent><Tabs defaultValue="banned"><TabsList className="grid w-full grid-cols-2"><TabsTrigger value="banned">Banned ({bannedPubkeys.length})</TabsTrigger><TabsTrigger value="allowed">Allowed ({allowedPubkeys.length})</TabsTrigger></TabsList><TabsContent value="banned"><EntryList entries={bannedPubkeys} value={(entry) => entry.pubkey} actionLabel="Unban" disabled={!supported.has('unbanpubkey')} onAction={(entry) => void runAction('unbanpubkey', [entry.pubkey], 'Pubkey unbanned', refreshPubkeys)} /></TabsContent><TabsContent value="allowed"><EntryList entries={allowedPubkeys} value={(entry) => entry.pubkey} actionLabel="Remove" disabled={!supported.has('unallowpubkey')} onAction={(entry) => void runAction('unallowpubkey', [entry.pubkey], 'Pubkey removed from allowlist', refreshPubkeys)} /></TabsContent></Tabs></CardContent></Card>
</TabsContent>
<TabsContent value="events" className="grid gap-4 lg:grid-cols-[0.85fr_1.15fr]">
<Card><CardHeader><CardTitle>Event decision</CardTitle><CardDescription>Apply a moderation decision by event ID.</CardDescription></CardHeader><CardContent className="space-y-4">
<div className="space-y-2"><Label>Action</Label><Select value={eventAction} onValueChange={(value) => setEventAction(value as typeof eventAction)}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="banevent" disabled={!supported.has('banevent')}>Ban event</SelectItem><SelectItem value="allowevent" disabled={!supported.has('allowevent')}>Allow event</SelectItem></SelectContent></Select></div>
<div className="space-y-2"><Label htmlFor="event-id">Event ID hex</Label><Input id="event-id" className="font-mono" maxLength={64} value={eventId} onChange={(event) => setEventId(event.target.value)} placeholder="64-character event ID" /></div>
<div className="space-y-2"><Label htmlFor="event-reason">Reason</Label><Input id="event-reason" value={eventReason} onChange={(event) => setEventReason(event.target.value)} placeholder="Optional operator note" /></div>
<Button className="w-full" disabled={!!busy || !supported.has(eventAction)} onClick={() => void submitEvent()}>{busy === eventAction && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}Apply decision</Button>
</CardContent></Card>
<Card><CardHeader className="flex-row items-start justify-between space-y-0"><div><CardTitle>Moderation</CardTitle><CardDescription>Pending and banned relay events.</CardDescription></div><Button variant="outline" size="icon" disabled={!!busy} onClick={() => void refreshEvents()}><RefreshCw className="h-4 w-4" /></Button></CardHeader><CardContent><Tabs defaultValue="pending"><TabsList className="grid w-full grid-cols-2"><TabsTrigger value="pending">Pending ({moderationEvents.length})</TabsTrigger><TabsTrigger value="banned">Banned ({bannedEvents.length})</TabsTrigger></TabsList><TabsContent value="pending"><EntryList entries={moderationEvents} value={(entry) => entry.id} actionLabel="Allow" disabled={!supported.has('allowevent')} onAction={(entry) => void runAction('allowevent', [entry.id, 'Approved in relay console'], 'Event allowed', refreshEvents)} /></TabsContent><TabsContent value="banned"><EntryList entries={bannedEvents} value={(entry) => entry.id} actionLabel="Allow" disabled={!supported.has('allowevent')} onAction={(entry) => void runAction('allowevent', [entry.id, 'Reinstated in relay console'], 'Event allowed', refreshEvents)} /></TabsContent></Tabs></CardContent></Card>
</TabsContent>
<TabsContent value="kinds"><Card><CardHeader className="flex-row items-start justify-between space-y-0"><div><CardTitle>Allowed event kinds</CardTitle><CardDescription>Manage kind-level admission policy.</CardDescription></div><Button variant="outline" size="icon" disabled={!!busy || !supported.has('listallowedkinds')} onClick={() => void refreshKinds()}><RefreshCw className="h-4 w-4" /></Button></CardHeader><CardContent className="grid gap-6 lg:grid-cols-[320px_1fr]"><div className="space-y-3"><Label htmlFor="kind">Kind number</Label><Input id="kind" type="number" min="0" value={kind} onChange={(event) => setKind(event.target.value)} placeholder="e.g. 1" /><Button className="w-full" disabled={!!busy || !supported.has('allowkind')} onClick={() => { const number = Number(kind); if (!Number.isInteger(number) || number < 0) return setError('Enter a valid non-negative kind number.'); void runAction('allowkind', [number], 'Kind allowed', refreshKinds); setKind(''); }}><Tags className="mr-2 h-4 w-4" />Allow kind</Button></div><div className="flex min-h-32 flex-wrap content-start gap-2 rounded-md border p-4">{allowedKinds.length ? allowedKinds.sort((a, b) => a - b).map((item) => <Badge key={item} variant="secondary" className="h-8 gap-2 pl-3 font-mono">{item}<button aria-label={`Disallow kind ${item}`} disabled={!supported.has('disallowkind')} onClick={() => void runAction('disallowkind', [item], 'Kind disallowed', refreshKinds)} className="rounded-full p-0.5 hover:bg-destructive/15 hover:text-destructive"><X className="h-3.5 w-3.5" /></button></Badge>) : <span className="m-auto text-sm text-muted-foreground">Refresh to load allowed kinds.</span>}</div></CardContent></Card></TabsContent>
<TabsContent value="network"><Card><CardHeader className="flex-row items-start justify-between space-y-0"><div><CardTitle>Blocked IP addresses</CardTitle><CardDescription>Maintain relay network deny rules.</CardDescription></div><Button variant="outline" size="icon" disabled={!!busy || !supported.has('listblockedips')} onClick={() => void refreshIps()}><RefreshCw className="h-4 w-4" /></Button></CardHeader><CardContent className="grid gap-6 lg:grid-cols-[0.8fr_1.2fr]"><div className="space-y-4"><div className="space-y-2"><Label htmlFor="ip">IP address</Label><Input id="ip" value={ip} onChange={(event) => setIp(event.target.value)} placeholder="203.0.113.42" /></div><div className="space-y-2"><Label htmlFor="ip-reason">Reason</Label><Input id="ip-reason" value={ipReason} onChange={(event) => setIpReason(event.target.value)} placeholder="Optional operator note" /></div><Button className="w-full" disabled={!!busy || !supported.has('blockip') || !ip.trim()} onClick={() => { void runAction('blockip', [ip.trim(), ipReason.trim()], 'IP address blocked', refreshIps); setIp(''); setIpReason(''); }}><Ban className="mr-2 h-4 w-4" />Block address</Button></div><EntryList entries={blockedIps} value={(entry) => entry.ip} actionLabel="Unblock" disabled={!supported.has('unblockip')} onAction={(entry) => void runAction('unblockip', [entry.ip], 'IP address unblocked', refreshIps)} /></CardContent></Card></TabsContent>
<TabsContent value="settings" className="grid gap-4 xl:grid-cols-[1.2fr_0.8fr]">
<Card><CardHeader><CardTitle>Relay identity</CardTitle><CardDescription>Each field is saved with its own NIP-86 method.</CardDescription></CardHeader><CardContent className="space-y-5">
<div className="space-y-2"><Label htmlFor="relay-name">Name</Label><div className="flex gap-2"><Input id="relay-name" value={relayName} onChange={(event) => setRelayName(event.target.value)} placeholder="My relay" /><Button disabled={!!busy || !relayName || !supported.has('changerelayname')} onClick={() => void runAction('changerelayname', [relayName], 'Relay name updated')}><Save className="mr-2 h-4 w-4" />Save</Button></div></div>
<div className="space-y-2"><Label htmlFor="relay-description">Description</Label><Textarea id="relay-description" value={relayDescription} onChange={(event) => setRelayDescription(event.target.value)} placeholder="What this relay is for" /><Button disabled={!!busy || !relayDescription || !supported.has('changerelaydescription')} onClick={() => void runAction('changerelaydescription', [relayDescription], 'Relay description updated')}><Save className="mr-2 h-4 w-4" />Save description</Button></div>
<div className="space-y-2"><Label htmlFor="relay-icon">Icon URL</Label><div className="flex gap-2"><Input id="relay-icon" type="url" value={relayIcon} onChange={(event) => setRelayIcon(event.target.value)} placeholder="https://example.com/icon.png" /><Button disabled={!!busy || !relayIcon || !supported.has('changerelayicon')} onClick={() => void runAction('changerelayicon', [relayIcon], 'Relay icon updated')}><Save className="mr-2 h-4 w-4" />Save</Button></div></div>
</CardContent></Card>
<Card><CardHeader><CardTitle>Capability report</CardTitle><CardDescription>Methods advertised by this relay.</CardDescription></CardHeader><CardContent className="flex flex-wrap gap-2">{(['banpubkey','unbanpubkey','listbannedpubkeys','allowpubkey','unallowpubkey','listallowedpubkeys','listeventsneedingmoderation','allowevent','banevent','listbannedevents','changerelayname','changerelaydescription','changerelayicon','allowkind','disallowkind','listallowedkinds','blockip','unblockip','listblockedips'] as Nip86Method[]).map((method) => <MethodBadge key={method} method={method} supported={supported} />)}</CardContent></Card>
</TabsContent>
</Tabs>
</>
)}
<Alert className="bg-background"><Globe2 className="h-4 w-4" /><AlertTitle>Browser compatibility</AlertTitle><AlertDescription>NIP-86 is optional and draft. Relay operators must enable the API, authorize your Nostr key, and permit browser CORS requests from this site.</AlertDescription></Alert>
</div>
</main>
</div>
</SidebarProvider>
);
}
export default RelayManagement;