diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts
index e72851af..daad7ea0 100644
--- a/packages/web/src/api/client.ts
+++ b/packages/web/src/api/client.ts
@@ -71,6 +71,7 @@ export interface FederationPeer {
lastSeenAt: number | null;
lastFailureAt: number | null;
consecutiveFailures: number | null;
+ consecutiveAuthFailures: number;
lastSyncedAt: number | null;
createdAt: number;
secretRotatedAt: number | null;
diff --git a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx
index 2c811a7a..bf313a8d 100644
--- a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx
+++ b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx
@@ -175,6 +175,7 @@ function peerStatusColor(status: string): string {
case 'unreachable': return 'bg-accent-amber/15 text-accent-amber';
case 'rejected': return 'bg-accent-rose/15 text-accent-rose';
case 'awaiting_approval': return 'bg-accent-amber/15 text-accent-amber';
+ case 'needs_attention': return 'bg-accent-rose/15 text-accent-rose';
case 'revoked': return 'bg-white/5 text-txt-tertiary';
default: return 'bg-white/5 text-txt-tertiary';
}
@@ -187,6 +188,7 @@ function peerStatusDotColor(status: string): string {
case 'unreachable': return 'bg-accent-amber';
case 'rejected': return 'bg-accent-rose';
case 'awaiting_approval': return 'bg-accent-amber';
+ case 'needs_attention': return 'bg-accent-rose';
default: return 'bg-txt-tertiary';
}
}
@@ -199,13 +201,14 @@ function peerStatusLabel(status: string): string {
case 'rejected': return 'Rejected (auto-peering denied)';
case 'revoked': return 'Revoked';
case 'awaiting_approval': return 'Awaiting Approval';
+ case 'needs_attention': return 'Needs Attention';
default: return status;
}
}
type PeerView = 'active' | 'revoked';
type SortBy = 'name' | 'lastSeen' | 'dateAdded' | 'failures';
-type StatusFilter = 'active' | 'unreachable' | 'pending' | 'rejected' | 'awaiting_approval';
+type StatusFilter = 'active' | 'unreachable' | 'pending' | 'rejected' | 'awaiting_approval' | 'needs_attention';
// ─── Filter Dropdown ─────────────────────────────────────────────────────────
@@ -267,7 +270,7 @@ function FilterDropdown({
{view === 'active' && (
<>
Status
- {(['active', 'unreachable', 'pending', 'rejected', 'awaiting_approval'] as StatusFilter[]).map((s) => (
+ {(['active', 'unreachable', 'pending', 'rejected', 'awaiting_approval', 'needs_attention'] as StatusFilter[]).map((s) => (
))}
@@ -386,7 +393,7 @@ function PeerRow({ peer, view, expanded, onToggleExpand, onAction, defaultAutoRo
view: PeerView;
expanded: boolean;
onToggleExpand: () => void;
- onAction: (type: 'rotate' | 'revoke' | 'reinitiate' | 'delete') => void;
+ onAction: (type: 'rotate' | 'revoke' | 'reinitiate' | 'delete' | 'reset') => void;
defaultAutoRotateIntervalDays: number;
}) {
const [editingInterval, setEditingInterval] = useState(false);
@@ -484,6 +491,14 @@ function PeerRow({ peer, view, expanded, onToggleExpand, onAction, defaultAutoRo
{peer.consecutiveFailures ?? 0}
+ {peer.status === 'needs_attention' && (
+
+
Auth Failures
+
+ {peer.consecutiveAuthFailures}
+
+
+ )}
Last Failure
{formatRelativeTime(peer.lastFailureAt)}
@@ -547,30 +562,42 @@ function PeerRow({ peer, view, expanded, onToggleExpand, onAction, defaultAutoRo
{/* Actions */}
-
-
- {!editingInterval && (
+ {peer.status === 'needs_attention' ? (
+ ) : (
+ <>
+
+
+ {!editingInterval && (
+
+ )}
+ >
)}
>
@@ -750,13 +777,15 @@ export function FederationPanel({ onApprovalCountChange }: { onApprovalCountChan
const [peersLoading, setPeersLoading] = useState(false);
const [peersError, setPeersError] = useState('');
const [view, setView] = useState
('active');
- const [statusFilter, setStatusFilter] = useState>(new Set(['active', 'unreachable', 'pending', 'rejected', 'awaiting_approval']));
+ const [statusFilter, setStatusFilter] = useState>(
+ new Set(['active', 'unreachable', 'pending', 'rejected', 'awaiting_approval', 'needs_attention']),
+ );
const [sortBy, setSortBy] = useState('name');
const [expandedPeerId, setExpandedPeerId] = useState(null);
// Confirm dialog state (used in Task 10)
const [confirmAction, setConfirmAction] = useState<{
- type: 'rotate' | 'revoke' | 'reinitiate' | 'delete';
+ type: 'rotate' | 'revoke' | 'reinitiate' | 'delete' | 'reset';
peer: FederationPeer;
} | null>(null);
const [actionLoading, setActionLoading] = useState(false);
@@ -855,6 +884,12 @@ export function FederationPanel({ onApprovalCountChange }: { onApprovalCountChan
addToast('Peer permanently deleted', 'success', 2000);
break;
}
+ case 'reset': {
+ await api.federation.resetPeer(peer.id);
+ setPeers((prev) => prev.filter((p) => p.id !== peer.id));
+ addToast(`Peering reset for ${peer.instanceName || peer.origin}`, 'success', 3000);
+ break;
+ }
}
} catch (err) {
addToast(err instanceof Error ? err.message : 'Action failed', 'warning', 3000);
@@ -891,6 +926,12 @@ export function FederationPanel({ onApprovalCountChange }: { onApprovalCountChan
confirmLabel: 'Delete',
variant: 'danger' as const,
};
+ case 'reset': return {
+ title: 'Reset Peering',
+ description: `Reset peering with ${name}? This deletes the local peer record and all pending outbox entries. You must re-initiate peering with the remote admin out of band after reset. This cannot be undone.`,
+ confirmLabel: 'Reset',
+ variant: 'danger' as const,
+ };
}
})() : null;