Files
archy/neode-ui/src/views/federation/PendingRequestsPanel.vue
T
DorianandClaude Opus 4.7 2e8417e39b feat(federation): cancel button for outbound pending peer requests
Previously the Pending Peer Requests panel only had Approve/Reject for
inbound rows; outbound rows in the 'sent' state had no action and
would sit there until the target explicitly approved or rejected. Now
you can Cancel an outbound request — the local row is dropped and a
PeerCancel nostr DM is sent so the target's inbound row also
disappears.

Backend:
- HandshakeMessage::PeerCancel {reason: Option<String>} variant.
- nostr_handshake::send_peer_cancel() mirrors send_peer_reject.
- handshake.poll handler dispatches inbound PeerCancel: finds the
  matching inbound pending row (same from_nostr_pubkey, state=Pending)
  and deletes it. Reply shape gains `cancelled_inbound: [id]`.
- federation::pending::delete() — hard-remove (set_state only
  transitions; we don't want 'Cancelled' ghosts in the audit trail).
- federation.cancel-request RPC: outbound+Sent only, default
  notify=true (cancelling silently is a footgun), best-effort DM
  (relay failure doesn't block local deletion). Wired in dispatcher.

Frontend:
- PendingRequestsPanel.vue: Cancel button appears only on
  outbound+sent rows. Emits 'cancel' event with request id.
- Federation.vue: cancelPending(id) handler calls
  rpcClient.federationCancelRequest and reloads the list.
- rpcClient.federationCancelRequest(id, reason?, notify=true).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 02:28:16 -04:00

140 lines
5.3 KiB
Vue

<template>
<div v-if="visibleRequests.length > 0" class="glass-card p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-base font-semibold text-white">Pending Peer Requests</h2>
<p class="text-xs text-white/60">
Inbound requests await your approval. Outbound requests show what you've sent.
Approved peers are added as <span class="text-orange-300">Observer</span> — promote
to Trusted manually if you want them to receive state syncs.
</p>
</div>
<button
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/80 hover:text-white disabled:opacity-50"
:disabled="polling"
@click="$emit('poll')"
>
{{ polling ? 'Polling' : 'Poll Now' }}
</button>
</div>
<div class="space-y-3">
<div
v-for="req in visibleRequests"
:key="req.id"
class="p-3 bg-white/5 rounded-lg border border-white/10"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<span
class="inline-block px-2 py-0.5 text-[10px] uppercase tracking-wide rounded"
:class="badgeClass(req)"
>{{ badgeLabel(req) }}</span>
<span class="text-sm text-white font-medium truncate">
{{ req.from_name || shortNpub(req.from_nostr_npub) }}
</span>
</div>
<div class="text-[11px] text-white/50 font-mono truncate">{{ req.from_nostr_npub }}</div>
<div v-if="req.from_did" class="text-[11px] text-white/40 font-mono truncate">{{ req.from_did }}</div>
<p v-if="req.message" class="mt-2 text-xs text-white/70 italic">"{{ req.message }}"</p>
<p class="mt-1 text-[10px] text-white/40">{{ relative(req.received_at) }}</p>
</div>
<div v-if="req.state === 'pending' && !req.outbound" class="flex flex-col gap-2 shrink-0">
<button
class="px-3 py-1 glass-button glass-button-sm rounded text-xs text-green-300 hover:text-green-200 disabled:opacity-50"
:disabled="busyId === req.id"
@click="$emit('approve', req.id)"
>
{{ busyId === req.id ? '' : 'Approve' }}
</button>
<button
class="px-3 py-1 glass-button glass-button-sm rounded text-xs text-red-300 hover:text-red-200 disabled:opacity-50"
:disabled="busyId === req.id"
@click="$emit('reject', req.id)"
>
Reject
</button>
</div>
<div v-else-if="req.outbound && req.state === 'sent'" class="flex flex-col gap-2 shrink-0">
<button
class="px-3 py-1 glass-button glass-button-sm rounded text-xs text-white/70 hover:text-red-300 disabled:opacity-50"
:disabled="busyId === req.id"
:title="'Withdraw the request and notify the peer to drop their pending row'"
@click="$emit('cancel', req.id)"
>
{{ busyId === req.id ? '' : 'Cancel' }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { PendingPeerRequest } from '@/api/rpc-client'
const props = defineProps<{
requests: PendingPeerRequest[]
polling: boolean
busyId: string | null
}>()
defineEmits<{
poll: []
approve: [id: string]
reject: [id: string]
cancel: [id: string]
}>()
// Hide already-handled rows older than 24h to keep the panel from growing
// indefinitely. The backend keeps the full audit trail in the file; the UI
// only shows what's actionable or recent.
const visibleRequests = computed(() => {
const cutoff = Date.now() - 24 * 60 * 60 * 1000
return props.requests.filter((r) => {
if (r.state === 'pending' || r.state === 'sent') return true
const ts = new Date(r.received_at).getTime()
return Number.isFinite(ts) && ts >= cutoff
})
})
function badgeClass(r: PendingPeerRequest): string {
if (r.state === 'pending') return 'bg-yellow-500/20 text-yellow-300'
if (r.state === 'sent') return 'bg-blue-500/20 text-blue-300'
if (r.state === 'approved') return 'bg-green-500/20 text-green-300'
if (r.state === 'rejected') return 'bg-red-500/20 text-red-300'
return 'bg-white/10 text-white/50'
}
function badgeLabel(r: PendingPeerRequest): string {
if (r.outbound && r.state === 'sent') return 'Sent'
if (r.outbound && r.state === 'approved') return 'They approved'
if (r.outbound && r.state === 'rejected') return 'They rejected'
if (r.outbound) return r.state
if (r.state === 'pending') return 'Inbound'
return r.state
}
function shortNpub(npub: string): string {
if (!npub || npub.length < 16) return npub
return `${npub.slice(0, 12)}${npub.slice(-6)}`
}
function relative(iso: string): string {
const ts = new Date(iso).getTime()
if (!Number.isFinite(ts)) return iso
const diff = Date.now() - ts
const sec = Math.floor(diff / 1000)
if (sec < 60) return `${sec}s ago`
const min = Math.floor(sec / 60)
if (min < 60) return `${min}m ago`
const hr = Math.floor(min / 60)
if (hr < 24) return `${hr}h ago`
const day = Math.floor(hr / 24)
return `${day}d ago`
}
</script>