feat(federation): reset-cleanup panel — informational detach copy, real server-side Dismiss, Keep removed (detach spec §4.6)
This commit is contained in:
+86
-2
@@ -1,9 +1,10 @@
|
||||
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(() => ({
|
||||
const { peers, resetEvents, acknowledgeResetEvent, resetPeer, initiatePeering, deleteUser, addToast } = vi.hoisted(() => ({
|
||||
peers: vi.fn(),
|
||||
resetEvents: vi.fn(),
|
||||
acknowledgeResetEvent: vi.fn(),
|
||||
resetPeer: vi.fn(),
|
||||
initiatePeering: vi.fn(),
|
||||
deleteUser: vi.fn(),
|
||||
@@ -21,6 +22,7 @@ vi.mock('../../../api/client', async () => {
|
||||
peers,
|
||||
approvalRequests: vi.fn().mockResolvedValue({ requests: [] }),
|
||||
resetEvents,
|
||||
acknowledgeResetEvent,
|
||||
resetPeer,
|
||||
initiatePeering,
|
||||
},
|
||||
@@ -73,16 +75,21 @@ function orphanedAccount(overrides: Record<string, unknown> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function resetEvent(accounts: ReturnType<typeof orphanedAccount>[]) {
|
||||
function resetEvent(
|
||||
accounts: ReturnType<typeof orphanedAccount>[],
|
||||
overrides: Record<string, unknown> = {},
|
||||
) {
|
||||
return {
|
||||
origin: 'https://peer.example',
|
||||
deadEpoch: 'epoch-old',
|
||||
newEpoch: 'epoch-new',
|
||||
detectedAt: Date.now(),
|
||||
resolvedAt: null,
|
||||
acknowledgedAt: null,
|
||||
stubCount: 3,
|
||||
orphanedAccountCount: accounts.length,
|
||||
orphanedAccounts: accounts,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,6 +97,7 @@ describe('FederationPanel — Reset cleanup', () => {
|
||||
beforeEach(() => {
|
||||
peers.mockReset();
|
||||
resetEvents.mockReset();
|
||||
acknowledgeResetEvent.mockReset();
|
||||
resetPeer.mockReset();
|
||||
initiatePeering.mockReset();
|
||||
deleteUser.mockReset();
|
||||
@@ -272,4 +280,80 @@ describe('FederationPanel — Reset cleanup', () => {
|
||||
'warning',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the detached-accounts card with Dismiss + Remove and informational copy, no Keep/frozen', async () => {
|
||||
peers.mockResolvedValue({ peers: [] });
|
||||
resetEvents.mockResolvedValue({ events: [resetEvent([orphanedAccount()])] });
|
||||
|
||||
render(<FederationPanel />);
|
||||
|
||||
// Both real actions are present.
|
||||
await screen.findByRole('button', { name: /Dismiss/ });
|
||||
expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument();
|
||||
|
||||
// Informational detach copy — not urgent-cleanup language.
|
||||
expect(screen.getAllByText(/detached/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/existing password/i)).toBeInTheDocument();
|
||||
|
||||
// The fake client-only Keep/frozen affordance is fully gone.
|
||||
expect(screen.queryByRole('button', { name: 'Keep' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/frozen/i)).not.toBeInTheDocument();
|
||||
// No "orphaned" urgency wording in the detached-accounts copy.
|
||||
expect(screen.queryByText(/with local content orphaned/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render an acknowledged event and excludes it from the badge count', async () => {
|
||||
peers.mockResolvedValue({ peers: [] });
|
||||
resetEvents.mockResolvedValue({
|
||||
events: [resetEvent([orphanedAccount()], { acknowledgedAt: 1234 })],
|
||||
});
|
||||
|
||||
render(<FederationPanel />);
|
||||
|
||||
// Give effects a chance to run, then assert the whole section stays absent.
|
||||
await waitFor(() => expect(resetEvents).toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Reset Cleanup')).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Dismiss/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('dismisses an event via the acknowledge API and re-fetches', async () => {
|
||||
peers.mockResolvedValue({ peers: [] });
|
||||
// First load: unacknowledged. After acknowledge, re-fetch returns it acknowledged.
|
||||
resetEvents
|
||||
.mockResolvedValueOnce({ events: [resetEvent([orphanedAccount()])] })
|
||||
.mockResolvedValue({ events: [resetEvent([orphanedAccount()], { acknowledgedAt: 1234 })] });
|
||||
acknowledgeResetEvent.mockResolvedValue({ success: true });
|
||||
|
||||
render(<FederationPanel />);
|
||||
|
||||
const dismissBtn = await screen.findByRole('button', { name: /Dismiss/ });
|
||||
fireEvent.click(dismissBtn);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(acknowledgeResetEvent).toHaveBeenCalledWith('https://peer.example'),
|
||||
);
|
||||
// fetchAll re-runs after acknowledge (peers + resetEvents both hit twice).
|
||||
await waitFor(() => expect(resetEvents).toHaveBeenCalledTimes(2));
|
||||
// The card disappears once the re-fetch marks the event acknowledged.
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('button', { name: /Dismiss/ })).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces an error toast when dismiss fails', async () => {
|
||||
peers.mockResolvedValue({ peers: [] });
|
||||
resetEvents.mockResolvedValue({ events: [resetEvent([orphanedAccount()])] });
|
||||
acknowledgeResetEvent.mockRejectedValue(new Error('Network down'));
|
||||
|
||||
render(<FederationPanel />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Dismiss/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(addToast).toHaveBeenCalledWith('Network down', 'warning'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -815,15 +815,21 @@ 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:
|
||||
// Admin attention surface for the instance-epoch self-healing flow (§6.4) and
|
||||
// the orphaned-account detach flow (detach spec §4.6). 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.
|
||||
// server-side heal on activation. This one is genuinely actionable, so it
|
||||
// keeps the rose/danger styling.
|
||||
// 2. Per-origin, informational cards for the dead incarnation's detached real
|
||||
// accounts. Detachment is not a failure state: these accounts keep working
|
||||
// locally and their owners sign in with the same password. The card offers a
|
||||
// real, server-side Dismiss (acknowledgeResetEvent — hides the card without
|
||||
// touching the accounts) and a per-account Remove (full purge via the existing
|
||||
// admin delete) for the ones that truly are abandoned. Neutral tier styling —
|
||||
// no urgency. Acknowledged events are filtered out client-side (the endpoint
|
||||
// keeps returning them for audit).
|
||||
|
||||
function peerName(peer: FederationPeer): string {
|
||||
if (peer.instanceName) return peer.instanceName;
|
||||
@@ -853,7 +859,6 @@ function ResetCleanup() {
|
||||
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);
|
||||
@@ -924,11 +929,6 @@ function ResetCleanup() {
|
||||
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) {
|
||||
@@ -962,7 +962,25 @@ function ResetCleanup() {
|
||||
}
|
||||
};
|
||||
|
||||
const eventsWithOrphans = events.filter((e) => e.orphanedAccounts.length > 0);
|
||||
// Dismiss the detached-accounts card without touching the accounts — the event
|
||||
// stays in the DB (acknowledged) for audit but stops surfacing to the admin.
|
||||
const handleDismiss = async (origin: string) => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await api.federation.acknowledgeResetEvent(origin);
|
||||
await fetchAll();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Failed to dismiss', 'warning');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Only unacknowledged events with detached accounts surface a card. Dismissed
|
||||
// (acknowledged) events are filtered out here and drop off the badge count.
|
||||
const eventsWithOrphans = events.filter(
|
||||
(e) => e.orphanedAccounts.length > 0 && e.acknowledgedAt === null,
|
||||
);
|
||||
|
||||
// Render nothing when there is no reset-detected peer and no orphaned account —
|
||||
// exactly as PendingApprovals returns null when empty (loading also renders null).
|
||||
@@ -973,7 +991,7 @@ function ResetCleanup() {
|
||||
<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)}
|
||||
{resetPeers.length + eventsWithOrphans.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1019,61 +1037,54 @@ function ResetCleanup() {
|
||||
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.length === 1 ? 'account' : 'accounts'} with local content detached.
|
||||
Detached accounts keep working locally — owners keep access with their existing password.
|
||||
</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>
|
||||
)}
|
||||
{event.orphanedAccounts.map((account) => (
|
||||
<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="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 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">
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDismiss(event.origin)}
|
||||
disabled={actionLoading}
|
||||
className="mt-2 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 disabled:opacity-50"
|
||||
>
|
||||
Dismiss — keep all detached accounts
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user