From 8260af2d571945426028b8c1dc3080e106024a78 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Tue, 25 Aug 2026 01:36:19 +0800 Subject: [PATCH] fix: harden collaboration room access (#1332) * fix: harden collaboration room access * fix: confirm guest link lifecycle changes --- .../repositories/collab-room-repository.ts | 22 ++ src/backend/hosts/collab/guest-rate-limit.ts | 21 ++ src/backend/hosts/collab/routes.ts | 195 ++++++++--- src/backend/hosts/terminal/index.ts | 7 + src/backend/hosts/terminal/session-manager.ts | 43 +++ src/backend/tests/hosts/collab/routes.test.ts | 31 ++ .../hosts/terminal/session-manager.test.ts | 35 ++ src/ui/api/collab-api.ts | 2 +- src/ui/features/collab/CollabRoomTab.tsx | 307 ++++++++++++++++-- src/ui/locales/en.json | 14 + src/ui/sidebar/CollabPanel.tsx | 19 +- 11 files changed, 619 insertions(+), 77 deletions(-) create mode 100644 src/backend/hosts/collab/guest-rate-limit.ts diff --git a/src/backend/database/repositories/collab-room-repository.ts b/src/backend/database/repositories/collab-room-repository.ts index 78b9bd4a..0d212fe3 100644 --- a/src/backend/database/repositories/collab-room-repository.ts +++ b/src/backend/database/repositories/collab-room-repository.ts @@ -2,6 +2,7 @@ import { and, desc, eq, isNull } from "drizzle-orm"; import { collabRoomMembers, collabRooms, users } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; import { insertReturning } from "./returning.js"; +import { rowsAffected } from "./mutation-result.js"; export type CollabRoomRecord = typeof collabRooms.$inferSelect; export type CollabRoomMemberRecord = typeof collabRoomMembers.$inferSelect; @@ -164,6 +165,27 @@ export class CollabRoomRepository { await this.afterWrite(); } + async replaceStage( + roomId: string, + expectedShareId: string | null, + stage: CollabRoomStage, + ): Promise { + const result = await this.context.drizzle + .update(collabRooms) + .set(stage) + .where( + and( + eq(collabRooms.id, roomId), + expectedShareId + ? eq(collabRooms.stageShareId, expectedShareId) + : isNull(collabRooms.stageShareId), + ), + ); + const changed = rowsAffected(result) > 0; + if (changed) await this.afterWrite(); + return changed; + } + async clearStage(roomId: string): Promise { return this.updateStage(roomId, { presenterUserId: null, diff --git a/src/backend/hosts/collab/guest-rate-limit.ts b/src/backend/hosts/collab/guest-rate-limit.ts new file mode 100644 index 00000000..0d488d11 --- /dev/null +++ b/src/backend/hosts/collab/guest-rate-limit.ts @@ -0,0 +1,21 @@ +const WINDOW_MS = 60_000; +const MAX_ATTEMPTS = 60; +const attempts = new Map(); + +export function isCollabGuestRateLimited(ip: string): boolean { + const now = Date.now(); + const entry = attempts.get(ip); + if (!entry || now - entry.windowStart > WINDOW_MS) { + attempts.set(ip, { count: 1, windowStart: now }); + return false; + } + entry.count += 1; + return entry.count > MAX_ATTEMPTS; +} + +setInterval(() => { + const now = Date.now(); + for (const [ip, entry] of attempts) { + if (now - entry.windowStart > WINDOW_MS) attempts.delete(ip); + } +}, 5 * WINDOW_MS).unref(); diff --git a/src/backend/hosts/collab/routes.ts b/src/backend/hosts/collab/routes.ts index 8fd81d19..d329b8b1 100644 --- a/src/backend/hosts/collab/routes.ts +++ b/src/backend/hosts/collab/routes.ts @@ -11,6 +11,7 @@ import { import { GuacamoleTokenService } from "../guacamole/token-service.js"; import { collabRoomHub } from "./room-hub.js"; import { getStageController, setStageController } from "./stage-control.js"; +import { isCollabGuestRateLimited } from "./guest-rate-limit.js"; import { sessionManager } from "../terminal/session-manager.js"; import { isLiveSession, @@ -40,7 +41,10 @@ const authenticateJWT = authManager.createAuthMiddleware(); const tokenService = GuacamoleTokenService.getInstance(); const STAGE_SHARE_EXPIRY_HOURS = 12; +const MAX_INVITE_TARGETS = 200; +const CONTROL_REQUEST_COOLDOWN_MS = 5000; const PROTOCOLS: LiveProtocol[] = ["ssh", "rdp", "vnc", "telnet"]; +const controlRequestTimes = new Map(); function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; @@ -60,13 +64,22 @@ async function requireRoomMember( async function revokeStageShare(room: CollabRoomRecord): Promise { if (!room.stageShareId) return; - try { - await createCurrentSessionShareRepository().revokeAsAdmin( - room.stageShareId, - ); - } catch { - // A stale share must never block switching presenters. + const repository = createCurrentSessionShareRepository(); + const share = await repository.findActiveById(room.stageShareId); + if (!share) return; + if (!(await repository.revokeAsAdmin(room.stageShareId))) { + throw new Error("Failed to revoke the active stage share"); } + if (share.protocol === "ssh") { + sessionManager.disconnectShareParticipants(share.sessionId, share.id, { + reason: "The collaboration stage ended", + }); + } +} + +function publicRoom(room: CollabRoomRecord) { + const { guestLinkToken: _secret, ...safeRoom } = room; + return { ...safeRoom, guestLinkEnabled: Boolean(_secret) }; } function stagePayload(room: CollabRoomRecord) { @@ -122,7 +135,7 @@ router.post("/rooms", authenticateJWT, async (req: Request, res: Response) => { success: true, }); - res.json({ room }); + res.json({ room: publicRoom(room) }); } catch (error) { sshLogger.error("Failed to create collab room", error, { operation: "collab_room_create_error", @@ -143,7 +156,7 @@ router.get("/rooms", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId!; try { const rooms = await createCurrentCollabRoomRepository().listForUser(userId); - res.json({ rooms }); + res.json({ rooms: rooms.map(publicRoom) }); } catch (error) { sshLogger.error("Failed to list collab rooms", error, { operation: "collab_room_list_error", @@ -174,7 +187,7 @@ router.get( const members = await createCurrentCollabRoomRepository().listMembers(roomId); res.json({ - room: access.room, + room: publicRoom(access.room), me: userId, isHost: access.isHost, members, @@ -218,6 +231,9 @@ router.post( error: "userIds (user ids) or roleIds (integers) are required", }); } + if (userIds.length + roleIds.length > MAX_INVITE_TARGETS) { + return res.status(400).json({ error: "Too many invite targets" }); + } try { const access = await requireRoomMember(roomId, userId); @@ -256,6 +272,20 @@ router.post( }); } + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId, + username: await getAuditUsername(userId), + action: "collab_room_invite", + resourceType: "collab_room", + resourceId: roomId, + resourceName: access.room.name, + details: JSON.stringify({ memberCount: expanded.size }), + ipAddress, + userAgent, + success: true, + }); + collabRoomHub.broadcast(roomId, { type: "collab_members_changed", roomId, @@ -303,6 +333,23 @@ router.delete( const repository = createCurrentCollabRoomRepository(); await repository.removeMember(roomId, targetId); + if (access.room.stageShareId && access.room.stageProtocol === "ssh") { + const share = + await createCurrentSessionShareRepository().findActiveById( + access.room.stageShareId, + ); + if (share) { + sessionManager.disconnectShareParticipants( + share.sessionId, + share.id, + { + userId: targetId, + reason: "You were removed from the collaboration room", + }, + ); + } + } + if (getStageController(roomId) === targetId) { await applyStageControl(access.room, roomId, null); } @@ -317,6 +364,23 @@ router.delete( }); } + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId, + username: await getAuditUsername(userId), + action: + targetId === userId + ? "collab_room_leave" + : "collab_room_remove_member", + resourceType: "collab_room", + resourceId: roomId, + resourceName: access.room.name, + details: JSON.stringify({ targetUserId: targetId }), + ipAddress, + userAgent, + success: true, + }); + collabRoomHub.broadcast(roomId, { type: "collab_members_changed", roomId, @@ -393,12 +457,20 @@ router.post( await revokeStageShare(access.room); setStageController(roomId, null); const repository = createCurrentCollabRoomRepository(); - await repository.updateStage(roomId, { - presenterUserId: userId, - stageProtocol: protocol, - stageHostId: numericHostId, - stageShareId: share.id, - }); + const replaced = await repository.replaceStage( + roomId, + access.room.stageShareId, + { + presenterUserId: userId, + stageProtocol: protocol, + stageHostId: numericHostId, + stageShareId: share.id, + }, + ); + if (!replaced) { + await shareRepository.revokeAsAdmin(share.id); + return res.status(409).json({ error: "The stage changed; try again" }); + } const stage = { presenterUserId: userId, @@ -469,6 +541,18 @@ router.post( roomId, stage: null, }); + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId, + username: await getAuditUsername(userId), + action: "collab_room_stop_presenting", + resourceType: "collab_room", + resourceId: roomId, + resourceName: access.room.name, + ipAddress, + userAgent, + success: true, + }); res.json({ success: true }); } catch (error) { sshLogger.error("Failed to stop collab room stage", error, { @@ -626,6 +710,19 @@ router.post( } await applyStageControl(access.room, roomId, targetId); + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId, + username: await getAuditUsername(userId), + action: targetId ? "collab_control_grant" : "collab_control_revoke", + resourceType: "collab_room", + resourceId: roomId, + resourceName: access.room.name, + details: JSON.stringify({ targetUserId: targetId }), + ipAddress, + userAgent, + success: true, + }); res.json({ controllerUserId: targetId }); } catch (error) { sshLogger.error("Failed to change collab stage control", error, { @@ -663,12 +760,33 @@ router.post( error: "Remote desktop stages are read-only", }); } + const requestKey = `${roomId}:${userId}`; + const now = Date.now(); + if ( + now - (controlRequestTimes.get(requestKey) ?? 0) < + CONTROL_REQUEST_COOLDOWN_MS + ) { + return res.status(429).json({ error: "Control was already requested" }); + } + controlRequestTimes.set(requestKey, now); collabRoomHub.broadcast(roomId, { type: "collab_control_requested", roomId, userId, username: await getAuditUsername(userId), }); + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId, + username: await getAuditUsername(userId), + action: "collab_control_request", + resourceType: "collab_room", + resourceId: roomId, + resourceName: access.room.name, + ipAddress, + userAgent, + success: true, + }); res.json({ success: true }); } catch (error) { sshLogger.error("Failed to request collab stage control", error, { @@ -711,6 +829,28 @@ router.post( ? crypto.randomBytes(24).toString("base64url") : null; await createCurrentCollabRoomRepository().setGuestToken(roomId, token); + if ( + (!enabled || access.room.guestLinkToken) && + access.room.stageShareId && + access.room.stageProtocol === "ssh" + ) { + const share = + await createCurrentSessionShareRepository().findActiveById( + access.room.stageShareId, + ); + if (share) { + sessionManager.disconnectShareParticipants( + share.sessionId, + share.id, + { + userId: null, + reason: enabled + ? "The guest link was rotated" + : "The guest link was disabled", + }, + ); + } + } const { ipAddress, userAgent } = getRequestMeta(req); await logAudit({ @@ -736,28 +876,6 @@ router.post( }, ); -const GUEST_WINDOW_MS = 60 * 1000; -const GUEST_MAX_ATTEMPTS = 60; -const guestAttempts = new Map(); - -function isGuestRateLimited(ip: string): boolean { - const now = Date.now(); - const entry = guestAttempts.get(ip); - if (!entry || now - entry.windowStart > GUEST_WINDOW_MS) { - guestAttempts.set(ip, { count: 1, windowStart: now }); - return false; - } - entry.count += 1; - return entry.count > GUEST_MAX_ATTEMPTS; -} - -setInterval(() => { - const now = Date.now(); - for (const [ip, entry] of guestAttempts) { - if (now - entry.windowStart > GUEST_WINDOW_MS) guestAttempts.delete(ip); - } -}, 5 * GUEST_WINDOW_MS).unref(); - /** * @openapi * /collab/guest/{token}: @@ -769,7 +887,7 @@ setInterval(() => { */ router.get("/guest/:token", async (req: Request, res: Response) => { const ip = req.ip || req.socket.remoteAddress || "unknown"; - if (isGuestRateLimited(ip)) { + if (isCollabGuestRateLimited(ip)) { return res.status(429).json({ error: "Too many requests" }); } const token = String(req.params.token); @@ -846,6 +964,7 @@ router.post( setStageController(roomId, null); const repository = createCurrentCollabRoomRepository(); if (access.room.persistent) { + await repository.setGuestToken(roomId, null); await repository.clearStage(roomId); collabRoomHub.broadcast(roomId, { type: "collab_stage_changed", diff --git a/src/backend/hosts/terminal/index.ts b/src/backend/hosts/terminal/index.ts index 00979ffc..94a8a152 100644 --- a/src/backend/hosts/terminal/index.ts +++ b/src/backend/hosts/terminal/index.ts @@ -171,6 +171,13 @@ async function handleRoomGuestConnection( req: import("http").IncomingMessage, roomGuestToken: string, ): Promise { + const { isCollabGuestRateLimited } = + await import("../collab/guest-rate-limit.js"); + const ip = req.socket.remoteAddress ?? "unknown"; + if (isCollabGuestRateLimited(ip)) { + ws.close(1008, "Too many requests"); + return; + } const room = await createCurrentCollabRoomRepository().findByGuestToken(roomGuestToken); const share = room?.stageShareId diff --git a/src/backend/hosts/terminal/session-manager.ts b/src/backend/hosts/terminal/session-manager.ts index 258830fe..f009651f 100644 --- a/src/backend/hosts/terminal/session-manager.ts +++ b/src/backend/hosts/terminal/session-manager.ts @@ -478,6 +478,49 @@ class TerminalSessionManager { } } + /** + * Disconnects non-owner participants that joined through one share. + * Supplying userId narrows the kick to that authenticated user; null targets + * anonymous guests. Omitting it revokes the share for every participant. + */ + disconnectShareParticipants( + sessionId: string, + shareId: string, + options: { userId?: string | null; reason: string }, + ): number { + const session = this.sessions.get(sessionId); + if (!session) return 0; + const filterByUser = Object.hasOwn(options, "userId"); + let disconnected = 0; + for (const [id, participant] of session.participants.entries()) { + if ( + participant.isOwner || + participant.joinedViaShareId !== shareId || + (filterByUser && participant.userId !== options.userId) + ) { + continue; + } + session.participants.delete(id); + disconnected += 1; + if (participant.ws.readyState === WebSocket.OPEN) { + try { + participant.ws.send( + JSON.stringify({ + type: "sessionExpired", + sessionId, + message: options.reason, + }), + ); + participant.ws.close(1008, options.reason); + } catch { + participant.ws.terminate(); + } + } + } + if (disconnected > 0) this.broadcastParticipants(sessionId); + return disconnected; + } + /** Fans out a message to every OPEN participant socket; skips closed ones and send failures. */ broadcast(sessionId: string, message: object): void { const session = this.sessions.get(sessionId); diff --git a/src/backend/tests/hosts/collab/routes.test.ts b/src/backend/tests/hosts/collab/routes.test.ts index 19846319..928ca68c 100644 --- a/src/backend/tests/hosts/collab/routes.test.ts +++ b/src/backend/tests/hosts/collab/routes.test.ts @@ -72,6 +72,7 @@ vi.mock("../../../hosts/terminal/session-manager.js", () => ({ setRoomShareControl: (...args: unknown[]) => { state.control.push(args); }, + disconnectShareParticipants: vi.fn(() => 0), }, })); vi.mock("../../../hosts/session-sharing/live-sessions.js", () => ({ @@ -147,6 +148,16 @@ vi.mock("../../../database/repositories/factory.js", () => ({ updateStage: async (roomId: string, stage: Partial) => { Object.assign(state.rooms.get(roomId)!, stage); }, + replaceStage: async ( + roomId: string, + expectedShareId: string | null, + stage: Partial, + ) => { + const room = state.rooms.get(roomId)!; + if (room.stageShareId !== expectedShareId) return false; + Object.assign(room, stage); + return true; + }, clearStage: async (roomId: string) => { Object.assign(state.rooms.get(roomId)!, { presenterUserId: null, @@ -307,6 +318,26 @@ describe("collab room routes", () => { expect((other as { statusCode: number }).statusCode).toBe(404); }); + it("never exposes the guest bearer token in room responses", async () => { + const roomId = await createRoom(); + await invite(roomId, ["alice"]); + state.rooms.get(roomId)!.guestLinkToken = "secret-token"; + + const list = await as("alice", () => invoke("get", "/rooms")); + const listedRoom = (list as Awaited>).jsonBody! + .rooms as Array>; + expect(listedRoom[0]).not.toHaveProperty("guestLinkToken"); + expect(listedRoom[0]).toHaveProperty("guestLinkEnabled", true); + + const detail = await as("alice", () => + invoke("get", "/rooms/:id", { params: { id: roomId } }), + ); + const room = (detail as Awaited>).jsonBody! + .room as Record; + expect(room).not.toHaveProperty("guestLinkToken"); + expect(room).toHaveProperty("guestLinkEnabled", true); + }); + it("rejects an empty or oversized room name", async () => { expect( (await invoke("post", "/rooms", { body: { name: " " } })).statusCode, diff --git a/src/backend/tests/hosts/terminal/session-manager.test.ts b/src/backend/tests/hosts/terminal/session-manager.test.ts index 2d6d6ac8..41b96dd5 100644 --- a/src/backend/tests/hosts/terminal/session-manager.test.ts +++ b/src/backend/tests/hosts/terminal/session-manager.test.ts @@ -57,6 +57,8 @@ function makeFakeWs(readyState = 1 /* OPEN */) { return { readyState, send: vi.fn(), + close: vi.fn(), + terminate: vi.fn(), } as unknown as import("ws").WebSocket; } const WS_OPEN = 1; @@ -254,6 +256,39 @@ describe("TerminalSessionManager - multiplayer participants", () => { sessionManager.destroySession(id); }); + it("disconnectShareParticipants revokes only the selected share participants", () => { + const id = createConnectedSession(); + const ownerWs = makeFakeWs(); + const aliceWs = makeFakeWs(); + const guestWs = makeFakeWs(); + sessionManager.attachWs(id, "owner-1", ownerWs); + const session = sessionManager.joinAsParticipant(id, aliceWs, { + userId: "alice", + permissionLevel: "read-only", + shareId: "stage-share", + })!; + sessionManager.joinAsParticipant(id, guestWs, { + userId: null, + permissionLevel: "read-only", + shareId: "stage-share", + }); + + expect( + sessionManager.disconnectShareParticipants(id, "stage-share", { + userId: "alice", + reason: "Removed", + }), + ).toBe(1); + expect(sessionManager.getParticipantForWs(session, aliceWs)).toBeNull(); + expect(sessionManager.getParticipantForWs(session, guestWs)).not.toBeNull(); + expect(sessionManager.getParticipantForWs(session, ownerWs)?.isOwner).toBe( + true, + ); + expect(aliceWs.close).toHaveBeenCalledWith(1008, "Removed"); + + sessionManager.destroySession(id); + }); + it("joinAsParticipant returns null for a nonexistent or unconnected session", () => { expect( sessionManager.joinAsParticipant("does-not-exist", makeFakeWs(), { diff --git a/src/ui/api/collab-api.ts b/src/ui/api/collab-api.ts index 9f8422a3..289ac472 100644 --- a/src/ui/api/collab-api.ts +++ b/src/ui/api/collab-api.ts @@ -11,7 +11,7 @@ export interface CollabRoom { stageProtocol: string | null; stageHostId: number | null; stageShareId: string | null; - guestLinkToken: string | null; + guestLinkEnabled: boolean; createdAt: string; endedAt: string | null; } diff --git a/src/ui/features/collab/CollabRoomTab.tsx b/src/ui/features/collab/CollabRoomTab.tsx index ed65941b..654ff140 100644 --- a/src/ui/features/collab/CollabRoomTab.tsx +++ b/src/ui/features/collab/CollabRoomTab.tsx @@ -22,6 +22,17 @@ import { DialogHeader, DialogTitle, } from "@/components/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/alert-dialog"; +import { Input } from "@/components/input"; import { Terminal } from "@/features/terminal/Terminal"; import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext"; import { GuacamoleDisplay } from "@/features/guacamole/GuacamoleDisplay.tsx"; @@ -76,6 +87,10 @@ type PresentDraft = token: string; guacamoleConnectionId: string; }; +type PresentChoice = { + host: SSHHostWithStatus; + protocol: "ssh" | "rdp" | "vnc" | "telnet"; +}; export function CollabRoomTab({ roomId, @@ -88,9 +103,21 @@ export function CollabRoomTab({ const [detail, setDetail] = useState(null); const [stage, setStage] = useState(null); const [ended, setEnded] = useState(false); + const [loadError, setLoadError] = useState(null); const [draft, setDraft] = useState(null); const [presentOpen, setPresentOpen] = useState(false); + const [presentLoading, setPresentLoading] = useState(false); const [inviteOpen, setInviteOpen] = useState(false); + const [endOpen, setEndOpen] = useState(false); + const [takeoverChoice, setTakeoverChoice] = useState( + null, + ); + const [guestLinkToken, setGuestLinkToken] = useState(null); + const [guestLinkAction, setGuestLinkAction] = useState< + "disable" | "rotate" | null + >(null); + const [hostSearch, setHostSearch] = useState(""); + const [inviteSearch, setInviteSearch] = useState(""); const [hosts, setHosts] = useState([]); const [users, setUsers] = useState>( [], @@ -103,12 +130,16 @@ export function CollabRoomTab({ const draftRef = useRef(null); draftRef.current = draft; const stageKeyRef = useRef(null); + const refreshSequence = useRef(0); const refresh = useCallback(async () => { if (!roomId) return; + const sequence = ++refreshSequence.current; try { const nextDetail = await getCollabRoom(roomId); + if (sequence !== refreshSequence.current) return; setDetail(nextDetail); + setLoadError(null); // Presenting locally? The local session is the stage - don't join it. if ( nextDetail.stage.shareId && @@ -130,8 +161,10 @@ export function CollabRoomTab({ // The stage was cleared elsewhere; stop presenting locally too. if (draftRef.current) setDraft(null); } - } catch { - setEnded(true); + } catch (error) { + if (sequence === refreshSequence.current) { + setLoadError(getErrorMessage(error)); + } } }, [roomId]); @@ -139,16 +172,35 @@ export function CollabRoomTab({ void refresh(); }, [refresh]); - // Live room events, with slow polling as the fallback path. + // Live room events. Poll only while the socket is unavailable. useEffect(() => { if (!roomId) return; let ws: WebSocket | null = null; let pingTimer: ReturnType | null = null; + let pollTimer: ReturnType | null = null; + let reconnectTimer: ReturnType | null = null; + let reconnectAttempt = 0; let cancelled = false; - try { - ws = new WebSocket(roomEventsWsUrl()); + const startPolling = () => { + pollTimer ??= setInterval(() => void refresh(), POLL_FALLBACK_MS); + }; + const stopPolling = () => { + if (pollTimer) clearInterval(pollTimer); + pollTimer = null; + }; + const connect = () => { + if (cancelled) return; + try { + ws = new WebSocket(roomEventsWsUrl()); + } catch { + startPolling(); + reconnectTimer = setTimeout(connect, 5000); + return; + } ws.onopen = () => { + reconnectAttempt = 0; + stopPolling(); ws?.send( JSON.stringify({ type: "collab_subscribe", data: { roomId } }), ); @@ -192,15 +244,23 @@ export function CollabRoomTab({ break; } }; - } catch { - /* polling still covers us */ - } + ws.onclose = () => { + if (cancelled) return; + if (pingTimer) clearInterval(pingTimer); + pingTimer = null; + startPolling(); + const delay = Math.min(1000 * 2 ** reconnectAttempt++, 15000); + reconnectTimer = setTimeout(connect, delay); + }; + ws.onerror = () => ws?.close(); + }; - const pollTimer = setInterval(() => void refresh(), POLL_FALLBACK_MS); + connect(); return () => { cancelled = true; if (pingTimer) clearInterval(pingTimer); - clearInterval(pollTimer); + if (pollTimer) clearInterval(pollTimer); + if (reconnectTimer) clearTimeout(reconnectTimer); ws?.close(); }; }, [roomId, refresh]); @@ -242,15 +302,30 @@ export function CollabRoomTab({ async function openPresentDialog() { setPresentOpen(true); if (hosts.length === 0) { + setPresentLoading(true); try { setHosts(await getSSHHosts({ includeStatus: false })); } catch (error) { toast.error(getErrorMessage(error)); + } finally { + setPresentLoading(false); } } } - async function choosePresent( + function choosePresent( + host: SSHHostWithStatus, + protocol: "ssh" | "rdp" | "vnc" | "telnet", + ) { + if (presenterUserId && !iAmPresenter) { + setPresentOpen(false); + setTakeoverChoice({ host, protocol }); + return; + } + void startPresent(host, protocol); + } + + async function startPresent( host: SSHHostWithStatus, protocol: "ssh" | "rdp" | "vnc" | "telnet", ) { @@ -342,8 +417,9 @@ export function CollabRoomTab({ async function handleGuestLink(enabled: boolean) { if (!roomId) return; try { - await setCollabGuestLink(roomId, enabled); - void refresh(); + const result = await setCollabGuestLink(roomId, enabled); + setGuestLinkToken(result.guestLinkToken); + await refresh(); } catch (error) { toast.error(getErrorMessage(error)); } @@ -391,11 +467,46 @@ export function CollabRoomTab({ ); } + if (!detail && loadError) { + return ( +
+
+ +

{loadError}

+ +
+
+ ); + } + const memberIds = new Set(detail?.members.map((member) => member.userId)); - const invitableUsers = users.filter((user) => !memberIds.has(user.id)); + const normalizedInviteSearch = inviteSearch.trim().toLocaleLowerCase(); + const invitableUsers = users.filter( + (user) => + !memberIds.has(user.id) && + user.username.toLocaleLowerCase().includes(normalizedInviteSearch), + ); + const normalizedHostSearch = hostSearch.trim().toLocaleLowerCase(); + const filteredHosts = hosts.filter((host) => + host.name.toLocaleLowerCase().includes(normalizedHostSearch), + ); return (
+ {loadError && ( +
+ + {loadError} + +
+ )} {/* Header: roster + controls */}
@@ -414,7 +525,7 @@ export function CollabRoomTab({ void changeControl(hasControl ? null : member.userId) } @@ -450,7 +564,7 @@ export function CollabRoomTab({ @@ -514,35 +628,50 @@ export function CollabRoomTab({
{isHost && ( -
+
- {detail?.room.guestLinkToken + {detail?.room.guestLinkEnabled ? t("collab.guestLinkOn") : t("collab.guestLinkOff")} - {detail?.room.guestLinkToken && ( + {guestLinkToken && ( )} + {detail?.room.guestLinkEnabled && !guestLinkToken && ( + + )}
)} @@ -641,13 +770,22 @@ export function CollabRoomTab({ {t("collab.presentTitle")} + setHostSearch(event.target.value)} + />
- {hosts.length === 0 && ( + {presentLoading && (
)} - {hosts.map((host) => { + {!presentLoading && filteredHosts.length === 0 && ( + + )} + {filteredHosts.map((host) => { const protocols: Array<"ssh" | "rdp" | "vnc" | "telnet"> = []; if (host.enableTerminal || host.enableSsh) protocols.push("ssh"); if (host.enableRdp) protocols.push("rdp"); @@ -665,8 +803,8 @@ export function CollabRoomTab({ key={protocol} size="sm" variant="outline" - className="h-6 text-[10px] uppercase" - onClick={() => void choosePresent(host, protocol)} + className="h-8 text-xs uppercase" + onClick={() => choosePresent(host, protocol)} > {protocol} @@ -684,6 +822,12 @@ export function CollabRoomTab({ {t("collab.inviteTitle")} + setInviteSearch(event.target.value)} + />
{roles.length > 0 && ( @@ -747,6 +891,99 @@ export function CollabRoomTab({ + + + + + {t("collab.endConfirmTitle")} + + {t( + detail?.room.persistent + ? "collab.endPersistentDescription" + : "collab.endDescription", + { name: detail?.room.name }, + )} + + + + {t("common.cancel")} + void handleEnd()} + > + {t("collab.endRoom")} + + + + + + !open && setTakeoverChoice(null)} + > + + + + {t("collab.takeOverConfirmTitle")} + + + {t("collab.takeOverDescription", { name: presenterName })} + + + + {t("common.cancel")} + { + const choice = takeoverChoice; + setTakeoverChoice(null); + if (choice) void startPresent(choice.host, choice.protocol); + }} + > + {t("collab.takeOver")} + + + + + + !open && setGuestLinkAction(null)} + > + + + + {t( + guestLinkAction === "rotate" + ? "collab.rotateLinkConfirmTitle" + : "collab.disableLinkConfirmTitle", + )} + + + {t( + guestLinkAction === "rotate" + ? "collab.rotateLinkDescription" + : "collab.disableLinkDescription", + )} + + + + {t("common.cancel")} + { + const enabled = guestLinkAction === "rotate"; + setGuestLinkAction(null); + void handleGuestLink(enabled); + }} + > + {t( + guestLinkAction === "rotate" + ? "collab.rotateLink" + : "collab.disableLink", + )} + + + +
); } diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 692aed66..4606515f 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -1826,9 +1826,13 @@ "present": "Present", "presentTitle": "Take the stage", "presentHost": "Host", + "searchHosts": "Search hosts", + "noHostsFound": "No available hosts found.", "presentProtocol": "Protocol", "stopPresenting": "Stop presenting", "takeOver": "Take over", + "takeOverConfirmTitle": "Replace the current presentation?", + "takeOverDescription": "{{name}} is presenting now. Taking over will disconnect everyone from that stage.", "emptyStage": "Nobody is presenting. Take the stage to share a session.", "stageLoading": "Connecting to the stage...", "stageEnded": "The presentation ended", @@ -1838,9 +1842,19 @@ "guestLinkOn": "Guest link is on. Anyone with the link can watch the stage.", "guestLinkOff": "Guest link is off.", "copyLink": "Copy link", + "rotateLink": "Rotate link", + "disableLink": "Disable link", + "rotateLinkConfirmTitle": "Rotate the guest link?", + "rotateLinkDescription": "The current link will stop working immediately. Connected SSH guests will be disconnected.", + "disableLinkConfirmTitle": "Disable the guest link?", + "disableLinkDescription": "Anonymous SSH viewers will be disconnected immediately and the current link will stop working.", "linkCopied": "Link copied", "roles": "Roles", "users": "Users", + "searchUsers": "Search users", + "endConfirmTitle": "End this meeting?", + "endDescription": "{{name}} will end for everyone and cannot be reopened.", + "endPersistentDescription": "The active presentation in {{name}} will stop. The room remains available for reuse.", "invitedTo": "You were invited to \"{{name}}\"", "guest": { "title": "Meeting", diff --git a/src/ui/sidebar/CollabPanel.tsx b/src/ui/sidebar/CollabPanel.tsx index 7a89817b..8f1c4756 100644 --- a/src/ui/sidebar/CollabPanel.tsx +++ b/src/ui/sidebar/CollabPanel.tsx @@ -45,6 +45,8 @@ export function CollabPanel({ useEffect(() => { void refresh(); + const timer = window.setInterval(() => void refresh(), 30000); + return () => window.clearInterval(timer); }, [refresh]); async function handleCreate() { @@ -70,7 +72,7 @@ export function CollabPanel({
@@ -106,7 +110,12 @@ export function CollabPanel({ {room.name} {room.presenterUserId && ( - + )} {room.persistent && ( @@ -124,7 +133,11 @@ export function CollabPanel({ {t("collab.createRoom")}
+ setName(e.target.value)}