fix: harden collaboration room access (#1332)

* fix: harden collaboration room access

* fix: confirm guest link lifecycle changes
This commit is contained in:
ZacharyZcR
2026-08-25 01:36:19 +08:00
committed by GitHub
parent c51c3a9449
commit 8260af2d57
11 changed files with 619 additions and 77 deletions
+1 -1
View File
@@ -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;
}
+272 -35
View File
@@ -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<CollabRoomDetail | null>(null);
const [stage, setStage] = useState<CollabStage | null>(null);
const [ended, setEnded] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [draft, setDraft] = useState<PresentDraft | null>(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<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 [users, setUsers] = useState<Array<{ id: string; username: string }>>(
[],
@@ -103,12 +130,16 @@ export function CollabRoomTab({
const draftRef = useRef<PresentDraft | null>(null);
draftRef.current = draft;
const stageKeyRef = useRef<string | null>(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<typeof setInterval> | null = null;
let pollTimer: ReturnType<typeof setInterval> | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | 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 (
<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 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 (
<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 */}
<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" />
@@ -414,7 +525,7 @@ export function CollabRoomTab({
<Badge
key={canToggleControl ? undefined : member.userId}
variant={hasControl ? "default" : "outline"}
className="text-[10px] gap-1"
className="text-xs gap-1"
>
<span
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(
hasControl ? "collab.revokeControl" : "collab.grantControl",
)}
aria-label={`${member.username}: ${t(
hasControl ? "collab.revokeControl" : "collab.grantControl",
)}`}
onClick={() =>
void changeControl(hasControl ? null : member.userId)
}
@@ -450,7 +564,7 @@ export function CollabRoomTab({
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
className="h-8 text-xs"
onClick={() => void openInviteDialog()}
>
<UserPlus className="size-3.5 mr-1" />
@@ -464,7 +578,7 @@ export function CollabRoomTab({
<Button
size="sm"
variant={controllerUserId === me ? "default" : "outline"}
className="h-7 text-xs"
className="h-8 text-xs"
onClick={() =>
controllerUserId === me
? void changeControl(null)
@@ -483,7 +597,7 @@ export function CollabRoomTab({
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
className="h-8 text-xs"
onClick={() => void handleStop()}
>
<Square className="size-3.5 mr-1" />
@@ -492,7 +606,7 @@ export function CollabRoomTab({
)}
<Button
size="sm"
className="h-7 text-xs"
className="h-8 text-xs"
onClick={() => void openPresentDialog()}
>
<MonitorUp className="size-3.5 mr-1" />
@@ -504,8 +618,8 @@ export function CollabRoomTab({
<Button
size="sm"
variant="destructive"
className="h-7 text-xs"
onClick={() => void handleEnd()}
className="h-8 text-xs"
onClick={() => setEndOpen(true)}
>
{t("collab.endRoom")}
</Button>
@@ -514,35 +628,50 @@ export function CollabRoomTab({
</div>
{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" />
<span className="flex-1 truncate">
{detail?.room.guestLinkToken
{detail?.room.guestLinkEnabled
? t("collab.guestLinkOn")
: t("collab.guestLinkOff")}
</span>
{detail?.room.guestLinkToken && (
{guestLinkToken && (
<Button
size="sm"
variant="outline"
className="h-6 text-[10px]"
className="h-8 text-xs"
onClick={() => {
void navigator.clipboard
.writeText(guestLinkUrl(detail.room.guestLinkToken!))
.then(() => toast.success(t("collab.linkCopied")));
.writeText(guestLinkUrl(guestLinkToken))
.then(() => toast.success(t("collab.linkCopied")))
.catch((error) => toast.error(getErrorMessage(error)));
}}
>
{t("collab.copyLink")}
</Button>
)}
{detail?.room.guestLinkEnabled && !guestLinkToken && (
<Button
size="sm"
variant="outline"
className="h-8 text-xs"
onClick={() => setGuestLinkAction("rotate")}
>
{t("collab.rotateLink")}
</Button>
)}
<Button
size="sm"
variant={detail?.room.guestLinkToken ? "destructive" : "outline"}
className="h-6 text-[10px]"
onClick={() => void handleGuestLink(!detail?.room.guestLinkToken)}
variant={detail?.room.guestLinkEnabled ? "destructive" : "outline"}
className="h-8 text-xs"
onClick={() =>
detail?.room.guestLinkEnabled
? setGuestLinkAction("disable")
: void handleGuestLink(true)
}
>
{t("collab.guestLink")}:{" "}
{detail?.room.guestLinkToken ? "ON" : "OFF"}
{detail?.room.guestLinkEnabled ? "ON" : "OFF"}
</Button>
</div>
)}
@@ -641,13 +770,22 @@ export function CollabRoomTab({
<DialogHeader>
<DialogTitle>{t("collab.presentTitle")}</DialogTitle>
</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">
{hosts.length === 0 && (
{presentLoading && (
<div className="flex justify-center py-4">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
)}
{hosts.map((host) => {
{!presentLoading && filteredHosts.length === 0 && (
<CenteredNote text={t("collab.noHostsFound")} />
)}
{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}
</Button>
@@ -684,6 +822,12 @@ export function CollabRoomTab({
<DialogHeader>
<DialogTitle>{t("collab.inviteTitle")}</DialogTitle>
</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">
{roles.length > 0 && (
<span className="text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
@@ -747,6 +891,99 @@ export function CollabRoomTab({
</DialogFooter>
</DialogContent>
</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>
);
}
+14
View File
@@ -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",
+16 -3
View File
@@ -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({
<div className="flex items-center gap-1.5">
<Button
size="sm"
className="h-7 text-xs flex-1"
className="h-8 text-xs flex-1"
onClick={() => setCreateOpen(true)}
>
<Plus className="size-3.5 mr-1" />
@@ -79,8 +81,10 @@ export function CollabPanel({
<Button
size="sm"
variant="outline"
className="h-7 px-2"
className="h-8 px-2"
onClick={() => void refresh()}
aria-label={t("common.refresh")}
title={t("common.refresh")}
>
<RefreshCw className="size-3.5" />
</Button>
@@ -106,7 +110,12 @@ export function CollabPanel({
<Presentation className="size-3.5 shrink-0 text-muted-foreground" />
<span className="flex-1 text-xs truncate">{room.name}</span>
{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 && (
<Badge variant="outline" className="text-[9px] px-1 py-0">
@@ -124,7 +133,11 @@ export function CollabPanel({
<DialogTitle>{t("collab.createRoom")}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3">
<label htmlFor="collab-room-name" className="text-xs font-medium">
{t("collab.roomName")}
</label>
<Input
id="collab-room-name"
placeholder={t("collab.roomName")}
value={name}
onChange={(e) => setName(e.target.value)}