mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-30 02:41:34 +00:00
feat: collaboration rooms with switchable presenter (#1328)
* feat: add collaboration rooms with switchable presenter Rooms are a group of members watching one stage - the live SSH/RDP/VNC session the current presenter shares. Any member can take over the stage; the host can invite, force-stop and end the meeting. Stages reuse session_shares (new room share type), so gating, recording, expiry and the global sharing toggle all apply unchanged. * feat: add stage control handoff to collaboration rooms The presenter or host can grant any member write access to the live stage and take it back; members can raise a hand to ask. SSH flips the participant's permission on the live gate; RDP/VNC re-mint the viewer's join token. Control clears on every stage switch. * feat: guest links, role invites and invite awareness for collab rooms - Anonymous guest link per room (host toggles/rotates), followed by polling the public resolve endpoint; SSH guests join over the terminal WS with roomGuestToken, guac guests get read-only join tokens - Invite by role (expands to current members, snapshot semantics) - Toast when a room you were invited to appears - Stale stages are cleared lazily when the presenter is gone - Telnet presenting, expired-tab fallback, documented single-instance and guac-kick limits - Tests for the collab routes, room hub, share access and control flip * fix: keep remote desktop collaboration read-only
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, Presentation } from "lucide-react";
|
||||
import { GuacamoleDisplay } from "@/features/guacamole/GuacamoleDisplay.tsx";
|
||||
import { GuestTerminalView } from "@/features/session-sharing/SharedSessionView";
|
||||
import {
|
||||
resolveCollabGuestStage,
|
||||
type CollabGuestStage,
|
||||
} from "@/api/collab-api";
|
||||
|
||||
const POLL_MS = 5000;
|
||||
|
||||
/**
|
||||
* Anonymous guest page for a collab room (?view=collab-guest&token=...).
|
||||
* Guests have no account and no event channel, so they poll the public
|
||||
* resolve endpoint and remount the viewer whenever the stage share changes.
|
||||
*/
|
||||
export default function CollabGuestView() {
|
||||
const { t } = useTranslation();
|
||||
const token = new URLSearchParams(window.location.search).get("token");
|
||||
const [roomName, setRoomName] = useState<string | null>(null);
|
||||
const [stage, setStage] = useState<CollabGuestStage | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const stageShareIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setError(t("collab.guest.linkInvalid"));
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const result = await resolveCollabGuestStage(token);
|
||||
if (cancelled) return;
|
||||
setRoomName(result.roomName);
|
||||
setError(null);
|
||||
const nextShareId = result.stage?.shareId ?? null;
|
||||
// Tokens are minted per resolve; only swap the viewer on a real change.
|
||||
if (nextShareId !== stageShareIdRef.current) {
|
||||
stageShareIdRef.current = nextShareId;
|
||||
setStage(result.stage);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setError(t("collab.guest.linkInvalid"));
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
const timer = setInterval(() => void poll(), POLL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [token, t]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col h-screen w-screen"
|
||||
style={{ backgroundColor: "var(--bg-base)", color: "var(--foreground)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border text-sm">
|
||||
<Presentation className="size-4 text-muted-foreground" />
|
||||
<span className="font-semibold">
|
||||
{roomName ?? t("collab.guest.title")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("sessionSharing.guestView.readOnlyBadge")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative flex-1 min-h-0">
|
||||
{error ? (
|
||||
<Note icon={<AlertCircle className="size-8" />} text={error} />
|
||||
) : !stage ? (
|
||||
<Note
|
||||
icon={<Presentation className="size-8" />}
|
||||
text={t("collab.guest.waiting")}
|
||||
/>
|
||||
) : stage.protocol === "ssh" ? (
|
||||
<GuestTerminalView
|
||||
key={stage.shareId}
|
||||
share={{ permissionLevel: "read-only" }}
|
||||
wsQuery={`roomGuestToken=${encodeURIComponent(token ?? "")}`}
|
||||
/>
|
||||
) : stage.connectParams?.token ? (
|
||||
<GuacamoleDisplay
|
||||
key={stage.shareId}
|
||||
connectionConfig={{
|
||||
token: stage.connectParams.token,
|
||||
protocol: stage.protocol,
|
||||
type: stage.protocol,
|
||||
}}
|
||||
isVisible
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Note({ icon, text }: { icon: React.ReactNode; text: string }) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
{icon}
|
||||
<p className="text-sm">{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user