feat: federation batch 1+2 — bug fixes and password enforcement
Batch 1 — Bug fixes: - Fix stale voice state on remote reconnect (clearVoiceUsersForOrigin) - Fix logout not cleaning remote servers from store - Fix reply-to asset normalization for remote messages - Fix HTTPS hardcoded in autoConnectAll (store full origin, legacy fallback) Batch 2 — Password enforcement + replication flow: - Add connectToRemote() with home password verification before remote auth - Auto-cascade: verify home password → register on remote → login fallback - Replace Register/Login tabs with single password field in ConnectedInstances - Add DifferentPasswordError for typed fallback-login UI transition - Fallback login form shown only when remote has different password
This commit is contained in:
@@ -82,10 +82,13 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
if (!Array.isArray(replicatedInstances)) {
|
if (!Array.isArray(replicatedInstances)) {
|
||||||
return reply.code(400).send({ error: 'replicatedInstances must be an array', statusCode: 400 });
|
return reply.code(400).send({ error: 'replicatedInstances must be an array', statusCode: 400 });
|
||||||
}
|
}
|
||||||
// Validate each entry has domain and username strings
|
// Validate each entry has (origin or domain) and username strings
|
||||||
for (const inst of replicatedInstances) {
|
for (const inst of replicatedInstances) {
|
||||||
if (!inst || typeof inst.domain !== 'string' || typeof inst.username !== 'string') {
|
if (!inst || typeof inst.username !== 'string') {
|
||||||
return reply.code(400).send({ error: 'Each replicated instance must have domain and username strings', statusCode: 400 });
|
return reply.code(400).send({ error: 'Each replicated instance must have username string', statusCode: 400 });
|
||||||
|
}
|
||||||
|
if (typeof inst.origin !== 'string' && typeof inst.domain !== 'string') {
|
||||||
|
return reply.code(400).send({ error: 'Each replicated instance must have origin or domain string', statusCode: 400 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (replicatedInstances.length > 50) {
|
if (replicatedInstances.length > 50) {
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ export interface User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ReplicatedInstance {
|
export interface ReplicatedInstance {
|
||||||
domain: string;
|
origin: string; // Full URL with protocol, e.g. "https://orbit.ddns.net"
|
||||||
username: string;
|
username: string;
|
||||||
|
domain?: string; // Legacy field — kept for backward compat with existing data
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserStatus = 'online' | 'idle' | 'dnd' | 'offline';
|
export type UserStatus = 'online' | 'idle' | 'dnd' | 'offline';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import type { InstanceInfoResponse } from '@backspace/shared';
|
import type { InstanceInfoResponse } from '@backspace/shared';
|
||||||
import { useInstanceStore } from '../../stores/instanceStore';
|
import { useInstanceStore, DifferentPasswordError } from '../../stores/instanceStore';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
|
||||||
// ─── Status indicator ────────────────────────────────────────────────────────
|
// ─── Status indicator ────────────────────────────────────────────────────────
|
||||||
@@ -17,21 +17,21 @@ function StatusDot({ status }: { status: string }) {
|
|||||||
// ─── Add Instance flow ───────────────────────────────────────────────────────
|
// ─── Add Instance flow ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
type AddStep = 'url' | 'auth' | 'done';
|
type AddStep = 'url' | 'auth' | 'done';
|
||||||
type AuthTab = 'register' | 'login';
|
type AuthPhase = 'password' | 'fallback-login';
|
||||||
|
|
||||||
function AddInstanceFlow({ onDone }: { onDone: () => void }) {
|
function AddInstanceFlow({ onDone }: { onDone: () => void }) {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const registerOnRemote = useInstanceStore((s) => s.registerOnRemote);
|
const connectToRemote = useInstanceStore((s) => s.connectToRemote);
|
||||||
const loginToRemote = useInstanceStore((s) => s.loginToRemote);
|
const loginToRemote = useInstanceStore((s) => s.loginToRemote);
|
||||||
const probeInstance = useInstanceStore((s) => s.probeInstance);
|
const probeInstance = useInstanceStore((s) => s.probeInstance);
|
||||||
|
|
||||||
const [step, setStep] = useState<AddStep>('url');
|
const [step, setStep] = useState<AddStep>('url');
|
||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('');
|
||||||
const [probeResult, setProbeResult] = useState<(InstanceInfoResponse & { origin: string }) | null>(null);
|
const [probeResult, setProbeResult] = useState<(InstanceInfoResponse & { origin: string }) | null>(null);
|
||||||
const [authTab, setAuthTab] = useState<AuthTab>('register');
|
const [authPhase, setAuthPhase] = useState<AuthPhase>('password');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [loginUsername, setLoginUsername] = useState('');
|
const [fallbackUsername, setFallbackUsername] = useState('');
|
||||||
const [loginPassword, setLoginPassword] = useState('');
|
const [fallbackPassword, setFallbackPassword] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
|
|||||||
try {
|
try {
|
||||||
const result = await probeInstance(url);
|
const result = await probeInstance(url);
|
||||||
setProbeResult(result);
|
setProbeResult(result);
|
||||||
setAuthTab(result.registrationOpen ? 'register' : 'login');
|
setAuthPhase('password');
|
||||||
setStep('auth');
|
setStep('auth');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
@@ -50,12 +50,12 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRegister = async () => {
|
const handleConnect = async () => {
|
||||||
if (!probeResult) return;
|
if (!probeResult) return;
|
||||||
setError('');
|
setError('');
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await registerOnRemote(
|
await connectToRemote(
|
||||||
probeResult.origin,
|
probeResult.origin,
|
||||||
password,
|
password,
|
||||||
user?.displayName || undefined,
|
user?.displayName || undefined,
|
||||||
@@ -63,18 +63,25 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
|
|||||||
setStep('done');
|
setStep('done');
|
||||||
onDone();
|
onDone();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof DifferentPasswordError) {
|
||||||
|
setAuthPhase('fallback-login');
|
||||||
|
setFallbackUsername(err.remoteUsername);
|
||||||
|
setFallbackPassword('');
|
||||||
|
setError('');
|
||||||
|
} else {
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleFallbackLogin = async () => {
|
||||||
if (!probeResult) return;
|
if (!probeResult) return;
|
||||||
setError('');
|
setError('');
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await loginToRemote(probeResult.origin, loginUsername, loginPassword);
|
await loginToRemote(probeResult.origin, fallbackUsername, fallbackPassword);
|
||||||
setStep('done');
|
setStep('done');
|
||||||
onDone();
|
onDone();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -119,8 +126,8 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 2: Auth */}
|
{/* Step 2: Auth — single password */}
|
||||||
{step === 'auth' && probeResult && (
|
{step === 'auth' && probeResult && authPhase === 'password' && (
|
||||||
<>
|
<>
|
||||||
{/* Instance info card */}
|
{/* Instance info card */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -131,109 +138,104 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
|
|||||||
</div>
|
</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 className="space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-txt-tertiary mb-1">Username</label>
|
<label className="block text-xs text-txt-tertiary mb-1">
|
||||||
<input
|
Enter your password to connect to {new URL(probeResult.origin).host}
|
||||||
type="text"
|
</label>
|
||||||
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
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && !isLoading && password && handleRegister()}
|
onKeyDown={(e) => e.key === 'Enter' && !isLoading && password && handleConnect()}
|
||||||
placeholder="Create a password for this instance"
|
placeholder="Your account password"
|
||||||
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"
|
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}
|
disabled={isLoading}
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
<div className="text-xs text-txt-tertiary mt-1">
|
||||||
|
Your password is verified locally, then used to create or access your account on the remote instance.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleRegister}
|
onClick={handleConnect}
|
||||||
disabled={isLoading || !password}
|
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"
|
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'}
|
{isLoading ? 'Connecting...' : 'Connect'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Login form */}
|
{/* Step 2b: Fallback login — different password on remote */}
|
||||||
{authTab === 'login' && (
|
{step === 'auth' && probeResult && authPhase === 'fallback-login' && (
|
||||||
|
<>
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
<div className="p-2 bg-accent-amber/10 border border-accent-amber/30 rounded text-xs text-accent-amber">
|
||||||
|
An account already exists on this instance with a different password. Enter the credentials you used on that instance.
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-txt-tertiary mb-1">Username</label>
|
<label className="block text-xs text-txt-tertiary mb-1">Username</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={loginUsername}
|
value={fallbackUsername}
|
||||||
onChange={(e) => setLoginUsername(e.target.value)}
|
onChange={(e) => setFallbackUsername(e.target.value)}
|
||||||
placeholder="Your username on this instance"
|
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"
|
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}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-txt-tertiary mb-1">Password</label>
|
<label className="block text-xs text-txt-tertiary mb-1">Password for this instance</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={loginPassword}
|
value={fallbackPassword}
|
||||||
onChange={(e) => setLoginPassword(e.target.value)}
|
onChange={(e) => setFallbackPassword(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && !isLoading && loginUsername && loginPassword && handleLogin()}
|
onKeyDown={(e) => e.key === 'Enter' && !isLoading && fallbackUsername && fallbackPassword && handleFallbackLogin()}
|
||||||
placeholder="Your password on this instance"
|
placeholder="Password on the remote 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"
|
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}
|
disabled={isLoading}
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleLogin}
|
onClick={handleFallbackLogin}
|
||||||
disabled={isLoading || !loginUsername || !loginPassword}
|
disabled={isLoading || !fallbackUsername || !fallbackPassword}
|
||||||
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"
|
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'}
|
{isLoading ? 'Logging in...' : 'Login & Connect'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Back button */}
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setStep('url'); setProbeResult(null); setError(''); }}
|
onClick={() => { setAuthPhase('password'); setPassword(''); setError(''); }}
|
||||||
className="text-xs text-txt-tertiary hover:text-txt-secondary transition-colors"
|
className="text-xs text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
const { setUser } = useAuthStore.getState();
|
const { setUser } = useAuthStore.getState();
|
||||||
const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember, addDmChannel, removeDmChannel } = useServerStore.getState();
|
const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember, addDmChannel, removeDmChannel } = useServerStore.getState();
|
||||||
const { addMessage, addRealtimeMessage, updateMessage, removeMessage, setTyping, onReactionAdded, onReactionRemoved } = useChatStore.getState();
|
const { addMessage, addRealtimeMessage, updateMessage, removeMessage, setTyping, onReactionAdded, onReactionRemoved } = useChatStore.getState();
|
||||||
const { addVoiceUser, removeVoiceUser, clearAllVoiceUsers, setVoiceUsers, setVoiceUserStatus, clearVoiceUserStatus } = useVoiceStore.getState();
|
const { addVoiceUser, removeVoiceUser, clearAllVoiceUsers, clearVoiceUsersForOrigin, setVoiceUsers, setVoiceUserStatus, clearVoiceUserStatus } = useVoiceStore.getState();
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'ready':
|
case 'ready':
|
||||||
@@ -124,9 +124,11 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Voice states — process for all origins (shows who's in voice on remote servers)
|
// Clear voice state for the reconnecting origin before repopulating
|
||||||
if (isHome) {
|
if (isHome) {
|
||||||
clearAllVoiceUsers();
|
clearAllVoiceUsers();
|
||||||
|
} else {
|
||||||
|
clearVoiceUsersForOrigin(origin);
|
||||||
}
|
}
|
||||||
if (event.voiceStates) {
|
if (event.voiceStates) {
|
||||||
for (const [channelId, userIds] of Object.entries(event.voiceStates)) {
|
for (const [channelId, userIds] of Object.entries(event.voiceStates)) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { User, InstanceInfoResponse, ReplicatedInstance } from '@backspace/shared';
|
import type { User, InstanceInfoResponse, ReplicatedInstance, AuthResponse } from '@backspace/shared';
|
||||||
import { BackspaceApiClient, createApiClient, api } from '../api/client';
|
import { BackspaceApiClient, createApiClient, api } from '../api/client';
|
||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
import { setApiForOriginResolver, useServerStore } from './serverStore';
|
import { setApiForOriginResolver, useServerStore } from './serverStore';
|
||||||
@@ -50,6 +50,16 @@ function saveCachedTokens(instances: ConnectedInstance[]): void {
|
|||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(cache));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(cache));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Error types ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Thrown when the remote instance already has an account for this user with a different password. */
|
||||||
|
export class DifferentPasswordError extends Error {
|
||||||
|
constructor(public remoteUsername: string) {
|
||||||
|
super('Account exists with a different password on this instance');
|
||||||
|
this.name = 'DifferentPasswordError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── URL normalization ───────────────────────────────────────────────────────
|
// ─── URL normalization ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
function normalizeOrigin(url: string): string {
|
function normalizeOrigin(url: string): string {
|
||||||
@@ -78,7 +88,7 @@ interface InstanceState {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
||||||
probeInstance: (url: string) => Promise<InstanceInfoResponse & { origin: string }>;
|
probeInstance: (url: string) => Promise<InstanceInfoResponse & { origin: string }>;
|
||||||
registerOnRemote: (origin: string, password: string, displayName?: string) => Promise<void>;
|
connectToRemote: (origin: string, password: string, displayName?: string) => Promise<void>;
|
||||||
loginToRemote: (origin: string, username: string, password: string) => Promise<void>;
|
loginToRemote: (origin: string, username: string, password: string) => Promise<void>;
|
||||||
removeInstance: (origin: string) => void;
|
removeInstance: (origin: string) => void;
|
||||||
syncInstanceList: () => Promise<void>;
|
syncInstanceList: () => Promise<void>;
|
||||||
@@ -111,21 +121,29 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
|||||||
return { ...info, origin };
|
return { ...info, origin };
|
||||||
},
|
},
|
||||||
|
|
||||||
registerOnRemote: async (origin: string, password: string, displayName?: string) => {
|
connectToRemote: async (origin: string, password: string, displayName?: string) => {
|
||||||
const currentUser = useAuthStore.getState().user;
|
const currentUser = useAuthStore.getState().user;
|
||||||
if (!currentUser) throw new Error('Not logged in');
|
if (!currentUser) throw new Error('Not logged in');
|
||||||
|
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Step 1: Verify password against HOME instance
|
||||||
|
const { valid } = await api.users.verifyPassword(password);
|
||||||
|
if (!valid) {
|
||||||
|
throw new Error('Incorrect password');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Try register on remote, then fall back to login
|
||||||
const homeInstance = window.location.host;
|
const homeInstance = window.location.host;
|
||||||
const tempClient = createApiClient(origin, () => null);
|
const tempClient = createApiClient(origin, () => null);
|
||||||
|
|
||||||
let response;
|
let response: AuthResponse | null = null;
|
||||||
let finalUsername = currentUser.username;
|
let finalUsername = currentUser.username;
|
||||||
|
let needsLogin = false;
|
||||||
|
|
||||||
|
// 2a: Attempt registration with plain username
|
||||||
try {
|
try {
|
||||||
// First attempt: register with current username
|
|
||||||
response = await tempClient.auth.register({
|
response = await tempClient.auth.register({
|
||||||
username: currentUser.username,
|
username: currentUser.username,
|
||||||
password,
|
password,
|
||||||
@@ -134,8 +152,9 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = (err as Error).message;
|
const message = (err as Error).message;
|
||||||
// If username taken, retry with domain-qualified username
|
|
||||||
if (message.includes('already taken') || message.includes('409')) {
|
if (message.includes('already taken') || message.includes('409')) {
|
||||||
|
// Username collision — try domain-qualified username
|
||||||
|
try {
|
||||||
finalUsername = `${currentUser.username}@${homeInstance}`;
|
finalUsername = `${currentUser.username}@${homeInstance}`;
|
||||||
response = await tempClient.auth.register({
|
response = await tempClient.auth.register({
|
||||||
username: finalUsername,
|
username: finalUsername,
|
||||||
@@ -143,15 +162,52 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
|||||||
displayName: displayName || currentUser.displayName || undefined,
|
displayName: displayName || currentUser.displayName || undefined,
|
||||||
homeInstance,
|
homeInstance,
|
||||||
});
|
});
|
||||||
|
} catch (err2) {
|
||||||
|
const msg2 = (err2 as Error).message;
|
||||||
|
if (msg2.includes('already taken') || msg2.includes('409')) {
|
||||||
|
// Both usernames exist on remote — fall through to login
|
||||||
|
needsLogin = true;
|
||||||
|
} else {
|
||||||
|
throw err2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (message.includes('Registration is currently closed') || message.includes('403')) {
|
||||||
|
// Registration closed on remote — fall through to login
|
||||||
|
needsLogin = true;
|
||||||
} else {
|
} else {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch instance info for the label
|
// 2b: If registration didn't work, try login with the same password
|
||||||
const info = await tempClient.instance.info();
|
if (needsLogin) {
|
||||||
|
// Try plain username first, then domain-qualified
|
||||||
|
try {
|
||||||
|
response = await tempClient.auth.login({
|
||||||
|
username: currentUser.username,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
finalUsername = currentUser.username;
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
finalUsername = `${currentUser.username}@${homeInstance}`;
|
||||||
|
response = await tempClient.auth.login({
|
||||||
|
username: finalUsername,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Both login attempts failed — different password scenario
|
||||||
|
throw new DifferentPasswordError(currentUser.username);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create authenticated client
|
if (!response) {
|
||||||
|
throw new Error('Failed to authenticate with remote instance');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Complete connection
|
||||||
|
const info = await tempClient.instance.info();
|
||||||
const authenticatedClient = createApiClient(origin, () => response.token);
|
const authenticatedClient = createApiClient(origin, () => response.token);
|
||||||
|
|
||||||
const instance: ConnectedInstance = {
|
const instance: ConnectedInstance = {
|
||||||
@@ -244,7 +300,7 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
|||||||
|
|
||||||
// Build the replicated instances list from all connected remotes
|
// Build the replicated instances list from all connected remotes
|
||||||
const replicatedInstances: ReplicatedInstance[] = instances.map(inst => ({
|
const replicatedInstances: ReplicatedInstance[] = instances.map(inst => ({
|
||||||
domain: new URL(inst.origin).host,
|
origin: inst.origin,
|
||||||
username: inst.username,
|
username: inst.username,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -271,8 +327,7 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
|||||||
|
|
||||||
const cached = loadCachedTokens();
|
const cached = loadCachedTokens();
|
||||||
const toConnect = currentUser.replicatedInstances.filter(ri => {
|
const toConnect = currentUser.replicatedInstances.filter(ri => {
|
||||||
// Build origin from domain
|
const origin = ri.origin || `https://${ri.domain}`;
|
||||||
const origin = `https://${ri.domain}`;
|
|
||||||
// Only attempt if we have a cached token and aren't already connected
|
// Only attempt if we have a cached token and aren't already connected
|
||||||
return cached[origin] && !get().instances.some(i => i.origin === origin);
|
return cached[origin] && !get().instances.some(i => i.origin === origin);
|
||||||
});
|
});
|
||||||
@@ -282,7 +337,7 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
|||||||
// Connect all in parallel, individual failures are non-blocking
|
// Connect all in parallel, individual failures are non-blocking
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
toConnect.map(async (ri) => {
|
toConnect.map(async (ri) => {
|
||||||
const origin = `https://${ri.domain}`;
|
const origin = ri.origin || `https://${ri.domain}`;
|
||||||
const cachedEntry = cached[origin]!; // Guaranteed by filter above
|
const cachedEntry = cached[origin]!; // Guaranteed by filter above
|
||||||
|
|
||||||
// Create client with cached token
|
// Create client with cached token
|
||||||
@@ -291,7 +346,7 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
|||||||
// Set as connecting
|
// Set as connecting
|
||||||
const connectingInstance: ConnectedInstance = {
|
const connectingInstance: ConnectedInstance = {
|
||||||
origin,
|
origin,
|
||||||
label: cachedEntry.label || ri.domain,
|
label: cachedEntry.label || new URL(origin).host,
|
||||||
token: cachedEntry.token,
|
token: cachedEntry.token,
|
||||||
user: currentUser, // Placeholder until we verify
|
user: currentUser, // Placeholder until we verify
|
||||||
username: cachedEntry.username || ri.username,
|
username: cachedEntry.username || ri.username,
|
||||||
@@ -308,7 +363,7 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
|||||||
const user = await client.users.me();
|
const user = await client.users.me();
|
||||||
|
|
||||||
// Fetch instance info for fresh label
|
// Fetch instance info for fresh label
|
||||||
let label = cachedEntry.label || ri.domain;
|
let label = cachedEntry.label || new URL(origin).host;
|
||||||
try {
|
try {
|
||||||
const info = await client.instance.info();
|
const info = await client.instance.info();
|
||||||
label = info.name;
|
label = info.name;
|
||||||
@@ -356,6 +411,12 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
reset: () => {
|
reset: () => {
|
||||||
|
// Clean up server store for each connected remote instance before tearing down
|
||||||
|
const { instances } = get();
|
||||||
|
for (const inst of instances) {
|
||||||
|
useServerStore.getState().removeInstanceServers(inst.origin);
|
||||||
|
}
|
||||||
|
|
||||||
// Tear down all remote WebSocket connections
|
// Tear down all remote WebSocket connections
|
||||||
disconnectAllRemote();
|
disconnectAllRemote();
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { create } from 'zustand';
|
|||||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
import type { ParticipantInfo } from '../hooks/useLiveKit';
|
import type { ParticipantInfo } from '../hooks/useLiveKit';
|
||||||
import { AudioManager } from '../audio/AudioManager';
|
import { AudioManager } from '../audio/AudioManager';
|
||||||
|
import { useServerStore } from './serverStore';
|
||||||
|
|
||||||
export interface ScreenShareConfig {
|
export interface ScreenShareConfig {
|
||||||
height: 1080 | 720 | 540;
|
height: 1080 | 720 | 540;
|
||||||
@@ -87,6 +88,7 @@ interface VoiceState {
|
|||||||
clearVoiceUserStatus: (userId: string) => void;
|
clearVoiceUserStatus: (userId: string) => void;
|
||||||
getVoiceUsers: (channelId: string) => string[];
|
getVoiceUsers: (channelId: string) => string[];
|
||||||
clearAllVoiceUsers: () => void;
|
clearAllVoiceUsers: () => void;
|
||||||
|
clearVoiceUsersForOrigin: (origin: string) => void;
|
||||||
leaveVoice: () => void;
|
leaveVoice: () => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
@@ -275,6 +277,19 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
|
|
||||||
clearAllVoiceUsers: () => set({ voiceUsers: new Map(), voiceUserStates: new Map() }),
|
clearAllVoiceUsers: () => set({ voiceUsers: new Map(), voiceUserStates: new Map() }),
|
||||||
|
|
||||||
|
clearVoiceUsersForOrigin: (origin: string) => {
|
||||||
|
const { channelOriginMap } = useServerStore.getState();
|
||||||
|
set((state) => {
|
||||||
|
const newVoiceUsers = new Map(state.voiceUsers);
|
||||||
|
for (const [channelId] of newVoiceUsers) {
|
||||||
|
if ((channelOriginMap.get(channelId) ?? '') === origin) {
|
||||||
|
newVoiceUsers.delete(channelId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { voiceUsers: newVoiceUsers };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
// Leave voice without wiping the voiceUsers map (so sidebar still shows others)
|
// Leave voice without wiping the voiceUsers map (so sidebar still shows others)
|
||||||
leaveVoice: () => set({
|
leaveVoice: () => set({
|
||||||
currentVoiceChannelId: null,
|
currentVoiceChannelId: null,
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ export function normalizeUserAssets<T extends { avatar?: string | null }>(user:
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Rewrite user.avatar and attachment filenames on a message for remote origins.
|
* Rewrite user.avatar and attachment filenames on a message for remote origins.
|
||||||
* Mutates in-place.
|
* Also normalizes nested replyTo message assets. Mutates in-place.
|
||||||
*/
|
*/
|
||||||
export function normalizeMessageAssets<T extends { user: { avatar?: string | null }; attachments?: { filename: string }[] }>(
|
export function normalizeMessageAssets<T extends { user: { avatar?: string | null }; attachments?: { filename: string }[]; replyTo?: { user: { avatar?: string | null }; attachments?: { filename: string }[] } | null }>(
|
||||||
message: T,
|
message: T,
|
||||||
origin: string,
|
origin: string,
|
||||||
): T {
|
): T {
|
||||||
@@ -36,5 +36,14 @@ export function normalizeMessageAssets<T extends { user: { avatar?: string | nul
|
|||||||
att.filename = resolveAssetUrl(att.filename, origin) ?? att.filename;
|
att.filename = resolveAssetUrl(att.filename, origin) ?? att.filename;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Normalize reply-to message assets (remote replies have relative URLs)
|
||||||
|
if (message.replyTo) {
|
||||||
|
normalizeUserAssets(message.replyTo.user, origin);
|
||||||
|
if (message.replyTo.attachments) {
|
||||||
|
for (const att of message.replyTo.attachments) {
|
||||||
|
att.filename = resolveAssetUrl(att.filename, origin) ?? att.filename;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user