import React, { useState } from 'react'; import ReactDOM from 'react-dom'; import type { InstanceInfoResponse, FederationRegistryEntry } from '@backspace/shared'; import { useInstanceStore, DifferentPasswordError } from '../../stores/instanceStore'; import { useAuthStore } from '../../stores/authStore'; import { useUIStore } from '../../stores/uiStore'; import { isElectron } from '../../platform/platform'; import { ConfirmDialog } from '../ui/ConfirmDialog'; // ─── URL helpers ───────────────────────────────────────────────────────────── function safeHost(origin: string): string { try { return new URL(origin).host; } catch { return origin; } } // ─── Status indicator ──────────────────────────────────────────────────────── function StatusDot({ status }: { status: string }) { const colorClass = status === 'connected' ? 'bg-status-online' : status === 'connecting' ? 'bg-accent-amber' : 'bg-txt-tertiary'; return
; } // ─── Registry status helpers ──────────────────────────────────────────────── function registryStatusColor(status: string): string { switch (status) { case 'connected': return 'bg-status-online/15 text-status-online'; case 'disconnected': return 'bg-white/5 text-txt-tertiary'; case 'unreachable': return 'bg-accent-amber/15 text-accent-amber'; case 'auth_expired': return 'bg-accent-rose/15 text-accent-rose'; default: return 'bg-white/5 text-txt-tertiary'; } } function registryStatusDotColor(status: string): string { switch (status) { case 'connected': return 'bg-status-online'; case 'unreachable': return 'bg-accent-amber'; case 'auth_expired': return 'bg-accent-rose'; default: return 'bg-txt-tertiary'; } } function registryStatusLabel(status: string): string { switch (status) { case 'connected': return 'Connected'; case 'disconnected': return 'Disconnected'; case 'unreachable': return 'Unreachable'; case 'auth_expired': return 'Auth Expired'; default: return status; } } function formatRelativeTime(timestamp: number | null): string { if (!timestamp) return 'Never'; const seconds = Math.floor((Date.now() - timestamp) / 1000); if (seconds < 60) return 'Just now'; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h ago`; const days = Math.floor(hours / 24); return `${days}d ago`; } function formatAbsoluteDate(timestamp: number): string { return new Date(timestamp).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); } // ─── Add Instance flow ─────────────────────────────────────────────────────── type AddStep = 'url' | 'auth' | 'done'; type AuthPhase = 'password' | 'fallback-login'; function AddInstanceFlow({ onDone }: { onDone: () => void }) { const user = useAuthStore((s) => s.user); const connectToRemote = useInstanceStore((s) => s.connectToRemote); const loginToRemote = useInstanceStore((s) => s.loginToRemote); const probeInstance = useInstanceStore((s) => s.probeInstance); const [step, setStep] = useState('url'); const [url, setUrl] = useState(''); const [probeResult, setProbeResult] = useState<(InstanceInfoResponse & { origin: string }) | null>(null); const [authPhase, setAuthPhase] = useState('password'); const [password, setPassword] = useState(''); const [fallbackUsername, setFallbackUsername] = useState(''); const [fallbackPassword, setFallbackPassword] = useState(''); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); const handleProbe = async () => { setError(''); setIsLoading(true); try { const result = await probeInstance(url); setProbeResult(result); setAuthPhase('password'); setStep('auth'); } catch (err) { setError((err as Error).message); } finally { setIsLoading(false); } }; const handleConnect = async () => { if (!probeResult) return; setError(''); setIsLoading(true); try { await connectToRemote( probeResult.origin, password, user?.displayName || undefined, ); setStep('done'); onDone(); } catch (err) { if (err instanceof DifferentPasswordError) { setAuthPhase('fallback-login'); setFallbackUsername(err.remoteUsername); setFallbackPassword(''); setError(''); } else { setError((err as Error).message); } } finally { setIsLoading(false); } }; const handleFallbackLogin = async () => { if (!probeResult) return; setError(''); setIsLoading(true); try { await loginToRemote(probeResult.origin, fallbackUsername, fallbackPassword); setStep('done'); onDone(); } catch (err) { setError((err as Error).message); } finally { setIsLoading(false); } }; if (step === 'done') return null; return (
{/* Step 1: Enter URL */} {step === 'url' && ( <>
Add Remote Instance
setUrl(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && !isLoading && url.trim() && handleProbe()} placeholder="https://instance.example.com" className="input-standard flex-1" disabled={isLoading} />
)} {/* Step 2: Auth — single password */} {step === 'auth' && probeResult && authPhase === 'password' && ( <> {/* Instance info card */}
{probeResult.name}
{probeResult.origin}
{ e.preventDefault(); handleConnect(); }} className="space-y-2">
setPassword(e.target.value)} placeholder="Your account password" className="input-standard w-full" disabled={isLoading} autoFocus autoComplete="current-password" />
Your password is verified locally, then used to create or access your account on the remote instance.
)} {/* Step 2b: Fallback login — different password on remote */} {step === 'auth' && probeResult && authPhase === 'fallback-login' && ( <> {/* Instance info card */}
{probeResult.name}
{probeResult.origin}
An account already exists on this instance with a different password. Enter the credentials you used on that instance.
{ e.preventDefault(); handleFallbackLogin(); }} className="space-y-2">
setFallbackUsername(e.target.value)} placeholder="Your username on this instance" className="input-standard w-full" disabled={isLoading} autoComplete="username" />
setFallbackPassword(e.target.value)} placeholder="Password on the remote instance" className="input-standard w-full" disabled={isLoading} autoFocus autoComplete="current-password" />
)} {/* Error display */} {error && (
{error}
)}
); } // ─── Filter / Sort types ──────────────────────────────────────────────────── type StatusFilter = 'all' | 'connected' | 'disconnected' | 'issues'; type SortBy = 'name' | 'dateAdded' | 'lastConnected'; // ─── RegistryFilterBar ────────────────────────────────────────────────────── function RegistryFilterBar({ filter, setFilter, sortBy, setSortBy, counts, }: { filter: StatusFilter; setFilter: (f: StatusFilter) => void; sortBy: SortBy; setSortBy: (s: SortBy) => void; counts: { all: number; connected: number; disconnected: number; issues: number }; }) { const [sortOpen, setSortOpen] = useState(false); const tabs: Array<{ key: StatusFilter; label: string; count: number }> = [ { key: 'all', label: 'All', count: counts.all }, { key: 'connected', label: 'Connected', count: counts.connected }, { key: 'disconnected', label: 'Disconnected', count: counts.disconnected }, { key: 'issues', label: 'Issues', count: counts.issues }, ]; const sortOptions: Array<{ key: SortBy; label: string }> = [ { key: 'name', label: 'Name (A-Z)' }, { key: 'dateAdded', label: 'Date Added' }, { key: 'lastConnected', label: 'Last Connected' }, ]; return (
{tabs.map((tab) => ( ))}
{sortOpen && ( <>
setSortOpen(false)} />
Sort by
{sortOptions.map((opt) => ( ))}
)}
); } // ─── DeleteIdentityDialog ─────────────────────────────────────────────────── type DeletionMode = 'leave' | 'soft' | 'full'; type DeletionScope = 'this' | 'select' | 'all'; function DeleteIdentityDialog({ origin, label, onClose, }: { origin: string; label: string; onClose: () => void; }) { const deleteIdentity = useInstanceStore((s) => s.deleteIdentity); const registry = useInstanceStore((s) => s.registry); const [mode, setMode] = useState('leave'); const [scope, setScope] = useState('this'); const [selectedOrigins, setSelectedOrigins] = useState>(new Set()); const [loading, setLoading] = useState(false); const handleConfirm = async () => { // Resolve target origins based on scope let targetOrigins: string[]; if (scope === 'all') { targetOrigins = Array.from(registry.keys()); } else if (scope === 'select') { targetOrigins = Array.from(selectedOrigins); } else { targetOrigins = [origin]; } if (targetOrigins.length === 0) { onClose(); return; } if (mode !== 'leave') { setLoading(true); } const results = await deleteIdentity(targetOrigins, mode); // Check results const failed = Object.entries(results).filter(([, r]) => !r.success); if (failed.length === 0) { useUIStore.getState().addToast( mode === 'leave' ? 'Disconnected successfully' : targetOrigins.length === 1 ? 'Identity deleted successfully' : `Identity deleted on ${targetOrigins.length} instances`, 'success', 3000, ); onClose(); } else { for (const [failOrigin, result] of failed) { let host: string; try { host = new URL(failOrigin).hostname; } catch { host = failOrigin; } if (result.error === 'owns_spaces') { useUIStore.getState().addToast( `${host}: Transfer space ownership first`, 'warning', 5000, ); } else { useUIStore.getState().addToast( `${host}: ${result.error || 'Failed'}`, 'warning', 5000, ); } } // Close if some succeeded, keep open if all failed const succeeded = Object.values(results).filter(r => r.success).length; if (succeeded > 0) { onClose(); } else { setLoading(false); } } }; return ReactDOM.createPortal(

Delete Identity

Remove your federated identity on {label}. Choose how your data should be handled.

{/* Deletion mode selection */}
{/* Leave quietly */} {/* Delete User (soft) */} {/* Nuke everything (full) */}
{/* Scope selector */}
Scope
{([ { key: 'this' as DeletionScope, label: 'This instance only', disabled: false }, { key: 'select' as DeletionScope, label: 'Select instances...', disabled: false }, { key: 'all' as DeletionScope, label: 'All remote instances', disabled: false }, ]).map((opt) => ( ))}
{/* Instance picker for 'select' scope */} {scope === 'select' && (
{Array.from(registry.values()).map((entry) => { const checked = selectedOrigins.has(entry.origin); return ( ); })}
)} {/* Actions */}
, document.body, ); } // ─── RegistryRow ──────────────────────────────────────────────────────────── function RegistryRow({ entry, expanded, onToggleExpand, }: { entry: FederationRegistryEntry; expanded: boolean; onToggleExpand: () => void; }) { const instances = useInstanceStore((s) => s.instances); const disconnectInstance = useInstanceStore((s) => s.disconnectInstance); const reconnectInstance = useInstanceStore((s) => s.reconnectInstance); const forceRemoveEntry = useInstanceStore((s) => s.forceRemoveEntry); const reauthenticateInstance = useInstanceStore((s) => s.reauthenticateInstance); const [showForceRemoveConfirm, setShowForceRemoveConfirm] = useState(false); const [showDeleteIdentity, setShowDeleteIdentity] = useState(false); const [showReauth, setShowReauth] = useState(false); const [reauthPassword, setReauthPassword] = useState(''); const [reauthLoading, setReauthLoading] = useState(false); const [reauthError, setReauthError] = useState(''); const name = entry.label || safeHost(entry.origin); const isDisconnected = entry.status === 'disconnected'; const isConnected = entry.status === 'connected'; const hasIssue = entry.status === 'unreachable' || entry.status === 'auth_expired'; // Check if there's a live ConnectedInstance for reconnect actions const liveInstance = instances.find((i) => i.origin === entry.origin); // Build context-dependent metadata line let metadataText = ''; if (isConnected) { metadataText = `Added ${formatAbsoluteDate(entry.addedAt)}`; if (entry.lastConnectedAt) { metadataText += ` · Connected ${formatRelativeTime(entry.lastConnectedAt)}`; } } else if (isDisconnected) { metadataText = `Added ${formatAbsoluteDate(entry.addedAt)}`; if (entry.disconnectedAt) { metadataText += ` · Disconnected ${formatRelativeTime(entry.disconnectedAt)}`; } } else { metadataText = `Added ${formatAbsoluteDate(entry.addedAt)}`; if (entry.lastConnectedAt) { metadataText += ` · Last connected ${formatRelativeTime(entry.lastConnectedAt)}`; } } const handleDisconnect = () => { disconnectInstance(entry.origin); }; const handleReconnect = () => { if (liveInstance) { reconnectInstance(entry.origin); } }; const handleForceRemove = () => { forceRemoveEntry(entry.origin); setShowForceRemoveConfirm(false); }; const handleReauth = async () => { if (!reauthPassword) return; setReauthError(''); setReauthLoading(true); try { await reauthenticateInstance(entry.origin, reauthPassword); setShowReauth(false); setReauthPassword(''); } catch (err) { setReauthError((err as Error).message); } finally { setReauthLoading(false); } }; return ( <>
{/* Compact row */}
{name} {registryStatusLabel(entry.status)}
{safeHost(entry.origin)} {entry.username && ( as {entry.username} )}
{metadataText}
{expanded ? '\u25BE' : '\u25B8'}
{/* Expanded details */} {expanded && (
{/* Stats grid */}
Remote User ID
{entry.remoteUserId ? entry.remoteUserId.slice(0, 12) + '...' : 'Unknown'}
Added
{formatAbsoluteDate(entry.addedAt)}
{isDisconnected && entry.disconnectedAt ? ( <>
Disconnected
{formatAbsoluteDate(entry.disconnectedAt)}
) : ( <>
Last Connected
{entry.lastConnectedAt ? formatAbsoluteDate(entry.lastConnectedAt) : 'Never'}
)}
{/* Error message */} {entry.errorMessage && (
{entry.errorMessage}
)} {/* Re-auth inline form */} {showReauth && (
{ e.preventDefault(); handleReauth(); }} className="mt-3 space-y-2">
setReauthPassword(e.target.value)} placeholder="Your account password" className="input-standard flex-1 py-1.5" disabled={reauthLoading} autoFocus autoComplete="current-password" />
{reauthError && (
{reauthError}
)}
)} {/* Actions */}
{isConnected && ( <> )} {isDisconnected && ( <> {liveInstance && ( )} )} {hasIssue && ( <> {entry.status === 'unreachable' && liveInstance && ( )} {entry.status === 'auth_expired' && ( )} )}
)}
{/* Force Remove confirmation dialog */} setShowForceRemoveConfirm(false)} onConfirm={handleForceRemove} title="Force Remove Entry" description={`This will remove the registry entry for ${name}. The remote instance will not be notified. Use this only if the instance is permanently unreachable.`} confirmLabel="Force Remove" variant="warning" /> {/* Delete Identity dialog */} {showDeleteIdentity && ( setShowDeleteIdentity(false)} /> )} ); } // ─── Sorting ──────────────────────────────────────────────────────────────── function sortEntries(entries: FederationRegistryEntry[], sortBy: SortBy): FederationRegistryEntry[] { return [...entries].sort((a, b) => { switch (sortBy) { case 'name': { const nameA = (a.label || safeHost(a.origin)).toLowerCase(); const nameB = (b.label || safeHost(b.origin)).toLowerCase(); return nameA.localeCompare(nameB); } case 'dateAdded': return b.addedAt - a.addedAt; case 'lastConnected': return (b.lastConnectedAt ?? 0) - (a.lastConnectedAt ?? 0); default: return 0; } }); } // ─── Main component ────────────────────────────────────────────────────────── export function ConnectedInstances() { const instances = useInstanceStore((s) => s.instances); const registry = useInstanceStore((s) => s.registry); const user = useAuthStore((s) => s.user); const [showAddForm, setShowAddForm] = useState(false); const [filter, setFilter] = useState('all'); const [sortBy, setSortBy] = useState('dateAdded'); const [expandedOrigins, setExpandedOrigins] = useState>(new Set()); const toggleExpand = (origin: string) => { setExpandedOrigins((prev) => { const next = new Set(prev); if (next.has(origin)) { next.delete(origin); } else { next.add(origin); } return next; }); }; // Convert registry Map to array const registryEntries = Array.from(registry.values()); // Compute filter counts const counts = { all: registryEntries.length, connected: registryEntries.filter((e) => e.status === 'connected').length, disconnected: registryEntries.filter((e) => e.status === 'disconnected').length, issues: registryEntries.filter((e) => e.status === 'unreachable' || e.status === 'auth_expired').length, }; // Filter entries const filteredEntries = registryEntries.filter((entry) => { switch (filter) { case 'all': return true; case 'connected': return entry.status === 'connected'; case 'disconnected': return entry.status === 'disconnected'; case 'issues': return entry.status === 'unreachable' || entry.status === 'auth_expired'; default: return true; } }); // Sort entries const sortedEntries = sortEntries(filteredEntries, sortBy); // Empty state message const emptyMessage = registryEntries.length === 0 ? null : filteredEntries.length === 0 ? 'No instances match the current filter.' : null; return (
Connected Instances

Link accounts across federated Backspace instances.

{/* Home instance (always pinned, non-filterable) */}
Home Instance
{window.location.host} {user?.username && ( as {user.username} )}
Local {isElectron() && ( )}
{/* Filter bar (only if registry has entries) */} {registryEntries.length > 0 && ( )} {/* Registry rows */} {sortedEntries.length > 0 && (
{sortedEntries.map((entry) => ( toggleExpand(entry.origin)} /> ))}
)} {/* Empty state */} {emptyMessage && (
{emptyMessage}
)} {registryEntries.length === 0 && !showAddForm && (
No remote instances connected. Add one to start federating.
)} {/* Add instance button / flow */} {showAddForm ? ( setShowAddForm(false)} /> ) : ( )}
); }