diff --git a/packages/server/src/routes/users.ts b/packages/server/src/routes/users.ts index b9791966..f051accd 100644 --- a/packages/server/src/routes/users.ts +++ b/packages/server/src/routes/users.ts @@ -82,10 +82,13 @@ export async function userRoutes(app: FastifyInstance): Promise { if (!Array.isArray(replicatedInstances)) { 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) { - if (!inst || typeof inst.domain !== 'string' || typeof inst.username !== 'string') { - return reply.code(400).send({ error: 'Each replicated instance must have domain and username strings', statusCode: 400 }); + if (!inst || typeof inst.username !== 'string') { + 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) { diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index c3855540..90910815 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -14,8 +14,9 @@ export interface User { } export interface ReplicatedInstance { - domain: string; + origin: string; // Full URL with protocol, e.g. "https://orbit.ddns.net" username: string; + domain?: string; // Legacy field — kept for backward compat with existing data } export type UserStatus = 'online' | 'idle' | 'dnd' | 'offline'; diff --git a/packages/web/src/components/modals/ConnectedInstances.tsx b/packages/web/src/components/modals/ConnectedInstances.tsx index 1f06ce30..69e2bfbb 100644 --- a/packages/web/src/components/modals/ConnectedInstances.tsx +++ b/packages/web/src/components/modals/ConnectedInstances.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import type { InstanceInfoResponse } from '@backspace/shared'; -import { useInstanceStore } from '../../stores/instanceStore'; +import { useInstanceStore, DifferentPasswordError } from '../../stores/instanceStore'; import { useAuthStore } from '../../stores/authStore'; // ─── Status indicator ──────────────────────────────────────────────────────── @@ -17,21 +17,21 @@ function StatusDot({ status }: { status: string }) { // ─── Add Instance flow ─────────────────────────────────────────────────────── type AddStep = 'url' | 'auth' | 'done'; -type AuthTab = 'register' | 'login'; +type AuthPhase = 'password' | 'fallback-login'; function AddInstanceFlow({ onDone }: { onDone: () => void }) { 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 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 [authPhase, setAuthPhase] = useState('password'); const [password, setPassword] = useState(''); - const [loginUsername, setLoginUsername] = useState(''); - const [loginPassword, setLoginPassword] = useState(''); + const [fallbackUsername, setFallbackUsername] = useState(''); + const [fallbackPassword, setFallbackPassword] = useState(''); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); @@ -41,7 +41,7 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) { try { const result = await probeInstance(url); setProbeResult(result); - setAuthTab(result.registrationOpen ? 'register' : 'login'); + setAuthPhase('password'); setStep('auth'); } catch (err) { setError((err as Error).message); @@ -50,12 +50,12 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) { } }; - const handleRegister = async () => { + const handleConnect = async () => { if (!probeResult) return; setError(''); setIsLoading(true); try { - await registerOnRemote( + await connectToRemote( probeResult.origin, password, user?.displayName || undefined, @@ -63,18 +63,25 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) { setStep('done'); onDone(); } catch (err) { - setError((err as Error).message); + if (err instanceof DifferentPasswordError) { + setAuthPhase('fallback-login'); + setFallbackUsername(err.remoteUsername); + setFallbackPassword(''); + setError(''); + } else { + setError((err as Error).message); + } } finally { setIsLoading(false); } }; - const handleLogin = async () => { + const handleFallbackLogin = async () => { if (!probeResult) return; setError(''); setIsLoading(true); try { - await loginToRemote(probeResult.origin, loginUsername, loginPassword); + await loginToRemote(probeResult.origin, fallbackUsername, fallbackPassword); setStep('done'); onDone(); } catch (err) { @@ -119,8 +126,8 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) { )} - {/* Step 2: Auth */} - {step === 'auth' && probeResult && ( + {/* Step 2: Auth — single password */} + {step === 'auth' && probeResult && authPhase === 'password' && ( <> {/* Instance info card */}
@@ -131,106 +138,34 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
- {/* Auth tabs */} -
- {probeResult.registrationOpen && ( - - )} +
+
+ + setPassword(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && !isLoading && password && handleConnect()} + 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" + disabled={isLoading} + autoFocus + /> +
+ Your password is verified locally, then used to create or access your account on the remote instance. +
+
- {/* 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 && (
diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 0c5c3384..aec34c82 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -83,7 +83,7 @@ function handleEvent(origin: string, event: ServerEvent): void { const { setUser } = useAuthStore.getState(); const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember, addDmChannel, removeDmChannel } = useServerStore.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) { 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) { clearAllVoiceUsers(); + } else { + clearVoiceUsersForOrigin(origin); } if (event.voiceStates) { for (const [channelId, userIds] of Object.entries(event.voiceStates)) { diff --git a/packages/web/src/stores/instanceStore.ts b/packages/web/src/stores/instanceStore.ts index 840ed1f2..878b1f20 100644 --- a/packages/web/src/stores/instanceStore.ts +++ b/packages/web/src/stores/instanceStore.ts @@ -1,5 +1,5 @@ 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 { useAuthStore } from './authStore'; import { setApiForOriginResolver, useServerStore } from './serverStore'; @@ -50,6 +50,16 @@ function saveCachedTokens(instances: ConnectedInstance[]): void { 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 ─────────────────────────────────────────────────────── function normalizeOrigin(url: string): string { @@ -78,7 +88,7 @@ interface InstanceState { error: string | null; probeInstance: (url: string) => Promise; - registerOnRemote: (origin: string, password: string, displayName?: string) => Promise; + connectToRemote: (origin: string, password: string, displayName?: string) => Promise; loginToRemote: (origin: string, username: string, password: string) => Promise; removeInstance: (origin: string) => void; syncInstanceList: () => Promise; @@ -111,21 +121,29 @@ export const useInstanceStore = create((set, get) => ({ return { ...info, origin }; }, - registerOnRemote: async (origin: string, password: string, displayName?: string) => { + connectToRemote: 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 { + // 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 tempClient = createApiClient(origin, () => null); - let response; + let response: AuthResponse | null = null; let finalUsername = currentUser.username; + let needsLogin = false; + // 2a: Attempt registration with plain username try { - // First attempt: register with current username response = await tempClient.auth.register({ username: currentUser.username, password, @@ -134,24 +152,62 @@ export const useInstanceStore = create((set, get) => ({ }); } 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, - }); + // Username collision — try domain-qualified username + try { + finalUsername = `${currentUser.username}@${homeInstance}`; + response = await tempClient.auth.register({ + username: finalUsername, + password, + displayName: displayName || currentUser.displayName || undefined, + 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 { throw err; } } - // Fetch instance info for the label - const info = await tempClient.instance.info(); + // 2b: If registration didn't work, try login with the same password + 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 instance: ConnectedInstance = { @@ -244,7 +300,7 @@ export const useInstanceStore = create((set, get) => ({ // Build the replicated instances list from all connected remotes const replicatedInstances: ReplicatedInstance[] = instances.map(inst => ({ - domain: new URL(inst.origin).host, + origin: inst.origin, username: inst.username, })); @@ -271,8 +327,7 @@ export const useInstanceStore = create((set, get) => ({ const cached = loadCachedTokens(); const toConnect = currentUser.replicatedInstances.filter(ri => { - // Build origin from domain - const origin = `https://${ri.domain}`; + const origin = ri.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); }); @@ -282,7 +337,7 @@ export const useInstanceStore = create((set, get) => ({ // Connect all in parallel, individual failures are non-blocking const results = await Promise.allSettled( toConnect.map(async (ri) => { - const origin = `https://${ri.domain}`; + const origin = ri.origin || `https://${ri.domain}`; const cachedEntry = cached[origin]!; // Guaranteed by filter above // Create client with cached token @@ -291,7 +346,7 @@ export const useInstanceStore = create((set, get) => ({ // Set as connecting const connectingInstance: ConnectedInstance = { origin, - label: cachedEntry.label || ri.domain, + label: cachedEntry.label || new URL(origin).host, token: cachedEntry.token, user: currentUser, // Placeholder until we verify username: cachedEntry.username || ri.username, @@ -308,7 +363,7 @@ export const useInstanceStore = create((set, get) => ({ const user = await client.users.me(); // Fetch instance info for fresh label - let label = cachedEntry.label || ri.domain; + let label = cachedEntry.label || new URL(origin).host; try { const info = await client.instance.info(); label = info.name; @@ -356,6 +411,12 @@ export const useInstanceStore = create((set, get) => ({ }, 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 disconnectAllRemote(); diff --git a/packages/web/src/stores/voiceStore.ts b/packages/web/src/stores/voiceStore.ts index f1d74490..c196c408 100644 --- a/packages/web/src/stores/voiceStore.ts +++ b/packages/web/src/stores/voiceStore.ts @@ -2,6 +2,7 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import type { ParticipantInfo } from '../hooks/useLiveKit'; import { AudioManager } from '../audio/AudioManager'; +import { useServerStore } from './serverStore'; export interface ScreenShareConfig { height: 1080 | 720 | 540; @@ -87,6 +88,7 @@ interface VoiceState { clearVoiceUserStatus: (userId: string) => void; getVoiceUsers: (channelId: string) => string[]; clearAllVoiceUsers: () => void; + clearVoiceUsersForOrigin: (origin: string) => void; leaveVoice: () => void; reset: () => void; } @@ -275,6 +277,19 @@ export const useVoiceStore = create()( 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) leaveVoice: () => set({ currentVoiceChannelId: null, diff --git a/packages/web/src/utils/assetUrls.ts b/packages/web/src/utils/assetUrls.ts index 46a32c5d..456c3fc8 100644 --- a/packages/web/src/utils/assetUrls.ts +++ b/packages/web/src/utils/assetUrls.ts @@ -23,9 +23,9 @@ export function normalizeUserAssets(user: /** * 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( +export function normalizeMessageAssets( message: T, origin: string, ): T { @@ -36,5 +36,14 @@ export function normalizeMessageAssets