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:
Jannis Braun
2026-03-04 01:51:38 +01:00
parent 3f15a8e282
commit 64fd8edfc9
7 changed files with 232 additions and 139 deletions
+6 -3
View File
@@ -82,10 +82,13 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
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) {
+2 -1
View File
@@ -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';
@@ -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<AddStep>('url');
const [url, setUrl] = useState('');
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 [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 */}
<div className="flex items-center gap-2">
@@ -131,106 +138,34 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
</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>
)}
<div className="space-y-2">
<div>
<label className="block text-xs text-txt-tertiary mb-1">
Enter your password to connect to {new URL(probeResult.origin).host}
</label>
<input
type="password"
value={password}
onChange={(e) => 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
/>
<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>
<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'
}`}
onClick={handleConnect}
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"
>
Login
{isLoading ? 'Connecting...' : 'Connect'}
</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(''); }}
@@ -248,6 +183,73 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) {
</>
)}
{/* Step 2b: Fallback login — different password on remote */}
{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>
<label className="block text-xs text-txt-tertiary mb-1">Username</label>
<input
type="text"
value={fallbackUsername}
onChange={(e) => setFallbackUsername(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 for this instance</label>
<input
type="password"
value={fallbackPassword}
onChange={(e) => setFallbackPassword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !isLoading && fallbackUsername && fallbackPassword && handleFallbackLogin()}
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"
disabled={isLoading}
autoFocus
/>
</div>
<button
onClick={handleFallbackLogin}
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"
>
{isLoading ? 'Logging in...' : 'Login & Connect'}
</button>
</div>
<div className="flex gap-2">
<button
onClick={() => { setAuthPhase('password'); setPassword(''); 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">
+4 -2
View File
@@ -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)) {
+83 -22
View File
@@ -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<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>;
removeInstance: (origin: string) => void;
syncInstanceList: () => Promise<void>;
@@ -111,21 +121,29 @@ export const useInstanceStore = create<InstanceState>((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<InstanceState>((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<InstanceState>((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<InstanceState>((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<InstanceState>((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<InstanceState>((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<InstanceState>((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<InstanceState>((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();
+15
View File
@@ -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<VoiceState>()(
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,
+11 -2
View File
@@ -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.
* 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,
origin: string,
): T {
@@ -36,5 +36,14 @@ export function normalizeMessageAssets<T extends { user: { avatar?: string | nul
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;
}