From ef7aa3ff5a167a0885586366a7aad9a8d46ae8c2 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Mon, 2 Mar 2026 23:31:28 +0100 Subject: [PATCH] feat: add instance store and Connected Instances settings UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce instanceStore (Zustand) with full federation lifecycle: probe remote instances, register with username collision fallback, login to existing accounts, sync instance list across all connected instances, auto-reconnect from cached tokens on login/page load, and cleanup on logout. Add ConnectedInstances component to User Settings with home instance card, remote instance management, and inline add-instance flow (URL probe → register/login → connected). --- .../components/modals/ConnectedInstances.tsx | 337 +++++++++++++++++ .../src/components/modals/UserSettings.tsx | 4 + packages/web/src/stores/authStore.ts | 6 + packages/web/src/stores/instanceStore.ts | 343 ++++++++++++++++++ 4 files changed, 690 insertions(+) create mode 100644 packages/web/src/components/modals/ConnectedInstances.tsx create mode 100644 packages/web/src/stores/instanceStore.ts diff --git a/packages/web/src/components/modals/ConnectedInstances.tsx b/packages/web/src/components/modals/ConnectedInstances.tsx new file mode 100644 index 00000000..1f06ce30 --- /dev/null +++ b/packages/web/src/components/modals/ConnectedInstances.tsx @@ -0,0 +1,337 @@ +import React, { useState } from 'react'; +import type { InstanceInfoResponse } from '@backspace/shared'; +import { useInstanceStore } from '../../stores/instanceStore'; +import { useAuthStore } from '../../stores/authStore'; + +// ─── Status indicator ──────────────────────────────────────────────────────── + +function StatusDot({ status }: { status: string }) { + const colorClass = + status === 'connected' ? 'bg-status-online' : + status === 'connecting' ? 'bg-accent-amber' : + 'bg-txt-tertiary'; + + return
; +} + +// ─── Add Instance flow ─────────────────────────────────────────────────────── + +type AddStep = 'url' | 'auth' | 'done'; +type AuthTab = 'register' | 'login'; + +function AddInstanceFlow({ onDone }: { onDone: () => void }) { + const user = useAuthStore((s) => s.user); + const registerOnRemote = useInstanceStore((s) => s.registerOnRemote); + 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 [authTab, setAuthTab] = useState('register'); + const [password, setPassword] = useState(''); + const [loginUsername, setLoginUsername] = useState(''); + const [loginPassword, setLoginPassword] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + + const handleProbe = async () => { + setError(''); + setIsLoading(true); + try { + const result = await probeInstance(url); + setProbeResult(result); + setAuthTab(result.registrationOpen ? 'register' : 'login'); + setStep('auth'); + } catch (err) { + setError((err as Error).message); + } finally { + setIsLoading(false); + } + }; + + const handleRegister = async () => { + if (!probeResult) return; + setError(''); + setIsLoading(true); + try { + await registerOnRemote( + probeResult.origin, + password, + user?.displayName || undefined, + ); + setStep('done'); + onDone(); + } catch (err) { + setError((err as Error).message); + } finally { + setIsLoading(false); + } + }; + + const handleLogin = async () => { + if (!probeResult) return; + setError(''); + setIsLoading(true); + try { + await loginToRemote(probeResult.origin, loginUsername, loginPassword); + 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="flex-1 px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary" + disabled={isLoading} + /> + +
+ + + )} + + {/* Step 2: Auth */} + {step === 'auth' && probeResult && ( + <> + {/* Instance info card */} +
+ +
+
{probeResult.name}
+
{probeResult.origin}
+
+
+ + {/* Auth tabs */} +
+ {probeResult.registrationOpen && ( + + )} + +
+ + {/* Register form */} + {authTab === 'register' && ( +
+
+ + +
+ Your username will be replicated. If taken, it becomes {user?.username}@{window.location.host} +
+
+
+ + setPassword(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && !isLoading && password && handleRegister()} + placeholder="Create a password for this instance" + className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary" + disabled={isLoading} + /> +
+ +
+ )} + + {/* Login form */} + {authTab === 'login' && ( +
+
+ + setLoginUsername(e.target.value)} + placeholder="Your username on this instance" + className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary" + disabled={isLoading} + /> +
+
+ + setLoginPassword(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && !isLoading && loginUsername && loginPassword && handleLogin()} + placeholder="Your password on this instance" + className="w-full px-3 py-2 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary" + disabled={isLoading} + /> +
+ +
+ )} + + {/* Back button */} +
+ + +
+ + )} + + {/* Error display */} + {error && ( +
+ {error} +
+ )} +
+ ); +} + +// ─── Main component ────────────────────────────────────────────────────────── + +export function ConnectedInstances() { + const instances = useInstanceStore((s) => s.instances); + const removeInstance = useInstanceStore((s) => s.removeInstance); + const [showAddForm, setShowAddForm] = useState(false); + + return ( +
+

+ Connected Instances +

+ +
+ {/* Home instance (always shown, non-removable) */} +
+
+ +
+
+ Home Instance +
+
+ {window.location.host} +
+
+
+
+ Local +
+
+ + {/* Remote instances */} + {instances.map((inst) => ( +
+
+ +
+
+ {inst.label} +
+
+ {new URL(inst.origin).host} + {inst.username && ( + as {inst.username} + )} +
+ {inst.status === 'disconnected' && inst.error && ( +
{inst.error}
+ )} +
+
+ +
+ ))} + + {/* Add instance button / flow */} + {showAddForm ? ( + setShowAddForm(false)} /> + ) : ( + + )} +
+
+ ); +} diff --git a/packages/web/src/components/modals/UserSettings.tsx b/packages/web/src/components/modals/UserSettings.tsx index baee273b..036529a5 100644 --- a/packages/web/src/components/modals/UserSettings.tsx +++ b/packages/web/src/components/modals/UserSettings.tsx @@ -4,6 +4,7 @@ import { useUIStore } from '../../stores/uiStore'; import { useAuthStore } from '../../stores/authStore'; import { useVoiceStore } from '../../stores/voiceStore'; import { Avatar } from '../ui/Avatar'; +import { ConnectedInstances } from './ConnectedInstances'; export function UserSettingsModal() { const activeModal = useUIStore((s) => s.activeModal); @@ -168,6 +169,9 @@ export function UserSettingsModal() {
+ {/* Connected Instances */} + +