diff --git a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.recheck.test.tsx b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.recheck.test.tsx index 5f2c0f46..b896159d 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.recheck.test.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.recheck.test.tsx @@ -26,6 +26,7 @@ vi.mock('../../../stores/uiStore', () => ({ vi.mock('../../../hooks/useWebSocket', () => ({ onFederationPeersChanged: () => () => {}, + onFederationPeerResetDetected: () => () => {}, })); import { FederationPanel } from './FederationPanel'; diff --git a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.resetCleanup.test.tsx b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.resetCleanup.test.tsx new file mode 100644 index 00000000..9632ea90 --- /dev/null +++ b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.resetCleanup.test.tsx @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +const { peers, resetEvents, resetPeer, initiatePeering, deleteUser, addToast } = vi.hoisted(() => ({ + peers: vi.fn(), + resetEvents: vi.fn(), + resetPeer: vi.fn(), + initiatePeering: vi.fn(), + deleteUser: vi.fn(), + addToast: vi.fn(), +})); + +vi.mock('../../../api/client', () => ({ + api: { + federation: { + peers, + approvalRequests: vi.fn().mockResolvedValue({ requests: [] }), + resetEvents, + resetPeer, + initiatePeering, + }, + admin: { + deleteUser, + }, + }, +})); + +vi.mock('../../../stores/uiStore', () => ({ + useUIStore: (sel: (s: { addToast: typeof addToast }) => unknown) => sel({ addToast }), +})); + +vi.mock('../../../hooks/useWebSocket', () => ({ + onFederationPeersChanged: () => () => {}, + onFederationPeerResetDetected: () => () => {}, +})); + +import { FederationPanel } from './FederationPanel'; + +const resetPeerFixture = { + id: 'p1', + origin: 'https://peer.example', + instanceName: 'Peer', + status: 'needs_attention' as const, + needsAttentionReason: 'peer_reset_detected' as const, + lastSeenAt: Date.now(), + lastFailureAt: null, + consecutiveFailures: 0, + consecutiveAuthFailures: 0, + lastSyncedAt: Date.now(), + autoRotateIntervalDays: 90, + secretRotatedAt: null, + rotationInProgress: false, + createdAt: Date.now(), +}; + +function orphanedAccount(overrides: Record = {}) { + return { + id: 'acc1', + username: 'ghost', + displayName: 'Ghost', + avatarColor: null, + ownedSpaces: [], + spaceMemberCount: 2, + messageCount: 5, + ...overrides, + }; +} + +function resetEvent(accounts: ReturnType[]) { + return { + origin: 'https://peer.example', + deadEpoch: 'epoch-old', + newEpoch: 'epoch-new', + detectedAt: Date.now(), + resolvedAt: null, + stubCount: 3, + orphanedAccountCount: accounts.length, + orphanedAccounts: accounts, + }; +} + +describe('FederationPanel — Reset cleanup', () => { + beforeEach(() => { + peers.mockReset(); + resetEvents.mockReset(); + resetPeer.mockReset(); + initiatePeering.mockReset(); + deleteUser.mockReset(); + addToast.mockReset(); + }); + + it('shows a Re-peer banner for a peer_reset_detected peer and re-peers on click', async () => { + peers.mockResolvedValue({ peers: [resetPeerFixture] }); + resetEvents.mockResolvedValue({ events: [] }); + resetPeer.mockResolvedValue({ success: true }); + initiatePeering.mockResolvedValue({ peer: resetPeerFixture }); + + render(); + + // The banner is a persistent surface — its "Re-peer" button is directly visible. + const repeerBtn = await screen.findByRole('button', { name: 'Re-peer' }); + fireEvent.click(repeerBtn); + + // Confirm the warning dialog. + const confirmBtn = await screen.findByRole('button', { name: 'Re-peer & heal' }); + fireEvent.click(confirmBtn); + + await waitFor(() => expect(resetPeer).toHaveBeenCalledWith('p1')); + await waitFor(() => + expect(initiatePeering).toHaveBeenCalledWith({ remoteOrigin: 'https://peer.example' }), + ); + // Order matters: reset BEFORE initiate. + expect(resetPeer.mock.invocationCallOrder[0]).toBeLessThan( + initiatePeering.mock.invocationCallOrder[0], + ); + }); + + it('removes an orphaned account via the existing admin delete', async () => { + peers.mockResolvedValue({ peers: [] }); + resetEvents.mockResolvedValue({ events: [resetEvent([orphanedAccount()])] }); + deleteUser.mockResolvedValue({ success: true }); + + render(); + + const removeBtn = await screen.findByRole('button', { name: 'Remove' }); + fireEvent.click(removeBtn); + + const confirmBtn = await screen.findByRole('button', { name: 'Delete permanently' }); + fireEvent.click(confirmBtn); + + await waitFor(() => expect(deleteUser).toHaveBeenCalledWith('acc1')); + }); + + it('surfaces "transfer ownership first" when Remove hits owns-spaces 400', async () => { + peers.mockResolvedValue({ peers: [] }); + resetEvents.mockResolvedValue({ + events: [resetEvent([orphanedAccount({ ownedSpaces: [{ id: 's1', name: 'My Space' }] })])], + }); + deleteUser.mockRejectedValue( + Object.assign(new Error('User owns spaces — transfer ownership first'), { + status: 400, + ownedSpaces: [{ id: 's1', name: 'My Space' }], + }), + ); + + render(); + + const removeBtn = await screen.findByRole('button', { name: 'Remove' }); + fireEvent.click(removeBtn); + + const confirmBtn = await screen.findByRole('button', { name: 'Delete permanently' }); + fireEvent.click(confirmBtn); + + await waitFor(() => expect(deleteUser).toHaveBeenCalledWith('acc1')); + await waitFor(() => + expect(addToast).toHaveBeenCalledWith( + expect.stringContaining('transfer ownership first'), + 'warning', + ), + ); + }); +}); diff --git a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx index 6dcc04d3..13eda124 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx @@ -4,9 +4,9 @@ import { useUIStore } from '../../../stores/uiStore'; import { Toggle } from '../../ui/Toggle'; import { ConfirmDialog } from '../../ui/ConfirmDialog'; import { api } from '../../../api/client'; -import { onFederationPeersChanged } from '../../../hooks/useWebSocket'; +import { onFederationPeersChanged, onFederationPeerResetDetected } from '../../../hooks/useWebSocket'; import type { InstanceAdminSettings } from '@backspace/shared'; -import type { FederationPeer, ApprovalRequest } from '../../../api/client'; +import type { FederationPeer, ApprovalRequest, FederationResetEvent, FederationOrphanedAccount } from '../../../api/client'; // ─── Global Settings ───────────────────────────────────────────────────────── @@ -813,6 +813,278 @@ function PendingApprovals({ onCountChange }: { onCountChange?: (count: number) = ); } +// ─── Reset Cleanup ────────────────────────────────────────────────────────── +// +// Highest-priority admin attention surface for the instance-epoch self-healing +// flow (§6.4). Two stacked surfaces: +// 1. A persistent accent-rose banner per peer detected as reset +// (status === 'needs_attention' && needsAttentionReason === 'peer_reset_detected'), +// with a one-click Re-peer (resetPeer → initiatePeering) that triggers the +// server-side heal on activation. +// 2. Per-origin lists of the dead incarnation's orphaned real accounts with +// Keep (no-op resting/frozen state) and Remove (full purge via the existing +// admin delete) actions. + +function peerName(peer: FederationPeer): string { + if (peer.instanceName) return peer.instanceName; + try { + return new URL(peer.origin).host; + } catch { + return peer.origin; + } +} + +function originHost(origin: string): string { + try { + return new URL(origin).host; + } catch { + return origin; + } +} + +type ResetConfirmAction = + | { kind: 'repeer'; peer: FederationPeer } + | { kind: 'remove'; account: FederationOrphanedAccount; origin: string }; + +function ResetCleanup() { + const addToast = useUIStore((s) => s.addToast); + const [resetPeers, setResetPeers] = useState([]); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(false); + const [confirmAction, setConfirmAction] = useState(null); + const [actionLoading, setActionLoading] = useState(false); + const [keptIds, setKeptIds] = useState>(new Set()); + + const fetchAll = useCallback(async () => { + setLoading(true); + try { + const [peersResult, eventsResult] = await Promise.all([ + api.federation.peers(), + api.federation.resetEvents(), + ]); + setResetPeers( + peersResult.peers.filter( + (p) => p.status === 'needs_attention' && p.needsAttentionReason === 'peer_reset_detected', + ), + ); + setEvents(eventsResult.events); + } catch { + // Silently fail — empty surface shown; the peer list section surfaces load errors. + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchAll(); + }, [fetchAll]); + + // Real-time: re-fetch (debounced) on any federation change. + useEffect(() => { + let timeout: ReturnType; + const unsub = onFederationPeersChanged(() => { + clearTimeout(timeout); + timeout = setTimeout(() => { + fetchAll(); + }, 500); + }); + return () => { unsub(); clearTimeout(timeout); }; + }, [fetchAll]); + + // Real-time: a fresh reset detection nudges the admin (the banner is the real + // surface) and triggers an immediate re-fetch. + useEffect(() => { + const unsub = onFederationPeerResetDetected((origin) => { + addToast(`${originHost(origin)} was reset — federation needs re-establishing`, 'warning'); + fetchAll(); + }); + return () => { unsub(); }; + }, [addToast, fetchAll]); + + const handleConfirm = async () => { + if (!confirmAction) return; + setActionLoading(true); + try { + if (confirmAction.kind === 'repeer') { + const { peer } = confirmAction; + // Order matters: reset the stale local record BEFORE the fresh handshake, + // so activation heals stale friendships/DMs against the new incarnation. + await api.federation.resetPeer(peer.id); + await api.federation.initiatePeering({ remoteOrigin: peer.origin }); + addToast(`Re-peering initiated with ${peerName(peer)}`, 'success', 3000); + await fetchAll(); + } else { + const { account } = confirmAction; + await api.admin.deleteUser(account.id); + addToast(`Removed ${account.username} and all their content`, 'success', 3000); + setKeptIds((prev) => { + const next = new Set(prev); + next.delete(account.id); + return next; + }); + await fetchAll(); + } + } catch (err) { + if (confirmAction.kind === 'remove') { + const status = (err as { status?: number } | null | undefined)?.status; + const ownsSpaces = + status === 400 || + (err != null && typeof err === 'object' && 'ownedSpaces' in err) || + confirmAction.account.ownedSpaces.length > 0; + if (ownsSpaces) { + addToast( + `${confirmAction.account.username} owns spaces — transfer ownership first (Space Settings → Ownership).`, + 'warning', + ); + } else { + addToast(err instanceof Error ? err.message : 'Failed to remove account', 'warning'); + } + } else { + addToast(err instanceof Error ? err.message : 'Re-peering failed', 'warning'); + } + } finally { + setActionLoading(false); + setConfirmAction(null); + } + }; + + const eventsWithOrphans = events.filter((e) => e.orphanedAccounts.length > 0); + + // Render nothing when there is no reset-detected peer and no orphaned account — + // exactly as PendingApprovals returns null when empty (loading also renders null). + if (resetPeers.length === 0 && eventsWithOrphans.length === 0) return null; + + return ( +
+
+
Reset Cleanup
+ + {resetPeers.length + eventsWithOrphans.reduce((n, e) => n + e.orphanedAccounts.length, 0)} + +
+ + {/* Reset-detected peer banners */} + {resetPeers.length > 0 && ( +
+ {resetPeers.map((peer) => ( +
+
+
+
+ {peerName(peer)} was reset +
+
{peer.origin}
+

+ A new instance is running on this domain. Re-establish federation to heal stale friendships and DMs. +

+
+ +
+
+ ))} +
+ )} + + {/* Orphaned real accounts per reset origin */} + {eventsWithOrphans.length > 0 && ( +
+ {eventsWithOrphans.map((event) => ( +
+
+ {originHost(event.origin)}{' '} + was reset — {event.stubCount} replicated{' '} + {event.stubCount === 1 ? 'identity' : 'identities'} auto-cleaned,{' '} + {event.orphanedAccounts.length}{' '} + {event.orphanedAccounts.length === 1 ? 'account' : 'accounts'} with local content orphaned. +
+
+ {event.orphanedAccounts.map((account) => { + const kept = keptIds.has(account.id); + return ( +
+
+
+
+ {account.displayName || account.username} +
+
{account.username}
+
+ {account.spaceMemberCount}{' '} + {account.spaceMemberCount === 1 ? 'membership' : 'memberships'} ·{' '} + {account.messageCount}{' '} + {account.messageCount === 1 ? 'message' : 'messages'} +
+ {account.ownedSpaces.length > 0 && ( +
+ Owns: {account.ownedSpaces.map((s) => s.name).join(', ')} +
+ )} +
+
+ {kept ? ( + Kept — frozen + ) : ( + + )} + +
+
+
+ ); + })} +
+
+ ))} +
+ )} + + {confirmAction && ( + { if (!actionLoading) setConfirmAction(null); }} + onConfirm={handleConfirm} + title={confirmAction.kind === 'repeer' ? 'Re-establish Federation' : 'Remove Orphaned Account'} + description={ + confirmAction.kind === 'repeer' + ? `This deletes the local peer record and starts a fresh authenticated handshake with ${confirmAction.peer.origin}. The remote must be reachable and (if it does not auto-accept) approve the request.` + : `Permanently delete ${confirmAction.account.username} and all their content on this instance? This cannot be undone.` + } + confirmLabel={confirmAction.kind === 'repeer' ? 'Re-peer & heal' : 'Delete permanently'} + variant={confirmAction.kind === 'repeer' ? 'warning' : 'danger'} + loading={actionLoading} + /> + )} +
+ ); +} + // ─── Main Panel ────────────────────────────────────────────────────────────── export function FederationPanel({ onApprovalCountChange }: { onApprovalCountChange?: (count: number) => void }) { @@ -1018,6 +1290,8 @@ export function FederationPanel({ onApprovalCountChange }: { onApprovalCountChan + + {/* Peered Instances */}