mirror of
https://github.com/layer-systems/website.git
synced 2026-09-12 05:33:12 +02:00
Add NIP-86 session hook and relay-admin app UI
Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
38290bdaf2
commit
e4026379bb
108
src/apps/relay-admin/AuditSection.tsx
Normal file
108
src/apps/relay-admin/AuditSection.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useState } from 'react';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AuditEntry } from '@/hooks/useNip86';
|
||||
import { ListEmpty, Section } from './shared';
|
||||
|
||||
const STATUS_LABEL: Record<AuditEntry['status'], string> = {
|
||||
ok: 'OK',
|
||||
failed: 'Failed',
|
||||
cancelled: 'Cancelled',
|
||||
};
|
||||
|
||||
const STATUS_TONE: Record<AuditEntry['status'], string> = {
|
||||
ok: 'text-success',
|
||||
failed: 'text-destructive',
|
||||
cancelled: 'text-muted-foreground',
|
||||
};
|
||||
|
||||
/**
|
||||
* The session audit log: every management operation with its target, result
|
||||
* and operator-safe error detail. Entries are kept in memory only (never
|
||||
* persisted), capped, and contain no secrets — the "Copy" export is safe to
|
||||
* paste into an incident report.
|
||||
*/
|
||||
export function AuditSection({
|
||||
audit,
|
||||
onClear,
|
||||
}: {
|
||||
audit: AuditEntry[];
|
||||
onClear: () => void;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copy = async () => {
|
||||
const lines = audit
|
||||
.map((entry) =>
|
||||
[
|
||||
new Date(entry.at).toISOString(),
|
||||
entry.status.toUpperCase().padEnd(9),
|
||||
entry.method,
|
||||
`→ ${entry.target}`,
|
||||
entry.detail ? `(${entry.detail})` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
)
|
||||
.join('\n');
|
||||
try {
|
||||
await navigator.clipboard.writeText(lines);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard may be unavailable (permissions); the log stays on screen.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Session audit log"
|
||||
description="Every operation this window performed, newest last. Kept in memory only; cleared when the window closes."
|
||||
actions={
|
||||
audit.length > 0 ? (
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" variant="ghost" className="h-7 gap-1 px-2 text-xs" onClick={copy}>
|
||||
{copied ? (
|
||||
<Check className="size-3.5 text-success" aria-hidden />
|
||||
) : (
|
||||
<Copy className="size-3.5" aria-hidden />
|
||||
)}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs" onClick={onClear}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{audit.length === 0 ? (
|
||||
<ListEmpty title="No operations yet" hint="Operations you run against this relay will appear here." />
|
||||
) : (
|
||||
<ol className="divide-y divide-border border-t border-border" aria-live="polite">
|
||||
{[...audit].reverse().map((entry) => (
|
||||
<li key={entry.id} className="flex items-baseline gap-2 px-3 py-1.5 text-xs">
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{new Date(entry.at).toLocaleTimeString()}
|
||||
</span>
|
||||
<span className={cn('shrink-0 font-medium', STATUS_TONE[entry.status])}>
|
||||
{STATUS_LABEL[entry.status]}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="font-mono">{entry.method}</span>{' '}
|
||||
<span className="text-muted-foreground">→ {entry.target}</span>
|
||||
{entry.detail && (
|
||||
<span className="block truncate text-muted-foreground" title={entry.detail}>
|
||||
{entry.detail}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
86
src/apps/relay-admin/ExtensionsSection.tsx
Normal file
86
src/apps/relay-admin/ExtensionsSection.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useState } from 'react';
|
||||
import { Puzzle } from 'lucide-react';
|
||||
|
||||
import { useNip86Mutation, type AuditEntry, type Nip86Session } from '@/hooks/useNip86';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { TypedConfirmAction, type PendingTypedConfirm } from './dialogs';
|
||||
import { Section } from './shared';
|
||||
|
||||
type AuditFn = (entry: Omit<AuditEntry, 'id' | 'at'>) => void;
|
||||
|
||||
/**
|
||||
* Relay-specific extensions: advertised method names that are not part of the
|
||||
* NIP-86 standard (e.g. a documented event-purge operation). They are kept
|
||||
* visually and semantically separate from the core console, and every single
|
||||
* one requires typed confirmation — nothing here is assumed to be reversible.
|
||||
*
|
||||
* The console deliberately does not guess parameters: an extension's argument
|
||||
* list is defined by the relay, not by NIP-86, so these operations are
|
||||
* triggered without params and the relay's own documentation has the final
|
||||
* word on what they do.
|
||||
*/
|
||||
export function ExtensionsSection({
|
||||
session,
|
||||
onResult,
|
||||
}: {
|
||||
session: Nip86Session;
|
||||
onResult: AuditFn;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const mutation = useNip86Mutation(session, { onResult });
|
||||
const [pending, setPending] = useState<PendingTypedConfirm | undefined>(undefined);
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Relay-specific extensions"
|
||||
description="This relay advertises operations beyond the NIP-86 standard. They are specific to this relay software, may be irreversible, and are never run without typed confirmation."
|
||||
>
|
||||
<ul className="divide-y divide-border border-t border-dashed border-border">
|
||||
{session.extensions.map((method) => (
|
||||
<li key={method} className="flex items-center gap-3 px-3 py-2">
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md border border-dashed border-border bg-muted/40">
|
||||
<Puzzle className="size-4 text-muted-foreground" aria-hidden />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-mono text-xs font-medium">{method}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Not part of NIP-86 — see this relay’s documentation for what it does and what it
|
||||
destroys.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setPending({
|
||||
title: `Run ${method}?`,
|
||||
method,
|
||||
phrase: method,
|
||||
effect:
|
||||
'This console sends the operation without parameters. The relay decides the exact behavior; broad or destructive effects (such as purging events) cannot be ruled out.',
|
||||
run: async () => {
|
||||
try {
|
||||
await mutation.mutateAsync({ method, params: [] });
|
||||
toast({ title: `${method} completed` });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: `${method} failed`,
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
variant: 'destructive',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
className="shrink-0 rounded-md border border-destructive/40 px-2 py-1 text-xs font-medium text-destructive transition-colors hover:bg-destructive/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
||||
>
|
||||
Run…
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<TypedConfirmAction pending={pending} onClose={() => setPending(undefined)} />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
338
src/apps/relay-admin/ModerationSection.tsx
Normal file
338
src/apps/relay-admin/ModerationSection.tsx
Normal file
@@ -0,0 +1,338 @@
|
||||
import { useState } from 'react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Plus } from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import {
|
||||
useBannedEvents,
|
||||
useEventsNeedingModeration,
|
||||
useNip86Mutation,
|
||||
type AuditEntry,
|
||||
type Nip86Session,
|
||||
} from '@/hooks/useNip86';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { parseEventIdInput, relayWsUrl, type ModeratedEventRef } from '@/lib/nip86';
|
||||
import { displayName, relativeTime } from '@/lib/nostrUtils';
|
||||
import { BanEventDialog, ConfirmAction, type PendingConfirm } from './dialogs';
|
||||
import { useFilter } from './useFilter';
|
||||
import {
|
||||
IdText,
|
||||
ListEmpty,
|
||||
ListError,
|
||||
ListSkeleton,
|
||||
RowAction,
|
||||
Section,
|
||||
SectionToolbar,
|
||||
} from './shared';
|
||||
|
||||
type AuditFn = (entry: Omit<AuditEntry, 'id' | 'at'>) => void;
|
||||
|
||||
/**
|
||||
* Fetch the full event behind a moderation row straight from the managed
|
||||
* relay, so the operator reviews the actual content before deciding. The
|
||||
* event may be gone (already deleted, or never stored) — that is shown
|
||||
* honestly instead of hiding the row.
|
||||
*/
|
||||
function useModeratedEvent(session: Nip86Session, id: string) {
|
||||
const { nostr } = useNostr();
|
||||
const relay = relayWsUrl(session.url);
|
||||
|
||||
return useQuery<NostrEvent | null>({
|
||||
queryKey: ['nip86', session.url, 'moderated-event', id],
|
||||
staleTime: 60_000,
|
||||
queryFn: async ({ signal }) => {
|
||||
const [event] = await nostr.query([{ ids: [id] }], {
|
||||
signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]),
|
||||
relays: [relay],
|
||||
});
|
||||
return event ?? null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type Decision = 'allowevent' | 'banevent';
|
||||
|
||||
const DECISION_CONFIRM: Record<Decision, (id: string, canReverse: boolean) => Omit<PendingConfirm, 'run'>> = {
|
||||
allowevent: (id) => ({
|
||||
title: 'Allow this event?',
|
||||
target: id,
|
||||
effect: 'The relay will keep and serve this event. It leaves the moderation queue.',
|
||||
reversible: 'Reversible: you can ban the event later.',
|
||||
actionLabel: 'Allow event',
|
||||
}),
|
||||
banevent: (id, canReverse) => ({
|
||||
title: 'Ban this event?',
|
||||
target: id,
|
||||
effect:
|
||||
'The relay will stop serving this event and may delete its stored copy. The event can still exist on other relays.',
|
||||
reversible: canReverse ? 'Reversible: you can allow the event again.' : undefined,
|
||||
actionLabel: 'Ban event',
|
||||
}),
|
||||
};
|
||||
|
||||
function ModerationRow({
|
||||
session,
|
||||
entry,
|
||||
canAllow,
|
||||
canBan,
|
||||
pending,
|
||||
onDecide,
|
||||
}: {
|
||||
session: Nip86Session;
|
||||
entry: ModeratedEventRef;
|
||||
canAllow: boolean;
|
||||
canBan: boolean;
|
||||
pending: boolean;
|
||||
onDecide: (decision: Decision, id: string) => void;
|
||||
}) {
|
||||
const event = useModeratedEvent(session, entry.id);
|
||||
const author = useAuthor(event.data?.pubkey);
|
||||
|
||||
return (
|
||||
<li className="space-y-2 px-3 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<IdText value={entry.id} />
|
||||
{entry.reason && (
|
||||
<p className="truncate text-xs text-muted-foreground" title={entry.reason}>
|
||||
Flagged: {entry.reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{(canAllow || canBan) && (
|
||||
<div className="flex shrink-0 gap-1.5">
|
||||
{canAllow && (
|
||||
<RowAction label="Allow" pending={pending} onClick={() => onDecide('allowevent', entry.id)} />
|
||||
)}
|
||||
{canBan && (
|
||||
<RowAction
|
||||
label="Ban"
|
||||
destructive
|
||||
pending={pending}
|
||||
onClick={() => onDecide('banevent', entry.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Review context: the event itself, straight from the managed relay. */}
|
||||
{event.isLoading ? (
|
||||
<p className="text-xs text-muted-foreground">Loading the event for review…</p>
|
||||
) : event.data ? (
|
||||
<blockquote className="rounded-md border border-border bg-muted/40 px-2.5 py-1.5">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{displayName(event.data.pubkey, author.data?.metadata)} · kind {event.data.kind} ·{' '}
|
||||
{relativeTime(event.data.created_at)}
|
||||
</p>
|
||||
<p className="mt-0.5 line-clamp-3 whitespace-pre-wrap break-words text-sm">
|
||||
{event.data.content}
|
||||
</p>
|
||||
</blockquote>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The event itself is not available from this relay — it may already be deleted. You can
|
||||
still ban the ID to keep it out.
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function EventModerationSection({
|
||||
session,
|
||||
onResult,
|
||||
}: {
|
||||
session: Nip86Session;
|
||||
onResult: AuditFn;
|
||||
}) {
|
||||
const queue = useEventsNeedingModeration(session, { onResult });
|
||||
const banned = useBannedEvents(session, { onResult });
|
||||
const { toast } = useToast();
|
||||
const mutation = useNip86Mutation(session, { onResult });
|
||||
|
||||
const [banOpen, setBanOpen] = useState(false);
|
||||
const [confirm, setConfirm] = useState<PendingConfirm | undefined>(undefined);
|
||||
const queueFilter = useFilter(queue.data, (entry) => [entry.id, entry.reason]);
|
||||
const bannedFilter = useFilter(banned.data, (entry) => [entry.id, entry.reason]);
|
||||
|
||||
const hasQueue = session.methods.includes('listeventsneedingmoderation');
|
||||
const hasBanned = session.methods.includes('listbannedevents');
|
||||
const canAllow = session.methods.includes('allowevent');
|
||||
const canBan = session.methods.includes('banevent');
|
||||
|
||||
const run = async (method: Decision, id: string, success: string) => {
|
||||
try {
|
||||
await mutation.mutateAsync({
|
||||
method,
|
||||
params: [id],
|
||||
refresh: ['listeventsneedingmoderation', 'listbannedevents'],
|
||||
});
|
||||
toast({ title: success });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'The relay refused the operation',
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
variant: 'destructive',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const decide = (decision: Decision, id: string) =>
|
||||
setConfirm({
|
||||
...DECISION_CONFIRM[decision](id, canAllow),
|
||||
run: () => run(decision, id, decision === 'banevent' ? 'Event banned' : 'Event allowed'),
|
||||
});
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Event moderation"
|
||||
description="Events the relay flagged for review, and events it already bans. Review the content from the managed relay before deciding."
|
||||
actions={
|
||||
canBan ? (
|
||||
<Button size="sm" variant="outline" className="h-7 gap-1 px-2 text-xs" onClick={() => setBanOpen(true)}>
|
||||
<Plus className="size-3.5" aria-hidden />
|
||||
Ban by ID
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{hasQueue && (
|
||||
<>
|
||||
<SectionToolbar
|
||||
search={queueFilter.search}
|
||||
onSearch={queueFilter.setSearch}
|
||||
searchLabel="Filter the queue"
|
||||
count={queueFilter.filtered?.length}
|
||||
onRefresh={() => queue.refetch()}
|
||||
refreshing={queue.isFetching}
|
||||
/>
|
||||
{queue.isLoading ? (
|
||||
<ListSkeleton />
|
||||
) : queue.isError ? (
|
||||
<ListError error={queue.error} onRetry={() => queue.refetch()} />
|
||||
) : !queueFilter.filtered || queueFilter.filtered.length === 0 ? (
|
||||
<ListEmpty
|
||||
title={queueFilter.search ? 'No matches' : 'Nothing needs moderation'}
|
||||
hint={
|
||||
queueFilter.search
|
||||
? 'Nothing in the queue matches the filter.'
|
||||
: 'The relay’s moderation queue is empty.'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-border border-t border-border">
|
||||
{queueFilter.filtered.map((entry) => (
|
||||
<ModerationRow
|
||||
key={entry.id}
|
||||
session={session}
|
||||
entry={entry}
|
||||
canAllow={canAllow}
|
||||
canBan={canBan}
|
||||
pending={mutation.isPending}
|
||||
onDecide={decide}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasBanned && (
|
||||
<div className={hasQueue ? 'mt-3' : undefined}>
|
||||
<p className="px-3 pb-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Banned events
|
||||
</p>
|
||||
<SectionToolbar
|
||||
search={bannedFilter.search}
|
||||
onSearch={bannedFilter.setSearch}
|
||||
searchLabel="Filter banned events"
|
||||
count={bannedFilter.filtered?.length}
|
||||
onRefresh={() => banned.refetch()}
|
||||
refreshing={banned.isFetching}
|
||||
/>
|
||||
{banned.isLoading ? (
|
||||
<ListSkeleton rows={2} />
|
||||
) : banned.isError ? (
|
||||
<ListError error={banned.error} onRetry={() => banned.refetch()} />
|
||||
) : !bannedFilter.filtered || bannedFilter.filtered.length === 0 ? (
|
||||
<ListEmpty
|
||||
title={bannedFilter.search ? 'No matches' : 'No banned events'}
|
||||
hint={
|
||||
bannedFilter.search
|
||||
? 'Nothing on the ban list matches the filter.'
|
||||
: 'The relay reports no banned events.'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-border border-t border-border">
|
||||
{bannedFilter.filtered.map((entry) => (
|
||||
<li key={entry.id} className="flex items-center gap-3 px-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<IdText value={entry.id} />
|
||||
{entry.reason && (
|
||||
<p className="truncate text-xs text-muted-foreground" title={entry.reason}>
|
||||
Reason: {entry.reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{canAllow ? (
|
||||
<RowAction
|
||||
label="Unban"
|
||||
pending={mutation.isPending}
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
title: 'Unban this event?',
|
||||
target: entry.id,
|
||||
effect: 'The relay will serve this event again if it still has a copy.',
|
||||
reversible: 'Reversible: you can ban the event again.',
|
||||
actionLabel: 'Unban',
|
||||
run: () => run('allowevent', entry.id, 'Event unbanned'),
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px]">read-only</Badge>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BanEventDialog
|
||||
open={banOpen}
|
||||
onOpenChange={setBanOpen}
|
||||
pending={mutation.isPending}
|
||||
onSubmit={async (value, reason) => {
|
||||
const parsed = parseEventIdInput(value);
|
||||
if (typeof parsed !== 'string') {
|
||||
throw new Error(parsed.error);
|
||||
}
|
||||
try {
|
||||
await mutation.mutateAsync({
|
||||
method: 'banevent',
|
||||
params: reason.trim() ? [parsed, reason.trim()] : [parsed],
|
||||
refresh: ['listeventsneedingmoderation', 'listbannedevents'],
|
||||
});
|
||||
toast({ title: 'Event banned' });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'The relay refused the operation',
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
variant: 'destructive',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ConfirmAction pending={confirm} onClose={() => setConfirm(undefined)} />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
712
src/apps/relay-admin/PolicySections.tsx
Normal file
712
src/apps/relay-admin/PolicySections.tsx
Normal file
@@ -0,0 +1,712 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Loader2, Plus } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
useAllowedKinds,
|
||||
useAllowedPubkeys,
|
||||
useBannedPubkeys,
|
||||
useBlockedIps,
|
||||
useNip86Mutation,
|
||||
type Nip86Session,
|
||||
} from '@/hooks/useNip86';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import {
|
||||
parseKindInput,
|
||||
parsePubkeyInput,
|
||||
validateIpInput,
|
||||
validateReason,
|
||||
type BannedPubkey,
|
||||
type BlockedIp,
|
||||
type Nip86CoreMethod,
|
||||
} from '@/lib/nip86';
|
||||
import {
|
||||
ConfirmAction,
|
||||
Field,
|
||||
FormDialog,
|
||||
type PendingConfirm,
|
||||
} from './dialogs';
|
||||
import { useFilter } from './useFilter';
|
||||
import {
|
||||
IdText,
|
||||
ListEmpty,
|
||||
ListError,
|
||||
ListSkeleton,
|
||||
RowAction,
|
||||
Section,
|
||||
SectionToolbar,
|
||||
} from './shared';
|
||||
|
||||
/**
|
||||
* The policy sections: banned/allowed pubkeys, blocked IPs and allowed kinds.
|
||||
* Each section renders only when the relay advertised the matching list
|
||||
* method (the app shell gates them), and mutations only reference methods the
|
||||
* relay advertised — an advertised `listbannedpubkeys` without `banpubkey`
|
||||
* yields a read-only list, never a silently attempted call.
|
||||
*/
|
||||
|
||||
interface AddFormState {
|
||||
value: string;
|
||||
reason: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function usePolicyMutation(
|
||||
session: Nip86Session,
|
||||
onResult: (entry: { method: string; target: string; status: 'ok' | 'failed' | 'cancelled'; detail?: string }) => void,
|
||||
) {
|
||||
const { toast } = useToast();
|
||||
const mutation = useNip86Mutation(session, { onResult });
|
||||
|
||||
const run = async (input: {
|
||||
method: Nip86CoreMethod;
|
||||
params: unknown[];
|
||||
refresh: Parameters<typeof mutation.mutateAsync>[0]['refresh'];
|
||||
success: string;
|
||||
}) => {
|
||||
try {
|
||||
await mutation.mutateAsync({ method: input.method, params: input.params, refresh: input.refresh });
|
||||
toast({ title: input.success });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'The relay refused the operation',
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
variant: 'destructive',
|
||||
});
|
||||
// The audit entry is written by the mutation hook either way; rethrow so
|
||||
// the caller can keep its dialog open on failure.
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return { run, isPending: mutation.isPending };
|
||||
}
|
||||
|
||||
function ReasonInput({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const error = validateReason(value);
|
||||
return (
|
||||
<Field id={id} label="Reason (optional)" error={value ? error : undefined} hint="Stored by the relay and shown to other operators.">
|
||||
<Input
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
aria-invalid={Boolean(value && error)}
|
||||
aria-describedby={value && error ? `${id}-error` : undefined}
|
||||
className="text-sm"
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
function AddDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
idPrefix,
|
||||
valueLabel,
|
||||
valueHint,
|
||||
valuePlaceholder,
|
||||
validate,
|
||||
withReason,
|
||||
pending,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
idPrefix: string;
|
||||
valueLabel: string;
|
||||
valueHint: string;
|
||||
valuePlaceholder: string;
|
||||
validate: (value: string) => string | undefined;
|
||||
withReason?: boolean;
|
||||
pending: boolean;
|
||||
onSubmit: (value: string, reason: string) => Promise<void>;
|
||||
}) {
|
||||
const [form, setForm] = useState<AddFormState>({ value: '', reason: '' });
|
||||
|
||||
const close = (next: boolean) => {
|
||||
if (!next) setForm({ value: '', reason: '' });
|
||||
onOpenChange(next);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const error = validate(form.value);
|
||||
if (error) {
|
||||
setForm((current) => ({ ...current, error }));
|
||||
return;
|
||||
}
|
||||
await onSubmit(form.value.trim(), form.reason);
|
||||
close(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormDialog open={open} onOpenChange={close} title={title} description={description}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => close(false)} disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submit}
|
||||
disabled={pending || !form.value.trim() || Boolean(validateReason(form.reason))}
|
||||
>
|
||||
{pending && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
|
||||
{title}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field
|
||||
id={`${idPrefix}-value`}
|
||||
label={valueLabel}
|
||||
error={form.error}
|
||||
hint={form.error ? undefined : valueHint}
|
||||
>
|
||||
<Input
|
||||
id={`${idPrefix}-value`}
|
||||
value={form.value}
|
||||
onChange={(event) => setForm((current) => ({ ...current, value: event.target.value, error: undefined }))}
|
||||
onKeyDown={(event) => event.key === 'Enter' && submit()}
|
||||
placeholder={valuePlaceholder}
|
||||
aria-invalid={Boolean(form.error)}
|
||||
aria-describedby={form.error ? `${idPrefix}-value-error` : undefined}
|
||||
className="font-mono text-xs"
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
{withReason !== false && (
|
||||
<ReasonInput
|
||||
id={`${idPrefix}-reason`}
|
||||
value={form.reason}
|
||||
onChange={(reason) => setForm((current) => ({ ...current, reason }))}
|
||||
/>
|
||||
)}
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyRow({
|
||||
id,
|
||||
reason,
|
||||
reasonLabel,
|
||||
actions,
|
||||
}: {
|
||||
id: string;
|
||||
reason?: string;
|
||||
reasonLabel: string;
|
||||
actions: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<li className="flex items-center gap-3 px-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<IdText value={id} />
|
||||
{reason ? (
|
||||
<p className="truncate text-xs text-muted-foreground" title={reason}>
|
||||
{reasonLabel}: {reason}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{actions}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
* Banned pubkeys (public-relay blocklist side)
|
||||
* ------------------------------------------------------------------------ */
|
||||
|
||||
export function BannedPubkeysSection({
|
||||
session,
|
||||
onResult,
|
||||
}: {
|
||||
session: Nip86Session;
|
||||
onResult: (entry: { method: string; target: string; status: 'ok' | 'failed' | 'cancelled'; detail?: string }) => void;
|
||||
}) {
|
||||
const list = useBannedPubkeys(session, { onResult });
|
||||
const { run, isPending } = usePolicyMutation(session, onResult);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [confirm, setConfirm] = useState<PendingConfirm | undefined>(undefined);
|
||||
const { search, setSearch, filtered } = useFilter(list.data, (entry) => [entry.pubkey, entry.reason]);
|
||||
|
||||
const canBan = session.methods.includes('banpubkey');
|
||||
const canUnban = session.methods.includes('unbanpubkey');
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Banned pubkeys"
|
||||
description="Keys this relay refuses to serve or accept events from. A blocklist is reactive — it is not an exhaustive statement of who may use the relay."
|
||||
actions={
|
||||
canBan ? (
|
||||
<Button size="sm" variant="outline" className="h-7 gap-1 px-2 text-xs" onClick={() => setAddOpen(true)}>
|
||||
<Plus className="size-3.5" aria-hidden />
|
||||
Ban
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<SectionToolbar
|
||||
search={search}
|
||||
onSearch={setSearch}
|
||||
searchLabel="Filter by pubkey or reason"
|
||||
count={filtered?.length}
|
||||
onRefresh={() => list.refetch()}
|
||||
refreshing={list.isFetching}
|
||||
/>
|
||||
{list.isLoading ? (
|
||||
<ListSkeleton />
|
||||
) : list.isError ? (
|
||||
<ListError error={list.error} onRetry={() => list.refetch()} />
|
||||
) : !filtered || filtered.length === 0 ? (
|
||||
<ListEmpty
|
||||
title={search ? 'No matches' : 'No banned pubkeys'}
|
||||
hint={search ? 'Nothing on this list matches the filter.' : 'The relay reports an empty ban list.'}
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-border border-t border-border">
|
||||
{filtered.map((entry: BannedPubkey) => (
|
||||
<PolicyRow
|
||||
key={entry.pubkey}
|
||||
id={entry.pubkey}
|
||||
reason={entry.reason}
|
||||
reasonLabel="Reason"
|
||||
actions={
|
||||
canUnban ? (
|
||||
<RowAction
|
||||
label="Unban"
|
||||
pending={isPending}
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
title: 'Unban this pubkey?',
|
||||
target: entry.pubkey,
|
||||
effect:
|
||||
'The relay will accept and serve this key’s events again, subject to its other rules.',
|
||||
reversible: 'Reversible: you can ban the key again.',
|
||||
actionLabel: 'Unban',
|
||||
run: () =>
|
||||
run({
|
||||
method: 'unbanpubkey',
|
||||
params: [entry.pubkey],
|
||||
refresh: ['listbannedpubkeys'],
|
||||
success: 'Pubkey unbanned',
|
||||
}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px]">read-only</Badge>
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<AddDialog
|
||||
open={addOpen}
|
||||
onOpenChange={setAddOpen}
|
||||
title="Ban pubkey"
|
||||
description="Banning a key stops this relay from accepting or serving its events. You can reverse it with Unban."
|
||||
idPrefix="ban-pubkey"
|
||||
valueLabel="Public key"
|
||||
valueHint="64 hex characters, or an npub1… / nprofile1… identifier."
|
||||
valuePlaceholder="npub1…"
|
||||
pending={isPending}
|
||||
validate={(value) => {
|
||||
const parsed = parsePubkeyInput(value);
|
||||
return typeof parsed === 'string' ? undefined : parsed.error;
|
||||
}}
|
||||
onSubmit={async (value, reason) => {
|
||||
const parsed = parsePubkeyInput(value);
|
||||
if (typeof parsed !== 'string') return;
|
||||
await run({
|
||||
method: 'banpubkey',
|
||||
params: reason.trim() ? [parsed, reason.trim()] : [parsed],
|
||||
refresh: ['listbannedpubkeys'],
|
||||
success: 'Pubkey banned',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ConfirmAction pending={confirm} onClose={() => setConfirm(undefined)} />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
* Allowed pubkeys (private-relay allowlist side)
|
||||
* ------------------------------------------------------------------------ */
|
||||
|
||||
export function AllowedPubkeysSection({
|
||||
session,
|
||||
onResult,
|
||||
}: {
|
||||
session: Nip86Session;
|
||||
onResult: (entry: { method: string; target: string; status: 'ok' | 'failed' | 'cancelled'; detail?: string }) => void;
|
||||
}) {
|
||||
const list = useAllowedPubkeys(session, { onResult });
|
||||
const { run, isPending } = usePolicyMutation(session, onResult);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [confirm, setConfirm] = useState<PendingConfirm | undefined>(undefined);
|
||||
const { search, setSearch, filtered } = useFilter(list.data, (entry) => [entry.pubkey, entry.reason]);
|
||||
|
||||
const canAllow = session.methods.includes('allowpubkey');
|
||||
const canUnallow = session.methods.includes('unallowpubkey');
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Allowed pubkeys"
|
||||
description="Keys on this relay’s allowlist. Whether an allowlist restricts everyone else is decided by the relay’s own enforcement — an allowlist alone does not make a relay private."
|
||||
actions={
|
||||
canAllow ? (
|
||||
<Button size="sm" variant="outline" className="h-7 gap-1 px-2 text-xs" onClick={() => setAddOpen(true)}>
|
||||
<Plus className="size-3.5" aria-hidden />
|
||||
Allow
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<SectionToolbar
|
||||
search={search}
|
||||
onSearch={setSearch}
|
||||
searchLabel="Filter by pubkey or reason"
|
||||
count={filtered?.length}
|
||||
onRefresh={() => list.refetch()}
|
||||
refreshing={list.isFetching}
|
||||
/>
|
||||
{list.isLoading ? (
|
||||
<ListSkeleton />
|
||||
) : list.isError ? (
|
||||
<ListError error={list.error} onRetry={() => list.refetch()} />
|
||||
) : !filtered || filtered.length === 0 ? (
|
||||
<ListEmpty
|
||||
title={search ? 'No matches' : 'No allowed pubkeys'}
|
||||
hint={
|
||||
search
|
||||
? 'Nothing on this list matches the filter.'
|
||||
: 'The relay reports an empty allowlist. Depending on its configuration, that may mean no restriction at all.'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-border border-t border-border">
|
||||
{filtered.map((entry: BannedPubkey) => (
|
||||
<PolicyRow
|
||||
key={entry.pubkey}
|
||||
id={entry.pubkey}
|
||||
reason={entry.reason}
|
||||
reasonLabel="Note"
|
||||
actions={
|
||||
canUnallow ? (
|
||||
<RowAction
|
||||
label="Remove"
|
||||
destructive
|
||||
pending={isPending}
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
title: 'Remove this pubkey from the allowlist?',
|
||||
target: entry.pubkey,
|
||||
effect:
|
||||
'If this relay enforces its allowlist, the key immediately loses access. Check the relay’s documentation for its exact enforcement semantics.',
|
||||
reversible: 'Reversible: you can allow the key again.',
|
||||
actionLabel: 'Remove access',
|
||||
run: () =>
|
||||
run({
|
||||
method: 'unallowpubkey',
|
||||
params: [entry.pubkey],
|
||||
refresh: ['listallowedpubkeys'],
|
||||
success: 'Pubkey removed from the allowlist',
|
||||
}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px]">read-only</Badge>
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<AddDialog
|
||||
open={addOpen}
|
||||
onOpenChange={setAddOpen}
|
||||
title="Allow pubkey"
|
||||
description="Add a key to this relay’s allowlist. The relay decides what being allowed means."
|
||||
idPrefix="allow-pubkey"
|
||||
valueLabel="Public key"
|
||||
valueHint="64 hex characters, or an npub1… / nprofile1… identifier."
|
||||
valuePlaceholder="npub1…"
|
||||
pending={isPending}
|
||||
validate={(value) => {
|
||||
const parsed = parsePubkeyInput(value);
|
||||
return typeof parsed === 'string' ? undefined : parsed.error;
|
||||
}}
|
||||
onSubmit={async (value, reason) => {
|
||||
const parsed = parsePubkeyInput(value);
|
||||
if (typeof parsed !== 'string') return;
|
||||
await run({
|
||||
method: 'allowpubkey',
|
||||
params: reason.trim() ? [parsed, reason.trim()] : [parsed],
|
||||
refresh: ['listallowedpubkeys'],
|
||||
success: 'Pubkey allowed',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ConfirmAction pending={confirm} onClose={() => setConfirm(undefined)} />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
* Blocked IPs
|
||||
* ------------------------------------------------------------------------ */
|
||||
|
||||
export function BlockedIpsSection({
|
||||
session,
|
||||
onResult,
|
||||
}: {
|
||||
session: Nip86Session;
|
||||
onResult: (entry: { method: string; target: string; status: 'ok' | 'failed' | 'cancelled'; detail?: string }) => void;
|
||||
}) {
|
||||
const list = useBlockedIps(session, { onResult });
|
||||
const { run, isPending } = usePolicyMutation(session, onResult);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [confirm, setConfirm] = useState<PendingConfirm | undefined>(undefined);
|
||||
const { search, setSearch, filtered } = useFilter(list.data, (entry) => [entry.ip, entry.reason]);
|
||||
|
||||
const canBlock = session.methods.includes('blockip');
|
||||
const canUnblock = session.methods.includes('unblockip');
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Blocked IPs"
|
||||
description="Addresses or CIDR ranges the relay refuses connections from. Whether ranges are accepted depends on the relay — check its documentation."
|
||||
actions={
|
||||
canBlock ? (
|
||||
<Button size="sm" variant="outline" className="h-7 gap-1 px-2 text-xs" onClick={() => setAddOpen(true)}>
|
||||
<Plus className="size-3.5" aria-hidden />
|
||||
Block
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<SectionToolbar
|
||||
search={search}
|
||||
onSearch={setSearch}
|
||||
searchLabel="Filter by address or reason"
|
||||
count={filtered?.length}
|
||||
onRefresh={() => list.refetch()}
|
||||
refreshing={list.isFetching}
|
||||
/>
|
||||
{list.isLoading ? (
|
||||
<ListSkeleton />
|
||||
) : list.isError ? (
|
||||
<ListError error={list.error} onRetry={() => list.refetch()} />
|
||||
) : !filtered || filtered.length === 0 ? (
|
||||
<ListEmpty
|
||||
title={search ? 'No matches' : 'No blocked IPs'}
|
||||
hint={search ? 'Nothing on this list matches the filter.' : 'The relay reports no blocked addresses.'}
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-border border-t border-border">
|
||||
{filtered.map((entry: BlockedIp) => (
|
||||
<PolicyRow
|
||||
key={entry.ip}
|
||||
id={entry.ip}
|
||||
reason={entry.reason}
|
||||
reasonLabel="Reason"
|
||||
actions={
|
||||
canUnblock ? (
|
||||
<RowAction
|
||||
label="Unblock"
|
||||
pending={isPending}
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
title: 'Unblock this address?',
|
||||
target: entry.ip,
|
||||
effect: 'The relay will accept connections from this address again.',
|
||||
reversible: 'Reversible: you can block the address again.',
|
||||
actionLabel: 'Unblock',
|
||||
run: () =>
|
||||
run({
|
||||
method: 'unblockip',
|
||||
params: [entry.ip],
|
||||
refresh: ['listblockedips'],
|
||||
success: 'Address unblocked',
|
||||
}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px]">read-only</Badge>
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<AddDialog
|
||||
open={addOpen}
|
||||
onOpenChange={setAddOpen}
|
||||
title="Block IP"
|
||||
description="Block one address or a whole range. Everyone behind the address — including innocent users sharing it — loses access."
|
||||
idPrefix="block-ip"
|
||||
valueLabel="IP address or CIDR range"
|
||||
valueHint="e.g. 203.0.113.7 or 203.0.113.0/24. Check the relay’s documentation for what it accepts."
|
||||
valuePlaceholder="203.0.113.7"
|
||||
pending={isPending}
|
||||
validate={(value) => validateIpInput(value)}
|
||||
onSubmit={async (value, reason) => {
|
||||
await run({
|
||||
method: 'blockip',
|
||||
params: reason.trim() ? [value, reason.trim()] : [value],
|
||||
refresh: ['listblockedips'],
|
||||
success: 'Address blocked',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ConfirmAction pending={confirm} onClose={() => setConfirm(undefined)} />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
* Allowed kinds
|
||||
* ------------------------------------------------------------------------ */
|
||||
|
||||
export function AllowedKindsSection({
|
||||
session,
|
||||
onResult,
|
||||
}: {
|
||||
session: Nip86Session;
|
||||
onResult: (entry: { method: string; target: string; status: 'ok' | 'failed' | 'cancelled'; detail?: string }) => void;
|
||||
}) {
|
||||
const list = useAllowedKinds(session, { onResult });
|
||||
const { run, isPending } = usePolicyMutation(session, onResult);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [confirm, setConfirm] = useState<PendingConfirm | undefined>(undefined);
|
||||
const { search, setSearch, filtered } = useFilter(list.data?.map((kind) => ({ kind })), (entry) => [
|
||||
String(entry.kind),
|
||||
]);
|
||||
|
||||
const canAllow = session.methods.includes('allowkind');
|
||||
const canDisallow = session.methods.includes('disallowkind');
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Allowed kinds"
|
||||
description="Event kinds on this relay’s allowlist. Whether kinds outside the list are rejected is the relay’s own decision — some relays treat this list as advisory."
|
||||
actions={
|
||||
canAllow ? (
|
||||
<Button size="sm" variant="outline" className="h-7 gap-1 px-2 text-xs" onClick={() => setAddOpen(true)}>
|
||||
<Plus className="size-3.5" aria-hidden />
|
||||
Allow kind
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<SectionToolbar
|
||||
search={search}
|
||||
onSearch={setSearch}
|
||||
searchLabel="Filter by kind number"
|
||||
count={filtered?.length}
|
||||
onRefresh={() => list.refetch()}
|
||||
refreshing={list.isFetching}
|
||||
/>
|
||||
{list.isLoading ? (
|
||||
<ListSkeleton />
|
||||
) : list.isError ? (
|
||||
<ListError error={list.error} onRetry={() => list.refetch()} />
|
||||
) : !filtered || filtered.length === 0 ? (
|
||||
<ListEmpty
|
||||
title={search ? 'No matches' : 'No allowed kinds'}
|
||||
hint={
|
||||
search
|
||||
? 'Nothing on this list matches the filter.'
|
||||
: 'The relay reports an empty kind list — depending on its configuration, that may mean all kinds are accepted.'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-border border-t border-border">
|
||||
{filtered.map(({ kind }) => (
|
||||
<PolicyRow
|
||||
key={kind}
|
||||
id={`kind ${kind}`}
|
||||
reason={undefined}
|
||||
reasonLabel=""
|
||||
actions={
|
||||
canDisallow ? (
|
||||
<RowAction
|
||||
label="Disallow"
|
||||
destructive
|
||||
pending={isPending}
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
title: `Disallow kind ${kind}?`,
|
||||
target: `kind ${kind}`,
|
||||
effect:
|
||||
'If this relay enforces its kind allowlist, it will stop accepting and serving events of this kind.',
|
||||
reversible: 'Reversible: you can allow the kind again.',
|
||||
actionLabel: 'Disallow kind',
|
||||
run: () =>
|
||||
run({
|
||||
method: 'disallowkind',
|
||||
params: [kind],
|
||||
refresh: ['listallowedkinds'],
|
||||
success: `Kind ${kind} disallowed`,
|
||||
}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px]">read-only</Badge>
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<AddDialog
|
||||
open={addOpen}
|
||||
onOpenChange={setAddOpen}
|
||||
title="Allow kind"
|
||||
description="Add an event kind to this relay’s allowlist (0–65535)."
|
||||
idPrefix="allow-kind"
|
||||
valueLabel="Kind number"
|
||||
valueHint="e.g. 1 for short text notes, 30023 for long-form articles."
|
||||
valuePlaceholder="1"
|
||||
withReason={false}
|
||||
pending={isPending}
|
||||
validate={(value) => {
|
||||
const parsed = parseKindInput(value);
|
||||
return typeof parsed === 'number' ? undefined : parsed.error;
|
||||
}}
|
||||
onSubmit={async (value) => {
|
||||
const parsed = parseKindInput(value);
|
||||
if (typeof parsed !== 'number') return;
|
||||
await run({
|
||||
method: 'allowkind',
|
||||
params: [parsed],
|
||||
refresh: ['listallowedkinds'],
|
||||
success: `Kind ${parsed} allowed`,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ConfirmAction pending={confirm} onClose={() => setConfirm(undefined)} />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
245
src/apps/relay-admin/RelaySection.tsx
Normal file
245
src/apps/relay-admin/RelaySection.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { useState } from 'react';
|
||||
import { Globe, Loader2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
useNip86Mutation,
|
||||
type AuditEntry,
|
||||
type Nip86Session,
|
||||
} from '@/hooks/useNip86';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { sanitizeUrl } from '@/lib/nostrUtils';
|
||||
import { sanitizeIconUrl as validateIconUrl } from '@/lib/nip86';
|
||||
import { Field, FormDialog } from './dialogs';
|
||||
import { Section } from './shared';
|
||||
|
||||
type AuditFn = (entry: Omit<AuditEntry, 'id' | 'at'>) => void;
|
||||
|
||||
type PresentationMethod = 'changerelayname' | 'changerelaydescription' | 'changerelayicon';
|
||||
|
||||
const FIELDS: Record<
|
||||
PresentationMethod,
|
||||
{ title: string; label: string; description: string; placeholder: string; multiline?: boolean }
|
||||
> = {
|
||||
changerelayname: {
|
||||
title: 'Change relay name',
|
||||
label: 'Relay name',
|
||||
description: 'The name the relay publishes in its NIP-11 information document.',
|
||||
placeholder: 'My relay',
|
||||
},
|
||||
changerelaydescription: {
|
||||
title: 'Change relay description',
|
||||
label: 'Description',
|
||||
description: 'The description the relay publishes in its NIP-11 information document.',
|
||||
placeholder: 'What this relay is for, who may use it, …',
|
||||
multiline: true,
|
||||
},
|
||||
changerelayicon: {
|
||||
title: 'Change relay icon',
|
||||
label: 'Icon URL',
|
||||
description: 'An https:// image URL the relay publishes as its icon.',
|
||||
placeholder: 'https://example.com/icon.png',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Relay presentation: name, description and icon as published in the relay's
|
||||
* NIP-11 document. The current values come from the discovery-time NIP-11
|
||||
* fetch and are shown read-only; each change previews before it is sent. The
|
||||
* list re-reads the document on demand rather than trusting the mutation.
|
||||
*/
|
||||
export function RelayPresentationSection({
|
||||
session,
|
||||
onResult,
|
||||
}: {
|
||||
session: Nip86Session;
|
||||
onResult: AuditFn;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const mutation = useNip86Mutation(session, { onResult });
|
||||
const [editing, setEditing] = useState<PresentationMethod | undefined>(undefined);
|
||||
const [value, setValue] = useState('');
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const [openFor, setOpenFor] = useState<PresentationMethod | undefined>(undefined);
|
||||
|
||||
const available = (Object.keys(FIELDS) as PresentationMethod[]).filter((method) =>
|
||||
session.methods.includes(method),
|
||||
);
|
||||
|
||||
const current = (method: PresentationMethod): string | undefined =>
|
||||
method === 'changerelayname'
|
||||
? session.info?.name
|
||||
: method === 'changerelaydescription'
|
||||
? session.info?.description
|
||||
: session.info?.icon;
|
||||
|
||||
// Render-phase reset when a different field is opened.
|
||||
if (editing !== openFor) {
|
||||
setOpenFor(editing);
|
||||
setValue(editing ? (current(editing) ?? '') : '');
|
||||
setError(undefined);
|
||||
}
|
||||
|
||||
const iconPreview = editing === 'changerelayicon' ? sanitizeUrl(value.trim()) : undefined;
|
||||
const iconValid =
|
||||
editing === 'changerelayicon'
|
||||
? typeof validateIconUrl(value) === 'string'
|
||||
? undefined
|
||||
: (validateIconUrl(value) as { error: string }).error
|
||||
: undefined;
|
||||
|
||||
const submit = async () => {
|
||||
if (!editing) return;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
setError('Enter a value.');
|
||||
return;
|
||||
}
|
||||
if (editing === 'changerelayicon') {
|
||||
const result = validateIconUrl(trimmed);
|
||||
if (typeof result !== 'string') {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await mutation.mutateAsync({ method: editing, params: [trimmed] });
|
||||
toast({ title: `${FIELDS[editing].label} updated` });
|
||||
setEditing(undefined);
|
||||
} catch (cause) {
|
||||
toast({
|
||||
title: 'The relay refused the change',
|
||||
description: cause instanceof Error ? cause.message : undefined,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Relay presentation"
|
||||
description="Name, description and icon as published in the relay’s NIP-11 information document. Only the fields this relay advertises can be changed."
|
||||
>
|
||||
<ul className="divide-y divide-border border-t border-border">
|
||||
{available.map((method) => {
|
||||
const field = FIELDS[method];
|
||||
const existing = current(method);
|
||||
return (
|
||||
<li key={method} className="flex items-center gap-3 px-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium">{field.label}</p>
|
||||
{existing ? (
|
||||
method === 'changerelayicon' ? (
|
||||
<span className="mt-1 flex items-center gap-2">
|
||||
<IconPreview url={existing} />
|
||||
<span className="truncate font-mono text-xs text-muted-foreground" title={existing}>
|
||||
{existing}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<p className="truncate text-xs text-muted-foreground" title={existing}>
|
||||
{existing}
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-xs italic text-muted-foreground">not set</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 px-2 text-xs"
|
||||
onClick={() => setEditing(method)}
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<FormDialog
|
||||
open={Boolean(editing)}
|
||||
onOpenChange={(open) => !open && setEditing(undefined)}
|
||||
title={editing ? FIELDS[editing].title : ''}
|
||||
description={editing ? FIELDS[editing].description : undefined}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setEditing(undefined)} disabled={mutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={mutation.isPending || !value.trim() || Boolean(iconValid)}>
|
||||
{mutation.isPending && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field
|
||||
id="presentation-value"
|
||||
label={editing ? FIELDS[editing].label : ''}
|
||||
error={error ?? iconValid}
|
||||
>
|
||||
{editing === 'changerelaydescription' ? (
|
||||
<Textarea
|
||||
id="presentation-value"
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
setValue(event.target.value);
|
||||
setError(undefined);
|
||||
}}
|
||||
placeholder={FIELDS[editing].placeholder}
|
||||
aria-invalid={Boolean(error)}
|
||||
aria-describedby={error ? 'presentation-value-error' : undefined}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id="presentation-value"
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
setValue(event.target.value);
|
||||
setError(undefined);
|
||||
}}
|
||||
onKeyDown={(event) => event.key === 'Enter' && submit()}
|
||||
placeholder={editing ? FIELDS[editing].placeholder : undefined}
|
||||
aria-invalid={Boolean(error ?? iconValid)}
|
||||
aria-describedby={error ?? iconValid ? 'presentation-value-error' : undefined}
|
||||
className={editing === 'changerelayicon' ? 'font-mono text-xs' : undefined}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{editing === 'changerelayicon' && (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-dashed border-border p-3">
|
||||
{iconPreview ? (
|
||||
<IconPreview url={iconPreview} large />
|
||||
) : (
|
||||
<span className="flex size-10 items-center justify-center rounded-md bg-muted">
|
||||
<Globe className="size-5 text-muted-foreground" aria-hidden />
|
||||
</span>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{iconPreview ? 'Preview of the new icon.' : 'Enter a valid https:// URL to preview the icon.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</FormDialog>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function IconPreview({ url, large }: { url: string; large?: boolean }) {
|
||||
const safe = sanitizeUrl(url);
|
||||
if (!safe) return null;
|
||||
return (
|
||||
<img
|
||||
src={safe}
|
||||
alt=""
|
||||
className={large ? 'size-10 rounded-md object-cover' : 'size-6 rounded-md object-cover'}
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
}
|
||||
487
src/apps/relay-admin/RolesSection.tsx
Normal file
487
src/apps/relay-admin/RolesSection.tsx
Normal file
@@ -0,0 +1,487 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2, Pencil, Plus } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
useNip86Mutation,
|
||||
useRelayRoles,
|
||||
type AuditEntry,
|
||||
type Nip86Session,
|
||||
} from '@/hooks/useNip86';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import {
|
||||
parsePubkeyInput,
|
||||
parseRoles,
|
||||
validateRoleColor,
|
||||
validateRoleId,
|
||||
type Nip86Role,
|
||||
} from '@/lib/nip86';
|
||||
import { ConfirmAction, Field, FormDialog, type PendingConfirm } from './dialogs';
|
||||
import { ListEmpty, ListSkeleton, RowAction, Section } from './shared';
|
||||
|
||||
type AuditFn = (entry: Omit<AuditEntry, 'id' | 'at'>) => void;
|
||||
|
||||
/**
|
||||
* Roles. The mutation methods (createrole/editrole/deleterole/assignrole/
|
||||
* unassignrole) are standard NIP-86, but no standard "list roles" method
|
||||
* exists — roles are only listed when the relay advertises the common
|
||||
* `listroles` extension, and that is labelled as relay-specific.
|
||||
*/
|
||||
|
||||
interface RoleForm {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
color: string;
|
||||
order: string;
|
||||
}
|
||||
|
||||
const EMPTY_ROLE: RoleForm = { id: '', label: '', description: '', color: '', order: '0' };
|
||||
|
||||
function RoleDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
editing,
|
||||
pending,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** When set, the dialog edits this role (id immutable) instead of creating. */
|
||||
editing: Nip86Role | undefined;
|
||||
pending: boolean;
|
||||
onSubmit: (form: RoleForm) => Promise<void>;
|
||||
}) {
|
||||
const [form, setForm] = useState<RoleForm>(EMPTY_ROLE);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const [openFor, setOpenFor] = useState<Nip86Role | 'new' | undefined>(undefined);
|
||||
|
||||
// Render-phase reset whenever the dialog targets a different role.
|
||||
const target = open ? (editing ?? ('new' as const)) : undefined;
|
||||
if (target !== openFor) {
|
||||
setOpenFor(target);
|
||||
setError(undefined);
|
||||
setForm(
|
||||
editing
|
||||
? {
|
||||
id: editing.id,
|
||||
label: editing.label ?? '',
|
||||
description: editing.description ?? '',
|
||||
color: editing.color ?? '',
|
||||
order: String(editing.order ?? 0),
|
||||
}
|
||||
: EMPTY_ROLE,
|
||||
);
|
||||
}
|
||||
|
||||
const idError = editing ? undefined : validateRoleId(form.id);
|
||||
const colorError = validateRoleColor(form.color);
|
||||
const orderValid = /^\d+$/.test(form.order.trim());
|
||||
|
||||
const submit = async () => {
|
||||
if (!editing && idError) {
|
||||
setError(idError);
|
||||
return;
|
||||
}
|
||||
if (!orderValid) {
|
||||
setError('Order must be a whole number.');
|
||||
return;
|
||||
}
|
||||
if (colorError) {
|
||||
setError(colorError);
|
||||
return;
|
||||
}
|
||||
await onSubmit(form);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={editing ? `Edit role ${editing.id}` : 'Create role'}
|
||||
description="Roles are relay-side groupings (e.g. moderators). What a role grants is defined by the relay."
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submit}
|
||||
disabled={
|
||||
pending ||
|
||||
!form.id.trim() ||
|
||||
!form.label.trim() ||
|
||||
Boolean(form.id && idError) ||
|
||||
Boolean(colorError) ||
|
||||
!orderValid
|
||||
}
|
||||
>
|
||||
{pending && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
|
||||
{editing ? 'Save role' : 'Create role'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && (
|
||||
<p className="text-xs text-destructive" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Field id="role-id" label="Role ID" hint={editing ? 'The ID cannot be changed.' : 'Lowercase slug, e.g. moderator.'}>
|
||||
<Input
|
||||
id="role-id"
|
||||
value={form.id}
|
||||
onChange={(event) => setForm((current) => ({ ...current, id: event.target.value }))}
|
||||
disabled={Boolean(editing)}
|
||||
className="font-mono text-xs"
|
||||
autoFocus={!editing}
|
||||
/>
|
||||
</Field>
|
||||
<Field id="role-label" label="Label">
|
||||
<Input
|
||||
id="role-label"
|
||||
value={form.label}
|
||||
onChange={(event) => setForm((current) => ({ ...current, label: event.target.value }))}
|
||||
placeholder="Moderator"
|
||||
autoFocus={Boolean(editing)}
|
||||
/>
|
||||
</Field>
|
||||
<Field id="role-description" label="Description (optional)">
|
||||
<Input
|
||||
id="role-description"
|
||||
value={form.description}
|
||||
onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field id="role-color" label="Color (optional)" hint="#8b5cf6">
|
||||
<Input
|
||||
id="role-color"
|
||||
value={form.color}
|
||||
onChange={(event) => setForm((current) => ({ ...current, color: event.target.value }))}
|
||||
aria-invalid={Boolean(colorError)}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field id="role-order" label="Order">
|
||||
<Input
|
||||
id="role-order"
|
||||
value={form.order}
|
||||
onChange={(event) => setForm((current) => ({ ...current, order: event.target.value }))}
|
||||
aria-invalid={!orderValid}
|
||||
inputMode="numeric"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function AssignDialog({
|
||||
role,
|
||||
onOpenChange,
|
||||
pending,
|
||||
onSubmit,
|
||||
}: {
|
||||
role: Nip86Role | undefined;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
pending: boolean;
|
||||
onSubmit: (pubkey: string) => Promise<void>;
|
||||
}) {
|
||||
const [value, setValue] = useState('');
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const close = (next: boolean) => {
|
||||
if (!next) {
|
||||
setValue('');
|
||||
setError(undefined);
|
||||
}
|
||||
onOpenChange(next);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const parsed = parsePubkeyInput(value);
|
||||
if (typeof parsed !== 'string') {
|
||||
setError(parsed.error);
|
||||
return;
|
||||
}
|
||||
await onSubmit(parsed);
|
||||
close(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormDialog
|
||||
open={Boolean(role)}
|
||||
onOpenChange={close}
|
||||
title={`Assign role ${role?.id ?? ''}`}
|
||||
description="Give a pubkey this role. What members of the role may do is enforced by the relay."
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => close(false)} disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={pending || !value.trim()}>
|
||||
{pending && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
|
||||
Assign role
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field
|
||||
id="assign-pubkey"
|
||||
label="Public key"
|
||||
error={error}
|
||||
hint={error ? undefined : '64 hex characters, or an npub1… / nprofile1… identifier.'}
|
||||
>
|
||||
<Input
|
||||
id="assign-pubkey"
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
setValue(event.target.value);
|
||||
setError(undefined);
|
||||
}}
|
||||
onKeyDown={(event) => event.key === 'Enter' && submit()}
|
||||
placeholder="npub1…"
|
||||
aria-invalid={Boolean(error)}
|
||||
aria-describedby={error ? 'assign-pubkey-error' : undefined}
|
||||
className="font-mono text-xs"
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function RolesSection({
|
||||
session,
|
||||
onResult,
|
||||
}: {
|
||||
session: Nip86Session;
|
||||
onResult: AuditFn;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const mutation = useNip86Mutation(session, { onResult });
|
||||
const rolesQuery = useRelayRoles(session, { onResult });
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Nip86Role | undefined>(undefined);
|
||||
const [assigning, setAssigning] = useState<Nip86Role | undefined>(undefined);
|
||||
const [confirm, setConfirm] = useState<PendingConfirm | undefined>(undefined);
|
||||
const [unassignKey, setUnassignKey] = useState('');
|
||||
|
||||
const canCreate = session.methods.includes('createrole');
|
||||
const canEdit = session.methods.includes('editrole');
|
||||
const canDelete = session.methods.includes('deleterole');
|
||||
const canAssign = session.methods.includes('assignrole');
|
||||
const canUnassign = session.methods.includes('unassignrole');
|
||||
|
||||
const roles = session.canListRoles ? parseRoles(rolesQuery.data) : [];
|
||||
|
||||
const run = async (
|
||||
method: 'createrole' | 'editrole' | 'deleterole' | 'assignrole' | 'unassignrole',
|
||||
params: unknown[],
|
||||
success: string,
|
||||
) => {
|
||||
try {
|
||||
await mutation.mutateAsync({
|
||||
method,
|
||||
params,
|
||||
refresh: session.canListRoles ? ['listroles'] : [],
|
||||
});
|
||||
toast({ title: success });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'The relay refused the operation',
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
variant: 'destructive',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Roles"
|
||||
description="Relay-side groupings such as moderators. Deleting or reassigning roles changes what people can do — treat both as sensitive."
|
||||
actions={
|
||||
canCreate ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 gap-1 px-2 text-xs"
|
||||
onClick={() => {
|
||||
setEditing(undefined);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5" aria-hidden />
|
||||
New role
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{session.canListRoles ? (
|
||||
<>
|
||||
<p className="px-3 pb-2 text-xs text-muted-foreground">
|
||||
The role list below comes from <span className="font-mono">listroles</span>, a
|
||||
relay-specific extension — it is not standard NIP-86.
|
||||
</p>
|
||||
{rolesQuery.isLoading ? (
|
||||
<ListSkeleton rows={2} />
|
||||
) : rolesQuery.isError ? (
|
||||
<ListEmpty title="Could not load roles" hint={rolesQuery.error.message} />
|
||||
) : roles.length === 0 ? (
|
||||
<ListEmpty title="No roles" hint="The relay reports no roles yet." />
|
||||
) : (
|
||||
<ul className="divide-y divide-border border-t border-border">
|
||||
{roles.map((role) => (
|
||||
<li key={role.id} className="flex items-center gap-3 px-3 py-2">
|
||||
<span
|
||||
className="size-3 shrink-0 rounded-full border border-border"
|
||||
style={
|
||||
// Role colors come from the relay; only valid hex values
|
||||
// may reach a style attribute.
|
||||
role.color && validateRoleColor(role.color) === undefined && role.color
|
||||
? { backgroundColor: role.color }
|
||||
: undefined
|
||||
}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{role.label || role.id}{' '}
|
||||
<span className="font-mono text-xs font-normal text-muted-foreground">
|
||||
{role.id}
|
||||
</span>
|
||||
</p>
|
||||
{role.description && (
|
||||
<p className="truncate text-xs text-muted-foreground" title={role.description}>
|
||||
{role.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{canAssign && (
|
||||
<RowAction label="Assign" pending={mutation.isPending} onClick={() => setAssigning(role)} />
|
||||
)}
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
aria-label={`Edit role ${role.id}`}
|
||||
onClick={() => {
|
||||
setEditing(role);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-3.5" aria-hidden />
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<RowAction
|
||||
label="Delete"
|
||||
destructive
|
||||
pending={mutation.isPending}
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
title: `Delete role ${role.id}?`,
|
||||
target: `role ${role.id}`,
|
||||
effect:
|
||||
'The role stops existing. What happens to its current members depends on the relay.',
|
||||
reversible: 'Not directly reversible: you would have to recreate the role and reassign its members.',
|
||||
actionLabel: 'Delete role',
|
||||
run: () => run('deleterole', [role.id], `Role ${role.id} deleted`),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<ListEmpty
|
||||
title="Roles cannot be listed"
|
||||
hint="This relay does not advertise a way to list roles (listroles is not standard NIP-86). If you know a role's ID — from the relay's documentation — you can still manage it below."
|
||||
/>
|
||||
)}
|
||||
|
||||
{canUnassign && (
|
||||
<div className="px-3 pt-3">
|
||||
<Label htmlFor="unassign-form" className="text-xs">
|
||||
Remove a role from a pubkey
|
||||
</Label>
|
||||
<form
|
||||
id="unassign-form"
|
||||
className="mt-1.5 flex flex-wrap items-center gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const roleId = (new FormData(event.currentTarget).get('role') ?? '').toString().trim();
|
||||
const parsed = parsePubkeyInput(unassignKey);
|
||||
if (typeof parsed !== 'string' || !roleId) return;
|
||||
setConfirm({
|
||||
title: `Remove role ${roleId}?`,
|
||||
target: `role ${roleId} for pubkey ${parsed.slice(0, 16)}…`,
|
||||
effect: 'The key loses whatever this role grants on the relay.',
|
||||
reversible: 'Reversible: you can assign the role again.',
|
||||
actionLabel: 'Remove role',
|
||||
run: () => run('unassignrole', [parsed, roleId], 'Role removed'),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
name="role"
|
||||
placeholder="role id"
|
||||
aria-label="Role ID to remove"
|
||||
className="h-8 w-32 font-mono text-xs"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
value={unassignKey}
|
||||
onChange={(event) => setUnassignKey(event.target.value)}
|
||||
placeholder="npub1…"
|
||||
aria-label="Public key to remove the role from"
|
||||
className="h-8 min-w-40 flex-1 font-mono text-xs"
|
||||
required
|
||||
/>
|
||||
<Button type="submit" variant="outline" size="sm" className="h-8 text-xs" disabled={mutation.isPending}>
|
||||
Remove
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RoleDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
editing={editing}
|
||||
pending={mutation.isPending}
|
||||
onSubmit={(form) =>
|
||||
run(
|
||||
editing ? 'editrole' : 'createrole',
|
||||
[
|
||||
form.id.trim(),
|
||||
form.label.trim(),
|
||||
form.description.trim(),
|
||||
form.color.trim(),
|
||||
Number(form.order.trim()),
|
||||
],
|
||||
editing ? `Role ${form.id} updated` : `Role ${form.id} created`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<AssignDialog
|
||||
role={assigning}
|
||||
onOpenChange={(open) => !open && setAssigning(undefined)}
|
||||
pending={mutation.isPending}
|
||||
onSubmit={(pubkey) => run('assignrole', [pubkey, assigning?.id ?? ''], `Role ${assigning?.id} assigned`)}
|
||||
/>
|
||||
<ConfirmAction pending={confirm} onClose={() => setConfirm(undefined)} />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
343
src/apps/relay-admin/dialogs.tsx
Normal file
343
src/apps/relay-admin/dialogs.tsx
Normal file
@@ -0,0 +1,343 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Loader2, TriangleAlert } from 'lucide-react';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Safety dialogs. Two tiers:
|
||||
*
|
||||
* - `ConfirmAction` for destructive-but-reversible operations (bans, unbans,
|
||||
* unallowing access). It states the target, the likely effect, and whether
|
||||
* the relay offers a reverse operation.
|
||||
* - `TypedConfirmAction` for relay-specific extensions, which are treated as
|
||||
* potentially irreversible: the operator must type a phrase, and the dialog
|
||||
* says plainly that no undo is known.
|
||||
*
|
||||
* Cancelling a dialog never sends anything; the copy avoids implying a
|
||||
* cancelled request rolled back relay-side work.
|
||||
*/
|
||||
|
||||
export interface PendingConfirm {
|
||||
title: string;
|
||||
target: string;
|
||||
effect: string;
|
||||
reversible?: string;
|
||||
actionLabel: string;
|
||||
run: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function ConfirmAction({
|
||||
pending,
|
||||
onClose,
|
||||
}: {
|
||||
pending: PendingConfirm | undefined;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const confirm = async () => {
|
||||
if (!pending) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await pending.run();
|
||||
onClose();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
open={Boolean(pending)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !busy) onClose();
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{pending?.title}</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-2 text-left">
|
||||
<span className="block">
|
||||
Target: <span className="break-all font-mono text-xs">{pending?.target}</span>
|
||||
</span>
|
||||
<span className="block">{pending?.effect}</span>
|
||||
<span className="block">
|
||||
{pending?.reversible ?? 'The relay offers no operation that reverses this.'}
|
||||
</span>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={busy}>Cancel</AlertDialogCancel>
|
||||
<Button variant="destructive" onClick={confirm} disabled={busy}>
|
||||
{busy && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
|
||||
{pending?.actionLabel}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PendingTypedConfirm {
|
||||
title: string;
|
||||
method: string;
|
||||
/** The exact phrase the operator must type, e.g. the method name. */
|
||||
phrase: string;
|
||||
effect: string;
|
||||
run: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function TypedConfirmAction({
|
||||
pending,
|
||||
onClose,
|
||||
}: {
|
||||
pending: PendingTypedConfirm | undefined;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [typed, setTyped] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirmedFor, setConfirmedFor] = useState<PendingTypedConfirm | undefined>(undefined);
|
||||
|
||||
// Reset the typed phrase whenever a *different* operation is opened. This is
|
||||
// a render-phase reset (not an effect) so it passes the hooks lint rules.
|
||||
if (pending !== confirmedFor) {
|
||||
setConfirmedFor(pending);
|
||||
setTyped('');
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
const matches = pending ? typed.trim() === pending.phrase : false;
|
||||
|
||||
const confirm = async () => {
|
||||
if (!pending || !matches) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await pending.run();
|
||||
onClose();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
open={Boolean(pending)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !busy) onClose();
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<TriangleAlert className="size-5 text-destructive" aria-hidden />
|
||||
{pending?.title}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-2 text-left">
|
||||
<span className="block">
|
||||
<span className="font-mono text-xs">{pending?.method}</span> is not part of the
|
||||
NIP-86 standard. It is specific to this relay implementation, and its exact effect
|
||||
is defined by the relay — not by this console.
|
||||
</span>
|
||||
<span className="block">{pending?.effect}</span>
|
||||
<span className="block font-medium text-destructive">
|
||||
There is no standard way to reverse this operation.
|
||||
</span>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="typed-confirm" className="text-xs">
|
||||
Type <span className="font-mono font-semibold">{pending?.phrase}</span> to confirm
|
||||
</Label>
|
||||
<Input
|
||||
id="typed-confirm"
|
||||
value={typed}
|
||||
onChange={(event) => setTyped(event.target.value)}
|
||||
placeholder={pending?.phrase}
|
||||
autoComplete="off"
|
||||
className="font-mono text-xs"
|
||||
aria-invalid={typed.length > 0 && !matches}
|
||||
/>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={busy}>Cancel</AlertDialogCancel>
|
||||
<Button variant="destructive" onClick={confirm} disabled={!matches || busy}>
|
||||
{busy && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
|
||||
Run {pending?.method}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Banning an event by ID is destructive, so the flow is a dedicated dialog
|
||||
* with its own validation, not a generic form.
|
||||
*/
|
||||
export function BanEventDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
pending,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
pending: boolean;
|
||||
onSubmit: (value: string, reason: string) => Promise<void>;
|
||||
}) {
|
||||
const [id, setId] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const close = (next: boolean) => {
|
||||
if (!next) {
|
||||
setId('');
|
||||
setReason('');
|
||||
setError(undefined);
|
||||
}
|
||||
onOpenChange(next);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
// Local validation happens here in the dialog; the caller re-parses.
|
||||
const trimmed = id.trim();
|
||||
if (!trimmed) {
|
||||
setError('Enter an event ID.');
|
||||
return;
|
||||
}
|
||||
await onSubmit(trimmed, reason);
|
||||
close(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormDialog
|
||||
open={open}
|
||||
onOpenChange={close}
|
||||
title="Ban event by ID"
|
||||
description="The relay will stop serving this event and may delete its stored copy. The event can still exist on other relays."
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => close(false)} disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={submit} disabled={pending || !id.trim()}>
|
||||
{pending && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
|
||||
Ban event
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field id="ban-event-id" label="Event ID" error={error} hint={error ? undefined : '64 hex characters, or a note1… / nevent1… identifier.'}>
|
||||
<Input
|
||||
id="ban-event-id"
|
||||
value={id}
|
||||
onChange={(event) => {
|
||||
setId(event.target.value);
|
||||
setError(undefined);
|
||||
}}
|
||||
onKeyDown={(event) => event.key === 'Enter' && submit()}
|
||||
placeholder="note1…"
|
||||
aria-invalid={Boolean(error)}
|
||||
aria-describedby={error ? 'ban-event-id-error' : undefined}
|
||||
className="font-mono text-xs"
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
<Field id="ban-event-reason" label="Reason (optional)" hint="Stored by the relay and shown to other operators.">
|
||||
<Input
|
||||
id="ban-event-reason"
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
className="text-sm"
|
||||
/>
|
||||
</Field>
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Generic form dialog wrapper used by every "add / edit" flow. */
|
||||
export function FormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
wide,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
footer: ReactNode;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className={cn('max-h-[90dvh] overflow-y-auto', wide && 'sm:max-w-xl')}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description && <DialogDescription>{description}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">{children}</div>
|
||||
<DialogFooter>{footer}</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Validated text field: label, error wiring and hint in one place. */
|
||||
export function Field({
|
||||
id,
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={id} className="text-xs">
|
||||
{label}
|
||||
</Label>
|
||||
{children}
|
||||
{error ? (
|
||||
<p id={`${id}-error`} className="text-xs text-destructive" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : hint ? (
|
||||
<p className="text-xs text-muted-foreground">{hint}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
367
src/apps/relay-admin/index.tsx
Normal file
367
src/apps/relay-admin/index.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
CircleCheck,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
Plug,
|
||||
ShieldCheck,
|
||||
TriangleAlert,
|
||||
Unplug,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { AppBody, AppLayout, AppToolbar, EmptyState } from '@/components/os/AppChrome';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import {
|
||||
useNip86Connection,
|
||||
usePolicyMode,
|
||||
type Nip86Session,
|
||||
type PolicyMode,
|
||||
} from '@/hooks/useNip86';
|
||||
import { Nip86Error, relayWsUrl } from '@/lib/nip86';
|
||||
import { sanitizeUrl } from '@/lib/nostrUtils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AppProps } from '@/os/types';
|
||||
import { AuditSection } from './AuditSection';
|
||||
import { EventModerationSection } from './ModerationSection';
|
||||
import {
|
||||
AllowedKindsSection,
|
||||
AllowedPubkeysSection,
|
||||
BannedPubkeysSection,
|
||||
BlockedIpsSection,
|
||||
} from './PolicySections';
|
||||
import { RelayPresentationSection } from './RelaySection';
|
||||
import { RolesSection } from './RolesSection';
|
||||
import { ExtensionsSection } from './ExtensionsSection';
|
||||
|
||||
/**
|
||||
* Relay Admin — a NIP-86 management console.
|
||||
*
|
||||
* NIP-86 is a draft, optional HTTP(S) API, so the entire console is driven by
|
||||
* discovery: connect, authorize with NIP-98, call `supportedmethods`, and only
|
||||
* render what the relay actually advertised. Nothing is attempted optimistically.
|
||||
*/
|
||||
export default function RelayAdminApp({ params, setTitle, setParams }: AppProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const connection = useNip86Connection();
|
||||
const { session, isConnecting, error } = connection;
|
||||
|
||||
// The connected relay is part of the window's deep link, so a reload (or
|
||||
// the desktop ↔ mobile shell switch) restores the view. The audit log and
|
||||
// discovery intentionally are not restored: reconnecting re-runs them.
|
||||
useEffect(() => {
|
||||
if (session && params.relay !== session.url) {
|
||||
setParams({ relay: session.url });
|
||||
}
|
||||
}, [session, params.relay, setParams]);
|
||||
|
||||
useEffect(() => {
|
||||
const name = session?.info?.name?.trim();
|
||||
setTitle(session ? `Relay Admin — ${name || session.url.replace(/^https?:\/\//, '')}` : 'Relay Admin');
|
||||
}, [session, setTitle]);
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<AppToolbar>
|
||||
<ShieldCheck className="size-4 shrink-0 text-primary" aria-hidden />
|
||||
<span className="min-w-0 truncate text-[13px] font-medium">
|
||||
{session ? session.url.replace(/^https?:\/\//, '') : 'Relay Admin'}
|
||||
</span>
|
||||
{session && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto h-7 gap-1.5 px-2 text-xs"
|
||||
onClick={connection.disconnect}
|
||||
>
|
||||
<Unplug className="size-3.5" aria-hidden />
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
</AppToolbar>
|
||||
|
||||
<AppBody>
|
||||
{!session ? (
|
||||
<ConnectView
|
||||
initialUrl={params.relay}
|
||||
connecting={isConnecting}
|
||||
error={error}
|
||||
signedIn={Boolean(user)}
|
||||
onConnect={connection.connect}
|
||||
/>
|
||||
) : (
|
||||
<Console session={session} connection={connection} />
|
||||
)}
|
||||
</AppBody>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
* Connect view: URL normalization, auth state, discovery result.
|
||||
* ------------------------------------------------------------------------ */
|
||||
|
||||
function ConnectView({
|
||||
initialUrl,
|
||||
connecting,
|
||||
error,
|
||||
signedIn,
|
||||
onConnect,
|
||||
}: {
|
||||
initialUrl?: string;
|
||||
connecting: boolean;
|
||||
error: Nip86Error | undefined;
|
||||
signedIn: boolean;
|
||||
onConnect: (input: string) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<div className="mx-auto flex h-full w-full max-w-md flex-col justify-center gap-4 p-6">
|
||||
<div className="space-y-1 text-center">
|
||||
<h2 className="text-lg font-semibold tracking-tight">Manage a relay</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Connect to a relay that speaks NIP-86 — the draft, optional relay management API — with
|
||||
the key that administers it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const value = (new FormData(event.currentTarget).get('relay') ?? '').toString();
|
||||
void onConnect(value);
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="relay-url">Relay URL</Label>
|
||||
<Input
|
||||
id="relay-url"
|
||||
name="relay"
|
||||
defaultValue={initialUrl}
|
||||
placeholder="wss://relay.example.com"
|
||||
className="font-mono text-xs"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg border px-3 py-2 text-sm',
|
||||
signedIn ? 'border-border' : 'border-dashed border-border text-muted-foreground',
|
||||
)}
|
||||
role="status"
|
||||
>
|
||||
<KeyRound className="size-4 shrink-0" aria-hidden />
|
||||
{signedIn ? (
|
||||
<span>
|
||||
Signed in — requests will be authorized with your key via NIP-98. Your key never
|
||||
leaves your signer.
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex-1">Sign in with the relay operator key to authorize requests.</span>
|
||||
)}
|
||||
{!signedIn && <LoginArea />}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm"
|
||||
role="alert"
|
||||
>
|
||||
<TriangleAlert className="mt-0.5 size-4 shrink-0 text-destructive" aria-hidden />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-destructive">{errorTitle(error)}</p>
|
||||
<p className="text-muted-foreground">{error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full gap-1.5" disabled={connecting || !signedIn}>
|
||||
{connecting ? (
|
||||
<Loader2 className="size-4 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Plug className="size-4" aria-hidden />
|
||||
)}
|
||||
{connecting ? 'Discovering capabilities…' : 'Connect'}
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Discovery asks the relay which management methods it supports and renders only those.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function errorTitle(error: Nip86Error): string {
|
||||
switch (error.code) {
|
||||
case 'invalid-url':
|
||||
return 'Check the relay URL';
|
||||
case 'unreachable':
|
||||
return 'Relay unreachable';
|
||||
case 'unauthorized':
|
||||
return 'Not authorized';
|
||||
case 'forbidden':
|
||||
return 'Operation forbidden';
|
||||
case 'malformed':
|
||||
return 'No NIP-86 support';
|
||||
case 'not-logged-in':
|
||||
return 'Sign-in required';
|
||||
default:
|
||||
return 'Discovery failed';
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
* The console: identity, policy mode, capability summary, sections.
|
||||
* ------------------------------------------------------------------------ */
|
||||
|
||||
const POLICY_MODE: Record<PolicyMode, { label: string; tone: string; explanation: string }> = {
|
||||
blocklist: {
|
||||
label: 'Blocklist-led',
|
||||
tone: 'border-warning/50 bg-warning/10 text-warning-foreground',
|
||||
explanation:
|
||||
'This relay advertises ban lists (pubkeys, IPs, events): a reactive, public-relay operating model. A blocklist is never exhaustive — it is not a permission system.',
|
||||
},
|
||||
allowlist: {
|
||||
label: 'Allowlist-led',
|
||||
tone: 'border-primary/50 bg-primary/10 text-primary',
|
||||
explanation:
|
||||
'This relay advertises allowlists (pubkeys, kinds): a restrictive, private-relay operating model. Only the relay’s own enforcement decides what being on the list means — an allowlist alone does not make a relay private.',
|
||||
},
|
||||
unknown: {
|
||||
label: 'Policy mode unknown',
|
||||
tone: 'border-border bg-muted text-muted-foreground',
|
||||
explanation:
|
||||
'The advertised methods do not clearly favor a blocklist or allowlist model. Check the relay’s documentation for how it enforces policy.',
|
||||
},
|
||||
};
|
||||
|
||||
function Console({
|
||||
session,
|
||||
connection,
|
||||
}: {
|
||||
session: Nip86Session;
|
||||
connection: ReturnType<typeof useNip86Connection>;
|
||||
}) {
|
||||
const mode = usePolicyMode(session.methods);
|
||||
const modeInfo = POLICY_MODE[mode];
|
||||
const icon = session.info?.icon ? sanitizeUrl(session.info.icon) : undefined;
|
||||
|
||||
const has = (method: string) => session.methods.includes(method);
|
||||
const hasAny = (...methods: string[]) => methods.some((method) => has(method));
|
||||
|
||||
const rolesAdvertised = hasAny('createrole', 'editrole', 'deleterole', 'assignrole', 'unassignrole');
|
||||
const presentationAdvertised = hasAny('changerelayname', 'changerelaydescription', 'changerelayicon');
|
||||
|
||||
// Standard methods that produce no console section (supportedmethods itself
|
||||
// and `stat`, which predates NIP-86 and carries no policy data).
|
||||
const SECTION_METHODS = new Set([
|
||||
'listbannedpubkeys', 'banpubkey', 'unbanpubkey',
|
||||
'listallowedpubkeys', 'allowpubkey', 'unallowpubkey',
|
||||
'listblockedips', 'blockip', 'unblockip',
|
||||
'listallowedkinds', 'allowkind', 'disallowkind',
|
||||
'listeventsneedingmoderation', 'allowevent', 'banevent', 'listbannedevents',
|
||||
'createrole', 'editrole', 'deleterole', 'assignrole', 'unassignrole',
|
||||
'changerelayname', 'changerelaydescription', 'changerelayicon',
|
||||
]);
|
||||
const uncovered = session.methods.filter(
|
||||
(method) => !SECTION_METHODS.has(method) && method !== 'supportedmethods' && method !== 'stat',
|
||||
);
|
||||
|
||||
const anySection =
|
||||
has('listbannedpubkeys') ||
|
||||
has('listallowedpubkeys') ||
|
||||
has('listblockedips') ||
|
||||
has('listallowedkinds') ||
|
||||
hasAny('listeventsneedingmoderation', 'listbannedevents') ||
|
||||
rolesAdvertised ||
|
||||
presentationAdvertised;
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-3xl space-y-4 p-3 sm:p-4">
|
||||
{/* Identity + capability summary */}
|
||||
<section className="rounded-lg border border-border p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
{icon ? (
|
||||
<img src={icon} alt="" className="size-10 shrink-0 rounded-md object-cover" />
|
||||
) : (
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-md bg-muted">
|
||||
<ShieldCheck className="size-5 text-muted-foreground" aria-hidden />
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold">
|
||||
{session.info?.name?.trim() || relayWsUrl(session.url).replace(/^wss?:\/\//, '')}
|
||||
</p>
|
||||
{session.info?.description && (
|
||||
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
|
||||
{session.info.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{session.info?.software && (
|
||||
<Badge variant="outline" className="font-mono text-[10px]">
|
||||
{session.info.software.replace(/^https?:\/\//, '').split('/').pop()}
|
||||
{session.info.version ? ` ${session.info.version}` : ''}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="gap-1 text-[10px]">
|
||||
<CircleCheck className="size-3 text-success" aria-hidden />
|
||||
NIP-98 authorized
|
||||
</Badge>
|
||||
<span className="tabular-nums">
|
||||
{session.methods.length} standard · {session.extensions.length} extension
|
||||
{session.extensions.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-3" />
|
||||
|
||||
<div className={cn('rounded-md border px-2.5 py-2', modeInfo.tone)}>
|
||||
<p className="text-xs font-semibold">{modeInfo.label}</p>
|
||||
<p className="text-xs opacity-90">{modeInfo.explanation}</p>
|
||||
</div>
|
||||
|
||||
{uncovered.length > 0 && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Advertised but without a console UI:{' '}
|
||||
<span className="font-mono">{uncovered.join(', ')}</span> — shown for completeness, not
|
||||
called.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{!anySection && session.extensions.length === 0 && (
|
||||
<EmptyState
|
||||
title="Nothing to manage"
|
||||
hint="This relay speaks NIP-86 but advertises no manageable methods to your key — or only discovery itself. If you expected more, check that you are signed in with an operator key."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Capability-driven sections — each renders only what was advertised. */}
|
||||
{has('listbannedpubkeys') && <BannedPubkeysSection session={session} onResult={connection.record} />}
|
||||
{has('listallowedpubkeys') && <AllowedPubkeysSection session={session} onResult={connection.record} />}
|
||||
{has('listblockedips') && <BlockedIpsSection session={session} onResult={connection.record} />}
|
||||
{has('listallowedkinds') && <AllowedKindsSection session={session} onResult={connection.record} />}
|
||||
{hasAny('listeventsneedingmoderation', 'listbannedevents') && (
|
||||
<EventModerationSection session={session} onResult={connection.record} />
|
||||
)}
|
||||
{rolesAdvertised && <RolesSection session={session} onResult={connection.record} />}
|
||||
{presentationAdvertised && <RelayPresentationSection session={session} onResult={connection.record} />}
|
||||
{session.extensions.length > 0 && (
|
||||
<ExtensionsSection session={session} onResult={connection.record} />
|
||||
)}
|
||||
|
||||
<AuditSection audit={connection.audit} onClear={connection.clearAudit} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
src/apps/relay-admin/shared.tsx
Normal file
178
src/apps/relay-admin/shared.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Loader2, RefreshCw, Search } from 'lucide-react';
|
||||
|
||||
import { AppSectionTitle, EmptyState } from '@/components/os/AppChrome';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Nip86Error } from '@/lib/nip86';
|
||||
import type { Nip86Session } from '@/hooks/useNip86';
|
||||
|
||||
/**
|
||||
* Shared building blocks for the management console. Every capability section
|
||||
* is the same shape — a toolbar row (search, count, refresh, primary action),
|
||||
* then loading skeletons, an error with retry, an honest empty state, or the
|
||||
* list — so the shape lives here once.
|
||||
*/
|
||||
|
||||
export interface ConsoleSectionProps {
|
||||
session: Nip86Session;
|
||||
}
|
||||
|
||||
export function Section({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="border-b border-border pb-4 last:border-b-0">
|
||||
<div className="flex items-start justify-between gap-2 pr-3">
|
||||
<AppSectionTitle>{title}</AppSectionTitle>
|
||||
{actions}
|
||||
</div>
|
||||
<p className="px-3 pb-2 text-xs text-muted-foreground">{description}</p>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionToolbar({
|
||||
search,
|
||||
onSearch,
|
||||
searchLabel,
|
||||
count,
|
||||
onRefresh,
|
||||
refreshing,
|
||||
children,
|
||||
}: {
|
||||
search?: string;
|
||||
onSearch?: (value: string) => void;
|
||||
searchLabel?: string;
|
||||
count?: number;
|
||||
onRefresh?: () => void;
|
||||
refreshing?: boolean;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 px-3 pb-2">
|
||||
{onSearch && (
|
||||
<div className="relative min-w-40 flex-1">
|
||||
<Search
|
||||
className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
<Input
|
||||
value={search ?? ''}
|
||||
onChange={(event) => onSearch(event.target.value)}
|
||||
placeholder={searchLabel ?? 'Filter'}
|
||||
aria-label={searchLabel ?? 'Filter'}
|
||||
className="h-8 pl-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{count !== undefined && (
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{count} {count === 1 ? 'entry' : 'entries'}
|
||||
</span>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-2 text-xs"
|
||||
onClick={onRefresh}
|
||||
disabled={refreshing}
|
||||
aria-label="Refresh list"
|
||||
>
|
||||
<RefreshCw className={cn('size-3.5', refreshing && 'animate-spin')} aria-hidden />
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListSkeleton({ rows = 3 }: { rows?: number }) {
|
||||
return (
|
||||
<div className="space-y-2 px-3 py-1" aria-label="Loading">
|
||||
{Array.from({ length: rows }).map((_, index) => (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
<Skeleton className="h-4 flex-1" />
|
||||
<Skeleton className="h-7 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListError({ error, onRetry }: { error: Nip86Error; onRetry: () => void }) {
|
||||
return (
|
||||
<div className="px-3 pb-2">
|
||||
<EmptyState
|
||||
title="The request failed"
|
||||
hint={error.message}
|
||||
action={
|
||||
<Button size="sm" variant="outline" onClick={onRetry}>
|
||||
Try again
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListEmpty({ title, hint }: { title: string; hint?: string }) {
|
||||
return (
|
||||
<div className="px-3 pb-2">
|
||||
<p className="rounded-lg border border-dashed border-border px-3 py-4 text-center text-sm text-muted-foreground">
|
||||
<span className="block font-medium text-foreground">{title}</span>
|
||||
{hint}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Row action button: consistent sizing, pending spinner, destructive option. */
|
||||
export function RowAction({
|
||||
label,
|
||||
onClick,
|
||||
pending,
|
||||
destructive,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
pending?: boolean;
|
||||
destructive?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
variant={destructive ? 'destructive' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7 shrink-0 px-2 text-xs"
|
||||
onClick={onClick}
|
||||
disabled={pending || disabled}
|
||||
>
|
||||
{pending && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mono identifier cell: full value in the tooltip, truncated on screen. */
|
||||
export function IdText({ value, className }: { value: string; className?: string }) {
|
||||
return (
|
||||
<span title={value} className={cn('block min-w-0 truncate font-mono text-xs', className)}>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
15
src/apps/relay-admin/useFilter.ts
Normal file
15
src/apps/relay-admin/useFilter.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
/** Case-insensitive contains-filter over the visible fields of a row. */
|
||||
export function useFilter<T>(items: T[] | undefined, fields: (item: T) => (string | undefined)[]) {
|
||||
const [search, setSearch] = useState('');
|
||||
const filtered = useMemo(() => {
|
||||
if (!items) return undefined;
|
||||
const needle = search.trim().toLowerCase();
|
||||
if (!needle) return items;
|
||||
return items.filter((item) =>
|
||||
fields(item).some((field) => field?.toLowerCase().includes(needle)),
|
||||
);
|
||||
}, [items, search, fields]);
|
||||
return { search, setSearch, filtered };
|
||||
}
|
||||
414
src/hooks/useNip86.ts
Normal file
414
src/hooks/useNip86.ts
Normal file
@@ -0,0 +1,414 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type UseMutationResult,
|
||||
type UseQueryResult,
|
||||
} from '@tanstack/react-query';
|
||||
import type { NostrSigner } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
import {
|
||||
fetchRelayInfo,
|
||||
isBlockedIpList,
|
||||
isEventRefList,
|
||||
isKindList,
|
||||
isPubkeyList,
|
||||
isStringArray,
|
||||
Nip86Error,
|
||||
nip86Call,
|
||||
nip86Params,
|
||||
normalizeRelayHttpUrl,
|
||||
partitionMethods,
|
||||
type AllowedPubkey,
|
||||
type BannedEvent,
|
||||
type BannedPubkey,
|
||||
type BlockedIp,
|
||||
type ModeratedEventRef,
|
||||
type Nip86CoreMethod,
|
||||
type RelayInfo,
|
||||
} from '@/lib/nip86';
|
||||
|
||||
/**
|
||||
* Session state for the NIP-86 management console.
|
||||
*
|
||||
* A "session" is one connected relay: the normalized HTTP endpoint, the
|
||||
* NIP-11 identity document (when the relay publishes one), and the method
|
||||
* list the relay advertised for the *signed-in key*. Nothing here is
|
||||
* persisted — and no key material ever enters this state. Reconnecting to
|
||||
* the same relay re-runs discovery, so a rotated method list is picked up.
|
||||
*/
|
||||
export interface Nip86Session {
|
||||
/** Normalized `https://` management endpoint. */
|
||||
url: string;
|
||||
/** NIP-11 relay information document, when available. */
|
||||
info?: RelayInfo;
|
||||
/** Standard NIP-86 methods the relay advertised (including `stat`). */
|
||||
methods: string[];
|
||||
/** Advertised names that are not standard NIP-86 — relay-specific extensions. */
|
||||
extensions: string[];
|
||||
/** Whether `listroles` (a common non-standard companion to the role methods) is advertised. */
|
||||
canListRoles: boolean;
|
||||
}
|
||||
|
||||
/** Which side of the blocklist/allowlist model the advertised data suggests. */
|
||||
export type PolicyMode = 'blocklist' | 'allowlist' | 'unknown';
|
||||
|
||||
export function usePolicyMode(methods: string[]): PolicyMode {
|
||||
return useMemo(() => {
|
||||
const set = new Set(methods);
|
||||
const blocklist =
|
||||
set.has('listbannedpubkeys') || set.has('listblockedips') || set.has('listbannedevents');
|
||||
const allowlist = set.has('listallowedpubkeys') || set.has('listallowedkinds');
|
||||
if (allowlist && !blocklist) return 'allowlist';
|
||||
if (blocklist && !allowlist) return 'blocklist';
|
||||
// Both or neither: the method list alone cannot tell — the operator's
|
||||
// relay documentation has to answer this.
|
||||
return 'unknown';
|
||||
}, [methods]);
|
||||
}
|
||||
|
||||
/** One line in the session audit log. Never contains secrets or auth headers. */
|
||||
export interface AuditEntry {
|
||||
id: number;
|
||||
/** Unix milliseconds. */
|
||||
at: number;
|
||||
method: string;
|
||||
/** Operator-facing target description (pubkey, IP, role id, ...), not raw params. */
|
||||
target: string;
|
||||
status: 'ok' | 'failed' | 'cancelled';
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** Human-readable target for the audit log and confirm dialogs. */
|
||||
export function auditTarget(method: string, params: unknown[]): string {
|
||||
const [first] = params;
|
||||
switch (method) {
|
||||
case 'banpubkey':
|
||||
case 'unbanpubkey':
|
||||
case 'allowpubkey':
|
||||
case 'unallowpubkey':
|
||||
return `pubkey ${String(first).slice(0, 16)}…`;
|
||||
case 'banevent':
|
||||
case 'allowevent':
|
||||
return `event ${String(first).slice(0, 16)}…`;
|
||||
case 'blockip':
|
||||
case 'unblockip':
|
||||
return `IP ${String(first)}`;
|
||||
case 'allowkind':
|
||||
case 'disallowkind':
|
||||
return `kind ${String(first)}`;
|
||||
case 'createrole':
|
||||
case 'editrole':
|
||||
case 'deleterole':
|
||||
return `role ${String(first)}`;
|
||||
case 'assignrole':
|
||||
case 'unassignrole':
|
||||
return `role ${String(params[1])} for pubkey ${String(first).slice(0, 16)}…`;
|
||||
case 'changerelayname':
|
||||
return 'the relay name';
|
||||
case 'changerelaydescription':
|
||||
return 'the relay description';
|
||||
case 'changerelayicon':
|
||||
return 'the relay icon';
|
||||
default:
|
||||
return 'the relay';
|
||||
}
|
||||
}
|
||||
|
||||
export interface Nip86Connection {
|
||||
session: Nip86Session | undefined;
|
||||
connect: (input: string) => Promise<void>;
|
||||
disconnect: () => void;
|
||||
isConnecting: boolean;
|
||||
error: Nip86Error | undefined;
|
||||
/** True when a signer is available; discovery itself decides authorization. */
|
||||
canAuthorize: boolean;
|
||||
audit: AuditEntry[];
|
||||
record: (entry: Omit<AuditEntry, 'id' | 'at'>) => void;
|
||||
clearAudit: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect/disconnect state plus the audit log. Kept deliberately separate from
|
||||
* the query/mutation hooks below so the console shell and every section share
|
||||
* one connection object (created once in the app root).
|
||||
*/
|
||||
export function useNip86Connection(): Nip86Connection {
|
||||
const { user } = useCurrentUser();
|
||||
const [session, setSession] = useState<Nip86Session | undefined>(undefined);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [error, setError] = useState<Nip86Error | undefined>(undefined);
|
||||
const [audit, setAudit] = useState<AuditEntry[]>([]);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const record = useCallback((entry: Omit<AuditEntry, 'id' | 'at'>) => {
|
||||
setAudit((prev) => [...prev.slice(-99), { ...entry, id: (prev[prev.length - 1]?.id ?? 0) + 1, at: Date.now() }]);
|
||||
}, []);
|
||||
|
||||
const clearAudit = useCallback(() => setAudit([]), []);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
setSession(undefined);
|
||||
setError(undefined);
|
||||
}, []);
|
||||
|
||||
const connect = useCallback(
|
||||
async (input: string) => {
|
||||
const url = normalizeRelayHttpUrl(input);
|
||||
if (!url) {
|
||||
setError(new Nip86Error('invalid-url', 'That is not a valid relay URL.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const signer: NostrSigner | undefined = user?.signer;
|
||||
if (!signer) {
|
||||
setError(
|
||||
new Nip86Error(
|
||||
'not-logged-in',
|
||||
'Sign in with the key that manages this relay, then connect again.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConnecting(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
// Identity is nice-to-have and deliberately raced with discovery:
|
||||
// plenty of relays answer NIP-86 but publish no NIP-11 document.
|
||||
const [info, methodsResult] = await Promise.all([
|
||||
fetchRelayInfo(url),
|
||||
nip86Call<unknown>(url, { signer, method: 'supportedmethods' }),
|
||||
]);
|
||||
|
||||
if (!isStringArray(methodsResult)) {
|
||||
throw new Nip86Error(
|
||||
'malformed',
|
||||
'The relay answered supportedmethods with an unexpected shape.',
|
||||
);
|
||||
}
|
||||
|
||||
const { core, extensions } = partitionMethods(methodsResult);
|
||||
setSession({
|
||||
url,
|
||||
info,
|
||||
methods: core,
|
||||
extensions,
|
||||
canListRoles: methodsResult.includes('listroles'),
|
||||
});
|
||||
// A different relay must never show the previous relay's policy lists.
|
||||
queryClient.removeQueries({ queryKey: ['nip86'] });
|
||||
record({
|
||||
method: 'supportedmethods',
|
||||
target: 'discovery',
|
||||
status: 'ok',
|
||||
detail: `${methodsResult.length} method${methodsResult.length === 1 ? '' : 's'} advertised`,
|
||||
});
|
||||
} catch (cause) {
|
||||
setSession(undefined);
|
||||
setError(
|
||||
cause instanceof Nip86Error
|
||||
? cause
|
||||
: new Nip86Error('unreachable', 'The connection failed unexpectedly.'),
|
||||
);
|
||||
record({
|
||||
method: 'supportedmethods',
|
||||
target: 'discovery',
|
||||
status: cause instanceof DOMException && cause.name === 'AbortError' ? 'cancelled' : 'failed',
|
||||
detail: cause instanceof Nip86Error ? cause.message : 'Connection failed.',
|
||||
});
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
},
|
||||
[user, queryClient, record],
|
||||
);
|
||||
|
||||
return {
|
||||
session,
|
||||
connect,
|
||||
disconnect,
|
||||
isConnecting,
|
||||
error,
|
||||
canAuthorize: Boolean(user),
|
||||
audit,
|
||||
record,
|
||||
clearAudit,
|
||||
};
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
* List queries and mutations, keyed by relay URL so two windows managing
|
||||
* different relays never share cache entries.
|
||||
* ------------------------------------------------------------------------ */
|
||||
|
||||
const STALE_TIME = 15_000;
|
||||
|
||||
function useSessionGuard(session: Nip86Session | undefined) {
|
||||
const { user } = useCurrentUser();
|
||||
const signer = user?.signer;
|
||||
return { session, signer, ready: Boolean(session && signer) };
|
||||
}
|
||||
|
||||
type ListMethod =
|
||||
| 'listbannedpubkeys'
|
||||
| 'listallowedpubkeys'
|
||||
| 'listeventsneedingmoderation'
|
||||
| 'listbannedevents'
|
||||
| 'listallowedkinds'
|
||||
| 'listblockedips'
|
||||
| 'listroles';
|
||||
|
||||
const LIST_GUARDS = {
|
||||
listbannedpubkeys: isPubkeyList,
|
||||
listallowedpubkeys: isPubkeyList,
|
||||
listeventsneedingmoderation: isEventRefList,
|
||||
listbannedevents: isEventRefList,
|
||||
listallowedkinds: isKindList,
|
||||
listblockedips: isBlockedIpList,
|
||||
} as const;
|
||||
|
||||
interface Nip86ListOptions {
|
||||
/** Audit + cache only — mutations refresh their own list automatically. */
|
||||
onResult?: (entry: Omit<AuditEntry, 'id' | 'at'>) => void;
|
||||
}
|
||||
|
||||
function useNip86List<T>(
|
||||
session: Nip86Session | undefined,
|
||||
method: ListMethod,
|
||||
options?: Nip86ListOptions,
|
||||
): UseQueryResult<T, Nip86Error> {
|
||||
const { signer, ready } = useSessionGuard(session);
|
||||
// `listroles` is a relay-specific extension, not standard NIP-86.
|
||||
const advertised =
|
||||
method === 'listroles' ? session?.canListRoles === true : session?.methods.includes(method) === true;
|
||||
|
||||
return useQuery<T, Nip86Error>({
|
||||
queryKey: ['nip86', session?.url, method],
|
||||
enabled: ready && advertised,
|
||||
staleTime: STALE_TIME,
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!session || !signer) throw new Nip86Error('not-logged-in', 'Sign in to manage this relay.');
|
||||
|
||||
let result: unknown;
|
||||
try {
|
||||
result = await nip86Call<unknown>(session.url, { signer, method, signal });
|
||||
} catch (error) {
|
||||
options?.onResult?.({
|
||||
method,
|
||||
target: 'list',
|
||||
status: error instanceof DOMException && error.name === 'AbortError' ? 'cancelled' : 'failed',
|
||||
detail: error instanceof Nip86Error ? error.message : 'Request failed.',
|
||||
});
|
||||
throw error instanceof Nip86Error
|
||||
? error
|
||||
: new Nip86Error('unreachable', 'The request failed unexpectedly.');
|
||||
}
|
||||
|
||||
// `listroles` shapes vary by relay; parsing happens in the UI layer.
|
||||
if (method !== 'listroles') {
|
||||
const guard = LIST_GUARDS[method];
|
||||
if (!guard(result)) {
|
||||
const failure = {
|
||||
method,
|
||||
target: 'list',
|
||||
status: 'failed' as const,
|
||||
detail: 'The relay returned an unexpected list shape.',
|
||||
};
|
||||
options?.onResult?.(failure);
|
||||
throw new Nip86Error('malformed', failure.detail);
|
||||
}
|
||||
}
|
||||
|
||||
options?.onResult?.({ method, target: 'list', status: 'ok' });
|
||||
return result as T;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useBannedPubkeys(session: Nip86Session | undefined, opts?: Nip86ListOptions) {
|
||||
return useNip86List<BannedPubkey[]>(session, 'listbannedpubkeys', opts);
|
||||
}
|
||||
|
||||
export function useAllowedPubkeys(session: Nip86Session | undefined, opts?: Nip86ListOptions) {
|
||||
return useNip86List<AllowedPubkey[]>(session, 'listallowedpubkeys', opts);
|
||||
}
|
||||
|
||||
export function useEventsNeedingModeration(session: Nip86Session | undefined, opts?: Nip86ListOptions) {
|
||||
return useNip86List<ModeratedEventRef[]>(session, 'listeventsneedingmoderation', opts);
|
||||
}
|
||||
|
||||
export function useBannedEvents(session: Nip86Session | undefined, opts?: Nip86ListOptions) {
|
||||
return useNip86List<BannedEvent[]>(session, 'listbannedevents', opts);
|
||||
}
|
||||
|
||||
export function useAllowedKinds(session: Nip86Session | undefined, opts?: Nip86ListOptions) {
|
||||
return useNip86List<number[]>(session, 'listallowedkinds', opts);
|
||||
}
|
||||
|
||||
export function useBlockedIps(session: Nip86Session | undefined, opts?: Nip86ListOptions) {
|
||||
return useNip86List<BlockedIp[]>(session, 'listblockedips', opts);
|
||||
}
|
||||
|
||||
/** `listroles` is NOT part of NIP-86 — only called when explicitly advertised. */
|
||||
export function useRelayRoles(session: Nip86Session | undefined, opts?: Nip86ListOptions) {
|
||||
return useNip86List<unknown>(session, 'listroles', opts);
|
||||
}
|
||||
|
||||
export interface Nip86MutationInput {
|
||||
method: Nip86CoreMethod | (string & {});
|
||||
params: unknown[];
|
||||
/** Lists to refresh after the mutation succeeds. */
|
||||
refresh?: ListMethod[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One mutation hook for every management call. The audit entry and the list
|
||||
* refresh happen here, so a section can never forget either one.
|
||||
*/
|
||||
export function useNip86Mutation(
|
||||
session: Nip86Session | undefined,
|
||||
options?: Nip86ListOptions,
|
||||
): UseMutationResult<unknown, Nip86Error, Nip86MutationInput> {
|
||||
const { signer } = useSessionGuard(session);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<unknown, Nip86Error, Nip86MutationInput>({
|
||||
mutationFn: async ({ method, params }) => {
|
||||
if (!session || !signer) throw new Nip86Error('not-logged-in', 'Sign in to manage this relay.');
|
||||
const supported =
|
||||
session.methods.includes(method) || session.extensions.includes(method);
|
||||
if (!supported) {
|
||||
throw new Nip86Error('unsupported', `${method} is not advertised by this relay.`);
|
||||
}
|
||||
try {
|
||||
return await nip86Call<unknown>(session.url, { signer, method, params });
|
||||
} catch (error) {
|
||||
throw error instanceof Nip86Error
|
||||
? error
|
||||
: new Nip86Error('unreachable', 'The request failed unexpectedly.');
|
||||
}
|
||||
},
|
||||
onSuccess: (_data, { method, params, refresh }) => {
|
||||
options?.onResult?.({ method, target: auditTarget(method, params), status: 'ok' });
|
||||
if (session && refresh) {
|
||||
for (const list of refresh) {
|
||||
queryClient.invalidateQueries({ queryKey: ['nip86', session.url, list] });
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error, { method, params }) => {
|
||||
options?.onResult?.({
|
||||
method,
|
||||
target: auditTarget(method, params),
|
||||
status: 'failed',
|
||||
detail: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Convenience: build params for a core method with the shared builder. */
|
||||
export { nip86Params };
|
||||
@@ -1,5 +1,5 @@
|
||||
import { lazy } from 'react';
|
||||
import { Activity, Bookmark, BookOpen, CalendarDays, FileText, Image, Info, Link2, Radio, Rss, Search, Settings, Sparkles, User } from 'lucide-react';
|
||||
import { Activity, Bookmark, BookOpen, CalendarDays, FileText, Image, Info, Link2, Radio, Rss, Search, Settings, ShieldCheck, Sparkles, User } from 'lucide-react';
|
||||
import type { AppDefinition } from './types';
|
||||
|
||||
/**
|
||||
@@ -129,6 +129,17 @@ export const APPS: AppDefinition[] = [
|
||||
defaultSize: { width: 720, height: 480 },
|
||||
minSize: { width: 380, height: 280 },
|
||||
},
|
||||
{
|
||||
id: 'relay-admin',
|
||||
title: 'Relay Admin',
|
||||
description: 'NIP-86 management console for relays you operate',
|
||||
icon: ShieldCheck,
|
||||
category: 'system',
|
||||
component: lazy(() => import('@/apps/relay-admin')),
|
||||
defaultSize: { width: 720, height: 680 },
|
||||
minSize: { width: 360, height: 320 },
|
||||
requiresAuth: true,
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
title: 'Settings',
|
||||
|
||||
Reference in New Issue
Block a user