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:
@@ -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 { useAuthStore } from '../../stores/authStore';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
|
import { ConnectedInstances } from './ConnectedInstances';
|
||||||
|
|
||||||
export function UserSettingsModal() {
|
export function UserSettingsModal() {
|
||||||
const activeModal = useUIStore((s) => s.activeModal);
|
const activeModal = useUIStore((s) => s.activeModal);
|
||||||
@@ -168,6 +169,9 @@ export function UserSettingsModal() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Connected Instances */}
|
||||||
|
<ConnectedInstances />
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-2">
|
<div className="flex items-center justify-between pt-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useChatStore } from './chatStore';
|
|||||||
import { useServerStore } from './serverStore';
|
import { useServerStore } from './serverStore';
|
||||||
import { useSocialStore } from './socialStore';
|
import { useSocialStore } from './socialStore';
|
||||||
import { useVoiceStore } from './voiceStore';
|
import { useVoiceStore } from './voiceStore';
|
||||||
|
import { useInstanceStore } from './instanceStore';
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
token: string | null;
|
token: string | null;
|
||||||
@@ -32,6 +33,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
const response = await api.auth.login({ username, password });
|
const response = await api.auth.login({ username, password });
|
||||||
localStorage.setItem('backspace_token', response.token);
|
localStorage.setItem('backspace_token', response.token);
|
||||||
set({ token: response.token, user: response.user, isLoading: false });
|
set({ token: response.token, user: response.user, isLoading: false });
|
||||||
|
// Auto-connect to remote instances (fire-and-forget)
|
||||||
|
useInstanceStore.getState().autoConnectAll().catch(() => {});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({ isLoading: false, error: err instanceof Error ? err.message : 'Login failed' });
|
set({ isLoading: false, error: err instanceof Error ? err.message : 'Login failed' });
|
||||||
throw err;
|
throw err;
|
||||||
@@ -57,6 +60,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
useServerStore.getState().populateFromReady([], [], []);
|
useServerStore.getState().populateFromReady([], [], []);
|
||||||
useSocialStore.getState().reset();
|
useSocialStore.getState().reset();
|
||||||
useVoiceStore.getState().clearAllVoiceUsers();
|
useVoiceStore.getState().clearAllVoiceUsers();
|
||||||
|
useInstanceStore.getState().reset();
|
||||||
set({ token: null, user: null });
|
set({ token: null, user: null });
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -68,6 +72,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
try {
|
try {
|
||||||
const user = await api.users.me();
|
const user = await api.users.me();
|
||||||
set({ user, isLoading: false });
|
set({ user, isLoading: false });
|
||||||
|
// Auto-connect to remote instances (fire-and-forget)
|
||||||
|
useInstanceStore.getState().autoConnectAll().catch(() => {});
|
||||||
} catch {
|
} catch {
|
||||||
localStorage.removeItem('backspace_token');
|
localStorage.removeItem('backspace_token');
|
||||||
set({ token: null, user: null, isLoading: false });
|
set({ token: null, user: null, isLoading: false });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user