From a7b5d819bf8f536164763054c6cb6225e47be9bd Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 8 Mar 2026 22:08:48 +0100 Subject: [PATCH] feat: add space visibility/description, instance re-auth, and per-channel permissions - Accept visibility and description fields when creating spaces - Register creator in connectionManager on space creation for immediate WS broadcasts - Return per-channel myPermissions and space-level myPermissions from GET /spaces/:id - Populate permission maps in spaceStore from REST response - Add reauthenticateInstance flow for tokenless federation placeholders - Handle expired/missing tokens gracefully in autoConnectAll with visible error state - Guard syncInstanceList against premature runs before autoConnectAll completes --- packages/server/src/routes/spaces.ts | 34 ++- packages/shared/src/types.ts | 2 + .../components/modals/ConnectedInstances.tsx | 154 ++++++++--- packages/web/src/stores/instanceStore.ts | 251 +++++++++++------- packages/web/src/stores/spaceStore.ts | 23 +- 5 files changed, 325 insertions(+), 139 deletions(-) diff --git a/packages/server/src/routes/spaces.ts b/packages/server/src/routes/spaces.ts index 1fa656de..747d64df 100644 --- a/packages/server/src/routes/spaces.ts +++ b/packages/server/src/routes/spaces.ts @@ -54,7 +54,7 @@ export async function spaceRoutes(app: FastifyInstance): Promise { app.post<{ Body: CreateSpaceRequest }>('/api/spaces', { preHandler: authenticate, }, async (request, reply) => { - const { name, icon } = request.body; + const { name, icon, visibility, description } = request.body; if (!name || typeof name !== 'string') { return reply.code(400).send({ error: 'Space name is required', statusCode: 400 }); @@ -65,6 +65,13 @@ export async function spaceRoutes(app: FastifyInstance): Promise { return reply.code(400).send({ error: 'Space name must be between 1 and 100 characters', statusCode: 400 }); } + // Validate visibility + const validVisibilities = ['public', 'request', 'private']; + const safeVisibility = visibility && validVisibilities.includes(visibility) ? visibility : 'private'; + + // Validate description + const safeDescription = description ? description.trim().slice(0, 200) || null : null; + const db = getDb(); const spaceId = generateSnowflake(); const channelId = generateSnowflake(); @@ -79,6 +86,8 @@ export async function spaceRoutes(app: FastifyInstance): Promise { icon: icon ?? null, ownerId: request.userId, inviteCode, + visibility: safeVisibility, + description: safeDescription, createdAt: now, }).run(); @@ -114,6 +123,9 @@ export async function spaceRoutes(app: FastifyInstance): Promise { return reply.code(500).send({ error: 'Failed to create space', statusCode: 500 }); } + // Register the creator in connectionManager so they receive WS broadcasts for this space + connectionManager.addUserSpace(request.userId, spaceId); + return reply.code(201).send(rowToSpace(server)); }); @@ -216,15 +228,24 @@ export async function spaceRoutes(app: FastifyInstance): Promise { }) .filter((m): m is MemberWithUser => m !== null); - // Filter channels by VIEW_CHANNEL permission before returning - const visibleChannels = channels.filter(ch => { + // Compute space-level permissions for the requesting user + const spacePerms = computePermissions(request.userId, id); + + // Filter channels by VIEW_CHANNEL permission and attach per-channel myPermissions + const visibleChannels: (Channel & { myPermissions: string })[] = []; + for (const ch of channels) { const perms = computePermissions(request.userId, id, ch.id); - return (perms & PermissionBits.VIEW_CHANNEL) !== 0n; - }); + if ((perms & PermissionBits.VIEW_CHANNEL) !== 0n) { + visibleChannels.push({ + ...rowToChannel(ch), + myPermissions: permissionsToString(perms), + }); + } + } const result: SpaceWithChannelsAndMembers = { ...rowToSpace(server), - channels: visibleChannels.map(rowToChannel), + channels: visibleChannels, members, roles: roles.map(r => ({ id: r.id, @@ -234,6 +255,7 @@ export async function spaceRoutes(app: FastifyInstance): Promise { position: r.position ?? 0, createdAt: r.createdAt, })), + myPermissions: permissionsToString(spacePerms), }; return reply.code(200).send(result); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index a1f81be9..7d643815 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -300,6 +300,8 @@ export interface AuthResponse { export interface CreateSpaceRequest { name: string; icon?: string; + visibility?: SpaceVisibility; + description?: string; } export interface CreateChannelRequest { diff --git a/packages/web/src/components/modals/ConnectedInstances.tsx b/packages/web/src/components/modals/ConnectedInstances.tsx index 93dde701..5c4860d9 100644 --- a/packages/web/src/components/modals/ConnectedInstances.tsx +++ b/packages/web/src/components/modals/ConnectedInstances.tsx @@ -262,10 +262,122 @@ function AddInstanceFlow({ onDone }: { onDone: () => void }) { // ─── Main component ────────────────────────────────────────────────────────── -export function ConnectedInstances() { - const instances = useInstanceStore((s) => s.instances); +function InstanceRow({ inst }: { inst: import('../../stores/instanceStore').ConnectedInstance }) { const removeInstance = useInstanceStore((s) => s.removeInstance); const reconnectInstance = useInstanceStore((s) => s.reconnectInstance); + const reauthenticateInstance = useInstanceStore((s) => s.reauthenticateInstance); + + const [showReauth, setShowReauth] = useState(false); + const [reauthPassword, setReauthPassword] = useState(''); + const [reauthLoading, setReauthLoading] = useState(false); + const [reauthError, setReauthError] = useState(''); + + const isTokenless = !inst.token; + + const handleReauth = async () => { + if (!reauthPassword) return; + setReauthError(''); + setReauthLoading(true); + try { + await reauthenticateInstance(inst.origin, reauthPassword); + setShowReauth(false); + setReauthPassword(''); + } catch (err) { + setReauthError((err as Error).message); + } finally { + setReauthLoading(false); + } + }; + + return ( +
+
+
+ +
+
+ {inst.label} +
+
+ {new URL(inst.origin).host} + {inst.username && ( + as {inst.username} + )} +
+ {(inst.status === 'disconnected' || inst.status === 'error') && inst.error && ( +
{inst.error}
+ )} +
+
+
+ {(inst.status === 'disconnected' || inst.status === 'error') && ( + isTokenless ? ( + + ) : ( + + ) + )} + +
+
+ + {/* Inline re-authentication prompt */} + {showReauth && ( +
+
+ setReauthPassword(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && !reauthLoading && reauthPassword && handleReauth()} + placeholder="Your account password" + className="flex-1 px-3 py-1.5 bg-surface-input rounded text-txt-primary text-sm outline-none focus:ring-2 focus:ring-accent-primary" + disabled={reauthLoading} + autoFocus + /> + + +
+ {reauthError && ( +
+ {reauthError} +
+ )} +
+ )} +
+ ); +} + +export function ConnectedInstances() { + const instances = useInstanceStore((s) => s.instances); const [showAddForm, setShowAddForm] = useState(false); return ( @@ -295,43 +407,7 @@ export function ConnectedInstances() { {/* Remote instances */} {instances.map((inst) => ( -
-
- -
-
- {inst.label} -
-
- {new URL(inst.origin).host} - {inst.username && ( - as {inst.username} - )} -
- {inst.status === 'disconnected' && inst.error && ( -
{inst.error}
- )} -
-
-
- {(inst.status === 'disconnected' || inst.status === 'error') && ( - - )} - -
-
+ ))} {/* Add instance button / flow */} diff --git a/packages/web/src/stores/instanceStore.ts b/packages/web/src/stores/instanceStore.ts index c49a9c0c..20d92005 100644 --- a/packages/web/src/stores/instanceStore.ts +++ b/packages/web/src/stores/instanceStore.ts @@ -41,6 +41,9 @@ function loadCachedTokens(): Record { function saveCachedTokens(instances: ConnectedInstance[]): void { const cache: Record = {}; for (const inst of instances) { + // Skip tokenless placeholders — writing an empty token would cause + // autoConnectAll to find a truthy cached entry with an empty bearer token + if (!inst.token) continue; cache[inst.origin] = { token: inst.token, label: inst.label, @@ -94,6 +97,7 @@ interface InstanceState { instances: ConnectedInstance[]; isLoading: boolean; error: string | null; + _autoConnectDone: boolean; probeInstance: (url: string) => Promise; connectToRemote: (origin: string, password: string, displayName?: string) => Promise; @@ -101,6 +105,7 @@ interface InstanceState { removeInstance: (origin: string) => void; setInstanceStatus: (origin: string, status: ConnectedInstance['status'], error?: string) => void; reconnectInstance: (origin: string) => Promise; + reauthenticateInstance: (origin: string, password: string) => Promise; syncInstanceList: () => Promise; autoConnectAll: () => Promise; reset: () => void; @@ -110,6 +115,7 @@ export const useInstanceStore = create((set, get) => ({ instances: [], isLoading: false, error: null, + _autoConnectDone: false, probeInstance: async (url: string) => { const origin = normalizeOrigin(url); @@ -293,6 +299,9 @@ export const useInstanceStore = create((set, get) => ({ const inst = get().instances.find(i => i.origin === origin); if (!inst || inst.status === 'connected' || inst.status === 'connecting') return; + // Tokenless placeholders can't reconnect — they need full re-authentication + if (!inst.token) return; + // Set to connecting set((state) => ({ instances: state.instances.map(i => @@ -332,7 +341,36 @@ export const useInstanceStore = create((set, get) => ({ } }, + reauthenticateInstance: async (origin: string, password: string) => { + const inst = get().instances.find(i => i.origin === origin); + if (!inst) return; + + // Remove the stale placeholder + set((state) => ({ + instances: state.instances.filter(i => i.origin !== origin), + })); + + // Remove its spaces from the space store + useSpaceStore.getState().removeInstanceSpaces(origin); + + // Disconnect any lingering WS + disconnectInstance(origin); + + // Re-connect through the standard flow (handles register/login) + const currentUser = useAuthStore.getState().user; + await get().connectToRemote( + origin, + password, + currentUser?.displayName || undefined, + ); + }, + syncInstanceList: async () => { + // Prevent premature sync before autoConnectAll has populated all server-known + // instances — otherwise a user action (add/remove) would overwrite the server + // record with only the currently-loaded subset, permanently erasing the rest + if (!get()._autoConnectDone) return; + const { instances } = get(); const currentUser = useAuthStore.getState().user; if (!currentUser) return; @@ -362,120 +400,153 @@ export const useInstanceStore = create((set, get) => ({ autoConnectAll: async () => { const currentUser = useAuthStore.getState().user; - if (!currentUser || currentUser.replicatedInstances.length === 0) return; + if (!currentUser || currentUser.replicatedInstances.length === 0) { + set({ _autoConnectDone: true }); + return; + } const cached = loadCachedTokens(); - const toConnect = currentUser.replicatedInstances.filter(ri => { + + // Split server-known instances into two groups: + // - withToken: have a cached token → attempt reconnection + // - withoutToken: no cached token → add as error placeholder + const withToken: Array<{ origin: string; ri: (typeof currentUser.replicatedInstances)[0]; entry: CachedInstanceToken }> = []; + const withoutToken: Array<{ origin: string; ri: (typeof currentUser.replicatedInstances)[0] }> = []; + + for (const ri of currentUser.replicatedInstances) { 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); - }); + if (get().instances.some(i => i.origin === origin)) continue; // already loaded + const entry = cached[origin]; + if (entry) { + withToken.push({ origin, ri, entry }); + } else { + withoutToken.push({ origin, ri }); + } + } - 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 = ri.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 = { + // Immediately add tokenless placeholders so they're visible in Zustand + // (and therefore won't be erased by syncInstanceList) + if (withoutToken.length > 0) { + set((state) => { + const placeholders: ConnectedInstance[] = withoutToken.map(({ origin, ri }) => ({ origin, - label: cachedEntry.label || new URL(origin).host, - 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], + label: new URL(origin).host, + token: '', + user: currentUser, // placeholder + username: ri.username, + status: 'error' as const, + error: 'Session expired — re-authenticate to reconnect', + api: createApiClient(origin, () => null), })); + return { instances: [...state.instances, ...placeholders] }; + }); + } - try { - // Verify the token is still valid - const user = await client.users.me(); + // Connect instances with cached tokens in parallel + if (withToken.length > 0) { + const results = await Promise.allSettled( + withToken.map(async ({ origin, ri, entry: cachedEntry }) => { + // Create client with cached token + const client = createApiClient(origin, () => cachedEntry.token); - // Fetch instance info for fresh label - let label = cachedEntry.label || new URL(origin).host; - try { - const info = await client.instance.info(); - label = info.name; - } catch { - // Non-critical — keep cached label - } - - // Backfill homeUserId if missing (existing federated users before this field existed) - if (user.homeInstance && !user.homeUserId) { - const homeUser = useAuthStore.getState().user; - if (homeUser) { - client.users.update({ homeUserId: homeUser.id }).catch(() => {}); - } - } - - // Backfill cached username if stale after server-side migration - // (e.g. "test" was renamed to "test@nova.ddns.net") - if (user.username !== cachedEntry.username) { - cachedEntry.username = user.username; - } - - const connectedInstance: ConnectedInstance = { + // Set as connecting + const connectingInstance: ConnectedInstance = { origin, - label, + label: cachedEntry.label || new URL(origin).host, token: cachedEntry.token, - user, - username: user.username, - status: 'connected', + user: currentUser, // Placeholder until we verify + username: cachedEntry.username || ri.username, + status: 'connecting', api: client, }; set((state) => ({ - instances: state.instances.map(i => i.origin === origin ? connectedInstance : i), + instances: [...state.instances.filter(i => i.origin !== origin), connectingInstance], })); - // Open WebSocket connection now that we've verified the token - connectInstance(origin, cachedEntry.token); - } catch (err) { - if (isNetworkError(err)) { - // Instance unreachable (NAT hairpinning, DNS, server down) — token may still be valid + try { + // Verify the token is still valid + const user = await client.users.me(); + + // Fetch instance info for fresh label + let label = cachedEntry.label || new URL(origin).host; + try { + const info = await client.instance.info(); + label = info.name; + } catch { + // Non-critical — keep cached label + } + + // Backfill homeUserId if missing (existing federated users before this field existed) + if (user.homeInstance && !user.homeUserId) { + const homeUser = useAuthStore.getState().user; + if (homeUser) { + client.users.update({ homeUserId: homeUser.id }).catch(() => {}); + } + } + + // Backfill cached username if stale after server-side migration + // (e.g. "test" was renamed to "test@nova.ddns.net") + if (user.username !== cachedEntry.username) { + cachedEntry.username = user.username; + } + + 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 - ? { ...i, status: 'disconnected' as const, error: 'Instance unreachable — retrying in background' } - : i - ), + instances: state.instances.map(i => i.origin === origin ? connectedInstance : i), })); - // Start WebSocket — its built-in exponential backoff retry will auto-recover - // when the network path becomes available (e.g. user switches networks) + + // Open WebSocket connection now that we've verified the token connectInstance(origin, cachedEntry.token); - } else { - // Auth failure (401, invalid token, etc.) - set((state) => ({ - instances: state.instances.map(i => - i.origin === origin - ? { ...i, status: 'error' as const, error: 'Token expired — re-authenticate to reconnect' } - : i - ), - })); + } catch (err) { + if (isNetworkError(err)) { + // Instance unreachable (NAT hairpinning, DNS, server down) — token may still be valid + set((state) => ({ + instances: state.instances.map(i => + i.origin === origin + ? { ...i, status: 'disconnected' as const, error: 'Instance unreachable — retrying in background' } + : i + ), + })); + // Start WebSocket — its built-in exponential backoff retry will auto-recover + // when the network path becomes available (e.g. user switches networks) + connectInstance(origin, cachedEntry.token); + } else { + // Auth failure (401, invalid token, etc.) + set((state) => ({ + instances: state.instances.map(i => + i.origin === origin + ? { ...i, status: 'error' as const, error: 'Token expired — re-authenticate to reconnect' } + : i + ), + })); + } } - } - }) - ); + }) + ); + + // Log any failures for debugging + const failures = results.filter(r => r.status === 'rejected'); + if (failures.length > 0) { + console.warn(`autoConnectAll: ${failures.length}/${withToken.length} instances failed to connect`); + } + } // Save final state to localStorage — persist ALL instances regardless of status // so disconnected instances survive page reload and can auto-reconnect later saveCachedTokens(get().instances); - // 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`); - } + // Mark auto-connect complete so syncInstanceList is now safe to run + set({ _autoConnectDone: true }); }, reset: () => { @@ -488,7 +559,7 @@ export const useInstanceStore = create((set, get) => ({ // Tear down all remote WebSocket connections disconnectAllRemote(); - set({ instances: [], isLoading: false, error: null }); + set({ instances: [], isLoading: false, error: null, _autoConnectDone: false }); localStorage.removeItem(STORAGE_KEY); }, })); diff --git a/packages/web/src/stores/spaceStore.ts b/packages/web/src/stores/spaceStore.ts index d3904b8a..13417e4e 100644 --- a/packages/web/src/stores/spaceStore.ts +++ b/packages/web/src/stores/spaceStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import type { Space, Channel, MemberWithUser, SpaceWithChannelsAndMembers, Role, SpaceFolder, DmChannel, User, UpdateSpaceRequest } from '@backspace/shared'; +import type { Space, Channel, MemberWithUser, SpaceWithChannelsAndMembers, Role, SpaceFolder, DmChannel, User, UpdateSpaceRequest, CreateSpaceRequest } from '@backspace/shared'; import { api, BackspaceApiClient } from '../api/client'; import { resolveAssetUrl, normalizeUserAssets } from '../utils/assetUrls'; import { isSelf } from '../utils/identity'; @@ -49,7 +49,7 @@ interface SpaceState { loadSpaces: () => Promise; loadSpaceDetail: (spaceId: string) => Promise; loadDmChannels: () => Promise; - createSpace: (name: string, icon?: string) => Promise; + createSpace: (data: CreateSpaceRequest) => Promise; updateSpace: (spaceId: string, data: UpdateSpaceRequest) => Promise; deleteSpace: (spaceId: string) => Promise; joinSpace: (spaceId: string, inviteCode: string) => Promise; @@ -156,11 +156,26 @@ export const useSpaceStore = create((set, get) => ({ normalizeUserAssets(member.user, origin); } } + + // Populate permission maps from REST response + const spacePermissions = new Map(get().spacePermissions); + const channelPermissions = new Map(get().channelPermissions); + if (detail.myPermissions) { + spacePermissions.set(spaceId, detail.myPermissions); + } + for (const ch of detail.channels) { + if (ch.myPermissions) { + channelPermissions.set(ch.id, ch.myPermissions); + } + } + set({ currentSpaceId: spaceId, channels: detail.channels.sort((a, b) => a.position - b.position), members: detail.members, roles: detail.roles.sort((a, b) => b.position - a.position), + spacePermissions, + channelPermissions, }); } catch { // Handle error silently @@ -176,8 +191,8 @@ export const useSpaceStore = create((set, get) => ({ } }, - createSpace: async (name: string, icon?: string) => { - const space =await api.spaces.create({ name, icon }); + createSpace: async (data: CreateSpaceRequest) => { + const space = await api.spaces.create(data); const tagged: TaggedSpace = { ...space, _instanceOrigin: '' }; set((state) => ({ spaces: [...state.spaces, tagged] })); return space;