diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index d086a43e..44f47e93 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -469,6 +469,13 @@ export async function federationRoutes(app: FastifyInstance): Promise { .run(); } + // Notify admin users that a new approval request arrived + connectionManager.sendToAdmins({ + type: 'federation_approval_request_received' as const, + origin: sourceOrigin, + instanceName: reqInstanceName ?? undefined, + }); + return reply.code(202).send({ queued: true, message: 'Request queued for admin approval', @@ -514,6 +521,8 @@ export async function federationRoutes(app: FastifyInstance): Promise { }); } + connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); + return reply.code(200).send({ accepted: true }); } if (existing.status === 'awaiting_approval') { @@ -535,6 +544,8 @@ export async function federationRoutes(app: FastifyInstance): Promise { }); } + connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); + return reply.code(200).send({ accepted: true }); } // Pending — update with new secret and activate @@ -547,6 +558,8 @@ export async function federationRoutes(app: FastifyInstance): Promise { .where(eq(schema.federationPeers.id, existing.id)) .run(); + connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); + return reply.code(200).send({ accepted: true }); } @@ -561,6 +574,8 @@ export async function federationRoutes(app: FastifyInstance): Promise { createdAt: Date.now(), }).run(); + connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); + return reply.code(200).send({ accepted: true }); }, ); @@ -733,6 +748,8 @@ export async function federationRoutes(app: FastifyInstance): Promise { .where(eq(schema.federationPeers.id, peer.id)) .run(); + connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); + // Push federation_peer_rejected WS event to affected users const entries = db .select({ @@ -1041,6 +1058,8 @@ export async function federationRoutes(app: FastifyInstance): Promise { .where(eq(schema.peerApprovalRequests.id, id)) .run(); + connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); + const peer = db .select() .from(schema.federationPeers) @@ -1137,6 +1156,8 @@ export async function federationRoutes(app: FastifyInstance): Promise { .run(); } + connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); + db.delete(schema.peerApprovalRequests) .where(eq(schema.peerApprovalRequests.id, id)) .run(); diff --git a/packages/server/src/utils/federationPeering.ts b/packages/server/src/utils/federationPeering.ts index 1fc49761..2bf49fcd 100644 --- a/packages/server/src/utils/federationPeering.ts +++ b/packages/server/src/utils/federationPeering.ts @@ -145,6 +145,8 @@ async function performHandshake( .set({ status: 'awaiting_approval' }) .where(eq(schema.federationPeers.id, peerId)) .run(); + const { connectionManager } = await import('../ws/handler.js'); + connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); return { status: 'pending', error: 'Awaiting admin approval on remote instance' }; } @@ -154,6 +156,8 @@ async function performHandshake( .set({ status: 'active', lastSeenAt: Date.now() }) .where(eq(schema.federationPeers.id, peerId)) .run(); + const { connectionManager } = await import('../ws/handler.js'); + connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); return { status: 'active', peerId }; } @@ -174,6 +178,8 @@ async function performHandshake( .set({ status: 'rejected' }) .where(eq(schema.federationPeers.id, peerId)) .run(); + const { connectionManager } = await import('../ws/handler.js'); + connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); return { status: 'rejected', error: errorMessage }; } diff --git a/packages/server/src/utils/federationWorker.ts b/packages/server/src/utils/federationWorker.ts index 801a696b..17d7a433 100644 --- a/packages/server/src/utils/federationWorker.ts +++ b/packages/server/src/utils/federationWorker.ts @@ -283,6 +283,7 @@ async function processOutboxTick(): Promise { }) .where(eq(schema.federationPeers.id, peerId)) .run(); + connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); } else { console.warn( `[federation-worker] Peer ${peerOrigin} returned HTTP ${response.status}`, @@ -388,6 +389,8 @@ async function resolvePendingPeers(): Promise { switch (result.status) { case 'active': console.log(`[federation-worker] Auto-peered with ${peerOrigin} — entries will deliver next tick`); + // Notify admins of peer state change + connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); break; case 'rejected': { @@ -417,6 +420,8 @@ async function resolvePendingPeers(): Promise { // Push federation_peer_rejected WS event to affected users pushPeerRejectedEvent(peerOrigin, contextMap); + // Notify admins of peer state change + connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const }); break; } diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index 75b5afbe..713c20c8 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -876,6 +876,18 @@ class ConnectionManager { return this.connections; } + /** Send an event to all connected admin users. */ + sendToAdmins(event: ServerEvent): void { + const db = getDb(); + for (const userId of this.connections.keys()) { + const user = db.select({ isAdmin: schema.users.isAdmin }) + .from(schema.users).where(eq(schema.users.id, userId)).get(); + if (user?.isAdmin === 1) { + this.sendToUser(userId, event); + } + } + } + /** Push a fresh ready payload to a specific user, forcing full store re-sync. */ pushReadyPayload(userId: string): void { const connections = this.getUserConnections(userId); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 68017c64..24b452e9 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -446,6 +446,8 @@ export type ServerEvent = | { type: 'federation_file_rejected'; messageId: string; dmChannelId: string; attachmentId: string; affectedUsers: Array<{ userId: string; username: string; limit: number }> } | { type: 'federation_peer_rejected'; peerOrigin: string; peerLabel?: string; reason: string; affectedContexts: Array<{ contextType: 'dm' | 'friend'; contextId: string; contextLabel: string }> } | { type: 'federation_peer_active'; peerOrigin: string } + | { type: 'federation_peers_changed' } + | { type: 'federation_approval_request_received'; origin: string; instanceName?: string } | { type: 'dm_owner_updated'; dmChannelId: string; newOwnerId: string } | { type: 'pong' } | { type: 'error'; message: string }; diff --git a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx index bdab5806..2c811a7a 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/FederationPanel.tsx @@ -4,6 +4,7 @@ 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 type { InstanceAdminSettings } from '@backspace/shared'; import type { FederationPeer, ApprovalRequest } from '../../../api/client'; @@ -611,6 +612,18 @@ function PendingApprovals({ onCountChange }: { onCountChange?: (count: number) = fetchRequests(); }, [fetchRequests]); + // Real-time updates: re-fetch approval requests on federation changes + useEffect(() => { + let timeout: ReturnType; + const unsub = onFederationPeersChanged(() => { + clearTimeout(timeout); + timeout = setTimeout(() => { + fetchRequests(); + }, 500); + }); + return () => { unsub(); clearTimeout(timeout); }; + }, [fetchRequests]); + const handleConfirm = async () => { if (!confirmAction) return; const { type, request: req } = confirmAction; @@ -765,6 +778,18 @@ export function FederationPanel({ onApprovalCountChange }: { onApprovalCountChan fetchPeers(); }, [fetchPeers]); + // Real-time updates: re-fetch peers and approval requests on any federation change + useEffect(() => { + let timeout: ReturnType; + const unsub = onFederationPeersChanged(() => { + clearTimeout(timeout); + timeout = setTimeout(() => { + fetchPeers(); + }, 500); + }); + return () => { unsub(); clearTimeout(timeout); }; + }, [fetchPeers]); + // Derived peer lists const activePeers = peers.filter((p) => p.status !== 'revoked'); const revokedPeers = peers.filter((p) => p.status === 'revoked'); diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 93f13535..9ee59274 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -36,6 +36,18 @@ export function getActivePeerOrigins(): Set { return activePeerOrigins; } +// ─── Federation change listeners (for real-time panel updates) ─────────────── +const federationChangeListeners = new Set<() => void>(); + +export function onFederationPeersChanged(cb: () => void): () => void { + federationChangeListeners.add(cb); + return () => { federationChangeListeners.delete(cb); }; +} + +function notifyFederationChangeListeners(): void { + for (const cb of federationChangeListeners) cb(); +} + // ─── Connection state ───────────────────────────────────────────────────────── interface ConnectionState { @@ -717,6 +729,7 @@ function handleEvent(origin: string, event: ServerEvent): void { 'warning', 10000, ); + notifyFederationChangeListeners(); break; } @@ -724,6 +737,17 @@ function handleEvent(origin: string, event: ServerEvent): void { rejectedPeerOrigins.delete(event.peerOrigin); awaitingApprovalPeerOrigins.delete(event.peerOrigin); activePeerOrigins.add(event.peerOrigin); + notifyFederationChangeListeners(); + break; + } + + case 'federation_peers_changed': { + notifyFederationChangeListeners(); + break; + } + + case 'federation_approval_request_received': { + notifyFederationChangeListeners(); break; }