feat: add instance store and Connected Instances settings UI

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).
This commit is contained in:
Jannis Braun
2026-03-02 23:31:28 +01:00
parent c72fcdda70
commit ef7aa3ff5a
4 changed files with 690 additions and 0 deletions
@@ -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 <div className={`w-2 h-2 rounded-full shrink-0 ${colorClass}`} />;
}
// ─── 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<AddStep>('url');
const [url, setUrl] = useState('');
const [probeResult, setProbeResult] = useState<(InstanceInfoResponse & { origin: string }) | null>(null);
const [authTab, setAuthTab] = useState<AuthTab>('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 (
<div className="mt-3 p-3 bg-surface-channel rounded-lg space-y-3">
{/* Step 1: Enter URL */}
{step === 'url' && (
<>
<div className="text-sm text-txt-primary font-medium">Add Remote Instance</div>
<div className="flex gap-2">
<input
type="text"
value={url}
onChange={(e) => 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}
/>
<button
onClick={handleProbe}
disabled={isLoading || !url.trim()}
className="px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{isLoading ? 'Probing...' : 'Connect'}
</button>
</div>
<button
onClick={onDone}
className="text-xs text-txt-tertiary hover:text-txt-secondary transition-colors"
>
Cancel
</button>
</>
)}
{/* Step 2: Auth */}
{step === 'auth' && probeResult && (
<>
{/* Instance info card */}
<div className="flex items-center gap-2">
<StatusDot status="connecting" />
<div>
<div className="text-sm text-txt-primary font-medium">{probeResult.name}</div>
<div className="text-xs text-txt-tertiary">{probeResult.origin}</div>
</div>
</div>
{/* Auth tabs */}
<div className="flex gap-1 bg-surface-input rounded p-0.5">
{probeResult.registrationOpen && (
<button
onClick={() => setAuthTab('register')}
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded transition-colors ${
authTab === 'register'
? 'bg-surface-channel text-txt-primary'
: 'text-txt-tertiary hover:text-txt-secondary'
}`}
>
Register
</button>
)}
<button
onClick={() => setAuthTab('login')}
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded transition-colors ${
authTab === 'login'
? 'bg-surface-channel text-txt-primary'
: 'text-txt-tertiary hover:text-txt-secondary'
}`}
>
Login
</button>
</div>
{/* Register form */}
{authTab === 'register' && (
<div className="space-y-2">
<div>
<label className="block text-xs text-txt-tertiary mb-1">Username</label>
<input
type="text"
value={user?.username ?? ''}
disabled
className="w-full px-3 py-2 bg-surface-input rounded text-txt-secondary text-sm opacity-60"
/>
<div className="text-xs text-txt-tertiary mt-1">
Your username will be replicated. If taken, it becomes {user?.username}@{window.location.host}
</div>
</div>
<div>
<label className="block text-xs text-txt-tertiary mb-1">Password for this instance</label>
<input
type="password"
value={password}
onChange={(e) => 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}
/>
</div>
<button
onClick={handleRegister}
disabled={isLoading || !password}
className="w-full px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{isLoading ? 'Registering...' : 'Register & Connect'}
</button>
</div>
)}
{/* Login form */}
{authTab === 'login' && (
<div className="space-y-2">
<div>
<label className="block text-xs text-txt-tertiary mb-1">Username</label>
<input
type="text"
value={loginUsername}
onChange={(e) => 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}
/>
</div>
<div>
<label className="block text-xs text-txt-tertiary mb-1">Password</label>
<input
type="password"
value={loginPassword}
onChange={(e) => 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}
/>
</div>
<button
onClick={handleLogin}
disabled={isLoading || !loginUsername || !loginPassword}
className="w-full px-4 py-2 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{isLoading ? 'Logging in...' : 'Login & Connect'}
</button>
</div>
)}
{/* Back button */}
<div className="flex gap-2">
<button
onClick={() => { setStep('url'); setProbeResult(null); setError(''); }}
className="text-xs text-txt-tertiary hover:text-txt-secondary transition-colors"
>
Back
</button>
<button
onClick={onDone}
className="text-xs text-txt-tertiary hover:text-txt-secondary transition-colors"
>
Cancel
</button>
</div>
</>
)}
{/* Error display */}
{error && (
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-xs">
{error}
</div>
)}
</div>
);
}
// ─── Main component ──────────────────────────────────────────────────────────
export function ConnectedInstances() {
const instances = useInstanceStore((s) => s.instances);
const removeInstance = useInstanceStore((s) => s.removeInstance);
const [showAddForm, setShowAddForm] = useState(false);
return (
<div className="border-t border-white/[0.06] pt-4">
<h3 className="text-xs font-bold text-txt-secondary uppercase mb-3">
Connected Instances
</h3>
<div className="space-y-2">
{/* Home instance (always shown, non-removable) */}
<div className="flex items-center justify-between p-3 bg-surface-channel rounded-lg">
<div className="flex items-center gap-2 min-w-0">
<StatusDot status="connected" />
<div className="min-w-0">
<div className="text-sm text-txt-primary font-medium truncate">
Home Instance
</div>
<div className="text-xs text-txt-tertiary truncate">
{window.location.host}
</div>
</div>
</div>
<div className="text-xs text-txt-tertiary shrink-0 ml-2">
Local
</div>
</div>
{/* Remote instances */}
{instances.map((inst) => (
<div key={inst.origin} className="flex items-center justify-between p-3 bg-surface-channel rounded-lg">
<div className="flex items-center gap-2 min-w-0">
<StatusDot status={inst.status} />
<div className="min-w-0">
<div className="text-sm text-txt-primary font-medium truncate">
{inst.label}
</div>
<div className="text-xs text-txt-tertiary truncate">
{new URL(inst.origin).host}
{inst.username && (
<span className="ml-1 text-txt-quaternary">as {inst.username}</span>
)}
</div>
{inst.status === 'disconnected' && inst.error && (
<div className="text-xs text-accent-amber mt-0.5">{inst.error}</div>
)}
</div>
</div>
<button
onClick={() => removeInstance(inst.origin)}
className="px-2 py-1 text-xs text-txt-danger hover:bg-accent-rose/10 rounded transition-colors shrink-0 ml-2"
title="Disconnect"
>
Disconnect
</button>
</div>
))}
{/* Add instance button / flow */}
{showAddForm ? (
<AddInstanceFlow onDone={() => setShowAddForm(false)} />
) : (
<button
onClick={() => setShowAddForm(true)}
className="w-full p-2 text-sm text-txt-secondary hover:text-txt-primary hover:bg-surface-channel/50 rounded-lg border border-dashed border-white/[0.06] hover:border-white/[0.12] transition-colors"
>
+ Add Instance
</button>
)}
</div>
</div>
);
}
@@ -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() {
</div>
</div>
{/* Connected Instances */}
<ConnectedInstances />
<div className="flex items-center justify-between pt-2">
<button
onClick={handleLogout}
+6
View File
@@ -5,6 +5,7 @@ import { useChatStore } from './chatStore';
import { useServerStore } from './serverStore';
import { useSocialStore } from './socialStore';
import { useVoiceStore } from './voiceStore';
import { useInstanceStore } from './instanceStore';
interface AuthState {
token: string | null;
@@ -32,6 +33,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
const response = await api.auth.login({ username, password });
localStorage.setItem('backspace_token', response.token);
set({ token: response.token, user: response.user, isLoading: false });
// Auto-connect to remote instances (fire-and-forget)
useInstanceStore.getState().autoConnectAll().catch(() => {});
} catch (err) {
set({ isLoading: false, error: err instanceof Error ? err.message : 'Login failed' });
throw err;
@@ -57,6 +60,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
useServerStore.getState().populateFromReady([], [], []);
useSocialStore.getState().reset();
useVoiceStore.getState().clearAllVoiceUsers();
useInstanceStore.getState().reset();
set({ token: null, user: null });
},
@@ -68,6 +72,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
try {
const user = await api.users.me();
set({ user, isLoading: false });
// Auto-connect to remote instances (fire-and-forget)
useInstanceStore.getState().autoConnectAll().catch(() => {});
} catch {
localStorage.removeItem('backspace_token');
set({ token: null, user: null, isLoading: false });
+343
View File
@@ -0,0 +1,343 @@
import { create } from 'zustand';
import type { User, InstanceInfoResponse, ReplicatedInstance } from '@backspace/shared';
import { BackspaceApiClient, createApiClient, api } from '../api/client';
import { useAuthStore } from './authStore';
// ─── Types ───────────────────────────────────────────────────────────────────
export interface ConnectedInstance {
origin: string;
label: string;
token: string;
user: User;
username: string;
status: 'connected' | 'connecting' | 'disconnected' | 'error';
error?: string;
api: BackspaceApiClient;
}
interface CachedInstanceToken {
token: string;
label: string;
username: string;
}
const STORAGE_KEY = 'backspace_instances';
// ─── localStorage helpers ────────────────────────────────────────────────────
function loadCachedTokens(): Record<string, CachedInstanceToken> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
return JSON.parse(raw) as Record<string, CachedInstanceToken>;
} catch {
return {};
}
}
function saveCachedTokens(instances: ConnectedInstance[]): void {
const cache: Record<string, CachedInstanceToken> = {};
for (const inst of instances) {
cache[inst.origin] = {
token: inst.token,
label: inst.label,
username: inst.username,
};
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(cache));
}
// ─── URL normalization ───────────────────────────────────────────────────────
function normalizeOrigin(url: string): string {
let normalized = url.trim();
// Add https:// if no protocol
if (!/^https?:\/\//i.test(normalized)) {
normalized = `https://${normalized}`;
}
try {
const parsed = new URL(normalized);
return parsed.origin; // "https://domain.com" — no path, no trailing slash
} catch {
throw new Error('Invalid URL');
}
}
// ─── Store ───────────────────────────────────────────────────────────────────
interface InstanceState {
instances: ConnectedInstance[];
isLoading: boolean;
error: string | null;
probeInstance: (url: string) => Promise<InstanceInfoResponse & { origin: string }>;
registerOnRemote: (origin: string, password: string, displayName?: string) => Promise<void>;
loginToRemote: (origin: string, username: string, password: string) => Promise<void>;
removeInstance: (origin: string) => void;
syncInstanceList: () => Promise<void>;
autoConnectAll: () => Promise<void>;
reset: () => void;
}
export const useInstanceStore = create<InstanceState>((set, get) => ({
instances: [],
isLoading: false,
error: null,
probeInstance: async (url: string) => {
const origin = normalizeOrigin(url);
// Reject self-connection
if (origin === window.location.origin) {
throw new Error('Cannot add your home instance as a remote instance');
}
// Reject duplicates
if (get().instances.some(i => i.origin === origin)) {
throw new Error('This instance is already connected');
}
// Probe with unauthenticated client
const tempClient = createApiClient(origin, () => null);
const info = await tempClient.instance.info();
return { ...info, origin };
},
registerOnRemote: async (origin: string, password: string, displayName?: string) => {
const currentUser = useAuthStore.getState().user;
if (!currentUser) throw new Error('Not logged in');
set({ isLoading: true, error: null });
try {
const homeInstance = window.location.host;
const tempClient = createApiClient(origin, () => null);
let response;
let finalUsername = currentUser.username;
try {
// First attempt: register with current username
response = await tempClient.auth.register({
username: currentUser.username,
password,
displayName: displayName || currentUser.displayName || undefined,
homeInstance,
});
} catch (err) {
const message = (err as Error).message;
// If username taken, retry with domain-qualified username
if (message.includes('already taken') || message.includes('409')) {
finalUsername = `${currentUser.username}@${homeInstance}`;
response = await tempClient.auth.register({
username: finalUsername,
password,
displayName: displayName || currentUser.displayName || undefined,
homeInstance,
});
} else {
throw err;
}
}
// Fetch instance info for the label
const info = await tempClient.instance.info();
// Create authenticated client
const authenticatedClient = createApiClient(origin, () => response.token);
const instance: ConnectedInstance = {
origin,
label: info.name,
token: response.token,
user: response.user,
username: finalUsername,
status: 'connected',
api: authenticatedClient,
};
set((state) => {
const updated = [...state.instances, instance];
saveCachedTokens(updated);
return { instances: updated, isLoading: false };
});
// Sync instance list to all instances (fire-and-forget)
get().syncInstanceList().catch(() => {});
} catch (err) {
set({ isLoading: false, error: (err as Error).message });
throw err;
}
},
loginToRemote: async (origin: string, username: string, password: string) => {
set({ isLoading: true, error: null });
try {
const tempClient = createApiClient(origin, () => null);
const response = await tempClient.auth.login({ username, password });
// Fetch instance info for the label
const info = await tempClient.instance.info();
const authenticatedClient = createApiClient(origin, () => response.token);
const instance: ConnectedInstance = {
origin,
label: info.name,
token: response.token,
user: response.user,
username: response.user.username,
status: 'connected',
api: authenticatedClient,
};
set((state) => {
const updated = [...state.instances, instance];
saveCachedTokens(updated);
return { instances: updated, isLoading: false };
});
// Sync instance list to all instances (fire-and-forget)
get().syncInstanceList().catch(() => {});
} catch (err) {
set({ isLoading: false, error: (err as Error).message });
throw err;
}
},
removeInstance: (origin: string) => {
set((state) => {
const updated = state.instances.filter(i => i.origin !== origin);
saveCachedTokens(updated);
return { instances: updated };
});
// Sync updated list to remaining instances (fire-and-forget)
get().syncInstanceList().catch(() => {});
},
syncInstanceList: async () => {
const { instances } = get();
const currentUser = useAuthStore.getState().user;
if (!currentUser) return;
// Build the replicated instances list from all connected remotes
const replicatedInstances: ReplicatedInstance[] = instances.map(inst => ({
domain: new URL(inst.origin).host,
username: inst.username,
}));
// Push to home instance
const homePromise = api.users.update({ replicatedInstances }).catch((err) => {
console.warn('Failed to sync instance list to home:', err);
});
// Push to each remote instance
const remotePromises = instances
.filter(inst => inst.status === 'connected')
.map(inst =>
inst.api.users.update({ replicatedInstances }).catch((err) => {
console.warn(`Failed to sync instance list to ${inst.origin}:`, err);
})
);
await Promise.all([homePromise, ...remotePromises]);
},
autoConnectAll: async () => {
const currentUser = useAuthStore.getState().user;
if (!currentUser || currentUser.replicatedInstances.length === 0) return;
const cached = loadCachedTokens();
const toConnect = currentUser.replicatedInstances.filter(ri => {
// Build origin from domain
const origin = `https://${ri.domain}`;
// Only attempt if we have a cached token and aren't already connected
return cached[origin] && !get().instances.some(i => i.origin === origin);
});
if (toConnect.length === 0) return;
// Connect all in parallel, individual failures are non-blocking
const results = await Promise.allSettled(
toConnect.map(async (ri) => {
const origin = `https://${ri.domain}`;
const cachedEntry = cached[origin]!; // Guaranteed by filter above
// Create client with cached token
const client = createApiClient(origin, () => cachedEntry.token);
// Set as connecting
const connectingInstance: ConnectedInstance = {
origin,
label: cachedEntry.label || ri.domain,
token: cachedEntry.token,
user: currentUser, // Placeholder until we verify
username: cachedEntry.username || ri.username,
status: 'connecting',
api: client,
};
set((state) => ({
instances: [...state.instances.filter(i => i.origin !== origin), connectingInstance],
}));
try {
// Verify the token is still valid
const user = await client.users.me();
// Fetch instance info for fresh label
let label = cachedEntry.label || ri.domain;
try {
const info = await client.instance.info();
label = info.name;
} catch {
// Non-critical — keep cached label
}
const connectedInstance: ConnectedInstance = {
origin,
label,
token: cachedEntry.token,
user,
username: user.username,
status: 'connected',
api: client,
};
set((state) => ({
instances: state.instances.map(i => i.origin === origin ? connectedInstance : i),
}));
} catch {
// Token expired or instance unreachable
set((state) => ({
instances: state.instances.map(i =>
i.origin === origin
? { ...i, status: 'disconnected' as const, error: 'Token expired — re-authenticate to reconnect' }
: i
),
}));
}
})
);
// Save final state to localStorage
saveCachedTokens(get().instances.filter(i => i.status === 'connected'));
// Log any failures for debugging
const failures = results.filter(r => r.status === 'rejected');
if (failures.length > 0) {
console.warn(`autoConnectAll: ${failures.length}/${toConnect.length} instances failed to connect`);
}
},
reset: () => {
set({ instances: [], isLoading: false, error: null });
localStorage.removeItem(STORAGE_KEY);
},
}));