mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
fix: harden collaboration room access (#1332)
* fix: harden collaboration room access * fix: confirm guest link lifecycle changes
This commit is contained in:
@@ -2,6 +2,7 @@ import { and, desc, eq, isNull } from "drizzle-orm";
|
|||||||
import { collabRoomMembers, collabRooms, users } from "../db/schema.js";
|
import { collabRoomMembers, collabRooms, users } from "../db/schema.js";
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
import { insertReturning } from "./returning.js";
|
import { insertReturning } from "./returning.js";
|
||||||
|
import { rowsAffected } from "./mutation-result.js";
|
||||||
|
|
||||||
export type CollabRoomRecord = typeof collabRooms.$inferSelect;
|
export type CollabRoomRecord = typeof collabRooms.$inferSelect;
|
||||||
export type CollabRoomMemberRecord = typeof collabRoomMembers.$inferSelect;
|
export type CollabRoomMemberRecord = typeof collabRoomMembers.$inferSelect;
|
||||||
@@ -164,6 +165,27 @@ export class CollabRoomRepository {
|
|||||||
await this.afterWrite();
|
await this.afterWrite();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async replaceStage(
|
||||||
|
roomId: string,
|
||||||
|
expectedShareId: string | null,
|
||||||
|
stage: CollabRoomStage,
|
||||||
|
): Promise<boolean> {
|
||||||
|
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<void> {
|
async clearStage(roomId: string): Promise<void> {
|
||||||
return this.updateStage(roomId, {
|
return this.updateStage(roomId, {
|
||||||
presenterUserId: null,
|
presenterUserId: null,
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
const WINDOW_MS = 60_000;
|
||||||
|
const MAX_ATTEMPTS = 60;
|
||||||
|
const attempts = new Map<string, { count: number; windowStart: number }>();
|
||||||
|
|
||||||
|
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();
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { GuacamoleTokenService } from "../guacamole/token-service.js";
|
import { GuacamoleTokenService } from "../guacamole/token-service.js";
|
||||||
import { collabRoomHub } from "./room-hub.js";
|
import { collabRoomHub } from "./room-hub.js";
|
||||||
import { getStageController, setStageController } from "./stage-control.js";
|
import { getStageController, setStageController } from "./stage-control.js";
|
||||||
|
import { isCollabGuestRateLimited } from "./guest-rate-limit.js";
|
||||||
import { sessionManager } from "../terminal/session-manager.js";
|
import { sessionManager } from "../terminal/session-manager.js";
|
||||||
import {
|
import {
|
||||||
isLiveSession,
|
isLiveSession,
|
||||||
@@ -40,7 +41,10 @@ const authenticateJWT = authManager.createAuthMiddleware();
|
|||||||
const tokenService = GuacamoleTokenService.getInstance();
|
const tokenService = GuacamoleTokenService.getInstance();
|
||||||
|
|
||||||
const STAGE_SHARE_EXPIRY_HOURS = 12;
|
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 PROTOCOLS: LiveProtocol[] = ["ssh", "rdp", "vnc", "telnet"];
|
||||||
|
const controlRequestTimes = new Map<string, number>();
|
||||||
|
|
||||||
function isNonEmptyString(value: unknown): value is string {
|
function isNonEmptyString(value: unknown): value is string {
|
||||||
return typeof value === "string" && value.trim().length > 0;
|
return typeof value === "string" && value.trim().length > 0;
|
||||||
@@ -60,13 +64,22 @@ async function requireRoomMember(
|
|||||||
|
|
||||||
async function revokeStageShare(room: CollabRoomRecord): Promise<void> {
|
async function revokeStageShare(room: CollabRoomRecord): Promise<void> {
|
||||||
if (!room.stageShareId) return;
|
if (!room.stageShareId) return;
|
||||||
try {
|
const repository = createCurrentSessionShareRepository();
|
||||||
await createCurrentSessionShareRepository().revokeAsAdmin(
|
const share = await repository.findActiveById(room.stageShareId);
|
||||||
room.stageShareId,
|
if (!share) return;
|
||||||
);
|
if (!(await repository.revokeAsAdmin(room.stageShareId))) {
|
||||||
} catch {
|
throw new Error("Failed to revoke the active stage share");
|
||||||
// A stale share must never block switching presenters.
|
|
||||||
}
|
}
|
||||||
|
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) {
|
function stagePayload(room: CollabRoomRecord) {
|
||||||
@@ -122,7 +135,7 @@ router.post("/rooms", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
success: true,
|
success: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ room });
|
res.json({ room: publicRoom(room) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sshLogger.error("Failed to create collab room", error, {
|
sshLogger.error("Failed to create collab room", error, {
|
||||||
operation: "collab_room_create_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!;
|
const userId = (req as AuthenticatedRequest).userId!;
|
||||||
try {
|
try {
|
||||||
const rooms = await createCurrentCollabRoomRepository().listForUser(userId);
|
const rooms = await createCurrentCollabRoomRepository().listForUser(userId);
|
||||||
res.json({ rooms });
|
res.json({ rooms: rooms.map(publicRoom) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sshLogger.error("Failed to list collab rooms", error, {
|
sshLogger.error("Failed to list collab rooms", error, {
|
||||||
operation: "collab_room_list_error",
|
operation: "collab_room_list_error",
|
||||||
@@ -174,7 +187,7 @@ router.get(
|
|||||||
const members =
|
const members =
|
||||||
await createCurrentCollabRoomRepository().listMembers(roomId);
|
await createCurrentCollabRoomRepository().listMembers(roomId);
|
||||||
res.json({
|
res.json({
|
||||||
room: access.room,
|
room: publicRoom(access.room),
|
||||||
me: userId,
|
me: userId,
|
||||||
isHost: access.isHost,
|
isHost: access.isHost,
|
||||||
members,
|
members,
|
||||||
@@ -218,6 +231,9 @@ router.post(
|
|||||||
error: "userIds (user ids) or roleIds (integers) are required",
|
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 {
|
try {
|
||||||
const access = await requireRoomMember(roomId, userId);
|
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, {
|
collabRoomHub.broadcast(roomId, {
|
||||||
type: "collab_members_changed",
|
type: "collab_members_changed",
|
||||||
roomId,
|
roomId,
|
||||||
@@ -303,6 +333,23 @@ router.delete(
|
|||||||
const repository = createCurrentCollabRoomRepository();
|
const repository = createCurrentCollabRoomRepository();
|
||||||
await repository.removeMember(roomId, targetId);
|
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) {
|
if (getStageController(roomId) === targetId) {
|
||||||
await applyStageControl(access.room, roomId, null);
|
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, {
|
collabRoomHub.broadcast(roomId, {
|
||||||
type: "collab_members_changed",
|
type: "collab_members_changed",
|
||||||
roomId,
|
roomId,
|
||||||
@@ -393,12 +457,20 @@ router.post(
|
|||||||
await revokeStageShare(access.room);
|
await revokeStageShare(access.room);
|
||||||
setStageController(roomId, null);
|
setStageController(roomId, null);
|
||||||
const repository = createCurrentCollabRoomRepository();
|
const repository = createCurrentCollabRoomRepository();
|
||||||
await repository.updateStage(roomId, {
|
const replaced = await repository.replaceStage(
|
||||||
presenterUserId: userId,
|
roomId,
|
||||||
stageProtocol: protocol,
|
access.room.stageShareId,
|
||||||
stageHostId: numericHostId,
|
{
|
||||||
stageShareId: share.id,
|
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 = {
|
const stage = {
|
||||||
presenterUserId: userId,
|
presenterUserId: userId,
|
||||||
@@ -469,6 +541,18 @@ router.post(
|
|||||||
roomId,
|
roomId,
|
||||||
stage: null,
|
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 });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sshLogger.error("Failed to stop collab room stage", error, {
|
sshLogger.error("Failed to stop collab room stage", error, {
|
||||||
@@ -626,6 +710,19 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await applyStageControl(access.room, roomId, targetId);
|
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 });
|
res.json({ controllerUserId: targetId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sshLogger.error("Failed to change collab stage control", error, {
|
sshLogger.error("Failed to change collab stage control", error, {
|
||||||
@@ -663,12 +760,33 @@ router.post(
|
|||||||
error: "Remote desktop stages are read-only",
|
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, {
|
collabRoomHub.broadcast(roomId, {
|
||||||
type: "collab_control_requested",
|
type: "collab_control_requested",
|
||||||
roomId,
|
roomId,
|
||||||
userId,
|
userId,
|
||||||
username: await getAuditUsername(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 });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sshLogger.error("Failed to request collab stage control", error, {
|
sshLogger.error("Failed to request collab stage control", error, {
|
||||||
@@ -711,6 +829,28 @@ router.post(
|
|||||||
? crypto.randomBytes(24).toString("base64url")
|
? crypto.randomBytes(24).toString("base64url")
|
||||||
: null;
|
: null;
|
||||||
await createCurrentCollabRoomRepository().setGuestToken(roomId, token);
|
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);
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||||
await logAudit({
|
await logAudit({
|
||||||
@@ -736,28 +876,6 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const GUEST_WINDOW_MS = 60 * 1000;
|
|
||||||
const GUEST_MAX_ATTEMPTS = 60;
|
|
||||||
const guestAttempts = new Map<string, { count: number; windowStart: number }>();
|
|
||||||
|
|
||||||
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
|
* @openapi
|
||||||
* /collab/guest/{token}:
|
* /collab/guest/{token}:
|
||||||
@@ -769,7 +887,7 @@ setInterval(() => {
|
|||||||
*/
|
*/
|
||||||
router.get("/guest/:token", async (req: Request, res: Response) => {
|
router.get("/guest/:token", async (req: Request, res: Response) => {
|
||||||
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
||||||
if (isGuestRateLimited(ip)) {
|
if (isCollabGuestRateLimited(ip)) {
|
||||||
return res.status(429).json({ error: "Too many requests" });
|
return res.status(429).json({ error: "Too many requests" });
|
||||||
}
|
}
|
||||||
const token = String(req.params.token);
|
const token = String(req.params.token);
|
||||||
@@ -846,6 +964,7 @@ router.post(
|
|||||||
setStageController(roomId, null);
|
setStageController(roomId, null);
|
||||||
const repository = createCurrentCollabRoomRepository();
|
const repository = createCurrentCollabRoomRepository();
|
||||||
if (access.room.persistent) {
|
if (access.room.persistent) {
|
||||||
|
await repository.setGuestToken(roomId, null);
|
||||||
await repository.clearStage(roomId);
|
await repository.clearStage(roomId);
|
||||||
collabRoomHub.broadcast(roomId, {
|
collabRoomHub.broadcast(roomId, {
|
||||||
type: "collab_stage_changed",
|
type: "collab_stage_changed",
|
||||||
|
|||||||
@@ -171,6 +171,13 @@ async function handleRoomGuestConnection(
|
|||||||
req: import("http").IncomingMessage,
|
req: import("http").IncomingMessage,
|
||||||
roomGuestToken: string,
|
roomGuestToken: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
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 =
|
const room =
|
||||||
await createCurrentCollabRoomRepository().findByGuestToken(roomGuestToken);
|
await createCurrentCollabRoomRepository().findByGuestToken(roomGuestToken);
|
||||||
const share = room?.stageShareId
|
const share = room?.stageShareId
|
||||||
|
|||||||
@@ -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. */
|
/** Fans out a message to every OPEN participant socket; skips closed ones and send failures. */
|
||||||
broadcast(sessionId: string, message: object): void {
|
broadcast(sessionId: string, message: object): void {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ vi.mock("../../../hosts/terminal/session-manager.js", () => ({
|
|||||||
setRoomShareControl: (...args: unknown[]) => {
|
setRoomShareControl: (...args: unknown[]) => {
|
||||||
state.control.push(args);
|
state.control.push(args);
|
||||||
},
|
},
|
||||||
|
disconnectShareParticipants: vi.fn(() => 0),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
vi.mock("../../../hosts/session-sharing/live-sessions.js", () => ({
|
vi.mock("../../../hosts/session-sharing/live-sessions.js", () => ({
|
||||||
@@ -147,6 +148,16 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
|||||||
updateStage: async (roomId: string, stage: Partial<Room>) => {
|
updateStage: async (roomId: string, stage: Partial<Room>) => {
|
||||||
Object.assign(state.rooms.get(roomId)!, stage);
|
Object.assign(state.rooms.get(roomId)!, stage);
|
||||||
},
|
},
|
||||||
|
replaceStage: async (
|
||||||
|
roomId: string,
|
||||||
|
expectedShareId: string | null,
|
||||||
|
stage: Partial<Room>,
|
||||||
|
) => {
|
||||||
|
const room = state.rooms.get(roomId)!;
|
||||||
|
if (room.stageShareId !== expectedShareId) return false;
|
||||||
|
Object.assign(room, stage);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
clearStage: async (roomId: string) => {
|
clearStage: async (roomId: string) => {
|
||||||
Object.assign(state.rooms.get(roomId)!, {
|
Object.assign(state.rooms.get(roomId)!, {
|
||||||
presenterUserId: null,
|
presenterUserId: null,
|
||||||
@@ -307,6 +318,26 @@ describe("collab room routes", () => {
|
|||||||
expect((other as { statusCode: number }).statusCode).toBe(404);
|
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<ReturnType<typeof invoke>>).jsonBody!
|
||||||
|
.rooms as Array<Record<string, unknown>>;
|
||||||
|
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<ReturnType<typeof invoke>>).jsonBody!
|
||||||
|
.room as Record<string, unknown>;
|
||||||
|
expect(room).not.toHaveProperty("guestLinkToken");
|
||||||
|
expect(room).toHaveProperty("guestLinkEnabled", true);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects an empty or oversized room name", async () => {
|
it("rejects an empty or oversized room name", async () => {
|
||||||
expect(
|
expect(
|
||||||
(await invoke("post", "/rooms", { body: { name: " " } })).statusCode,
|
(await invoke("post", "/rooms", { body: { name: " " } })).statusCode,
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ function makeFakeWs(readyState = 1 /* OPEN */) {
|
|||||||
return {
|
return {
|
||||||
readyState,
|
readyState,
|
||||||
send: vi.fn(),
|
send: vi.fn(),
|
||||||
|
close: vi.fn(),
|
||||||
|
terminate: vi.fn(),
|
||||||
} as unknown as import("ws").WebSocket;
|
} as unknown as import("ws").WebSocket;
|
||||||
}
|
}
|
||||||
const WS_OPEN = 1;
|
const WS_OPEN = 1;
|
||||||
@@ -254,6 +256,39 @@ describe("TerminalSessionManager - multiplayer participants", () => {
|
|||||||
sessionManager.destroySession(id);
|
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", () => {
|
it("joinAsParticipant returns null for a nonexistent or unconnected session", () => {
|
||||||
expect(
|
expect(
|
||||||
sessionManager.joinAsParticipant("does-not-exist", makeFakeWs(), {
|
sessionManager.joinAsParticipant("does-not-exist", makeFakeWs(), {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export interface CollabRoom {
|
|||||||
stageProtocol: string | null;
|
stageProtocol: string | null;
|
||||||
stageHostId: number | null;
|
stageHostId: number | null;
|
||||||
stageShareId: string | null;
|
stageShareId: string | null;
|
||||||
guestLinkToken: string | null;
|
guestLinkEnabled: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
endedAt: string | null;
|
endedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,17 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/dialog";
|
} 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 { Terminal } from "@/features/terminal/Terminal";
|
||||||
import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext";
|
import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext";
|
||||||
import { GuacamoleDisplay } from "@/features/guacamole/GuacamoleDisplay.tsx";
|
import { GuacamoleDisplay } from "@/features/guacamole/GuacamoleDisplay.tsx";
|
||||||
@@ -76,6 +87,10 @@ type PresentDraft =
|
|||||||
token: string;
|
token: string;
|
||||||
guacamoleConnectionId: string;
|
guacamoleConnectionId: string;
|
||||||
};
|
};
|
||||||
|
type PresentChoice = {
|
||||||
|
host: SSHHostWithStatus;
|
||||||
|
protocol: "ssh" | "rdp" | "vnc" | "telnet";
|
||||||
|
};
|
||||||
|
|
||||||
export function CollabRoomTab({
|
export function CollabRoomTab({
|
||||||
roomId,
|
roomId,
|
||||||
@@ -88,9 +103,21 @@ export function CollabRoomTab({
|
|||||||
const [detail, setDetail] = useState<CollabRoomDetail | null>(null);
|
const [detail, setDetail] = useState<CollabRoomDetail | null>(null);
|
||||||
const [stage, setStage] = useState<CollabStage | null>(null);
|
const [stage, setStage] = useState<CollabStage | null>(null);
|
||||||
const [ended, setEnded] = useState(false);
|
const [ended, setEnded] = useState(false);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
const [draft, setDraft] = useState<PresentDraft | null>(null);
|
const [draft, setDraft] = useState<PresentDraft | null>(null);
|
||||||
const [presentOpen, setPresentOpen] = useState(false);
|
const [presentOpen, setPresentOpen] = useState(false);
|
||||||
|
const [presentLoading, setPresentLoading] = useState(false);
|
||||||
const [inviteOpen, setInviteOpen] = useState(false);
|
const [inviteOpen, setInviteOpen] = useState(false);
|
||||||
|
const [endOpen, setEndOpen] = useState(false);
|
||||||
|
const [takeoverChoice, setTakeoverChoice] = useState<PresentChoice | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [guestLinkToken, setGuestLinkToken] = useState<string | null>(null);
|
||||||
|
const [guestLinkAction, setGuestLinkAction] = useState<
|
||||||
|
"disable" | "rotate" | null
|
||||||
|
>(null);
|
||||||
|
const [hostSearch, setHostSearch] = useState("");
|
||||||
|
const [inviteSearch, setInviteSearch] = useState("");
|
||||||
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
|
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
|
||||||
const [users, setUsers] = useState<Array<{ id: string; username: string }>>(
|
const [users, setUsers] = useState<Array<{ id: string; username: string }>>(
|
||||||
[],
|
[],
|
||||||
@@ -103,12 +130,16 @@ export function CollabRoomTab({
|
|||||||
const draftRef = useRef<PresentDraft | null>(null);
|
const draftRef = useRef<PresentDraft | null>(null);
|
||||||
draftRef.current = draft;
|
draftRef.current = draft;
|
||||||
const stageKeyRef = useRef<string | null>(null);
|
const stageKeyRef = useRef<string | null>(null);
|
||||||
|
const refreshSequence = useRef(0);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
if (!roomId) return;
|
if (!roomId) return;
|
||||||
|
const sequence = ++refreshSequence.current;
|
||||||
try {
|
try {
|
||||||
const nextDetail = await getCollabRoom(roomId);
|
const nextDetail = await getCollabRoom(roomId);
|
||||||
|
if (sequence !== refreshSequence.current) return;
|
||||||
setDetail(nextDetail);
|
setDetail(nextDetail);
|
||||||
|
setLoadError(null);
|
||||||
// Presenting locally? The local session is the stage - don't join it.
|
// Presenting locally? The local session is the stage - don't join it.
|
||||||
if (
|
if (
|
||||||
nextDetail.stage.shareId &&
|
nextDetail.stage.shareId &&
|
||||||
@@ -130,8 +161,10 @@ export function CollabRoomTab({
|
|||||||
// The stage was cleared elsewhere; stop presenting locally too.
|
// The stage was cleared elsewhere; stop presenting locally too.
|
||||||
if (draftRef.current) setDraft(null);
|
if (draftRef.current) setDraft(null);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
setEnded(true);
|
if (sequence === refreshSequence.current) {
|
||||||
|
setLoadError(getErrorMessage(error));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [roomId]);
|
}, [roomId]);
|
||||||
|
|
||||||
@@ -139,16 +172,35 @@ export function CollabRoomTab({
|
|||||||
void refresh();
|
void refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
// Live room events, with slow polling as the fallback path.
|
// Live room events. Poll only while the socket is unavailable.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!roomId) return;
|
if (!roomId) return;
|
||||||
let ws: WebSocket | null = null;
|
let ws: WebSocket | null = null;
|
||||||
let pingTimer: ReturnType<typeof setInterval> | null = null;
|
let pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let reconnectAttempt = 0;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
try {
|
const startPolling = () => {
|
||||||
ws = new WebSocket(roomEventsWsUrl());
|
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 = () => {
|
ws.onopen = () => {
|
||||||
|
reconnectAttempt = 0;
|
||||||
|
stopPolling();
|
||||||
ws?.send(
|
ws?.send(
|
||||||
JSON.stringify({ type: "collab_subscribe", data: { roomId } }),
|
JSON.stringify({ type: "collab_subscribe", data: { roomId } }),
|
||||||
);
|
);
|
||||||
@@ -192,15 +244,23 @@ export function CollabRoomTab({
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch {
|
ws.onclose = () => {
|
||||||
/* polling still covers us */
|
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 () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (pingTimer) clearInterval(pingTimer);
|
if (pingTimer) clearInterval(pingTimer);
|
||||||
clearInterval(pollTimer);
|
if (pollTimer) clearInterval(pollTimer);
|
||||||
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
ws?.close();
|
ws?.close();
|
||||||
};
|
};
|
||||||
}, [roomId, refresh]);
|
}, [roomId, refresh]);
|
||||||
@@ -242,15 +302,30 @@ export function CollabRoomTab({
|
|||||||
async function openPresentDialog() {
|
async function openPresentDialog() {
|
||||||
setPresentOpen(true);
|
setPresentOpen(true);
|
||||||
if (hosts.length === 0) {
|
if (hosts.length === 0) {
|
||||||
|
setPresentLoading(true);
|
||||||
try {
|
try {
|
||||||
setHosts(await getSSHHosts({ includeStatus: false }));
|
setHosts(await getSSHHosts({ includeStatus: false }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(getErrorMessage(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,
|
host: SSHHostWithStatus,
|
||||||
protocol: "ssh" | "rdp" | "vnc" | "telnet",
|
protocol: "ssh" | "rdp" | "vnc" | "telnet",
|
||||||
) {
|
) {
|
||||||
@@ -342,8 +417,9 @@ export function CollabRoomTab({
|
|||||||
async function handleGuestLink(enabled: boolean) {
|
async function handleGuestLink(enabled: boolean) {
|
||||||
if (!roomId) return;
|
if (!roomId) return;
|
||||||
try {
|
try {
|
||||||
await setCollabGuestLink(roomId, enabled);
|
const result = await setCollabGuestLink(roomId, enabled);
|
||||||
void refresh();
|
setGuestLinkToken(result.guestLinkToken);
|
||||||
|
await refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(getErrorMessage(error));
|
toast.error(getErrorMessage(error));
|
||||||
}
|
}
|
||||||
@@ -391,11 +467,46 @@ export function CollabRoomTab({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!detail && loadError) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 h-full items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-3 text-muted-foreground">
|
||||||
|
<AlertCircle className="size-8" />
|
||||||
|
<p className="max-w-sm text-center text-sm">{loadError}</p>
|
||||||
|
<Button variant="outline" onClick={() => void refresh()}>
|
||||||
|
{t("common.retry")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const memberIds = new Set(detail?.members.map((member) => member.userId));
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
|
{loadError && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 border-b border-destructive/40 bg-destructive/10 px-3 py-2 text-xs"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<AlertCircle className="size-4 shrink-0 text-destructive" />
|
||||||
|
<span className="flex-1 truncate">{loadError}</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => void refresh()}>
|
||||||
|
{t("common.retry")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Header: roster + controls */}
|
{/* Header: roster + controls */}
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border flex-wrap">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border flex-wrap">
|
||||||
<Presentation className="size-4 text-muted-foreground shrink-0" />
|
<Presentation className="size-4 text-muted-foreground shrink-0" />
|
||||||
@@ -414,7 +525,7 @@ export function CollabRoomTab({
|
|||||||
<Badge
|
<Badge
|
||||||
key={canToggleControl ? undefined : member.userId}
|
key={canToggleControl ? undefined : member.userId}
|
||||||
variant={hasControl ? "default" : "outline"}
|
variant={hasControl ? "default" : "outline"}
|
||||||
className="text-[10px] gap-1"
|
className="text-xs gap-1"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`size-1.5 rounded-full ${onlineIds.has(member.userId) ? "bg-green-500" : "bg-muted-foreground/30"}`}
|
className={`size-1.5 rounded-full ${onlineIds.has(member.userId) ? "bg-green-500" : "bg-muted-foreground/30"}`}
|
||||||
@@ -434,6 +545,9 @@ export function CollabRoomTab({
|
|||||||
title={t(
|
title={t(
|
||||||
hasControl ? "collab.revokeControl" : "collab.grantControl",
|
hasControl ? "collab.revokeControl" : "collab.grantControl",
|
||||||
)}
|
)}
|
||||||
|
aria-label={`${member.username}: ${t(
|
||||||
|
hasControl ? "collab.revokeControl" : "collab.grantControl",
|
||||||
|
)}`}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void changeControl(hasControl ? null : member.userId)
|
void changeControl(hasControl ? null : member.userId)
|
||||||
}
|
}
|
||||||
@@ -450,7 +564,7 @@ export function CollabRoomTab({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-7 text-xs"
|
className="h-8 text-xs"
|
||||||
onClick={() => void openInviteDialog()}
|
onClick={() => void openInviteDialog()}
|
||||||
>
|
>
|
||||||
<UserPlus className="size-3.5 mr-1" />
|
<UserPlus className="size-3.5 mr-1" />
|
||||||
@@ -464,7 +578,7 @@ export function CollabRoomTab({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={controllerUserId === me ? "default" : "outline"}
|
variant={controllerUserId === me ? "default" : "outline"}
|
||||||
className="h-7 text-xs"
|
className="h-8 text-xs"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
controllerUserId === me
|
controllerUserId === me
|
||||||
? void changeControl(null)
|
? void changeControl(null)
|
||||||
@@ -483,7 +597,7 @@ export function CollabRoomTab({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-7 text-xs"
|
className="h-8 text-xs"
|
||||||
onClick={() => void handleStop()}
|
onClick={() => void handleStop()}
|
||||||
>
|
>
|
||||||
<Square className="size-3.5 mr-1" />
|
<Square className="size-3.5 mr-1" />
|
||||||
@@ -492,7 +606,7 @@ export function CollabRoomTab({
|
|||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 text-xs"
|
className="h-8 text-xs"
|
||||||
onClick={() => void openPresentDialog()}
|
onClick={() => void openPresentDialog()}
|
||||||
>
|
>
|
||||||
<MonitorUp className="size-3.5 mr-1" />
|
<MonitorUp className="size-3.5 mr-1" />
|
||||||
@@ -504,8 +618,8 @@ export function CollabRoomTab({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="h-7 text-xs"
|
className="h-8 text-xs"
|
||||||
onClick={() => void handleEnd()}
|
onClick={() => setEndOpen(true)}
|
||||||
>
|
>
|
||||||
{t("collab.endRoom")}
|
{t("collab.endRoom")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -514,35 +628,50 @@ export function CollabRoomTab({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isHost && (
|
{isHost && (
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border text-[11px] text-muted-foreground">
|
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border text-xs text-muted-foreground">
|
||||||
<Link2 className="size-3.5" />
|
<Link2 className="size-3.5" />
|
||||||
<span className="flex-1 truncate">
|
<span className="flex-1 truncate">
|
||||||
{detail?.room.guestLinkToken
|
{detail?.room.guestLinkEnabled
|
||||||
? t("collab.guestLinkOn")
|
? t("collab.guestLinkOn")
|
||||||
: t("collab.guestLinkOff")}
|
: t("collab.guestLinkOff")}
|
||||||
</span>
|
</span>
|
||||||
{detail?.room.guestLinkToken && (
|
{guestLinkToken && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-6 text-[10px]"
|
className="h-8 text-xs"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void navigator.clipboard
|
void navigator.clipboard
|
||||||
.writeText(guestLinkUrl(detail.room.guestLinkToken!))
|
.writeText(guestLinkUrl(guestLinkToken))
|
||||||
.then(() => toast.success(t("collab.linkCopied")));
|
.then(() => toast.success(t("collab.linkCopied")))
|
||||||
|
.catch((error) => toast.error(getErrorMessage(error)));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("collab.copyLink")}
|
{t("collab.copyLink")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{detail?.room.guestLinkEnabled && !guestLinkToken && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
onClick={() => setGuestLinkAction("rotate")}
|
||||||
|
>
|
||||||
|
{t("collab.rotateLink")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={detail?.room.guestLinkToken ? "destructive" : "outline"}
|
variant={detail?.room.guestLinkEnabled ? "destructive" : "outline"}
|
||||||
className="h-6 text-[10px]"
|
className="h-8 text-xs"
|
||||||
onClick={() => void handleGuestLink(!detail?.room.guestLinkToken)}
|
onClick={() =>
|
||||||
|
detail?.room.guestLinkEnabled
|
||||||
|
? setGuestLinkAction("disable")
|
||||||
|
: void handleGuestLink(true)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{t("collab.guestLink")}:{" "}
|
{t("collab.guestLink")}:{" "}
|
||||||
{detail?.room.guestLinkToken ? "ON" : "OFF"}
|
{detail?.room.guestLinkEnabled ? "ON" : "OFF"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -641,13 +770,22 @@ export function CollabRoomTab({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("collab.presentTitle")}</DialogTitle>
|
<DialogTitle>{t("collab.presentTitle")}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
<Input
|
||||||
|
aria-label={t("collab.searchHosts")}
|
||||||
|
placeholder={t("collab.searchHosts")}
|
||||||
|
value={hostSearch}
|
||||||
|
onChange={(event) => setHostSearch(event.target.value)}
|
||||||
|
/>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{hosts.length === 0 && (
|
{presentLoading && (
|
||||||
<div className="flex justify-center py-4">
|
<div className="flex justify-center py-4">
|
||||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hosts.map((host) => {
|
{!presentLoading && filteredHosts.length === 0 && (
|
||||||
|
<CenteredNote text={t("collab.noHostsFound")} />
|
||||||
|
)}
|
||||||
|
{filteredHosts.map((host) => {
|
||||||
const protocols: Array<"ssh" | "rdp" | "vnc" | "telnet"> = [];
|
const protocols: Array<"ssh" | "rdp" | "vnc" | "telnet"> = [];
|
||||||
if (host.enableTerminal || host.enableSsh) protocols.push("ssh");
|
if (host.enableTerminal || host.enableSsh) protocols.push("ssh");
|
||||||
if (host.enableRdp) protocols.push("rdp");
|
if (host.enableRdp) protocols.push("rdp");
|
||||||
@@ -665,8 +803,8 @@ export function CollabRoomTab({
|
|||||||
key={protocol}
|
key={protocol}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-6 text-[10px] uppercase"
|
className="h-8 text-xs uppercase"
|
||||||
onClick={() => void choosePresent(host, protocol)}
|
onClick={() => choosePresent(host, protocol)}
|
||||||
>
|
>
|
||||||
{protocol}
|
{protocol}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -684,6 +822,12 @@ export function CollabRoomTab({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("collab.inviteTitle")}</DialogTitle>
|
<DialogTitle>{t("collab.inviteTitle")}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
<Input
|
||||||
|
aria-label={t("collab.searchUsers")}
|
||||||
|
placeholder={t("collab.searchUsers")}
|
||||||
|
value={inviteSearch}
|
||||||
|
onChange={(event) => setInviteSearch(event.target.value)}
|
||||||
|
/>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{roles.length > 0 && (
|
{roles.length > 0 && (
|
||||||
<span className="text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
|
<span className="text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
@@ -747,6 +891,99 @@ export function CollabRoomTab({
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<AlertDialog open={endOpen} onOpenChange={setEndOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("collab.endConfirmTitle")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t(
|
||||||
|
detail?.room.persistent
|
||||||
|
? "collab.endPersistentDescription"
|
||||||
|
: "collab.endDescription",
|
||||||
|
{ name: detail?.room.name },
|
||||||
|
)}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
onClick={() => void handleEnd()}
|
||||||
|
>
|
||||||
|
{t("collab.endRoom")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={Boolean(takeoverChoice)}
|
||||||
|
onOpenChange={(open) => !open && setTakeoverChoice(null)}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{t("collab.takeOverConfirmTitle")}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t("collab.takeOverDescription", { name: presenterName })}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => {
|
||||||
|
const choice = takeoverChoice;
|
||||||
|
setTakeoverChoice(null);
|
||||||
|
if (choice) void startPresent(choice.host, choice.protocol);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("collab.takeOver")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={Boolean(guestLinkAction)}
|
||||||
|
onOpenChange={(open) => !open && setGuestLinkAction(null)}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{t(
|
||||||
|
guestLinkAction === "rotate"
|
||||||
|
? "collab.rotateLinkConfirmTitle"
|
||||||
|
: "collab.disableLinkConfirmTitle",
|
||||||
|
)}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t(
|
||||||
|
guestLinkAction === "rotate"
|
||||||
|
? "collab.rotateLinkDescription"
|
||||||
|
: "collab.disableLinkDescription",
|
||||||
|
)}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => {
|
||||||
|
const enabled = guestLinkAction === "rotate";
|
||||||
|
setGuestLinkAction(null);
|
||||||
|
void handleGuestLink(enabled);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
guestLinkAction === "rotate"
|
||||||
|
? "collab.rotateLink"
|
||||||
|
: "collab.disableLink",
|
||||||
|
)}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1826,9 +1826,13 @@
|
|||||||
"present": "Present",
|
"present": "Present",
|
||||||
"presentTitle": "Take the stage",
|
"presentTitle": "Take the stage",
|
||||||
"presentHost": "Host",
|
"presentHost": "Host",
|
||||||
|
"searchHosts": "Search hosts",
|
||||||
|
"noHostsFound": "No available hosts found.",
|
||||||
"presentProtocol": "Protocol",
|
"presentProtocol": "Protocol",
|
||||||
"stopPresenting": "Stop presenting",
|
"stopPresenting": "Stop presenting",
|
||||||
"takeOver": "Take over",
|
"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.",
|
"emptyStage": "Nobody is presenting. Take the stage to share a session.",
|
||||||
"stageLoading": "Connecting to the stage...",
|
"stageLoading": "Connecting to the stage...",
|
||||||
"stageEnded": "The presentation ended",
|
"stageEnded": "The presentation ended",
|
||||||
@@ -1838,9 +1842,19 @@
|
|||||||
"guestLinkOn": "Guest link is on. Anyone with the link can watch the stage.",
|
"guestLinkOn": "Guest link is on. Anyone with the link can watch the stage.",
|
||||||
"guestLinkOff": "Guest link is off.",
|
"guestLinkOff": "Guest link is off.",
|
||||||
"copyLink": "Copy link",
|
"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",
|
"linkCopied": "Link copied",
|
||||||
"roles": "Roles",
|
"roles": "Roles",
|
||||||
"users": "Users",
|
"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}}\"",
|
"invitedTo": "You were invited to \"{{name}}\"",
|
||||||
"guest": {
|
"guest": {
|
||||||
"title": "Meeting",
|
"title": "Meeting",
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export function CollabPanel({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
void refresh();
|
||||||
|
const timer = window.setInterval(() => void refresh(), 30000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
async function handleCreate() {
|
async function handleCreate() {
|
||||||
@@ -70,7 +72,7 @@ export function CollabPanel({
|
|||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 text-xs flex-1"
|
className="h-8 text-xs flex-1"
|
||||||
onClick={() => setCreateOpen(true)}
|
onClick={() => setCreateOpen(true)}
|
||||||
>
|
>
|
||||||
<Plus className="size-3.5 mr-1" />
|
<Plus className="size-3.5 mr-1" />
|
||||||
@@ -79,8 +81,10 @@ export function CollabPanel({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-7 px-2"
|
className="h-8 px-2"
|
||||||
onClick={() => void refresh()}
|
onClick={() => void refresh()}
|
||||||
|
aria-label={t("common.refresh")}
|
||||||
|
title={t("common.refresh")}
|
||||||
>
|
>
|
||||||
<RefreshCw className="size-3.5" />
|
<RefreshCw className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -106,7 +110,12 @@ export function CollabPanel({
|
|||||||
<Presentation className="size-3.5 shrink-0 text-muted-foreground" />
|
<Presentation className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
<span className="flex-1 text-xs truncate">{room.name}</span>
|
<span className="flex-1 text-xs truncate">{room.name}</span>
|
||||||
{room.presenterUserId && (
|
{room.presenterUserId && (
|
||||||
<span className="size-1.5 rounded-full bg-red-500 shrink-0" />
|
<span
|
||||||
|
className="size-1.5 rounded-full bg-red-500 shrink-0"
|
||||||
|
role="img"
|
||||||
|
aria-label={t("collab.presenterBadge")}
|
||||||
|
title={t("collab.presenterBadge")}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{room.persistent && (
|
{room.persistent && (
|
||||||
<Badge variant="outline" className="text-[9px] px-1 py-0">
|
<Badge variant="outline" className="text-[9px] px-1 py-0">
|
||||||
@@ -124,7 +133,11 @@ export function CollabPanel({
|
|||||||
<DialogTitle>{t("collab.createRoom")}</DialogTitle>
|
<DialogTitle>{t("collab.createRoom")}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
|
<label htmlFor="collab-room-name" className="text-xs font-medium">
|
||||||
|
{t("collab.roomName")}
|
||||||
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
id="collab-room-name"
|
||||||
placeholder={t("collab.roomName")}
|
placeholder={t("collab.roomName")}
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
|||||||
Reference in New Issue
Block a user