feat(federation): admin Reset cleanup UI (Re-peer + Keep/Remove)

This commit is contained in:
Jannis Braun
2026-07-02 01:57:30 +02:00
parent 9d00cf024a
commit 946255c4a1
3 changed files with 439 additions and 2 deletions
@@ -26,6 +26,7 @@ vi.mock('../../../stores/uiStore', () => ({
vi.mock('../../../hooks/useWebSocket', () => ({
onFederationPeersChanged: () => () => {},
onFederationPeerResetDetected: () => () => {},
}));
import { FederationPanel } from './FederationPanel';
@@ -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<string, unknown> = {}) {
return {
id: 'acc1',
username: 'ghost',
displayName: 'Ghost',
avatarColor: null,
ownedSpaces: [],
spaceMemberCount: 2,
messageCount: 5,
...overrides,
};
}
function resetEvent(accounts: ReturnType<typeof orphanedAccount>[]) {
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(<FederationPanel />);
// 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(<FederationPanel />);
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(<FederationPanel />);
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',
),
);
});
});
@@ -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<FederationPeer[]>([]);
const [events, setEvents] = useState<FederationResetEvent[]>([]);
const [loading, setLoading] = useState(false);
const [confirmAction, setConfirmAction] = useState<ResetConfirmAction | null>(null);
const [actionLoading, setActionLoading] = useState(false);
const [keptIds, setKeptIds] = useState<Set<string>>(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<typeof setTimeout>;
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 (
<div>
<div className="flex items-center gap-2 mb-1.5">
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider">Reset Cleanup</div>
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-accent-rose/15 text-accent-rose">
{resetPeers.length + eventsWithOrphans.reduce((n, e) => n + e.orphanedAccounts.length, 0)}
</span>
</div>
{/* Reset-detected peer banners */}
{resetPeers.length > 0 && (
<div className="space-y-2 mb-3">
{resetPeers.map((peer) => (
<div
key={peer.id}
className="bg-accent-rose/10 border border-accent-rose/30 rounded-lg p-3.5"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-txt-primary">
{peerName(peer)} was reset
</div>
<div className="text-[11px] text-txt-tertiary truncate">{peer.origin}</div>
<p className="text-xs text-txt-secondary mt-1.5 leading-relaxed">
A new instance is running on this domain. Re-establish federation to heal stale friendships and DMs.
</p>
</div>
<button
type="button"
onClick={() => setConfirmAction({ kind: 'repeer', peer })}
disabled={actionLoading}
className="shrink-0 px-3 py-1.5 text-xs font-medium bg-accent-mint/10 text-accent-mint hover:bg-accent-mint/20 rounded transition-colors disabled:opacity-50"
>
Re-peer
</button>
</div>
</div>
))}
</div>
)}
{/* Orphaned real accounts per reset origin */}
{eventsWithOrphans.length > 0 && (
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-4 mb-5">
{eventsWithOrphans.map((event) => (
<div key={`${event.origin}:${event.deadEpoch}`}>
<div className="text-xs text-txt-tertiary mb-2 leading-relaxed">
<span className="font-medium text-txt-secondary">{originHost(event.origin)}</span>{' '}
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.
</div>
<div className="space-y-2">
{event.orphanedAccounts.map((account) => {
const kept = keptIds.has(account.id);
return (
<div key={account.id} className="bg-white/[0.02] rounded-md px-3 py-2.5">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium text-txt-primary truncate">
{account.displayName || account.username}
</div>
<div className="text-[11px] text-txt-tertiary truncate">{account.username}</div>
<div className="text-[11px] text-txt-tertiary mt-0.5">
{account.spaceMemberCount}{' '}
{account.spaceMemberCount === 1 ? 'membership' : 'memberships'} ·{' '}
{account.messageCount}{' '}
{account.messageCount === 1 ? 'message' : 'messages'}
</div>
{account.ownedSpaces.length > 0 && (
<div className="text-[11px] text-accent-amber mt-0.5 truncate">
Owns: {account.ownedSpaces.map((s) => s.name).join(', ')}
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0 ml-3">
{kept ? (
<span className="text-[11px] text-txt-tertiary italic">Kept frozen</span>
) : (
<button
type="button"
onClick={() =>
setKeptIds((prev) => new Set(prev).add(account.id))
}
className="px-3 py-1.5 text-xs font-medium text-txt-tertiary hover:text-txt-secondary bg-white/[0.04] hover:bg-white/[0.06] rounded transition-colors"
>
Keep
</button>
)}
<button
type="button"
onClick={() =>
setConfirmAction({ kind: 'remove', account, origin: event.origin })
}
disabled={actionLoading}
className="px-3 py-1.5 text-xs font-medium bg-accent-rose/10 text-txt-danger hover:bg-accent-rose/20 rounded transition-colors disabled:opacity-50"
>
Remove
</button>
</div>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
)}
{confirmAction && (
<ConfirmDialog
isOpen={true}
onClose={() => { 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}
/>
)}
</div>
);
}
// ─── Main Panel ──────────────────────────────────────────────────────────────
export function FederationPanel({ onApprovalCountChange }: { onApprovalCountChange?: (count: number) => void }) {
@@ -1018,6 +1290,8 @@ export function FederationPanel({ onApprovalCountChange }: { onApprovalCountChan
<FederationGlobalSettings />
<ResetCleanup />
<PendingApprovals onCountChange={handleApprovalCountChange} />
{/* Peered Instances */}