mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: move session sharing and collab rooms into a plugin (B12)
This commit is contained in:
1 parent
791d8656a1
commit
a276f6898c
207 files changed
+35771
-11072
No files matched your search
@@ -1,240 +0,0 @@
|
||||
import axios from "axios";
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
import { resolveApiBaseUrl } from "@/api/session-sharing-api";
|
||||
|
||||
export interface CollabRoom {
|
||||
id: string;
|
||||
name: string;
|
||||
ownerUserId: string;
|
||||
persistent: boolean;
|
||||
presenterUserId: string | null;
|
||||
stageProtocol: string | null;
|
||||
stageHostId: number | null;
|
||||
stageShareId: string | null;
|
||||
guestLinkEnabled: boolean;
|
||||
createdAt: string;
|
||||
endedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CollabRoomMember {
|
||||
userId: string;
|
||||
username: string;
|
||||
roomRole: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CollabOnlineUser {
|
||||
userId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface CollabStage {
|
||||
presenterUserId: string | null;
|
||||
protocol: "ssh" | "rdp" | "vnc" | "telnet" | null;
|
||||
hostId: number | null;
|
||||
shareId: string | null;
|
||||
sessionId?: string;
|
||||
controllerUserId?: string | null;
|
||||
connectParams?: { token: string };
|
||||
}
|
||||
|
||||
export interface CollabRoomDetail {
|
||||
room: CollabRoom;
|
||||
me: string;
|
||||
isHost: boolean;
|
||||
members: CollabRoomMember[];
|
||||
online: CollabOnlineUser[];
|
||||
stage: CollabStage;
|
||||
controllerUserId: string | null;
|
||||
controlRequests: CollabControlRequest[];
|
||||
/** Where live room events are served, as /plugin-ws/<id>/<path>. */
|
||||
eventsWsPath: string;
|
||||
}
|
||||
|
||||
export interface CollabControlRequest {
|
||||
userId: string;
|
||||
username: string;
|
||||
requestedAt: string;
|
||||
}
|
||||
|
||||
export async function listCollabRooms(): Promise<{ rooms: CollabRoom[] }> {
|
||||
try {
|
||||
const response = await authApi.get("/collab/rooms");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "list collab rooms");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCollabRoom(
|
||||
name: string,
|
||||
persistent: boolean,
|
||||
): Promise<{ room: CollabRoom }> {
|
||||
try {
|
||||
const response = await authApi.post("/collab/rooms", { name, persistent });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create collab room");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCollabRoom(roomId: string): Promise<CollabRoomDetail> {
|
||||
try {
|
||||
const response = await authApi.get(`/collab/rooms/${roomId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get collab room");
|
||||
}
|
||||
}
|
||||
|
||||
export async function inviteCollabMembers(
|
||||
roomId: string,
|
||||
targets: { userIds?: string[]; roleIds?: number[] },
|
||||
): Promise<void> {
|
||||
try {
|
||||
await authApi.post(`/collab/rooms/${roomId}/members`, targets);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "invite collab members");
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeCollabMember(
|
||||
roomId: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await authApi.delete(`/collab/rooms/${roomId}/members/${userId}`);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "remove collab member");
|
||||
}
|
||||
}
|
||||
|
||||
export async function presentCollabStage(
|
||||
roomId: string,
|
||||
input: { protocol: string; sessionId: string; hostId: number },
|
||||
): Promise<{ stage: CollabStage }> {
|
||||
try {
|
||||
const response = await authApi.post(
|
||||
`/collab/rooms/${roomId}/present`,
|
||||
input,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "start presenting");
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopCollabStage(roomId: string): Promise<void> {
|
||||
try {
|
||||
await authApi.post(`/collab/rooms/${roomId}/stop`);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "stop presenting");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCollabStage(
|
||||
roomId: string,
|
||||
): Promise<{ stage: CollabStage | null }> {
|
||||
try {
|
||||
const response = await authApi.get(`/collab/rooms/${roomId}/stage`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "resolve collab stage");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setCollabStageControl(
|
||||
roomId: string,
|
||||
userId: string | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await authApi.post(`/collab/rooms/${roomId}/control`, { userId });
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "change stage control");
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestCollabStageControl(
|
||||
roomId: string,
|
||||
): Promise<{ request: CollabControlRequest }> {
|
||||
try {
|
||||
const response = await authApi.post(
|
||||
`/collab/rooms/${roomId}/control/request`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "request stage control");
|
||||
}
|
||||
}
|
||||
|
||||
export async function listCollabControlRequests(
|
||||
roomId: string,
|
||||
): Promise<{ requests: CollabControlRequest[] }> {
|
||||
try {
|
||||
const response = await authApi.get(
|
||||
`/collab/rooms/${roomId}/control/requests`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "list control requests");
|
||||
}
|
||||
}
|
||||
|
||||
export async function dismissCollabControlRequest(
|
||||
roomId: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await authApi.delete(`/collab/rooms/${roomId}/control/requests/${userId}`);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "dismiss control request");
|
||||
}
|
||||
}
|
||||
|
||||
export async function endCollabRoom(roomId: string): Promise<void> {
|
||||
try {
|
||||
await authApi.post(`/collab/rooms/${roomId}/end`);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "end collab room");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCollabRoom(roomId: string): Promise<void> {
|
||||
try {
|
||||
await authApi.delete(`/collab/rooms/${roomId}`);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete collab room");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setCollabGuestLink(
|
||||
roomId: string,
|
||||
enabled: boolean,
|
||||
): Promise<{ guestLinkToken: string | null }> {
|
||||
try {
|
||||
const response = await authApi.post(`/collab/rooms/${roomId}/guest-link`, {
|
||||
enabled,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update guest link");
|
||||
}
|
||||
}
|
||||
|
||||
export interface CollabGuestStage {
|
||||
protocol: "ssh" | "rdp" | "vnc" | "telnet";
|
||||
shareId: string;
|
||||
wsPath?: string;
|
||||
connectParams?: { token: string };
|
||||
}
|
||||
|
||||
/** Anonymous: guests poll this to follow the presenter. Throws on 404/429. */
|
||||
export async function resolveCollabGuestStage(
|
||||
token: string,
|
||||
): Promise<{ roomName: string; stage: CollabGuestStage | null }> {
|
||||
const baseUrl = await resolveApiBaseUrl();
|
||||
const response = await axios.get(
|
||||
`${baseUrl}/collab/guest/${encodeURIComponent(token)}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
@@ -2,6 +2,9 @@ import { authApi } from "@/main-axios";
|
||||
import { createTtlRequestCache } from "@/lib/ttl-request-cache";
|
||||
import type { TerminalTheme } from "@/lib/terminal-themes";
|
||||
import type { CustomKeybinding } from "@/types/keybindings";
|
||||
import { invokeAction, isActionRegistered } from "@/shell/action-registry";
|
||||
|
||||
const SHARED_WITH_ME_ACTION = "sessions.sharedWithMe";
|
||||
|
||||
// OPEN TABS API
|
||||
// ============================================================================
|
||||
@@ -84,10 +87,21 @@ export async function addOpenTab(tab: OpenTabUpsertPayload): Promise<void> {
|
||||
await authApi.post("/open-tabs", tab);
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller's live sessions, plus sessions other users shared with them,
|
||||
* which the session sharing plugin answers through an action (none while it
|
||||
* is off).
|
||||
*/
|
||||
export async function getActiveSessions(): Promise<ActiveSessionInfo[]> {
|
||||
return activeSessionsCache.get(async () => {
|
||||
const response = await authApi.get("/open-tabs/active-sessions");
|
||||
return Array.isArray(response.data) ? response.data : [];
|
||||
const [response, shared] = await Promise.all([
|
||||
authApi.get("/open-tabs/active-sessions"),
|
||||
isActionRegistered(SHARED_WITH_ME_ACTION)
|
||||
? invokeAction(SHARED_WITH_ME_ACTION).catch(() => [])
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const own = Array.isArray(response.data) ? response.data : [];
|
||||
return Array.isArray(shared) ? [...own, ...shared] : own;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
import axios from "axios";
|
||||
import { getBasePath } from "@/lib/base-path";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
export interface ResolvedShareLink {
|
||||
protocol: "ssh" | "rdp" | "vnc" | "telnet";
|
||||
permissionLevel: "read-only" | "read-write";
|
||||
wsPath: string;
|
||||
connectParams?: { token: string };
|
||||
}
|
||||
|
||||
export type ShareLinkErrorKind = "not-found" | "rate-limited" | "unknown";
|
||||
|
||||
export class ShareLinkError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly kind: ShareLinkErrorKind,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ShareLinkError";
|
||||
}
|
||||
}
|
||||
|
||||
const isDev = (): boolean =>
|
||||
!isElectron() &&
|
||||
process.env.NODE_ENV === "development" &&
|
||||
(window.location.port === "3000" ||
|
||||
window.location.port === "5173" ||
|
||||
window.location.port === "");
|
||||
|
||||
// Guests have no session/JWT, so this deliberately builds a bare base URL
|
||||
// rather than going through main-axios's authenticated instances. The
|
||||
// desktop app always runs its embedded local backend as the source of
|
||||
// truth, so a share link opened there always resolves against it --
|
||||
// joining a session hosted on someone else's remote server isn't
|
||||
// supported from the desktop app today.
|
||||
export async function resolveApiBaseUrl(): Promise<string> {
|
||||
if (isDev()) {
|
||||
const protocol = window.location.protocol === "https:" ? "https" : "http";
|
||||
return `${protocol}://localhost:30001`;
|
||||
}
|
||||
if (isElectron()) {
|
||||
return "http://127.0.0.1:30001";
|
||||
}
|
||||
return getBasePath();
|
||||
}
|
||||
|
||||
export async function resolveShareLink(
|
||||
linkToken: string,
|
||||
): Promise<ResolvedShareLink> {
|
||||
const baseUrl = await resolveApiBaseUrl();
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${baseUrl}/session-sharing/resolve/${encodeURIComponent(linkToken)}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (error.response?.status === 404) {
|
||||
throw new ShareLinkError(
|
||||
"Share link is invalid, expired, or revoked",
|
||||
"not-found",
|
||||
);
|
||||
}
|
||||
if (error.response?.status === 429) {
|
||||
throw new ShareLinkError(
|
||||
"Too many attempts, please try again shortly",
|
||||
"rate-limited",
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new ShareLinkError("Failed to resolve share link", "unknown");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SESSION SHARING (authenticated owner-side API)
|
||||
// ============================================================================
|
||||
|
||||
export type SessionShareProtocol = "ssh" | "rdp" | "vnc" | "telnet";
|
||||
export type SessionShareType = "link" | "user";
|
||||
export type SessionSharePermissionLevel = "read-only" | "read-write";
|
||||
|
||||
export interface SessionShareRecord {
|
||||
id: string;
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
protocol: SessionShareProtocol;
|
||||
sessionId: string;
|
||||
tabInstanceId: string | null;
|
||||
shareType: SessionShareType;
|
||||
targetUserId: string | null;
|
||||
linkToken: string | null;
|
||||
permissionLevel: SessionSharePermissionLevel;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
revokedAt: string | null;
|
||||
lastJoinedAt: string | null;
|
||||
joinCount: number;
|
||||
}
|
||||
|
||||
export interface CreateSessionShareRequest {
|
||||
hostId: number;
|
||||
sessionId: string;
|
||||
tabInstanceId?: string;
|
||||
protocol: SessionShareProtocol;
|
||||
shareType: SessionShareType;
|
||||
targetUserId?: string;
|
||||
permissionLevel: SessionSharePermissionLevel;
|
||||
expiryHours?: number;
|
||||
}
|
||||
|
||||
export interface CreateSessionShareResponse {
|
||||
shareId: string;
|
||||
linkToken: string | null;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export async function createSessionShare(
|
||||
request: CreateSessionShareRequest,
|
||||
): Promise<CreateSessionShareResponse> {
|
||||
try {
|
||||
const response = await authApi.post("/session-sharing/create", request);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create session share");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getActiveSessionShares(
|
||||
hostId: number,
|
||||
): Promise<{ shares: SessionShareRecord[] }> {
|
||||
try {
|
||||
const response = await authApi.get(
|
||||
`/session-sharing/host/${hostId}/active`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch active session shares");
|
||||
}
|
||||
}
|
||||
|
||||
export async function revokeSessionShare(
|
||||
shareId: string,
|
||||
): Promise<{ success: true }> {
|
||||
try {
|
||||
const response = await authApi.delete(`/session-sharing/${shareId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "revoke session share");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GLOBAL ADMIN TOGGLE
|
||||
// ============================================================================
|
||||
|
||||
export async function getSessionSharingGloballyEnabled(): Promise<{
|
||||
enabled: boolean;
|
||||
}> {
|
||||
try {
|
||||
const response = await authApi.get("/users/session-sharing-enabled");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch session sharing enabled setting");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSessionSharingGloballyEnabled(
|
||||
enabled: boolean,
|
||||
): Promise<{ enabled: boolean }> {
|
||||
try {
|
||||
const response = await authApi.patch("/users/session-sharing-enabled", {
|
||||
enabled,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update session sharing enabled setting");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user