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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user