diff --git a/packages/web/src/components/modals/ConnectedInstances.tsx b/packages/web/src/components/modals/ConnectedInstances.tsx index 527bb851..85f68d7d 100644 --- a/packages/web/src/components/modals/ConnectedInstances.tsx +++ b/packages/web/src/components/modals/ConnectedInstances.tsx @@ -1,8 +1,11 @@ import React, { useState } from 'react'; -import type { InstanceInfoResponse } from '@backspace/shared'; +import ReactDOM from 'react-dom'; +import type { InstanceInfoResponse, FederationRegistryEntry } from '@backspace/shared'; import { useInstanceStore, DifferentPasswordError } from '../../stores/instanceStore'; +import type { ConnectedInstance } from '../../stores/instanceStore'; import { useAuthStore } from '../../stores/authStore'; import { isElectron } from '../../platform/platform'; +import { ConfirmDialog } from '../ui/ConfirmDialog'; // ─── Status indicator ──────────────────────────────────────────────────────── @@ -15,6 +18,53 @@ function StatusDot({ status }: { status: string }) { 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'; @@ -263,141 +313,528 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) { ); } -// ─── Main component ────────────────────────────────────────────────────────── +// ─── Filter / Sort types ──────────────────────────────────────────────────── -function InstanceRow({ inst }: { inst: import('../../stores/instanceStore').ConnectedInstance }) { - const disconnectInstance = useInstanceStore((s) => s.disconnectInstance); - const reconnectInstance = useInstanceStore((s) => s.reconnectInstance); - const reauthenticateInstance = useInstanceStore((s) => s.reauthenticateInstance); - const hasPendingSync = useInstanceStore((s) => s.pendingSyncOrigins.includes(inst.origin)); +type StatusFilter = 'all' | 'connected' | 'disconnected' | 'issues'; +type SortBy = 'name' | 'dateAdded' | 'lastConnected'; - const [showReauth, setShowReauth] = useState(false); - const [reauthPassword, setReauthPassword] = useState(''); - const [reauthLoading, setReauthLoading] = useState(false); - const [reauthError, setReauthError] = useState(''); +// ─── RegistryFilterBar ────────────────────────────────────────────────────── - const isTokenless = !inst.token; +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 handleReauth = async () => { - if (!reauthPassword) return; - setReauthError(''); - setReauthLoading(true); - try { - await reauthenticateInstance(inst.origin, reauthPassword); - setShowReauth(false); - setReauthPassword(''); - } catch (err) { - setReauthError((err as Error).message); - } finally { - setReauthLoading(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 ( -
-
-
- -
-
- {inst.label} -
-
- {new URL(inst.origin).host} - {inst.username && ( - as {inst.username} - )} -
- {(inst.status === 'disconnected' || inst.status === 'error') && inst.error && ( -
{inst.error}
- )} - {hasPendingSync && inst.status === 'connected' && ( -
- Password not synced -
- )} -
-
-
- {hasPendingSync && inst.status === 'connected' && ( - - )} - {(inst.status === 'disconnected' || inst.status === 'error') && ( - isTokenless ? ( - - ) : ( - - ) - )} +
+
+ {tabs.map((tab) => ( -
+ ))}
- {/* Inline re-authentication prompt */} - {showReauth && ( -
{ e.preventDefault(); handleReauth(); }} className="space-y-2 pt-1"> - -
- setReauthPassword(e.target.value)} - placeholder={hasPendingSync && inst.status === 'connected' ? 'Enter your current password' : 'Your account password'} - className="input-standard flex-1 py-1.5" - disabled={reauthLoading} - autoFocus - autoComplete="current-password" - /> - - -
- {reauthError && ( -
- {reauthError} +
+ + + {sortOpen && ( + <> +
setSortOpen(false)} /> +
+
Sort by
+ {sortOptions.map((opt) => ( + + ))}
- )} - - )} + + )} +
); } +// ─── DeleteIdentityDialog ─────────────────────────────────────────────────── + +type DeletionMode = 'leave' | 'nuke' | 'evaporate'; +type DeletionScope = 'this' | 'select' | 'all'; + +function DeleteIdentityDialog({ + origin, + label, + onClose, +}: { + origin: string; + label: string; + onClose: () => void; +}) { + const deleteIdentity = useInstanceStore((s) => s.deleteIdentity); + const [mode, setMode] = useState('leave'); + const [scope, setScope] = useState('this'); + + const handleConfirm = () => { + deleteIdentity(origin); + onClose(); + }; + + return ReactDOM.createPortal( +
+
+
+

Delete Identity

+

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

+ + {/* Deletion mode selection */} +
+ {/* Leave quietly */} + + + {/* Nuke everything */} + + + {/* Evaporate (coming soon) */} +
+
Evaporate
+
+ Gradually fade your presence — coming soon. +
+
+
+ + {/* Scope selector */} +
+
Scope
+
+ {([ + { key: 'this' as DeletionScope, label: 'This instance only' }, + { key: 'select' as DeletionScope, label: 'Select instances...' }, + { key: 'all' as DeletionScope, label: 'All remote instances' }, + ]).map((opt) => ( + + ))} +
+
+ + {/* 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 [showForceRemoveConfirm, setShowForceRemoveConfirm] = useState(false); + const [showDeleteIdentity, setShowDeleteIdentity] = useState(false); + + const name = entry.label || new URL(entry.origin).host; + 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); + }; + + return ( + <> +
+ {/* Compact row */} +
+
+
+
+
+ {name} + + {registryStatusLabel(entry.status)} + +
+
+ {new URL(entry.origin).host} + {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} +
+ )} + + {/* Actions */} +
+ {isConnected && ( + <> + + + + )} + + {isDisconnected && ( + <> + {liveInstance && ( + + )} + + + )} + + {hasIssue && ( + <> + {entry.status === 'unreachable' && liveInstance && ( + + )} + + + + )} +
+
+
+ )} +
+ + {/* 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="danger" + /> + + {/* 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 || new URL(a.origin).host).toLowerCase(); + const nameB = (b.label || new URL(b.origin).host).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('name'); + 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 (
@@ -407,7 +844,7 @@ export function ConnectedInstances() {

Link accounts across federated Backspace instances.

- {/* Home instance (always shown, non-removable) */} + {/* Home instance (always pinned, non-filterable) */}
@@ -417,6 +854,9 @@ export function ConnectedInstances() {
{window.location.host} + {user?.username && ( + as {user.username} + )}
@@ -433,10 +873,41 @@ export function ConnectedInstances() {
- {/* Remote instances */} - {instances.map((inst) => ( - - ))} + {/* 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 ? (