mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,760 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertCircle,
|
||||
Crown,
|
||||
Hand,
|
||||
Link2,
|
||||
Loader2,
|
||||
MonitorUp,
|
||||
MousePointerClick,
|
||||
Presentation,
|
||||
Square,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Badge } from "@/components/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog";
|
||||
import { Terminal } from "@/features/terminal/Terminal";
|
||||
import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext";
|
||||
import { GuacamoleDisplay } from "@/features/guacamole/GuacamoleDisplay.tsx";
|
||||
import { getGuacamoleTokenFromHost } from "@/api/guacamole-api";
|
||||
import { getSSHHosts, getUserList, type SSHHostWithStatus } from "@/main-axios";
|
||||
import { getRoles } from "@/api/rbac-api";
|
||||
import type { Role } from "@/main-axios";
|
||||
import { getBasePath } from "@/lib/base-path";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { getErrorMessage } from "@/lib/error-message";
|
||||
import {
|
||||
endCollabRoom,
|
||||
getCollabRoom,
|
||||
getCollabStage,
|
||||
inviteCollabMembers,
|
||||
presentCollabStage,
|
||||
requestCollabStageControl,
|
||||
setCollabGuestLink,
|
||||
setCollabStageControl,
|
||||
stopCollabStage,
|
||||
type CollabRoomDetail,
|
||||
type CollabStage,
|
||||
} from "@/api/collab-api";
|
||||
|
||||
const PING_INTERVAL_MS = 30000;
|
||||
const POLL_FALLBACK_MS = 15000;
|
||||
|
||||
// Mirrors SharedSessionView's construction (dev/electron/prod); authentication
|
||||
// rides on the jwt cookie the way every terminal WS connection does.
|
||||
function roomEventsWsUrl(): string {
|
||||
const isDev =
|
||||
!isElectron() &&
|
||||
process.env.NODE_ENV === "development" &&
|
||||
(window.location.port === "3000" ||
|
||||
window.location.port === "5173" ||
|
||||
window.location.port === "");
|
||||
if (isDev) {
|
||||
return `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`;
|
||||
}
|
||||
if (isElectron()) {
|
||||
return "ws://127.0.0.1:30002";
|
||||
}
|
||||
const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
return `${wsProtocol}://${window.location.host}${getBasePath()}/ssh/websocket/`;
|
||||
}
|
||||
|
||||
type PresentDraft =
|
||||
| { protocol: "ssh"; host: SSHHostWithStatus }
|
||||
| {
|
||||
protocol: "rdp" | "vnc" | "telnet";
|
||||
host: SSHHostWithStatus;
|
||||
token: string;
|
||||
guacamoleConnectionId: string;
|
||||
};
|
||||
|
||||
export function CollabRoomTab({
|
||||
roomId,
|
||||
isVisible,
|
||||
}: {
|
||||
roomId?: string;
|
||||
isVisible: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [detail, setDetail] = useState<CollabRoomDetail | null>(null);
|
||||
const [stage, setStage] = useState<CollabStage | null>(null);
|
||||
const [ended, setEnded] = useState(false);
|
||||
const [draft, setDraft] = useState<PresentDraft | null>(null);
|
||||
const [presentOpen, setPresentOpen] = useState(false);
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
|
||||
const [users, setUsers] = useState<Array<{ id: string; username: string }>>(
|
||||
[],
|
||||
);
|
||||
const [inviteSelection, setInviteSelection] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [roleSelection, setRoleSelection] = useState<Set<number>>(new Set());
|
||||
const draftRef = useRef<PresentDraft | null>(null);
|
||||
draftRef.current = draft;
|
||||
const stageKeyRef = useRef<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!roomId) return;
|
||||
try {
|
||||
const nextDetail = await getCollabRoom(roomId);
|
||||
setDetail(nextDetail);
|
||||
// Presenting locally? The local session is the stage - don't join it.
|
||||
if (
|
||||
nextDetail.stage.shareId &&
|
||||
nextDetail.stage.presenterUserId !== nextDetail.me
|
||||
) {
|
||||
// A guac viewer reconnects whenever its token changes, so the stage
|
||||
// is only re-resolved when the share or my control actually changed.
|
||||
const stageKey = `${nextDetail.stage.shareId}:${
|
||||
nextDetail.controllerUserId === nextDetail.me
|
||||
}`;
|
||||
if (stageKeyRef.current !== stageKey) {
|
||||
stageKeyRef.current = stageKey;
|
||||
const { stage: resolved } = await getCollabStage(roomId);
|
||||
setStage(resolved);
|
||||
}
|
||||
} else if (!nextDetail.stage.shareId) {
|
||||
stageKeyRef.current = null;
|
||||
setStage(null);
|
||||
// The stage was cleared elsewhere; stop presenting locally too.
|
||||
if (draftRef.current) setDraft(null);
|
||||
}
|
||||
} catch {
|
||||
setEnded(true);
|
||||
}
|
||||
}, [roomId]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// Live room events, with slow polling as the fallback path.
|
||||
useEffect(() => {
|
||||
if (!roomId) return;
|
||||
let ws: WebSocket | null = null;
|
||||
let pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
try {
|
||||
ws = new WebSocket(roomEventsWsUrl());
|
||||
ws.onopen = () => {
|
||||
ws?.send(
|
||||
JSON.stringify({ type: "collab_subscribe", data: { roomId } }),
|
||||
);
|
||||
pingTimer = setInterval(() => {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "ping" }));
|
||||
}
|
||||
}, PING_INTERVAL_MS);
|
||||
};
|
||||
ws.onmessage = (event) => {
|
||||
if (cancelled) return;
|
||||
let msg: { type?: string; roomId?: string };
|
||||
try {
|
||||
msg = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (msg.roomId !== roomId) return;
|
||||
switch (msg.type) {
|
||||
case "collab_online":
|
||||
case "collab_members_changed":
|
||||
case "collab_stage_changed":
|
||||
case "collab_control_changed":
|
||||
void refresh();
|
||||
break;
|
||||
case "collab_control_requested": {
|
||||
const request = msg as unknown as {
|
||||
userId: string;
|
||||
username?: string;
|
||||
};
|
||||
handleControlRequestRef.current?.(
|
||||
request.userId,
|
||||
request.username ?? "?",
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "collab_room_ended":
|
||||
setEnded(true);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
} catch {
|
||||
/* polling still covers us */
|
||||
}
|
||||
|
||||
const pollTimer = setInterval(() => void refresh(), POLL_FALLBACK_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (pingTimer) clearInterval(pingTimer);
|
||||
clearInterval(pollTimer);
|
||||
ws?.close();
|
||||
};
|
||||
}, [roomId, refresh]);
|
||||
|
||||
const me = detail?.me;
|
||||
const isHost = detail?.isHost ?? false;
|
||||
const controllerUserId = detail?.controllerUserId ?? null;
|
||||
const presenterUserId = detail?.stage.presenterUserId ?? null;
|
||||
const iAmPresenter = !!me && presenterUserId === me;
|
||||
const onlineIds = new Set(detail?.online.map((user) => user.userId));
|
||||
const presenterName = detail?.members.find(
|
||||
(member) => member.userId === presenterUserId,
|
||||
)?.username;
|
||||
|
||||
const handleControlRequestRef = useRef<
|
||||
((userId: string, username: string) => void) | null
|
||||
>(null);
|
||||
handleControlRequestRef.current = (userId, username) => {
|
||||
if (!roomId) return;
|
||||
const mayGrant = isHost || iAmPresenter;
|
||||
if (!mayGrant || userId === me) return;
|
||||
toast(t("collab.controlRequestedBy", { name: username }), {
|
||||
action: {
|
||||
label: t("collab.grant"),
|
||||
onClick: () => void setCollabStageControl(roomId, userId),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
async function changeControl(targetId: string | null) {
|
||||
if (!roomId) return;
|
||||
try {
|
||||
await setCollabStageControl(roomId, targetId);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function openPresentDialog() {
|
||||
setPresentOpen(true);
|
||||
if (hosts.length === 0) {
|
||||
try {
|
||||
setHosts(await getSSHHosts({ includeStatus: false }));
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function choosePresent(
|
||||
host: SSHHostWithStatus,
|
||||
protocol: "ssh" | "rdp" | "vnc" | "telnet",
|
||||
) {
|
||||
if (!roomId) return;
|
||||
setPresentOpen(false);
|
||||
try {
|
||||
if (protocol === "ssh") {
|
||||
setDraft({ protocol, host });
|
||||
return;
|
||||
}
|
||||
const response = await getGuacamoleTokenFromHost(
|
||||
Number(host.id),
|
||||
protocol,
|
||||
);
|
||||
if (!response.guacamoleConnectionId) {
|
||||
toast.error(t("collab.stageLoading"));
|
||||
return;
|
||||
}
|
||||
setDraft({
|
||||
protocol,
|
||||
host,
|
||||
token: response.token,
|
||||
guacamoleConnectionId: response.guacamoleConnectionId,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function registerStage(
|
||||
protocol: string,
|
||||
sessionId: string,
|
||||
hostId: number,
|
||||
) {
|
||||
if (!roomId) return;
|
||||
try {
|
||||
await presentCollabStage(roomId, { protocol, sessionId, hostId });
|
||||
void refresh();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
setDraft(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
if (!roomId) return;
|
||||
try {
|
||||
await stopCollabStage(roomId);
|
||||
setDraft(null);
|
||||
void refresh();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEnd() {
|
||||
if (!roomId) return;
|
||||
try {
|
||||
await endCollabRoom(roomId);
|
||||
setDraft(null);
|
||||
if (!detail?.room.persistent) setEnded(true);
|
||||
void refresh();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function openInviteDialog() {
|
||||
setInviteOpen(true);
|
||||
setInviteSelection(new Set());
|
||||
setRoleSelection(new Set());
|
||||
try {
|
||||
const [userResult, roleResult] = await Promise.all([
|
||||
getUserList(),
|
||||
getRoles().catch(() => ({ roles: [] as Role[] })),
|
||||
]);
|
||||
setUsers(
|
||||
userResult.users.map((user) => ({
|
||||
id: user.userId,
|
||||
username: user.username,
|
||||
})),
|
||||
);
|
||||
setRoles(roleResult.roles);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGuestLink(enabled: boolean) {
|
||||
if (!roomId) return;
|
||||
try {
|
||||
await setCollabGuestLink(roomId, enabled);
|
||||
void refresh();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
function guestLinkUrl(token: string) {
|
||||
return `${window.location.origin}${window.location.pathname}?view=collab-guest&token=${token}`;
|
||||
}
|
||||
|
||||
async function handleInvite() {
|
||||
if (!roomId || (inviteSelection.size === 0 && roleSelection.size === 0))
|
||||
return;
|
||||
try {
|
||||
await inviteCollabMembers(roomId, {
|
||||
userIds: Array.from(inviteSelection),
|
||||
roleIds: Array.from(roleSelection),
|
||||
});
|
||||
toast.success(t("collab.invited"));
|
||||
setInviteOpen(false);
|
||||
void refresh();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
if (!roomId) {
|
||||
return (
|
||||
<div className="flex flex-1 h-full items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<Presentation className="size-8" />
|
||||
<p className="text-sm">{t("collab.reopenFromPanel")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (ended) {
|
||||
return (
|
||||
<div className="flex flex-1 h-full items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<AlertCircle className="size-8" />
|
||||
<p className="text-sm">{t("collab.roomEnded")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const memberIds = new Set(detail?.members.map((member) => member.userId));
|
||||
const invitableUsers = users.filter((user) => !memberIds.has(user.id));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
{/* 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" />
|
||||
<span className="text-sm font-semibold truncate">
|
||||
{detail?.room.name}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 flex-wrap flex-1 min-w-0">
|
||||
{detail?.members.map((member) => {
|
||||
const canToggleControl =
|
||||
(isHost || iAmPresenter) &&
|
||||
detail?.stage.protocol === "ssh" &&
|
||||
!!detail?.stage.shareId &&
|
||||
member.userId !== presenterUserId;
|
||||
const hasControl = member.userId === controllerUserId;
|
||||
const badge = (
|
||||
<Badge
|
||||
key={canToggleControl ? undefined : member.userId}
|
||||
variant={hasControl ? "default" : "outline"}
|
||||
className="text-[10px] gap-1"
|
||||
>
|
||||
<span
|
||||
className={`size-1.5 rounded-full ${onlineIds.has(member.userId) ? "bg-green-500" : "bg-muted-foreground/30"}`}
|
||||
/>
|
||||
{member.username}
|
||||
{member.roomRole === "host" && <Crown className="size-2.5" />}
|
||||
{member.userId === presenterUserId && (
|
||||
<MonitorUp className="size-2.5 text-red-500" />
|
||||
)}
|
||||
{hasControl && <MousePointerClick className="size-2.5" />}
|
||||
</Badge>
|
||||
);
|
||||
return canToggleControl ? (
|
||||
<button
|
||||
key={member.userId}
|
||||
type="button"
|
||||
title={t(
|
||||
hasControl ? "collab.revokeControl" : "collab.grantControl",
|
||||
)}
|
||||
onClick={() =>
|
||||
void changeControl(hasControl ? null : member.userId)
|
||||
}
|
||||
>
|
||||
{badge}
|
||||
</button>
|
||||
) : (
|
||||
badge
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{isHost && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => void openInviteDialog()}
|
||||
>
|
||||
<UserPlus className="size-3.5 mr-1" />
|
||||
{t("collab.invite")}
|
||||
</Button>
|
||||
)}
|
||||
{!!detail?.stage.shareId &&
|
||||
detail.stage.protocol === "ssh" &&
|
||||
!iAmPresenter &&
|
||||
!draft && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={controllerUserId === me ? "default" : "outline"}
|
||||
className="h-7 text-xs"
|
||||
onClick={() =>
|
||||
controllerUserId === me
|
||||
? void changeControl(null)
|
||||
: void requestCollabStageControl(roomId).catch((error) =>
|
||||
toast.error(getErrorMessage(error)),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Hand className="size-3.5 mr-1" />
|
||||
{controllerUserId === me
|
||||
? t("collab.releaseControl")
|
||||
: t("collab.requestControl")}
|
||||
</Button>
|
||||
)}
|
||||
{(iAmPresenter || draft || (isHost && presenterUserId)) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => void handleStop()}
|
||||
>
|
||||
<Square className="size-3.5 mr-1" />
|
||||
{t("collab.stopPresenting")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => void openPresentDialog()}
|
||||
>
|
||||
<MonitorUp className="size-3.5 mr-1" />
|
||||
{presenterUserId && !iAmPresenter
|
||||
? t("collab.takeOver")
|
||||
: t("collab.present")}
|
||||
</Button>
|
||||
{isHost && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => void handleEnd()}
|
||||
>
|
||||
{t("collab.endRoom")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isHost && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border text-[11px] text-muted-foreground">
|
||||
<Link2 className="size-3.5" />
|
||||
<span className="flex-1 truncate">
|
||||
{detail?.room.guestLinkToken
|
||||
? t("collab.guestLinkOn")
|
||||
: t("collab.guestLinkOff")}
|
||||
</span>
|
||||
{detail?.room.guestLinkToken && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 text-[10px]"
|
||||
onClick={() => {
|
||||
void navigator.clipboard
|
||||
.writeText(guestLinkUrl(detail.room.guestLinkToken!))
|
||||
.then(() => toast.success(t("collab.linkCopied")));
|
||||
}}
|
||||
>
|
||||
{t("collab.copyLink")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant={detail?.room.guestLinkToken ? "destructive" : "outline"}
|
||||
className="h-6 text-[10px]"
|
||||
onClick={() => void handleGuestLink(!detail?.room.guestLinkToken)}
|
||||
>
|
||||
{t("collab.guestLink")}:{" "}
|
||||
{detail?.room.guestLinkToken ? "ON" : "OFF"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stage */}
|
||||
<div className="relative flex-1 min-h-0">
|
||||
{draft ? (
|
||||
draft.protocol === "ssh" ? (
|
||||
<CommandHistoryProvider>
|
||||
<Terminal
|
||||
hostConfig={{
|
||||
...draft.host,
|
||||
id: Number(draft.host.id),
|
||||
ip: draft.host.ip,
|
||||
port: draft.host.port,
|
||||
username: draft.host.username,
|
||||
instanceId: `collab-present-${roomId}`,
|
||||
}}
|
||||
isVisible={isVisible}
|
||||
disableAutoFocus={false}
|
||||
onSessionReady={(sessionId) =>
|
||||
void registerStage("ssh", sessionId, Number(draft.host.id))
|
||||
}
|
||||
/>
|
||||
</CommandHistoryProvider>
|
||||
) : (
|
||||
<GuacamoleDisplay
|
||||
connectionConfig={{
|
||||
token: draft.token,
|
||||
protocol: draft.protocol,
|
||||
type: draft.protocol,
|
||||
}}
|
||||
isVisible={isVisible}
|
||||
onConnect={() =>
|
||||
void registerStage(
|
||||
draft.protocol,
|
||||
draft.guacamoleConnectionId,
|
||||
Number(draft.host.id),
|
||||
)
|
||||
}
|
||||
onError={(err) => {
|
||||
toast.error(err);
|
||||
setDraft(null);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
) : stage && stage.protocol && !iAmPresenter ? (
|
||||
<>
|
||||
{presenterName && (
|
||||
<div className="absolute top-2 left-2 z-20 rounded px-2 py-0.5 text-[10px] bg-background/80 border border-border">
|
||||
{t("collab.presenterLabel", { name: presenterName })}
|
||||
</div>
|
||||
)}
|
||||
{stage.protocol === "ssh" ? (
|
||||
<CommandHistoryProvider>
|
||||
<Terminal
|
||||
hostConfig={{
|
||||
id: stage.hostId ?? undefined,
|
||||
name: detail?.room.name ?? "stage",
|
||||
ip: "",
|
||||
port: 0,
|
||||
username: "",
|
||||
authType: "none",
|
||||
instanceId: `collab-view-${roomId}-${stage.shareId}`,
|
||||
joinShareId: stage.shareId,
|
||||
joinSharedSessionId: stage.sessionId ?? null,
|
||||
}}
|
||||
isVisible={isVisible}
|
||||
disableAutoFocus
|
||||
/>
|
||||
</CommandHistoryProvider>
|
||||
) : stage.connectParams?.token ? (
|
||||
<GuacamoleDisplay
|
||||
key={stage.connectParams.token}
|
||||
connectionConfig={{
|
||||
token: stage.connectParams.token,
|
||||
protocol: stage.protocol,
|
||||
type: stage.protocol,
|
||||
}}
|
||||
isVisible={isVisible}
|
||||
/>
|
||||
) : (
|
||||
<CenteredNote text={t("collab.stageLoading")} />
|
||||
)}
|
||||
</>
|
||||
) : iAmPresenter && !draft ? (
|
||||
<CenteredNote text={t("collab.youArePresenting")} />
|
||||
) : (
|
||||
<CenteredNote text={t("collab.emptyStage")} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Present dialog */}
|
||||
<Dialog open={presentOpen} onOpenChange={setPresentOpen}>
|
||||
<DialogContent className="max-h-[70vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("collab.presentTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-1">
|
||||
{hosts.length === 0 && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{hosts.map((host) => {
|
||||
const protocols: Array<"ssh" | "rdp" | "vnc" | "telnet"> = [];
|
||||
if (host.enableTerminal || host.enableSsh) protocols.push("ssh");
|
||||
if (host.enableRdp) protocols.push("rdp");
|
||||
if (host.enableVnc) protocols.push("vnc");
|
||||
if (host.enableTelnet) protocols.push("telnet");
|
||||
if (protocols.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
key={host.id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 border border-border"
|
||||
>
|
||||
<span className="flex-1 text-xs truncate">{host.name}</span>
|
||||
{protocols.map((protocol) => (
|
||||
<Button
|
||||
key={protocol}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 text-[10px] uppercase"
|
||||
onClick={() => void choosePresent(host, protocol)}
|
||||
>
|
||||
{protocol}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Invite dialog */}
|
||||
<Dialog open={inviteOpen} onOpenChange={setInviteOpen}>
|
||||
<DialogContent className="max-h-[70vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("collab.inviteTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-1">
|
||||
{roles.length > 0 && (
|
||||
<span className="text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("collab.roles")}
|
||||
</span>
|
||||
)}
|
||||
{roles.map((role) => (
|
||||
<label
|
||||
key={role.id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-xs border border-border cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={roleSelection.has(role.id)}
|
||||
onChange={(e) => {
|
||||
setRoleSelection((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (e.target.checked) next.add(role.id);
|
||||
else next.delete(role.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{role.displayName || role.name}
|
||||
</label>
|
||||
))}
|
||||
<span className="text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("collab.users")}
|
||||
</span>
|
||||
{invitableUsers.map((user) => (
|
||||
<label
|
||||
key={user.id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-xs border border-border cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteSelection.has(user.id)}
|
||||
onChange={(e) => {
|
||||
setInviteSelection((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (e.target.checked) next.add(user.id);
|
||||
else next.delete(user.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{user.username}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setInviteOpen(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleInvite()}
|
||||
disabled={inviteSelection.size === 0 && roleSelection.size === 0}
|
||||
>
|
||||
{t("collab.invite")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CenteredNote({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -118,12 +118,12 @@ function CenteredMessage({
|
||||
);
|
||||
}
|
||||
|
||||
function GuestTerminalView({
|
||||
export function GuestTerminalView({
|
||||
share,
|
||||
linkToken,
|
||||
wsQuery,
|
||||
}: {
|
||||
share: ResolvedShareLink;
|
||||
linkToken: string;
|
||||
share: Pick<ResolvedShareLink, "permissionLevel">;
|
||||
wsQuery: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { instance: terminal, ref: xtermRef } = useXTerm();
|
||||
@@ -153,9 +153,7 @@ function GuestTerminalView({
|
||||
resolveTerminalWsBaseUrl().then((baseWsUrl) => {
|
||||
if (cancelled) return;
|
||||
const separator = baseWsUrl.includes("?") ? "&" : "?";
|
||||
ws = new WebSocket(
|
||||
`${baseWsUrl}${separator}shareToken=${encodeURIComponent(linkToken)}`,
|
||||
);
|
||||
ws = new WebSocket(`${baseWsUrl}${separator}${wsQuery}`);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
@@ -215,7 +213,7 @@ function GuestTerminalView({
|
||||
};
|
||||
// Deliberately runs once terminal mounts - share/token/permission are stable for the view's lifetime.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [terminal, linkToken]);
|
||||
}, [terminal, wsQuery]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
@@ -357,7 +355,10 @@ export default function SharedSessionView() {
|
||||
share &&
|
||||
linkToken &&
|
||||
(share.protocol === "ssh" ? (
|
||||
<GuestTerminalView share={share} linkToken={linkToken} />
|
||||
<GuestTerminalView
|
||||
share={share}
|
||||
wsQuery={`shareToken=${encodeURIComponent(linkToken)}`}
|
||||
/>
|
||||
) : (
|
||||
<GuestGuacamoleView share={share} />
|
||||
))}
|
||||
|
||||
@@ -131,6 +131,8 @@ interface SSHTerminalProps {
|
||||
onOpenTab?: (type: TabType) => void;
|
||||
/** False when this terminal sits in an unfocused split pane. */
|
||||
isFocusedPane?: boolean;
|
||||
/** Fires when the backend reports the created session id (collab presenting). */
|
||||
onSessionReady?: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
const ALTERNATE_SCREEN_SEQUENCE = /\x1b\[\?(47|1047|1049)([hl])/g;
|
||||
@@ -154,6 +156,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
{
|
||||
hostConfig,
|
||||
isVisible,
|
||||
onSessionReady,
|
||||
splitScreen = false,
|
||||
onClose,
|
||||
onTitleChange,
|
||||
@@ -1936,6 +1939,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
} else if (msg.type === "sessionCreated") {
|
||||
sessionIdRef.current = msg.sessionId;
|
||||
onSessionReady?.(msg.sessionId);
|
||||
if (hostConfig.instanceId) {
|
||||
import("@/main-axios").then(({ patchOpenTab }) => {
|
||||
patchOpenTab(hostConfig.instanceId!, {
|
||||
|
||||
Reference in New Issue
Block a user