mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21: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,34 @@
|
||||
CREATE TABLE `collab_room_members` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`room_id` varchar(255) NOT NULL,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`room_role` text NOT NULL DEFAULT ('member'),
|
||||
`added_by` varchar(255),
|
||||
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
CONSTRAINT `collab_room_members_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `idx_collab_room_members_room_user` UNIQUE(`room_id`,`user_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `collab_rooms` (
|
||||
`id` varchar(255) NOT NULL,
|
||||
`name` varchar(255) NOT NULL,
|
||||
`owner_user_id` varchar(255) NOT NULL,
|
||||
`persistent` boolean NOT NULL DEFAULT false,
|
||||
`presenter_user_id` varchar(255),
|
||||
`stage_protocol` text,
|
||||
`stage_host_id` int,
|
||||
`stage_share_id` varchar(255),
|
||||
`created_at` varchar(255) NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`ended_at` text,
|
||||
CONSTRAINT `collab_rooms_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `collab_room_members` ADD CONSTRAINT `collab_room_members_room_id_collab_rooms_id_fk` FOREIGN KEY (`room_id`) REFERENCES `collab_rooms`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `collab_room_members` ADD CONSTRAINT `collab_room_members_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `collab_room_members` ADD CONSTRAINT `collab_room_members_added_by_users_id_fk` FOREIGN KEY (`added_by`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `collab_rooms` ADD CONSTRAINT `collab_rooms_owner_user_id_users_id_fk` FOREIGN KEY (`owner_user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `collab_rooms` ADD CONSTRAINT `collab_rooms_presenter_user_id_users_id_fk` FOREIGN KEY (`presenter_user_id`) REFERENCES `users`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `collab_rooms` ADD CONSTRAINT `collab_rooms_stage_host_id_ssh_data_id_fk` FOREIGN KEY (`stage_host_id`) REFERENCES `ssh_data`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `collab_rooms` ADD CONSTRAINT `collab_rooms_stage_share_id_session_shares_id_fk` FOREIGN KEY (`stage_share_id`) REFERENCES `session_shares`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX `idx_collab_room_members_user` ON `collab_room_members` (`user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_collab_rooms_owner` ON `collab_rooms` (`owner_user_id`);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `collab_rooms` ADD `guest_link_token` varchar(255);--> statement-breakpoint
|
||||
ALTER TABLE `collab_rooms` ADD CONSTRAINT `idx_collab_rooms_guest_token` UNIQUE(`guest_link_token`);
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,20 @@
|
||||
"when": 1786757023790,
|
||||
"tag": "0014_bitter_nextwave",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "5",
|
||||
"when": 1787576997080,
|
||||
"tag": "0015_red_cobalt_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "5",
|
||||
"when": 1787581983770,
|
||||
"tag": "0016_unusual_tyrannus",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
CREATE TABLE "collab_room_members" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"room_id" varchar(255) NOT NULL,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"room_role" text DEFAULT 'member' NOT NULL,
|
||||
"added_by" varchar(255),
|
||||
"created_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "collab_rooms" (
|
||||
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"owner_user_id" varchar(255) NOT NULL,
|
||||
"persistent" boolean DEFAULT false NOT NULL,
|
||||
"presenter_user_id" varchar(255),
|
||||
"stage_protocol" text,
|
||||
"stage_host_id" integer,
|
||||
"stage_share_id" varchar(255),
|
||||
"created_at" varchar(255) DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"ended_at" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "collab_room_members" ADD CONSTRAINT "collab_room_members_room_id_collab_rooms_id_fk" FOREIGN KEY ("room_id") REFERENCES "public"."collab_rooms"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "collab_room_members" ADD CONSTRAINT "collab_room_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "collab_room_members" ADD CONSTRAINT "collab_room_members_added_by_users_id_fk" FOREIGN KEY ("added_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "collab_rooms" ADD CONSTRAINT "collab_rooms_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "collab_rooms" ADD CONSTRAINT "collab_rooms_presenter_user_id_users_id_fk" FOREIGN KEY ("presenter_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "collab_rooms" ADD CONSTRAINT "collab_rooms_stage_host_id_ssh_data_id_fk" FOREIGN KEY ("stage_host_id") REFERENCES "public"."ssh_data"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "collab_rooms" ADD CONSTRAINT "collab_rooms_stage_share_id_session_shares_id_fk" FOREIGN KEY ("stage_share_id") REFERENCES "public"."session_shares"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_collab_room_members_room_user" ON "collab_room_members" USING btree ("room_id","user_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_collab_room_members_user" ON "collab_room_members" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_collab_rooms_owner" ON "collab_rooms" USING btree ("owner_user_id");
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "collab_rooms" ADD COLUMN "guest_link_token" varchar(255);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_collab_rooms_guest_token" ON "collab_rooms" USING btree ("guest_link_token");
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,20 @@
|
||||
"when": 1786757021444,
|
||||
"tag": "0014_unusual_maelstrom",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1787576994553,
|
||||
"tag": "0015_early_spitfire",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1787581981251,
|
||||
"tag": "0016_slippery_anita_blake",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
CREATE TABLE `collab_room_members` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`room_id` text NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`room_role` text DEFAULT 'member' NOT NULL,
|
||||
`added_by` text,
|
||||
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY (`room_id`) REFERENCES `collab_rooms`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`added_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `idx_collab_room_members_room_user` ON `collab_room_members` (`room_id`,`user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_collab_room_members_user` ON `collab_room_members` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `collab_rooms` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`owner_user_id` text NOT NULL,
|
||||
`persistent` integer DEFAULT false NOT NULL,
|
||||
`presenter_user_id` text,
|
||||
`stage_protocol` text,
|
||||
`stage_host_id` integer,
|
||||
`stage_share_id` text,
|
||||
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
`ended_at` text,
|
||||
FOREIGN KEY (`owner_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`presenter_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
FOREIGN KEY (`stage_host_id`) REFERENCES `ssh_data`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
FOREIGN KEY (`stage_share_id`) REFERENCES `session_shares`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_collab_rooms_owner` ON `collab_rooms` (`owner_user_id`);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `collab_rooms` ADD `guest_link_token` text;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `idx_collab_rooms_guest_token` ON `collab_rooms` (`guest_link_token`);
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,20 @@
|
||||
"when": 1786757019248,
|
||||
"tag": "0010_mean_queen_noir",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "6",
|
||||
"when": 1787576992255,
|
||||
"tag": "0011_wakeful_titanium_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "6",
|
||||
"when": 1787581978969,
|
||||
"tag": "0012_yellow_firedrake",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import terminalRoutes from "./routes/terminal.js";
|
||||
import sessionLogRoutes from "./routes/session-log-routes.js";
|
||||
import guacamoleRoutes from "../hosts/guacamole/routes.js";
|
||||
import sessionSharingRoutes from "../hosts/session-sharing/routes.js";
|
||||
import collabRoutes from "../hosts/collab/routes.js";
|
||||
import networkTopologyRoutes from "./routes/network-topology.js";
|
||||
import rbacRoutes from "./routes/rbac.js";
|
||||
import openTabsRoutes from "./routes/open-tabs.js";
|
||||
@@ -1752,6 +1753,7 @@ app.use("/terminal", terminalRoutes);
|
||||
app.use("/session_logs", sessionLogRoutes);
|
||||
app.use("/guacamole", guacamoleRoutes);
|
||||
app.use("/session-sharing", sessionSharingRoutes);
|
||||
app.use("/collab", collabRoutes);
|
||||
app.use("/network-topology", networkTopologyRoutes);
|
||||
app.use("/rbac", rbacRoutes);
|
||||
app.use("/open-tabs", openTabsRoutes);
|
||||
|
||||
@@ -578,6 +578,37 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collab_rooms (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
owner_user_id TEXT NOT NULL,
|
||||
persistent INTEGER NOT NULL DEFAULT 0,
|
||||
presenter_user_id TEXT,
|
||||
stage_protocol TEXT,
|
||||
stage_host_id INTEGER,
|
||||
stage_share_id TEXT,
|
||||
guest_link_token TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ended_at TEXT,
|
||||
FOREIGN KEY (owner_user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (presenter_user_id) REFERENCES users (id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (stage_host_id) REFERENCES ssh_data (id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (stage_share_id) REFERENCES session_shares (id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collab_room_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
room_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
room_role TEXT NOT NULL DEFAULT 'member',
|
||||
added_by TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (room_id, user_id),
|
||||
FOREIGN KEY (room_id) REFERENCES collab_rooms (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (added_by) REFERENCES users (id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -2040,6 +2071,10 @@ const migrateSchema = () => {
|
||||
}
|
||||
|
||||
addColumnIfNotExists("users", "sso_provider_id", "INTEGER");
|
||||
addColumnIfNotExists("collab_rooms", "guest_link_token", "TEXT");
|
||||
sqlite.exec(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_collab_rooms_guest_token ON collab_rooms (guest_link_token)",
|
||||
);
|
||||
|
||||
try {
|
||||
const usersTableInfo = sqlite.prepare("PRAGMA table_info(users)").all() as Array<{
|
||||
|
||||
@@ -1939,3 +1939,80 @@ export const aiProposals = mysqlTable(
|
||||
],
|
||||
);
|
||||
// --- ai end ---
|
||||
|
||||
// --- collab rooms ---
|
||||
|
||||
/**
|
||||
* A collaboration room: a group of users watching one "stage" - the live
|
||||
* session the current presenter is showing. The stage points at a
|
||||
* shareType="room" row in session_shares, so transport, gating, recording and
|
||||
* expiry all reuse the session-sharing machinery.
|
||||
*/
|
||||
export const collabRooms = mysqlTable(
|
||||
"collab_rooms",
|
||||
{
|
||||
id: varchar("id", { length: 255 }).primaryKey(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
ownerUserId: varchar("owner_user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
// Persistent rooms survive being emptied and can be re-used; one-off
|
||||
// rooms are ended explicitly and never listed again.
|
||||
persistent: boolean("persistent")
|
||||
.notNull()
|
||||
.default(false),
|
||||
|
||||
presenterUserId: varchar("presenter_user_id", { length: 255 }).references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
stageProtocol: text("stage_protocol"),
|
||||
stageHostId: int("stage_host_id").references(() => hosts.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
stageShareId: varchar("stage_share_id", { length: 255 }).references(() => sessionShares.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
|
||||
// Set = anonymous guests may watch the stage through this token.
|
||||
guestLinkToken: varchar("guest_link_token", { length: 255 }),
|
||||
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
endedAt: text("ended_at"),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_collab_rooms_owner").on(table.ownerUserId),
|
||||
uniqueIndex("idx_collab_rooms_guest_token").on(table.guestLinkToken),
|
||||
],
|
||||
);
|
||||
|
||||
export const collabRoomMembers = mysqlTable(
|
||||
"collab_room_members",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
roomId: varchar("room_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => collabRooms.id, { onDelete: "cascade" }),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
// "host" runs the room: invites, force-switches the presenter, ends it.
|
||||
roomRole: text("room_role").notNull().default("member"),
|
||||
addedBy: varchar("added_by", { length: 255 }).references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_collab_room_members_room_user").on(
|
||||
table.roomId,
|
||||
table.userId,
|
||||
),
|
||||
index("idx_collab_room_members_user").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1940,3 +1940,80 @@ export const aiProposals = pgTable(
|
||||
],
|
||||
);
|
||||
// --- ai end ---
|
||||
|
||||
// --- collab rooms ---
|
||||
|
||||
/**
|
||||
* A collaboration room: a group of users watching one "stage" - the live
|
||||
* session the current presenter is showing. The stage points at a
|
||||
* shareType="room" row in session_shares, so transport, gating, recording and
|
||||
* expiry all reuse the session-sharing machinery.
|
||||
*/
|
||||
export const collabRooms = pgTable(
|
||||
"collab_rooms",
|
||||
{
|
||||
id: varchar("id", { length: 255 }).primaryKey(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
ownerUserId: varchar("owner_user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
// Persistent rooms survive being emptied and can be re-used; one-off
|
||||
// rooms are ended explicitly and never listed again.
|
||||
persistent: boolean("persistent")
|
||||
.notNull()
|
||||
.default(false),
|
||||
|
||||
presenterUserId: varchar("presenter_user_id", { length: 255 }).references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
stageProtocol: text("stage_protocol"),
|
||||
stageHostId: integer("stage_host_id").references(() => hosts.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
stageShareId: varchar("stage_share_id", { length: 255 }).references(() => sessionShares.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
|
||||
// Set = anonymous guests may watch the stage through this token.
|
||||
guestLinkToken: varchar("guest_link_token", { length: 255 }),
|
||||
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
endedAt: text("ended_at"),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_collab_rooms_owner").on(table.ownerUserId),
|
||||
uniqueIndex("idx_collab_rooms_guest_token").on(table.guestLinkToken),
|
||||
],
|
||||
);
|
||||
|
||||
export const collabRoomMembers = pgTable(
|
||||
"collab_room_members",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
roomId: varchar("room_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => collabRooms.id, { onDelete: "cascade" }),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
// "host" runs the room: invites, force-switches the presenter, ends it.
|
||||
roomRole: text("room_role").notNull().default("member"),
|
||||
addedBy: varchar("added_by", { length: 255 }).references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: varchar("created_at", { length: 255 })
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_collab_room_members_room_user").on(
|
||||
table.roomId,
|
||||
table.userId,
|
||||
),
|
||||
index("idx_collab_room_members_user").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1936,3 +1936,80 @@ export const aiProposals = sqliteTable(
|
||||
],
|
||||
);
|
||||
// --- ai end ---
|
||||
|
||||
// --- collab rooms ---
|
||||
|
||||
/**
|
||||
* A collaboration room: a group of users watching one "stage" - the live
|
||||
* session the current presenter is showing. The stage points at a
|
||||
* shareType="room" row in session_shares, so transport, gating, recording and
|
||||
* expiry all reuse the session-sharing machinery.
|
||||
*/
|
||||
export const collabRooms = sqliteTable(
|
||||
"collab_rooms",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
ownerUserId: text("owner_user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
// Persistent rooms survive being emptied and can be re-used; one-off
|
||||
// rooms are ended explicitly and never listed again.
|
||||
persistent: integer("persistent", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
|
||||
presenterUserId: text("presenter_user_id").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
stageProtocol: text("stage_protocol"),
|
||||
stageHostId: integer("stage_host_id").references(() => hosts.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
stageShareId: text("stage_share_id").references(() => sessionShares.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
|
||||
// Set = anonymous guests may watch the stage through this token.
|
||||
guestLinkToken: text("guest_link_token"),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
endedAt: text("ended_at"),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_collab_rooms_owner").on(table.ownerUserId),
|
||||
uniqueIndex("idx_collab_rooms_guest_token").on(table.guestLinkToken),
|
||||
],
|
||||
);
|
||||
|
||||
export const collabRoomMembers = sqliteTable(
|
||||
"collab_room_members",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
roomId: text("room_id")
|
||||
.notNull()
|
||||
.references(() => collabRooms.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
// "host" runs the room: invites, force-switches the presenter, ends it.
|
||||
roomRole: text("room_role").notNull().default("member"),
|
||||
addedBy: text("added_by").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_collab_room_members_room_user").on(
|
||||
table.roomId,
|
||||
table.userId,
|
||||
),
|
||||
index("idx_collab_room_members_user").on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
import { collabRoomMembers, collabRooms, users } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { insertReturning } from "./returning.js";
|
||||
|
||||
export type CollabRoomRecord = typeof collabRooms.$inferSelect;
|
||||
export type CollabRoomMemberRecord = typeof collabRoomMembers.$inferSelect;
|
||||
|
||||
export type CollabRoomRole = "host" | "member";
|
||||
|
||||
export interface CollabRoomMemberWithUser {
|
||||
userId: string;
|
||||
username: string;
|
||||
roomRole: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CollabRoomStage {
|
||||
presenterUserId: string | null;
|
||||
stageProtocol: string | null;
|
||||
stageHostId: number | null;
|
||||
stageShareId: string | null;
|
||||
}
|
||||
|
||||
export class CollabRoomRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async createRoom(input: {
|
||||
id: string;
|
||||
name: string;
|
||||
ownerUserId: string;
|
||||
persistent: boolean;
|
||||
}): Promise<CollabRoomRecord> {
|
||||
const [created] = await insertReturning(this.context, collabRooms, {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
ownerUserId: input.ownerUserId,
|
||||
persistent: input.persistent,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return created;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<CollabRoomRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(collabRooms)
|
||||
.where(eq(collabRooms.id, id))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/** The live room whose stage points at this share, if any. */
|
||||
async findByStageShareId(shareId: string): Promise<CollabRoomRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(collabRooms)
|
||||
.where(
|
||||
and(eq(collabRooms.stageShareId, shareId), isNull(collabRooms.endedAt)),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findByGuestToken(token: string): Promise<CollabRoomRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(collabRooms)
|
||||
.where(
|
||||
and(eq(collabRooms.guestLinkToken, token), isNull(collabRooms.endedAt)),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async setGuestToken(roomId: string, token: string | null): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(collabRooms)
|
||||
.set({ guestLinkToken: token })
|
||||
.where(eq(collabRooms.id, roomId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async listForUser(userId: string): Promise<CollabRoomRecord[]> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ room: collabRooms })
|
||||
.from(collabRoomMembers)
|
||||
.innerJoin(collabRooms, eq(collabRoomMembers.roomId, collabRooms.id))
|
||||
.where(
|
||||
and(eq(collabRoomMembers.userId, userId), isNull(collabRooms.endedAt)),
|
||||
)
|
||||
.orderBy(desc(collabRooms.createdAt));
|
||||
return rows.map((row) => row.room);
|
||||
}
|
||||
|
||||
async findMember(
|
||||
roomId: string,
|
||||
userId: string,
|
||||
): Promise<CollabRoomMemberRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(collabRoomMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(collabRoomMembers.roomId, roomId),
|
||||
eq(collabRoomMembers.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async addMember(input: {
|
||||
roomId: string;
|
||||
userId: string;
|
||||
roomRole: CollabRoomRole;
|
||||
addedBy: string | null;
|
||||
}): Promise<boolean> {
|
||||
if (await this.findMember(input.roomId, input.userId)) return false;
|
||||
await this.context.drizzle.insert(collabRoomMembers).values({
|
||||
roomId: input.roomId,
|
||||
userId: input.userId,
|
||||
roomRole: input.roomRole,
|
||||
addedBy: input.addedBy,
|
||||
});
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async removeMember(roomId: string, userId: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.delete(collabRoomMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(collabRoomMembers.roomId, roomId),
|
||||
eq(collabRoomMembers.userId, userId),
|
||||
),
|
||||
);
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async listMembers(roomId: string): Promise<CollabRoomMemberWithUser[]> {
|
||||
return this.context.drizzle
|
||||
.select({
|
||||
userId: collabRoomMembers.userId,
|
||||
username: users.username,
|
||||
roomRole: collabRoomMembers.roomRole,
|
||||
createdAt: collabRoomMembers.createdAt,
|
||||
})
|
||||
.from(collabRoomMembers)
|
||||
.innerJoin(users, eq(collabRoomMembers.userId, users.id))
|
||||
.where(eq(collabRoomMembers.roomId, roomId))
|
||||
.orderBy(users.username);
|
||||
}
|
||||
|
||||
async updateStage(roomId: string, stage: CollabRoomStage): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(collabRooms)
|
||||
.set(stage)
|
||||
.where(eq(collabRooms.id, roomId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async clearStage(roomId: string): Promise<void> {
|
||||
return this.updateStage(roomId, {
|
||||
presenterUserId: null,
|
||||
stageProtocol: null,
|
||||
stageHostId: null,
|
||||
stageShareId: null,
|
||||
});
|
||||
}
|
||||
|
||||
async endRoom(roomId: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.update(collabRooms)
|
||||
.set({
|
||||
endedAt: new Date().toISOString(),
|
||||
presenterUserId: null,
|
||||
stageProtocol: null,
|
||||
stageHostId: null,
|
||||
stageShareId: null,
|
||||
})
|
||||
.where(eq(collabRooms.id, roomId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async deleteRoom(roomId: string): Promise<void> {
|
||||
await this.context.drizzle
|
||||
.delete(collabRooms)
|
||||
.where(eq(collabRooms.id, roomId));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import { RoleRepository } from "./role-repository.js";
|
||||
import { SessionRecordingRepository } from "./session-recording-repository.js";
|
||||
import { SessionRepository } from "./session-repository.js";
|
||||
import { SessionShareRepository } from "./session-share-repository.js";
|
||||
import { CollabRoomRepository } from "./collab-room-repository.js";
|
||||
import { SettingsRepository } from "./settings-repository.js";
|
||||
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
|
||||
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
|
||||
@@ -395,6 +396,13 @@ export function createCurrentSessionRepository(): SessionRepository {
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentCollabRoomRepository(): CollabRoomRepository {
|
||||
return new CollabRoomRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("collab_room_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSessionShareRepository(): SessionShareRepository {
|
||||
return new SessionShareRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
|
||||
@@ -13,7 +13,9 @@ export type SessionShareRecord = typeof sessionShares.$inferSelect;
|
||||
export type SessionShareParticipantRecord =
|
||||
typeof sessionShareParticipants.$inferSelect;
|
||||
|
||||
export type SessionShareType = "link" | "user";
|
||||
// "room" shares are minted internally by the collab-room routes and are
|
||||
// joinable by any member of the room whose stage references them.
|
||||
export type SessionShareType = "link" | "user" | "room";
|
||||
export type SessionSharePermissionLevel = "read-only" | "read-write";
|
||||
|
||||
export interface SessionShareCreateInput {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { WebSocket } from "ws";
|
||||
|
||||
export interface CollabRoomClient {
|
||||
ws: WebSocket;
|
||||
userId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory fan-out for collab room events, mirroring the single-instance
|
||||
* assumption TerminalSessionManager already makes. REST mutations broadcast
|
||||
* through it; the terminal WS server feeds subscribe/unsubscribe.
|
||||
*/
|
||||
class CollabRoomHub {
|
||||
private rooms = new Map<string, Set<CollabRoomClient>>();
|
||||
|
||||
subscribe(roomId: string, client: CollabRoomClient): void {
|
||||
let clients = this.rooms.get(roomId);
|
||||
if (!clients) {
|
||||
clients = new Set();
|
||||
this.rooms.set(roomId, clients);
|
||||
}
|
||||
for (const existing of clients) {
|
||||
if (existing.ws === client.ws) return;
|
||||
}
|
||||
clients.add(client);
|
||||
this.broadcastOnline(roomId);
|
||||
}
|
||||
|
||||
/** Drops the socket from one room, or from every room when roomId is omitted. */
|
||||
unsubscribe(ws: WebSocket, roomId?: string): void {
|
||||
for (const [id, clients] of this.rooms) {
|
||||
if (roomId && id !== roomId) continue;
|
||||
let removed = false;
|
||||
for (const client of clients) {
|
||||
if (client.ws === ws) {
|
||||
clients.delete(client);
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
if (clients.size === 0) this.rooms.delete(id);
|
||||
if (removed) this.broadcastOnline(id);
|
||||
}
|
||||
}
|
||||
|
||||
broadcast(roomId: string, message: object): void {
|
||||
const clients = this.rooms.get(roomId);
|
||||
if (!clients) return;
|
||||
const payload = JSON.stringify(message);
|
||||
for (const client of clients) {
|
||||
if (client.ws.readyState !== client.ws.OPEN) continue;
|
||||
try {
|
||||
client.ws.send(payload);
|
||||
} catch {
|
||||
/* keep broadcasting to the rest */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onlineUsers(roomId: string): Array<{ userId: string; username: string }> {
|
||||
const clients = this.rooms.get(roomId);
|
||||
if (!clients) return [];
|
||||
const seen = new Map<string, string>();
|
||||
for (const client of clients) {
|
||||
seen.set(client.userId, client.username);
|
||||
}
|
||||
return Array.from(seen, ([userId, username]) => ({ userId, username }));
|
||||
}
|
||||
|
||||
private broadcastOnline(roomId: string): void {
|
||||
this.broadcast(roomId, {
|
||||
type: "collab_online",
|
||||
roomId,
|
||||
users: this.onlineUsers(roomId),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const collabRoomHub = new CollabRoomHub();
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createCurrentCollabRoomRepository } from "../../database/repositories/factory.js";
|
||||
|
||||
/**
|
||||
* Whether a user may join a room-stage share: the share must be the stage of
|
||||
* a live room they are a member of. Membership is the authorization - room
|
||||
* stages are read-only and never expose host credentials or config.
|
||||
*/
|
||||
export async function canJoinRoomStageShare(
|
||||
shareId: string,
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
const repository = createCurrentCollabRoomRepository();
|
||||
const room = await repository.findByStageShareId(shareId);
|
||||
if (!room) return false;
|
||||
return !!(await repository.findMember(room.id, userId));
|
||||
}
|
||||
@@ -0,0 +1,882 @@
|
||||
import crypto from "crypto";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
logAudit,
|
||||
getAuditUsername,
|
||||
getRequestMeta,
|
||||
} from "../../utils/audit-logger.js";
|
||||
import { GuacamoleTokenService } from "../guacamole/token-service.js";
|
||||
import { collabRoomHub } from "./room-hub.js";
|
||||
import { getStageController, setStageController } from "./stage-control.js";
|
||||
import { sessionManager } from "../terminal/session-manager.js";
|
||||
import {
|
||||
isLiveSession,
|
||||
isLiveSessionOwnedBy,
|
||||
isSharingEnabledForHost,
|
||||
type LiveProtocol,
|
||||
} from "../session-sharing/live-sessions.js";
|
||||
import {
|
||||
createCurrentCollabRoomRepository,
|
||||
createCurrentRoleRepository,
|
||||
createCurrentSessionShareRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import type { CollabRoomRecord } from "../../database/repositories/collab-room-repository.js";
|
||||
|
||||
/*
|
||||
* Known limits, shared with session sharing v1:
|
||||
* - Room events and stage control live in this process (room-hub,
|
||||
* stage-control). With more than one backend instance, members connected
|
||||
* to different instances do not see each other's events.
|
||||
* - Guacamole stages stay read-only because guacamole-lite cannot revoke a
|
||||
* writable viewer without disconnecting the whole shared session.
|
||||
*/
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const tokenService = GuacamoleTokenService.getInstance();
|
||||
|
||||
const STAGE_SHARE_EXPIRY_HOURS = 12;
|
||||
const PROTOCOLS: LiveProtocol[] = ["ssh", "rdp", "vnc", "telnet"];
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
async function requireRoomMember(
|
||||
roomId: string,
|
||||
userId: string,
|
||||
): Promise<{ room: CollabRoomRecord; isHost: boolean } | null> {
|
||||
const repository = createCurrentCollabRoomRepository();
|
||||
const room = await repository.findById(roomId);
|
||||
if (!room || room.endedAt) return null;
|
||||
const member = await repository.findMember(roomId, userId);
|
||||
if (!member) return null;
|
||||
return { room, isHost: member.roomRole === "host" };
|
||||
}
|
||||
|
||||
async function revokeStageShare(room: CollabRoomRecord): Promise<void> {
|
||||
if (!room.stageShareId) return;
|
||||
try {
|
||||
await createCurrentSessionShareRepository().revokeAsAdmin(
|
||||
room.stageShareId,
|
||||
);
|
||||
} catch {
|
||||
// A stale share must never block switching presenters.
|
||||
}
|
||||
}
|
||||
|
||||
function stagePayload(room: CollabRoomRecord) {
|
||||
return {
|
||||
presenterUserId: room.presenterUserId,
|
||||
protocol: room.stageProtocol,
|
||||
hostId: room.stageHostId,
|
||||
shareId: room.stageShareId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /collab/rooms:
|
||||
* post:
|
||||
* summary: Create a collaboration room
|
||||
* tags:
|
||||
* - Collab
|
||||
*/
|
||||
router.post("/rooms", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const { name, persistent } = req.body ?? {};
|
||||
|
||||
if (!isNonEmptyString(name) || name.trim().length > 120) {
|
||||
return res.status(400).json({ error: "Room name is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const repository = createCurrentCollabRoomRepository();
|
||||
const room = await repository.createRoom({
|
||||
id: crypto.randomUUID(),
|
||||
name: name.trim(),
|
||||
ownerUserId: userId,
|
||||
persistent: persistent === true,
|
||||
});
|
||||
await repository.addMember({
|
||||
roomId: room.id,
|
||||
userId,
|
||||
roomRole: "host",
|
||||
addedBy: userId,
|
||||
});
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action: "collab_room_create",
|
||||
resourceType: "collab_room",
|
||||
resourceId: room.id,
|
||||
resourceName: room.name,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ room });
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to create collab room", error, {
|
||||
operation: "collab_room_create_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to create room" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /collab/rooms:
|
||||
* get:
|
||||
* summary: List rooms the caller belongs to
|
||||
* tags:
|
||||
* - Collab
|
||||
*/
|
||||
router.get("/rooms", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
try {
|
||||
const rooms = await createCurrentCollabRoomRepository().listForUser(userId);
|
||||
res.json({ rooms });
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to list collab rooms", error, {
|
||||
operation: "collab_room_list_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list rooms" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /collab/rooms/{id}:
|
||||
* get:
|
||||
* summary: Get a room with members, online users and stage state
|
||||
* tags:
|
||||
* - Collab
|
||||
*/
|
||||
router.get(
|
||||
"/rooms/:id",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const roomId = String(req.params.id);
|
||||
try {
|
||||
const access = await requireRoomMember(roomId, userId);
|
||||
if (!access) {
|
||||
return res.status(404).json({ error: "Room not found" });
|
||||
}
|
||||
const members =
|
||||
await createCurrentCollabRoomRepository().listMembers(roomId);
|
||||
res.json({
|
||||
room: access.room,
|
||||
me: userId,
|
||||
isHost: access.isHost,
|
||||
members,
|
||||
online: collabRoomHub.onlineUsers(roomId),
|
||||
stage: stagePayload(access.room),
|
||||
controllerUserId: getStageController(roomId),
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to get collab room", error, {
|
||||
operation: "collab_room_get_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to get room" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /collab/rooms/{id}/members:
|
||||
* post:
|
||||
* summary: Invite users to a room (host only)
|
||||
* tags:
|
||||
* - Collab
|
||||
*/
|
||||
router.post(
|
||||
"/rooms/:id/members",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const roomId = String(req.params.id);
|
||||
const { userIds = [], roleIds = [] } = req.body ?? {};
|
||||
|
||||
if (
|
||||
!Array.isArray(userIds) ||
|
||||
userIds.some((id) => !isNonEmptyString(id)) ||
|
||||
!Array.isArray(roleIds) ||
|
||||
roleIds.some((id) => !Number.isInteger(id)) ||
|
||||
(userIds.length === 0 && roleIds.length === 0)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error: "userIds (user ids) or roleIds (integers) are required",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const access = await requireRoomMember(roomId, userId);
|
||||
if (!access) {
|
||||
return res.status(404).json({ error: "Room not found" });
|
||||
}
|
||||
if (!access.isHost) {
|
||||
return res.status(403).json({ error: "Only the host can invite" });
|
||||
}
|
||||
|
||||
const userRepository = createCurrentUserRepository();
|
||||
const repository = createCurrentCollabRoomRepository();
|
||||
for (const targetId of userIds as string[]) {
|
||||
if (!(await userRepository.findById(targetId))) {
|
||||
return res.status(404).json({ error: "User not found", targetId });
|
||||
}
|
||||
}
|
||||
// Roles expand to their current members - a snapshot, like folder
|
||||
// sharing; people joining the role later are not pulled in.
|
||||
const roleRepository = createCurrentRoleRepository();
|
||||
const expanded = new Set<string>(userIds as string[]);
|
||||
for (const roleId of roleIds as number[]) {
|
||||
if (!(await roleRepository.findRoleById(roleId))) {
|
||||
return res.status(404).json({ error: "Role not found", roleId });
|
||||
}
|
||||
for (const memberId of await roleRepository.listRoleUserIds(roleId)) {
|
||||
expanded.add(memberId);
|
||||
}
|
||||
}
|
||||
for (const targetId of expanded) {
|
||||
await repository.addMember({
|
||||
roomId,
|
||||
userId: targetId,
|
||||
roomRole: "member",
|
||||
addedBy: userId,
|
||||
});
|
||||
}
|
||||
|
||||
collabRoomHub.broadcast(roomId, {
|
||||
type: "collab_members_changed",
|
||||
roomId,
|
||||
});
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to invite collab room members", error, {
|
||||
operation: "collab_room_invite_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to invite members" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /collab/rooms/{id}/members/{userId}:
|
||||
* delete:
|
||||
* summary: Remove a member (host), or leave the room (self)
|
||||
* tags:
|
||||
* - Collab
|
||||
*/
|
||||
router.delete(
|
||||
"/rooms/:id/members/:userId",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const roomId = String(req.params.id);
|
||||
const targetId = String(req.params.userId);
|
||||
|
||||
try {
|
||||
const access = await requireRoomMember(roomId, userId);
|
||||
if (!access) {
|
||||
return res.status(404).json({ error: "Room not found" });
|
||||
}
|
||||
if (targetId !== userId && !access.isHost) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Only the host can remove members" });
|
||||
}
|
||||
if (targetId === access.room.ownerUserId) {
|
||||
return res.status(400).json({ error: "The owner cannot be removed" });
|
||||
}
|
||||
|
||||
const repository = createCurrentCollabRoomRepository();
|
||||
await repository.removeMember(roomId, targetId);
|
||||
|
||||
if (getStageController(roomId) === targetId) {
|
||||
await applyStageControl(access.room, roomId, null);
|
||||
}
|
||||
|
||||
if (access.room.presenterUserId === targetId) {
|
||||
await revokeStageShare(access.room);
|
||||
await repository.clearStage(roomId);
|
||||
collabRoomHub.broadcast(roomId, {
|
||||
type: "collab_stage_changed",
|
||||
roomId,
|
||||
stage: null,
|
||||
});
|
||||
}
|
||||
|
||||
collabRoomHub.broadcast(roomId, {
|
||||
type: "collab_members_changed",
|
||||
roomId,
|
||||
});
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to remove collab room member", error, {
|
||||
operation: "collab_room_remove_member_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to remove member" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /collab/rooms/{id}/present:
|
||||
* post:
|
||||
* summary: Take the stage with one of your live sessions
|
||||
* description: Any member may take over the stage; the previous stage share is revoked. The caller must own the live session and sharing must be enabled for the host.
|
||||
* tags:
|
||||
* - Collab
|
||||
*/
|
||||
router.post(
|
||||
"/rooms/:id/present",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const roomId = String(req.params.id);
|
||||
const { protocol, sessionId, hostId } = req.body ?? {};
|
||||
|
||||
if (!PROTOCOLS.includes(protocol)) {
|
||||
return res.status(400).json({ error: "Invalid protocol" });
|
||||
}
|
||||
if (!isNonEmptyString(sessionId) || !Number.isInteger(Number(hostId))) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "sessionId and hostId are required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const access = await requireRoomMember(roomId, userId);
|
||||
if (!access) {
|
||||
return res.status(404).json({ error: "Room not found" });
|
||||
}
|
||||
|
||||
const numericHostId = Number(hostId);
|
||||
const { enabled } = await isSharingEnabledForHost(numericHostId);
|
||||
if (!enabled) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Session sharing is disabled for this host" });
|
||||
}
|
||||
if (!isLiveSessionOwnedBy(protocol, String(sessionId), userId)) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "You do not own this live session" });
|
||||
}
|
||||
|
||||
const shareRepository = createCurrentSessionShareRepository();
|
||||
const share = await shareRepository.create({
|
||||
id: crypto.randomUUID(),
|
||||
hostId: numericHostId,
|
||||
ownerUserId: userId,
|
||||
protocol,
|
||||
sessionId: String(sessionId),
|
||||
shareType: "room",
|
||||
permissionLevel: "read-only",
|
||||
expiresAt: new Date(
|
||||
Date.now() + STAGE_SHARE_EXPIRY_HOURS * 60 * 60 * 1000,
|
||||
).toISOString(),
|
||||
});
|
||||
|
||||
await revokeStageShare(access.room);
|
||||
setStageController(roomId, null);
|
||||
const repository = createCurrentCollabRoomRepository();
|
||||
await repository.updateStage(roomId, {
|
||||
presenterUserId: userId,
|
||||
stageProtocol: protocol,
|
||||
stageHostId: numericHostId,
|
||||
stageShareId: share.id,
|
||||
});
|
||||
|
||||
const stage = {
|
||||
presenterUserId: userId,
|
||||
protocol,
|
||||
hostId: numericHostId,
|
||||
shareId: share.id,
|
||||
};
|
||||
collabRoomHub.broadcast(roomId, {
|
||||
type: "collab_stage_changed",
|
||||
roomId,
|
||||
stage,
|
||||
});
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action: "collab_room_present",
|
||||
resourceType: "collab_room",
|
||||
resourceId: roomId,
|
||||
resourceName: access.room.name,
|
||||
details: JSON.stringify({ protocol, hostId: numericHostId }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ stage });
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to take collab room stage", error, {
|
||||
operation: "collab_room_present_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to start presenting" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /collab/rooms/{id}/stop:
|
||||
* post:
|
||||
* summary: Stop presenting (presenter or host)
|
||||
* tags:
|
||||
* - Collab
|
||||
*/
|
||||
router.post(
|
||||
"/rooms/:id/stop",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const roomId = String(req.params.id);
|
||||
try {
|
||||
const access = await requireRoomMember(roomId, userId);
|
||||
if (!access) {
|
||||
return res.status(404).json({ error: "Room not found" });
|
||||
}
|
||||
if (access.room.presenterUserId !== userId && !access.isHost) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Only the presenter or host can stop the stage" });
|
||||
}
|
||||
|
||||
await revokeStageShare(access.room);
|
||||
setStageController(roomId, null);
|
||||
await createCurrentCollabRoomRepository().clearStage(roomId);
|
||||
collabRoomHub.broadcast(roomId, {
|
||||
type: "collab_stage_changed",
|
||||
roomId,
|
||||
stage: null,
|
||||
});
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to stop collab room stage", error, {
|
||||
operation: "collab_room_stop_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to stop presenting" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /collab/rooms/{id}/stage:
|
||||
* get:
|
||||
* summary: Get connect info for the current stage (members only)
|
||||
* description: SSH stages are joined over the terminal WS by shareId; guac stages get a freshly minted read-only join token.
|
||||
* tags:
|
||||
* - Collab
|
||||
*/
|
||||
router.get(
|
||||
"/rooms/:id/stage",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const roomId = String(req.params.id);
|
||||
try {
|
||||
const access = await requireRoomMember(roomId, userId);
|
||||
if (!access) {
|
||||
return res.status(404).json({ error: "Room not found" });
|
||||
}
|
||||
const { room } = access;
|
||||
if (!room.stageShareId || !room.stageProtocol) {
|
||||
return res.json({ stage: null });
|
||||
}
|
||||
|
||||
const share = await createCurrentSessionShareRepository().findActiveById(
|
||||
room.stageShareId,
|
||||
);
|
||||
const protocol = room.stageProtocol as LiveProtocol;
|
||||
if (!share || !isLiveSession(protocol, share.sessionId)) {
|
||||
// The presenter is gone (expired share or dead session): clear the
|
||||
// stale stage so the room stops pointing at it.
|
||||
setStageController(roomId, null);
|
||||
await createCurrentCollabRoomRepository().clearStage(roomId);
|
||||
collabRoomHub.broadcast(roomId, {
|
||||
type: "collab_stage_changed",
|
||||
roomId,
|
||||
stage: null,
|
||||
});
|
||||
return res.json({ stage: null });
|
||||
}
|
||||
|
||||
const controllerUserId = getStageController(roomId);
|
||||
const stage: Record<string, unknown> = {
|
||||
...stagePayload(room),
|
||||
sessionId: share.sessionId,
|
||||
controllerUserId,
|
||||
};
|
||||
if (protocol !== "ssh") {
|
||||
stage.connectParams = {
|
||||
token: tokenService.createJoinToken(share.sessionId, true),
|
||||
};
|
||||
}
|
||||
res.json({ stage });
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to resolve collab room stage", error, {
|
||||
operation: "collab_room_stage_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to resolve stage" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** Sets the controller everywhere it lives: memory, live SSH gate, hub. */
|
||||
async function applyStageControl(
|
||||
room: CollabRoomRecord,
|
||||
roomId: string,
|
||||
controllerUserId: string | null,
|
||||
): Promise<void> {
|
||||
setStageController(roomId, controllerUserId);
|
||||
if (room.stageShareId && room.stageProtocol === "ssh") {
|
||||
try {
|
||||
const share = await createCurrentSessionShareRepository().findActiveById(
|
||||
room.stageShareId,
|
||||
);
|
||||
if (share) {
|
||||
sessionManager.setRoomShareControl(
|
||||
share.sessionId,
|
||||
share.id,
|
||||
controllerUserId,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// The gate keeps its previous state; the broadcast still lands.
|
||||
}
|
||||
}
|
||||
collabRoomHub.broadcast(roomId, {
|
||||
type: "collab_control_changed",
|
||||
roomId,
|
||||
controllerUserId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /collab/rooms/{id}/control:
|
||||
* post:
|
||||
* summary: Grant or revoke stage control (presenter or host)
|
||||
* description: Grants a member write access to the current stage, or revokes it with a null userId. The controller may also release control themselves.
|
||||
* tags:
|
||||
* - Collab
|
||||
*/
|
||||
router.post(
|
||||
"/rooms/:id/control",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const roomId = String(req.params.id);
|
||||
const { userId: targetId } = req.body ?? {};
|
||||
|
||||
if (targetId !== null && !isNonEmptyString(targetId)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "userId must be a user id or null" });
|
||||
}
|
||||
|
||||
try {
|
||||
const access = await requireRoomMember(roomId, userId);
|
||||
if (!access) {
|
||||
return res.status(404).json({ error: "Room not found" });
|
||||
}
|
||||
if (!access.room.stageShareId) {
|
||||
return res.status(400).json({ error: "Nothing is being presented" });
|
||||
}
|
||||
if (access.room.stageProtocol !== "ssh") {
|
||||
return res.status(400).json({
|
||||
error: "Remote desktop stages are read-only",
|
||||
});
|
||||
}
|
||||
|
||||
const releasingOwnControl =
|
||||
targetId === null && getStageController(roomId) === userId;
|
||||
const mayGrant = access.isHost || access.room.presenterUserId === userId;
|
||||
if (!mayGrant && !releasingOwnControl) {
|
||||
return res.status(403).json({
|
||||
error: "Only the presenter or host can change stage control",
|
||||
});
|
||||
}
|
||||
|
||||
if (targetId) {
|
||||
const repository = createCurrentCollabRoomRepository();
|
||||
if (!(await repository.findMember(roomId, targetId))) {
|
||||
return res.status(404).json({ error: "Member not found" });
|
||||
}
|
||||
}
|
||||
|
||||
await applyStageControl(access.room, roomId, targetId);
|
||||
res.json({ controllerUserId: targetId });
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to change collab stage control", error, {
|
||||
operation: "collab_control_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to change stage control" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /collab/rooms/{id}/control/request:
|
||||
* post:
|
||||
* summary: Ask the presenter for stage control (hand raise)
|
||||
* tags:
|
||||
* - Collab
|
||||
*/
|
||||
router.post(
|
||||
"/rooms/:id/control/request",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const roomId = String(req.params.id);
|
||||
try {
|
||||
const access = await requireRoomMember(roomId, userId);
|
||||
if (!access) {
|
||||
return res.status(404).json({ error: "Room not found" });
|
||||
}
|
||||
if (!access.room.stageShareId) {
|
||||
return res.status(400).json({ error: "Nothing is being presented" });
|
||||
}
|
||||
if (access.room.stageProtocol !== "ssh") {
|
||||
return res.status(400).json({
|
||||
error: "Remote desktop stages are read-only",
|
||||
});
|
||||
}
|
||||
collabRoomHub.broadcast(roomId, {
|
||||
type: "collab_control_requested",
|
||||
roomId,
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
});
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to request collab stage control", error, {
|
||||
operation: "collab_control_request_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to request control" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /collab/rooms/{id}/guest-link:
|
||||
* post:
|
||||
* summary: Enable, rotate or disable the room's anonymous guest link (host only)
|
||||
* tags:
|
||||
* - Collab
|
||||
*/
|
||||
router.post(
|
||||
"/rooms/:id/guest-link",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const roomId = String(req.params.id);
|
||||
const { enabled } = req.body ?? {};
|
||||
if (typeof enabled !== "boolean") {
|
||||
return res.status(400).json({ error: "enabled must be a boolean" });
|
||||
}
|
||||
try {
|
||||
const access = await requireRoomMember(roomId, userId);
|
||||
if (!access) {
|
||||
return res.status(404).json({ error: "Room not found" });
|
||||
}
|
||||
if (!access.isHost) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Only the host can manage the guest link" });
|
||||
}
|
||||
const token = enabled
|
||||
? crypto.randomBytes(24).toString("base64url")
|
||||
: null;
|
||||
await createCurrentCollabRoomRepository().setGuestToken(roomId, token);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action: enabled
|
||||
? "collab_guest_link_enable"
|
||||
: "collab_guest_link_disable",
|
||||
resourceType: "collab_room",
|
||||
resourceId: roomId,
|
||||
resourceName: access.room.name,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
res.json({ guestLinkToken: token });
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to update collab guest link", error, {
|
||||
operation: "collab_guest_link_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to update guest link" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const GUEST_WINDOW_MS = 60 * 1000;
|
||||
const GUEST_MAX_ATTEMPTS = 60;
|
||||
const guestAttempts = new Map<string, { count: number; windowStart: number }>();
|
||||
|
||||
function isGuestRateLimited(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const entry = guestAttempts.get(ip);
|
||||
if (!entry || now - entry.windowStart > GUEST_WINDOW_MS) {
|
||||
guestAttempts.set(ip, { count: 1, windowStart: now });
|
||||
return false;
|
||||
}
|
||||
entry.count += 1;
|
||||
return entry.count > GUEST_MAX_ATTEMPTS;
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, entry] of guestAttempts) {
|
||||
if (now - entry.windowStart > GUEST_WINDOW_MS) guestAttempts.delete(ip);
|
||||
}
|
||||
}, 5 * GUEST_WINDOW_MS).unref();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /collab/guest/{token}:
|
||||
* get:
|
||||
* summary: Resolve a room's current stage for an anonymous guest
|
||||
* description: Public, rate-limited per IP. Guests poll this to follow presenter switches. Never returns host details; SSH stages are joined over the terminal WS with roomGuestToken, guac stages get a read-only join token.
|
||||
* tags:
|
||||
* - Collab
|
||||
*/
|
||||
router.get("/guest/:token", async (req: Request, res: Response) => {
|
||||
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
||||
if (isGuestRateLimited(ip)) {
|
||||
return res.status(429).json({ error: "Too many requests" });
|
||||
}
|
||||
const token = String(req.params.token);
|
||||
try {
|
||||
const room =
|
||||
await createCurrentCollabRoomRepository().findByGuestToken(token);
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: "Link not found" });
|
||||
}
|
||||
const response: Record<string, unknown> = {
|
||||
roomName: room.name,
|
||||
stage: null,
|
||||
};
|
||||
if (room.stageShareId && room.stageProtocol) {
|
||||
const share = await createCurrentSessionShareRepository().findActiveById(
|
||||
room.stageShareId,
|
||||
);
|
||||
const protocol = room.stageProtocol as LiveProtocol;
|
||||
if (share && isLiveSession(protocol, share.sessionId)) {
|
||||
const { enabled } = await isSharingEnabledForHost(share.hostId);
|
||||
if (enabled) {
|
||||
response.stage = {
|
||||
protocol,
|
||||
shareId: share.id,
|
||||
...(protocol === "ssh"
|
||||
? {
|
||||
wsPath: `/terminal/ws?roomGuestToken=${encodeURIComponent(token)}`,
|
||||
}
|
||||
: {
|
||||
connectParams: {
|
||||
token: tokenService.createJoinToken(share.sessionId, true),
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to resolve collab guest link", error, {
|
||||
operation: "collab_guest_resolve_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to resolve guest link" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /collab/rooms/{id}/end:
|
||||
* post:
|
||||
* summary: End the meeting (host only)
|
||||
* description: Clears the stage. One-off rooms are ended for good; persistent rooms stay listed for reuse.
|
||||
* tags:
|
||||
* - Collab
|
||||
*/
|
||||
router.post(
|
||||
"/rooms/:id/end",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const roomId = String(req.params.id);
|
||||
try {
|
||||
const access = await requireRoomMember(roomId, userId);
|
||||
if (!access) {
|
||||
return res.status(404).json({ error: "Room not found" });
|
||||
}
|
||||
if (!access.isHost) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Only the host can end the room" });
|
||||
}
|
||||
|
||||
await revokeStageShare(access.room);
|
||||
setStageController(roomId, null);
|
||||
const repository = createCurrentCollabRoomRepository();
|
||||
if (access.room.persistent) {
|
||||
await repository.clearStage(roomId);
|
||||
collabRoomHub.broadcast(roomId, {
|
||||
type: "collab_stage_changed",
|
||||
roomId,
|
||||
stage: null,
|
||||
});
|
||||
} else {
|
||||
await repository.endRoom(roomId);
|
||||
collabRoomHub.broadcast(roomId, { type: "collab_room_ended", roomId });
|
||||
}
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action: "collab_room_end",
|
||||
resourceType: "collab_room",
|
||||
resourceId: roomId,
|
||||
resourceName: access.room.name,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to end collab room", error, {
|
||||
operation: "collab_room_end_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to end room" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Who besides the presenter may drive the current stage, per room.
|
||||
*
|
||||
* Deliberately in-memory: control is a property of the live stage, and the
|
||||
* stage itself (SSH session / guacd connection) is process-local already.
|
||||
* Every stage switch clears it.
|
||||
*/
|
||||
const controllers = new Map<string, string>();
|
||||
|
||||
export function getStageController(roomId: string): string | null {
|
||||
return controllers.get(roomId) ?? null;
|
||||
}
|
||||
|
||||
export function setStageController(
|
||||
roomId: string,
|
||||
userId: string | null,
|
||||
): void {
|
||||
if (userId) controllers.set(roomId, userId);
|
||||
else controllers.delete(roomId);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { sessionManager } from "../terminal/session-manager.js";
|
||||
import { getGuacSessionInfo } from "../guacamole/guacamole-server.js";
|
||||
import {
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
|
||||
export type LiveProtocol = "ssh" | "rdp" | "vnc" | "telnet";
|
||||
|
||||
export async function isSharingEnabledForHost(hostId: number): Promise<{
|
||||
enabled: boolean;
|
||||
hostOwnerId: string | null;
|
||||
}> {
|
||||
const globalEnabled = await createCurrentSettingsRepository().getBoolean(
|
||||
"session_sharing_globally_enabled",
|
||||
true,
|
||||
);
|
||||
if (!globalEnabled) return { enabled: false, hostOwnerId: null };
|
||||
|
||||
const hostResolutionRepository = createCurrentHostResolutionRepository();
|
||||
const hostOwnerId = await hostResolutionRepository.findHostOwnerId(hostId);
|
||||
if (!hostOwnerId) return { enabled: false, hostOwnerId: null };
|
||||
|
||||
const host = await hostResolutionRepository.findHostById(hostId, hostOwnerId);
|
||||
if (!host) return { enabled: false, hostOwnerId: null };
|
||||
|
||||
return {
|
||||
enabled: host.allowSessionSharing !== false,
|
||||
hostOwnerId,
|
||||
};
|
||||
}
|
||||
|
||||
export function isLiveSessionOwnedBy(
|
||||
protocol: LiveProtocol,
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
): boolean {
|
||||
if (protocol === "ssh") {
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
return !!session && session.isConnected && session.userId === userId;
|
||||
}
|
||||
const info = getGuacSessionInfo(sessionId);
|
||||
return !!info && info.ownerUserId === userId;
|
||||
}
|
||||
|
||||
export function isLiveSession(
|
||||
protocol: LiveProtocol,
|
||||
sessionId: string,
|
||||
): boolean {
|
||||
if (protocol === "ssh") {
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
return !!session && session.isConnected;
|
||||
}
|
||||
return !!getGuacSessionInfo(sessionId);
|
||||
}
|
||||
@@ -5,13 +5,13 @@ import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
import { sessionManager } from "../terminal/session-manager.js";
|
||||
import { getGuacSessionInfo } from "../guacamole/guacamole-server.js";
|
||||
import { GuacamoleTokenService } from "../guacamole/token-service.js";
|
||||
import {
|
||||
createCurrentSessionShareRepository,
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentHostResolutionRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
isLiveSession,
|
||||
isLiveSessionOwnedBy,
|
||||
isSharingEnabledForHost,
|
||||
} from "./live-sessions.js";
|
||||
import { GuacamoleTokenService } from "../guacamole/token-service.js";
|
||||
import { createCurrentSessionShareRepository } from "../../database/repositories/factory.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
@@ -30,6 +30,14 @@ interface ResolveRateEntry {
|
||||
windowStart: number;
|
||||
}
|
||||
const resolveAttempts = new Map<string, ResolveRateEntry>();
|
||||
function computeExpiresAt(expiryHours: number | undefined): string {
|
||||
const hours = Math.min(
|
||||
Math.max(expiryHours ?? DEFAULT_EXPIRY_HOURS, 1),
|
||||
MAX_EXPIRY_HOURS,
|
||||
);
|
||||
return new Date(Date.now() + hours * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
const RESOLVE_WINDOW_MS = 60 * 1000;
|
||||
const RESOLVE_MAX_ATTEMPTS = 30;
|
||||
|
||||
@@ -55,58 +63,6 @@ setInterval(
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
|
||||
async function isSharingEnabledForHost(hostId: number): Promise<{
|
||||
enabled: boolean;
|
||||
hostOwnerId: string | null;
|
||||
}> {
|
||||
const globalEnabled = await createCurrentSettingsRepository().getBoolean(
|
||||
"session_sharing_globally_enabled",
|
||||
true,
|
||||
);
|
||||
if (!globalEnabled) return { enabled: false, hostOwnerId: null };
|
||||
|
||||
const hostResolutionRepository = createCurrentHostResolutionRepository();
|
||||
const hostOwnerId = await hostResolutionRepository.findHostOwnerId(hostId);
|
||||
if (!hostOwnerId) return { enabled: false, hostOwnerId: null };
|
||||
|
||||
const host = await hostResolutionRepository.findHostById(hostId, hostOwnerId);
|
||||
if (!host) return { enabled: false, hostOwnerId: null };
|
||||
|
||||
return {
|
||||
enabled: host.allowSessionSharing !== false,
|
||||
hostOwnerId,
|
||||
};
|
||||
}
|
||||
|
||||
function computeExpiresAt(expiryHours: number | undefined): string {
|
||||
const hours = Math.min(
|
||||
Math.max(expiryHours ?? DEFAULT_EXPIRY_HOURS, 1),
|
||||
MAX_EXPIRY_HOURS,
|
||||
);
|
||||
return new Date(Date.now() + hours * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
function isLiveSessionOwnedBy(
|
||||
protocol: Protocol,
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
): boolean {
|
||||
if (protocol === "ssh") {
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
return !!session && session.isConnected && session.userId === userId;
|
||||
}
|
||||
const info = getGuacSessionInfo(sessionId);
|
||||
return !!info && info.ownerUserId === userId;
|
||||
}
|
||||
|
||||
function isLiveSession(protocol: Protocol, sessionId: string): boolean {
|
||||
if (protocol === "ssh") {
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
return !!session && session.isConnected;
|
||||
}
|
||||
return !!getGuacSessionInfo(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /session-sharing/create:
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import { getAuditUsername } from "../../utils/audit-logger.js";
|
||||
import { collabRoomHub } from "../collab/room-hub.js";
|
||||
import type { SessionShareRecord } from "../../database/repositories/session-share-repository.js";
|
||||
import { createCurrentCollabRoomRepository } from "../../database/repositories/factory.js";
|
||||
import {
|
||||
parseWsMessage,
|
||||
asObject,
|
||||
@@ -150,12 +153,45 @@ async function handleShareTokenConnection(
|
||||
req: import("http").IncomingMessage,
|
||||
shareToken: string,
|
||||
): Promise<void> {
|
||||
const shareRepo = createCurrentSessionShareRepository();
|
||||
const share = await shareRepo.findByLinkToken(shareToken);
|
||||
const share =
|
||||
await createCurrentSessionShareRepository().findByLinkToken(shareToken);
|
||||
if (!share) {
|
||||
ws.close(1008, "Invalid or expired share link");
|
||||
return;
|
||||
}
|
||||
return attachShareGuest(ws, req, share);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth path for anonymous collab-room guests (?roomGuestToken=<token>): the
|
||||
* room's guest link resolves to whatever share is on stage right now.
|
||||
*/
|
||||
async function handleRoomGuestConnection(
|
||||
ws: WebSocket,
|
||||
req: import("http").IncomingMessage,
|
||||
roomGuestToken: string,
|
||||
): Promise<void> {
|
||||
const room =
|
||||
await createCurrentCollabRoomRepository().findByGuestToken(roomGuestToken);
|
||||
const share = room?.stageShareId
|
||||
? await createCurrentSessionShareRepository().findActiveById(
|
||||
room.stageShareId,
|
||||
)
|
||||
: null;
|
||||
if (!share) {
|
||||
ws.close(1008, "Nothing is being presented");
|
||||
return;
|
||||
}
|
||||
return attachShareGuest(ws, req, share);
|
||||
}
|
||||
|
||||
/** Joins an anonymous guest socket to a live shared SSH session, read-only or not per the share. */
|
||||
async function attachShareGuest(
|
||||
ws: WebSocket,
|
||||
req: import("http").IncomingMessage,
|
||||
share: SessionShareRecord,
|
||||
): Promise<void> {
|
||||
const shareRepo = createCurrentSessionShareRepository();
|
||||
if (share.protocol !== "ssh") {
|
||||
ws.close(1008, "Unsupported share protocol");
|
||||
return;
|
||||
@@ -300,6 +336,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
await handleShareTokenConnection(ws, req, shareToken);
|
||||
return;
|
||||
}
|
||||
const roomGuestToken = urlObj.searchParams.get("roomGuestToken");
|
||||
if (roomGuestToken) {
|
||||
await handleRoomGuestConnection(ws, req, roomGuestToken);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = extractWebSocketToken(req);
|
||||
@@ -399,6 +440,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
|
||||
ws.on("close", () => {
|
||||
clearInterval(wsPingInterval);
|
||||
collabRoomHub.unsubscribe(ws);
|
||||
sshLogger.info("Terminal WebSocket disconnected", {
|
||||
operation: "terminal_ws_disconnect",
|
||||
sessionId,
|
||||
@@ -1231,17 +1273,72 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
break;
|
||||
}
|
||||
|
||||
case "collab_subscribe": {
|
||||
const { roomId } = (data ?? {}) as { roomId?: string };
|
||||
if (typeof roomId !== "string" || !roomId) break;
|
||||
try {
|
||||
const repository = createCurrentCollabRoomRepository();
|
||||
const room = await repository.findById(roomId);
|
||||
const member =
|
||||
room && !room.endedAt
|
||||
? await repository.findMember(roomId, userId)
|
||||
: null;
|
||||
if (!member) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Room not found",
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
collabRoomHub.subscribe(roomId, {
|
||||
ws,
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
});
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to subscribe to collab room", error, {
|
||||
operation: "collab_subscribe_error",
|
||||
userId,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "collab_unsubscribe": {
|
||||
const { roomId } = (data ?? {}) as { roomId?: string };
|
||||
collabRoomHub.unsubscribe(
|
||||
ws,
|
||||
typeof roomId === "string" ? roomId : undefined,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "joinSharedSession": {
|
||||
const joinData = data as { shareId: string; tabInstanceId?: string };
|
||||
try {
|
||||
const shareRepo = createCurrentSessionShareRepository();
|
||||
const share = await shareRepo.findActiveById(joinData.shareId);
|
||||
// Room-stage shares are joinable by any member of the live
|
||||
// room whose stage points at them; user shares only by their
|
||||
// target.
|
||||
let eligible =
|
||||
!!share &&
|
||||
share.protocol === "ssh" &&
|
||||
share.shareType === "user" &&
|
||||
share.targetUserId === userId;
|
||||
if (
|
||||
!share ||
|
||||
share.shareType !== "user" ||
|
||||
share.targetUserId !== userId ||
|
||||
share.protocol !== "ssh"
|
||||
!eligible &&
|
||||
share &&
|
||||
share.protocol === "ssh" &&
|
||||
share.shareType === "room"
|
||||
) {
|
||||
const { canJoinRoomStageShare } =
|
||||
await import("../collab/room-share-access.js");
|
||||
eligible = await canJoinRoomStageShare(share.id, userId);
|
||||
}
|
||||
if (!eligible || !share) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
@@ -1251,13 +1348,18 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
break;
|
||||
}
|
||||
|
||||
// Room membership is the authorization for a room stage; the
|
||||
// read-only share never exposes host credentials or config.
|
||||
const { PermissionManager } =
|
||||
await import("../../utils/permission-manager.js");
|
||||
const access = await PermissionManager.getInstance().canAccessHost(
|
||||
userId,
|
||||
share.hostId,
|
||||
"connect",
|
||||
);
|
||||
const access =
|
||||
share.shareType === "room"
|
||||
? { hasAccess: true }
|
||||
: await PermissionManager.getInstance().canAccessHost(
|
||||
userId,
|
||||
share.hostId,
|
||||
"connect",
|
||||
);
|
||||
if (!access.hasAccess) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
|
||||
@@ -457,6 +457,27 @@ class TerminalSessionManager {
|
||||
this.broadcast(sessionId, { type: "participants", participants });
|
||||
}
|
||||
|
||||
/**
|
||||
* Grants stage control: participants joined via this share become
|
||||
* read-write only while they are the controller. The owner is untouched.
|
||||
*/
|
||||
setRoomShareControl(
|
||||
sessionId: string,
|
||||
shareId: string,
|
||||
controllerUserId: string | null,
|
||||
): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
for (const participant of session.participants.values()) {
|
||||
if (participant.isOwner || participant.joinedViaShareId !== shareId)
|
||||
continue;
|
||||
participant.permissionLevel =
|
||||
controllerUserId && participant.userId === controllerUserId
|
||||
? "read-write"
|
||||
: "read-only";
|
||||
}
|
||||
}
|
||||
|
||||
/** Fans out a message to every OPEN participant socket; skips closed ones and send failures. */
|
||||
broadcast(sessionId: string, message: object): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { WebSocket } from "ws";
|
||||
import { collabRoomHub } from "../../../hosts/collab/room-hub.js";
|
||||
|
||||
function fakeWs(open = true): WebSocket {
|
||||
return {
|
||||
OPEN: 1,
|
||||
readyState: open ? 1 : 3,
|
||||
send: vi.fn(),
|
||||
} as unknown as WebSocket;
|
||||
}
|
||||
|
||||
describe("collabRoomHub", () => {
|
||||
it("announces the online list on subscribe and unsubscribe, deduplicated per user", () => {
|
||||
const a1 = fakeWs();
|
||||
const a2 = fakeWs();
|
||||
const b = fakeWs();
|
||||
collabRoomHub.subscribe("room-1", { ws: a1, userId: "a", username: "A" });
|
||||
collabRoomHub.subscribe("room-1", { ws: a2, userId: "a", username: "A" });
|
||||
collabRoomHub.subscribe("room-1", { ws: b, userId: "b", username: "B" });
|
||||
|
||||
expect(collabRoomHub.onlineUsers("room-1")).toEqual([
|
||||
{ userId: "a", username: "A" },
|
||||
{ userId: "b", username: "B" },
|
||||
]);
|
||||
|
||||
const last = JSON.parse(
|
||||
(b.send as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[0] as string,
|
||||
);
|
||||
expect(last).toEqual({
|
||||
type: "collab_online",
|
||||
roomId: "room-1",
|
||||
users: [
|
||||
{ userId: "a", username: "A" },
|
||||
{ userId: "b", username: "B" },
|
||||
],
|
||||
});
|
||||
|
||||
collabRoomHub.unsubscribe(a1);
|
||||
expect(collabRoomHub.onlineUsers("room-1")).toHaveLength(2);
|
||||
collabRoomHub.unsubscribe(a2);
|
||||
expect(collabRoomHub.onlineUsers("room-1")).toEqual([
|
||||
{ userId: "b", username: "B" },
|
||||
]);
|
||||
collabRoomHub.unsubscribe(b);
|
||||
expect(collabRoomHub.onlineUsers("room-1")).toEqual([]);
|
||||
});
|
||||
|
||||
it("subscribing the same socket twice keeps one subscription", () => {
|
||||
const ws = fakeWs();
|
||||
collabRoomHub.subscribe("room-2", { ws, userId: "a", username: "A" });
|
||||
collabRoomHub.subscribe("room-2", { ws, userId: "a", username: "A" });
|
||||
expect((ws.send as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(1);
|
||||
collabRoomHub.unsubscribe(ws);
|
||||
});
|
||||
|
||||
it("broadcast skips closed sockets and rooms nobody watches", () => {
|
||||
const open = fakeWs();
|
||||
const closed = fakeWs(false);
|
||||
collabRoomHub.subscribe("room-3", { ws: open, userId: "a", username: "A" });
|
||||
collabRoomHub.subscribe("room-3", {
|
||||
ws: closed,
|
||||
userId: "b",
|
||||
username: "B",
|
||||
});
|
||||
(open.send as ReturnType<typeof vi.fn>).mockClear();
|
||||
(closed.send as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
collabRoomHub.broadcast("room-3", { type: "collab_stage_changed" });
|
||||
collabRoomHub.broadcast("nobody", { type: "collab_stage_changed" });
|
||||
|
||||
expect(open.send).toHaveBeenCalledTimes(1);
|
||||
expect(closed.send).not.toHaveBeenCalled();
|
||||
collabRoomHub.unsubscribe(open);
|
||||
collabRoomHub.unsubscribe(closed);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
roomsByShare: new Map<string, { id: string }>(),
|
||||
members: new Set<string>(), // `${roomId}:${userId}`
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentCollabRoomRepository: () => ({
|
||||
findByStageShareId: async (shareId: string) =>
|
||||
state.roomsByShare.get(shareId) ?? null,
|
||||
findMember: async (roomId: string, userId: string) =>
|
||||
state.members.has(`${roomId}:${userId}`) ? { roomId, userId } : null,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { canJoinRoomStageShare } =
|
||||
await import("../../../hosts/collab/room-share-access.js");
|
||||
|
||||
describe("canJoinRoomStageShare", () => {
|
||||
beforeEach(() => {
|
||||
state.roomsByShare.clear();
|
||||
state.members.clear();
|
||||
});
|
||||
|
||||
it("admits members of the live room whose stage is this share", async () => {
|
||||
state.roomsByShare.set("share-1", { id: "room-1" });
|
||||
state.members.add("room-1:alice");
|
||||
await expect(canJoinRoomStageShare("share-1", "alice")).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-members and shares that are not a room stage", async () => {
|
||||
state.roomsByShare.set("share-1", { id: "room-1" });
|
||||
await expect(canJoinRoomStageShare("share-1", "mallory")).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
await expect(canJoinRoomStageShare("share-9", "alice")).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,595 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
type Room = {
|
||||
id: string;
|
||||
name: string;
|
||||
ownerUserId: string;
|
||||
persistent: boolean;
|
||||
presenterUserId: string | null;
|
||||
stageProtocol: string | null;
|
||||
stageHostId: number | null;
|
||||
stageShareId: string | null;
|
||||
guestLinkToken: string | null;
|
||||
createdAt: string;
|
||||
endedAt: string | null;
|
||||
};
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
currentUserId: "host-1",
|
||||
rooms: new Map<string, Room>(),
|
||||
members: new Map<
|
||||
string,
|
||||
{ roomId: string; userId: string; roomRole: string }
|
||||
>(),
|
||||
shares: new Map<string, Record<string, unknown>>(),
|
||||
users: new Set<string>(["host-1", "alice", "bob"]),
|
||||
roles: new Map<number, string[]>([[7, ["alice", "carol"]]]),
|
||||
sharingEnabled: true,
|
||||
liveOwned: new Map<string, string>(), // sessionId -> owner
|
||||
broadcasts: [] as Array<Record<string, unknown>>,
|
||||
control: [] as Array<unknown[]>,
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
sshLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() =>
|
||||
(req: Record<string, unknown>, _res: unknown, next: () => void) => {
|
||||
req.userId = state.currentUserId;
|
||||
next();
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
logAudit: vi.fn(async () => undefined),
|
||||
getAuditUsername: vi.fn(async (id: string) => id.toUpperCase()),
|
||||
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||
}));
|
||||
vi.mock("../../../hosts/guacamole/token-service.js", () => ({
|
||||
GuacamoleTokenService: {
|
||||
getInstance: () => ({
|
||||
createJoinToken: (id: string, readOnly: boolean) =>
|
||||
`join:${id}:${readOnly}`,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../hosts/collab/room-hub.js", () => ({
|
||||
collabRoomHub: {
|
||||
broadcast: (roomId: string, message: Record<string, unknown>) => {
|
||||
state.broadcasts.push({ roomId, ...message });
|
||||
},
|
||||
onlineUsers: () => [],
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../hosts/terminal/session-manager.js", () => ({
|
||||
sessionManager: {
|
||||
setRoomShareControl: (...args: unknown[]) => {
|
||||
state.control.push(args);
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../hosts/session-sharing/live-sessions.js", () => ({
|
||||
isSharingEnabledForHost: async () => ({
|
||||
enabled: state.sharingEnabled,
|
||||
hostOwnerId: "host-1",
|
||||
}),
|
||||
isLiveSessionOwnedBy: (_p: string, sessionId: string, userId: string) =>
|
||||
state.liveOwned.get(sessionId) === userId,
|
||||
isLiveSession: (_p: string, sessionId: string) =>
|
||||
state.liveOwned.has(sessionId),
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentCollabRoomRepository: () => ({
|
||||
createRoom: async (
|
||||
input: Omit<
|
||||
Room,
|
||||
| "presenterUserId"
|
||||
| "stageProtocol"
|
||||
| "stageHostId"
|
||||
| "stageShareId"
|
||||
| "guestLinkToken"
|
||||
| "createdAt"
|
||||
| "endedAt"
|
||||
>,
|
||||
) => {
|
||||
const room: Room = {
|
||||
...input,
|
||||
presenterUserId: null,
|
||||
stageProtocol: null,
|
||||
stageHostId: null,
|
||||
stageShareId: null,
|
||||
guestLinkToken: null,
|
||||
createdAt: "2026-08-25T00:00:00.000Z",
|
||||
endedAt: null,
|
||||
};
|
||||
state.rooms.set(room.id, room);
|
||||
return room;
|
||||
},
|
||||
findById: async (id: string) => state.rooms.get(id) ?? null,
|
||||
findByGuestToken: async (token: string) =>
|
||||
[...state.rooms.values()].find(
|
||||
(r) => r.guestLinkToken === token && !r.endedAt,
|
||||
) ?? null,
|
||||
setGuestToken: async (roomId: string, token: string | null) => {
|
||||
state.rooms.get(roomId)!.guestLinkToken = token;
|
||||
},
|
||||
listForUser: async (userId: string) =>
|
||||
[...state.members.values()]
|
||||
.filter((m) => m.userId === userId)
|
||||
.map((m) => state.rooms.get(m.roomId)!)
|
||||
.filter((r) => !r.endedAt),
|
||||
findMember: async (roomId: string, userId: string) =>
|
||||
state.members.get(`${roomId}:${userId}`) ?? null,
|
||||
addMember: async (input: {
|
||||
roomId: string;
|
||||
userId: string;
|
||||
roomRole: string;
|
||||
}) => {
|
||||
const key = `${input.roomId}:${input.userId}`;
|
||||
if (state.members.has(key)) return false;
|
||||
state.members.set(key, input);
|
||||
return true;
|
||||
},
|
||||
removeMember: async (roomId: string, userId: string) => {
|
||||
state.members.delete(`${roomId}:${userId}`);
|
||||
},
|
||||
listMembers: async (roomId: string) =>
|
||||
[...state.members.values()]
|
||||
.filter((m) => m.roomId === roomId)
|
||||
.map((m) => ({ ...m, username: m.userId, createdAt: "" })),
|
||||
updateStage: async (roomId: string, stage: Partial<Room>) => {
|
||||
Object.assign(state.rooms.get(roomId)!, stage);
|
||||
},
|
||||
clearStage: async (roomId: string) => {
|
||||
Object.assign(state.rooms.get(roomId)!, {
|
||||
presenterUserId: null,
|
||||
stageProtocol: null,
|
||||
stageHostId: null,
|
||||
stageShareId: null,
|
||||
});
|
||||
},
|
||||
endRoom: async (roomId: string) => {
|
||||
Object.assign(state.rooms.get(roomId)!, {
|
||||
endedAt: "2026-08-25T01:00:00.000Z",
|
||||
presenterUserId: null,
|
||||
stageProtocol: null,
|
||||
stageHostId: null,
|
||||
stageShareId: null,
|
||||
});
|
||||
},
|
||||
}),
|
||||
createCurrentSessionShareRepository: () => ({
|
||||
create: async (input: Record<string, unknown>) => {
|
||||
const row = { ...input, revokedAt: null };
|
||||
state.shares.set(input.id as string, row);
|
||||
return row;
|
||||
},
|
||||
findActiveById: async (id: string) => {
|
||||
const share = state.shares.get(id);
|
||||
return share && !share.revokedAt ? share : null;
|
||||
},
|
||||
revokeAsAdmin: async (id: string) => {
|
||||
const share = state.shares.get(id);
|
||||
if (!share) return false;
|
||||
share.revokedAt = "now";
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async (id: string) => (state.users.has(id) ? { id } : null),
|
||||
}),
|
||||
createCurrentRoleRepository: () => ({
|
||||
findRoleById: async (id: number) => (state.roles.has(id) ? { id } : null),
|
||||
listRoleUserIds: async (id: number) => state.roles.get(id) ?? [],
|
||||
}),
|
||||
}));
|
||||
|
||||
const { default: router } = await import("../../../hosts/collab/routes.js");
|
||||
const { getStageController } =
|
||||
await import("../../../hosts/collab/stage-control.js");
|
||||
|
||||
type RouteLayer = {
|
||||
route?: {
|
||||
path: string;
|
||||
methods: Record<string, boolean>;
|
||||
stack: {
|
||||
handle: (req: Request, res: Response, next: () => void) => unknown;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
async function invoke(
|
||||
method: string,
|
||||
path: string,
|
||||
overrides: {
|
||||
body?: Record<string, unknown>;
|
||||
params?: Record<string, unknown>;
|
||||
ip?: string;
|
||||
} = {},
|
||||
) {
|
||||
const layers = (router as unknown as { stack: RouteLayer[] }).stack;
|
||||
const layer = layers.find(
|
||||
(l) => l.route?.path === path && l.route.methods[method],
|
||||
);
|
||||
if (!layer?.route) throw new Error(`No route for ${method} ${path}`);
|
||||
const req = {
|
||||
body: overrides.body ?? {},
|
||||
params: overrides.params ?? {},
|
||||
headers: {},
|
||||
ip: overrides.ip ?? "127.0.0.1",
|
||||
socket: { remoteAddress: overrides.ip ?? "127.0.0.1" },
|
||||
} as unknown as Request;
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
jsonBody: null as unknown,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload: unknown) {
|
||||
this.jsonBody = payload;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
for (const handler of layer.route.stack) {
|
||||
let calledNext = false;
|
||||
await handler.handle(req, res as unknown as Response, () => {
|
||||
calledNext = true;
|
||||
});
|
||||
if (!calledNext) break;
|
||||
}
|
||||
return res as {
|
||||
statusCode: number;
|
||||
jsonBody: Record<string, unknown> | null;
|
||||
};
|
||||
}
|
||||
|
||||
async function as(userId: string, run: () => Promise<unknown>) {
|
||||
const previous = state.currentUserId;
|
||||
state.currentUserId = userId;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
state.currentUserId = previous;
|
||||
}
|
||||
}
|
||||
|
||||
async function createRoom(persistent = false): Promise<string> {
|
||||
const response = await invoke("post", "/rooms", {
|
||||
body: { name: "Standup", persistent },
|
||||
});
|
||||
return (response.jsonBody!.room as Room).id;
|
||||
}
|
||||
|
||||
async function invite(roomId: string, userIds: string[]) {
|
||||
return invoke("post", "/rooms/:id/members", {
|
||||
params: { id: roomId },
|
||||
body: { userIds },
|
||||
});
|
||||
}
|
||||
|
||||
async function present(roomId: string, sessionId: string, protocol = "ssh") {
|
||||
return invoke("post", "/rooms/:id/present", {
|
||||
params: { id: roomId },
|
||||
body: { protocol, sessionId, hostId: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
describe("collab room routes", () => {
|
||||
beforeEach(() => {
|
||||
state.currentUserId = "host-1";
|
||||
state.rooms.clear();
|
||||
state.members.clear();
|
||||
state.shares.clear();
|
||||
state.liveOwned.clear();
|
||||
state.broadcasts.length = 0;
|
||||
state.control.length = 0;
|
||||
state.sharingEnabled = true;
|
||||
});
|
||||
|
||||
it("creating a room makes the creator its host and lists it for them only", async () => {
|
||||
const roomId = await createRoom();
|
||||
expect(state.members.get(`${roomId}:host-1`)?.roomRole).toBe("host");
|
||||
|
||||
const mine = await invoke("get", "/rooms");
|
||||
expect((mine.jsonBody!.rooms as Room[]).map((r) => r.id)).toEqual([roomId]);
|
||||
|
||||
const other = await as("alice", () =>
|
||||
invoke("get", "/rooms/:id", { params: { id: roomId } }),
|
||||
);
|
||||
expect((other as { statusCode: number }).statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects an empty or oversized room name", async () => {
|
||||
expect(
|
||||
(await invoke("post", "/rooms", { body: { name: " " } })).statusCode,
|
||||
).toBe(400);
|
||||
expect(
|
||||
(await invoke("post", "/rooms", { body: { name: "x".repeat(121) } }))
|
||||
.statusCode,
|
||||
).toBe(400);
|
||||
});
|
||||
|
||||
it("only the host invites; roles expand to their members; unknown users 404", async () => {
|
||||
const roomId = await createRoom();
|
||||
await invite(roomId, ["alice"]);
|
||||
const byMember = await as("alice", () => invite(roomId, ["bob"]));
|
||||
expect((byMember as { statusCode: number }).statusCode).toBe(403);
|
||||
|
||||
expect((await invite(roomId, ["nobody"])).statusCode).toBe(404);
|
||||
expect(
|
||||
(
|
||||
await invoke("post", "/rooms/:id/members", {
|
||||
params: { id: roomId },
|
||||
body: {},
|
||||
})
|
||||
).statusCode,
|
||||
).toBe(400);
|
||||
|
||||
const byRole = await invoke("post", "/rooms/:id/members", {
|
||||
params: { id: roomId },
|
||||
body: { roleIds: [7] },
|
||||
});
|
||||
expect(byRole.statusCode).toBe(200);
|
||||
expect(state.members.has(`${roomId}:carol`)).toBe(true);
|
||||
expect(state.broadcasts.at(-1)).toMatchObject({
|
||||
type: "collab_members_changed",
|
||||
});
|
||||
});
|
||||
|
||||
it("members may leave, only the host removes others, the owner is never removed", async () => {
|
||||
const roomId = await createRoom();
|
||||
await invite(roomId, ["alice", "bob"]);
|
||||
|
||||
const aliceRemovesBob = await as("alice", () =>
|
||||
invoke("delete", "/rooms/:id/members/:userId", {
|
||||
params: { id: roomId, userId: "bob" },
|
||||
}),
|
||||
);
|
||||
expect((aliceRemovesBob as { statusCode: number }).statusCode).toBe(403);
|
||||
|
||||
await as("alice", () =>
|
||||
invoke("delete", "/rooms/:id/members/:userId", {
|
||||
params: { id: roomId, userId: "alice" },
|
||||
}),
|
||||
);
|
||||
expect(state.members.has(`${roomId}:alice`)).toBe(false);
|
||||
|
||||
const removeOwner = await invoke("delete", "/rooms/:id/members/:userId", {
|
||||
params: { id: roomId, userId: "host-1" },
|
||||
});
|
||||
expect(removeOwner.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("presenting validates the protocol, the sharing toggle and live-session ownership", async () => {
|
||||
const roomId = await createRoom();
|
||||
expect((await present(roomId, "s1", "ftp")).statusCode).toBe(400);
|
||||
expect((await present(roomId, "s1")).statusCode).toBe(403); // not live
|
||||
|
||||
state.liveOwned.set("s1", "host-1");
|
||||
state.sharingEnabled = false;
|
||||
expect((await present(roomId, "s1")).statusCode).toBe(403);
|
||||
state.sharingEnabled = true;
|
||||
|
||||
const ok = await present(roomId, "s1");
|
||||
expect(ok.statusCode).toBe(200);
|
||||
const room = state.rooms.get(roomId)!;
|
||||
expect(room.presenterUserId).toBe("host-1");
|
||||
expect(state.shares.get(room.stageShareId!)).toMatchObject({
|
||||
shareType: "room",
|
||||
permissionLevel: "read-only",
|
||||
sessionId: "s1",
|
||||
});
|
||||
expect(state.broadcasts.at(-1)).toMatchObject({
|
||||
type: "collab_stage_changed",
|
||||
});
|
||||
});
|
||||
|
||||
it("a takeover revokes the previous stage share and clears control", async () => {
|
||||
const roomId = await createRoom();
|
||||
await invite(roomId, ["alice"]);
|
||||
state.liveOwned.set("s1", "host-1");
|
||||
state.liveOwned.set("s2", "alice");
|
||||
await present(roomId, "s1");
|
||||
const firstShare = state.rooms.get(roomId)!.stageShareId!;
|
||||
await invoke("post", "/rooms/:id/control", {
|
||||
params: { id: roomId },
|
||||
body: { userId: "alice" },
|
||||
});
|
||||
expect(getStageController(roomId)).toBe("alice");
|
||||
|
||||
await as("alice", () => present(roomId, "s2"));
|
||||
const room = state.rooms.get(roomId)!;
|
||||
expect(room.presenterUserId).toBe("alice");
|
||||
expect(state.shares.get(firstShare)!.revokedAt).toBeTruthy();
|
||||
expect(getStageController(roomId)).toBeNull();
|
||||
});
|
||||
|
||||
it("stop is for the presenter or host", async () => {
|
||||
const roomId = await createRoom();
|
||||
await invite(roomId, ["alice", "bob"]);
|
||||
state.liveOwned.set("s2", "alice");
|
||||
await as("alice", () => present(roomId, "s2"));
|
||||
|
||||
const bob = await as("bob", () =>
|
||||
invoke("post", "/rooms/:id/stop", { params: { id: roomId } }),
|
||||
);
|
||||
expect((bob as { statusCode: number }).statusCode).toBe(403);
|
||||
await invoke("post", "/rooms/:id/stop", { params: { id: roomId } }); // host
|
||||
expect(state.rooms.get(roomId)!.stageShareId).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps remote desktop stages read-only", async () => {
|
||||
const roomId = await createRoom();
|
||||
await invite(roomId, ["alice"]);
|
||||
const stageOf = (user: string) =>
|
||||
as(user, () =>
|
||||
invoke("get", "/rooms/:id/stage", { params: { id: roomId } }),
|
||||
) as Promise<{
|
||||
jsonBody: { stage: Record<string, unknown> | null };
|
||||
}>;
|
||||
|
||||
expect((await stageOf("alice")).jsonBody.stage).toBeNull();
|
||||
|
||||
state.liveOwned.set("s1", "host-1");
|
||||
await present(roomId, "s1");
|
||||
const ssh = (await stageOf("alice")).jsonBody.stage!;
|
||||
expect(ssh).toMatchObject({ protocol: "ssh", sessionId: "s1" });
|
||||
expect(ssh.connectParams).toBeUndefined();
|
||||
|
||||
state.liveOwned.set("g1", "host-1");
|
||||
await present(roomId, "g1", "rdp");
|
||||
expect((await stageOf("alice")).jsonBody.stage!.connectParams).toEqual({
|
||||
token: "join:g1:true",
|
||||
});
|
||||
const control = await invoke("post", "/rooms/:id/control", {
|
||||
params: { id: roomId },
|
||||
body: { userId: "alice" },
|
||||
});
|
||||
expect(control.statusCode).toBe(400);
|
||||
expect((await stageOf("alice")).jsonBody.stage!.connectParams).toEqual({
|
||||
token: "join:g1:true",
|
||||
});
|
||||
});
|
||||
|
||||
it("a dead presenter session clears the stale stage on resolve", async () => {
|
||||
const roomId = await createRoom();
|
||||
await invite(roomId, ["alice"]);
|
||||
state.liveOwned.set("s1", "host-1");
|
||||
await present(roomId, "s1");
|
||||
state.liveOwned.delete("s1");
|
||||
|
||||
const resolved = await as("alice", () =>
|
||||
invoke("get", "/rooms/:id/stage", { params: { id: roomId } }),
|
||||
);
|
||||
expect(
|
||||
(resolved as { jsonBody: { stage: unknown } }).jsonBody.stage,
|
||||
).toBeNull();
|
||||
expect(state.rooms.get(roomId)!.stageShareId).toBeNull();
|
||||
expect(state.broadcasts.at(-1)).toMatchObject({
|
||||
type: "collab_stage_changed",
|
||||
stage: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("control: presenter/host grant, controller releases, members may only ask", async () => {
|
||||
const roomId = await createRoom();
|
||||
await invite(roomId, ["alice", "bob"]);
|
||||
const control = (user: string, target: string | null) =>
|
||||
as(user, () =>
|
||||
invoke("post", "/rooms/:id/control", {
|
||||
params: { id: roomId },
|
||||
body: { userId: target },
|
||||
}),
|
||||
) as Promise<{ statusCode: number }>;
|
||||
|
||||
expect((await control("host-1", "alice")).statusCode).toBe(400); // nothing presented
|
||||
|
||||
state.liveOwned.set("s1", "host-1");
|
||||
await present(roomId, "s1");
|
||||
expect((await control("bob", "bob")).statusCode).toBe(403);
|
||||
expect((await control("host-1", "nobody")).statusCode).toBe(404);
|
||||
expect((await control("host-1", "alice")).statusCode).toBe(200);
|
||||
expect(state.control.at(-1)).toEqual([
|
||||
"s1",
|
||||
state.rooms.get(roomId)!.stageShareId,
|
||||
"alice",
|
||||
]);
|
||||
expect(state.broadcasts.at(-1)).toMatchObject({
|
||||
type: "collab_control_changed",
|
||||
controllerUserId: "alice",
|
||||
});
|
||||
|
||||
expect((await control("bob", null)).statusCode).toBe(403);
|
||||
expect((await control("alice", null)).statusCode).toBe(200);
|
||||
expect(getStageController(roomId)).toBeNull();
|
||||
|
||||
const asked = await as("bob", () =>
|
||||
invoke("post", "/rooms/:id/control/request", { params: { id: roomId } }),
|
||||
);
|
||||
expect((asked as { statusCode: number }).statusCode).toBe(200);
|
||||
expect(state.broadcasts.at(-1)).toMatchObject({
|
||||
type: "collab_control_requested",
|
||||
userId: "bob",
|
||||
username: "BOB",
|
||||
});
|
||||
});
|
||||
|
||||
it("guest link: host-only toggle, anonymous resolve follows the stage, rate limited", async () => {
|
||||
const roomId = await createRoom();
|
||||
await invite(roomId, ["alice"]);
|
||||
const toggle = (user: string, enabled: boolean) =>
|
||||
as(user, () =>
|
||||
invoke("post", "/rooms/:id/guest-link", {
|
||||
params: { id: roomId },
|
||||
body: { enabled },
|
||||
}),
|
||||
) as Promise<{
|
||||
statusCode: number;
|
||||
jsonBody: { guestLinkToken: string | null };
|
||||
}>;
|
||||
|
||||
expect((await toggle("alice", true)).statusCode).toBe(403);
|
||||
const { guestLinkToken } = (await toggle("host-1", true)).jsonBody;
|
||||
expect(guestLinkToken).toBeTruthy();
|
||||
|
||||
const resolve = (token: string, ip = "9.9.9.9") =>
|
||||
invoke("get", "/guest/:token", { params: { token }, ip });
|
||||
expect((await resolve("nope")).statusCode).toBe(404);
|
||||
expect((await resolve(guestLinkToken!)).jsonBody).toEqual({
|
||||
roomName: "Standup",
|
||||
stage: null,
|
||||
});
|
||||
|
||||
state.liveOwned.set("s1", "host-1");
|
||||
await present(roomId, "s1");
|
||||
expect((await resolve(guestLinkToken!)).jsonBody!.stage).toMatchObject({
|
||||
protocol: "ssh",
|
||||
wsPath: `/terminal/ws?roomGuestToken=${encodeURIComponent(guestLinkToken!)}`,
|
||||
});
|
||||
state.liveOwned.set("g1", "host-1");
|
||||
await present(roomId, "g1", "vnc");
|
||||
expect((await resolve(guestLinkToken!)).jsonBody!.stage).toMatchObject({
|
||||
protocol: "vnc",
|
||||
connectParams: { token: "join:g1:true" },
|
||||
});
|
||||
|
||||
await toggle("host-1", false);
|
||||
expect((await resolve(guestLinkToken!)).statusCode).toBe(404);
|
||||
|
||||
let last = 200;
|
||||
for (let i = 0; i < 61; i++) {
|
||||
last = (await resolve("x", "1.2.3.4")).statusCode;
|
||||
}
|
||||
expect(last).toBe(429);
|
||||
});
|
||||
|
||||
it("ending a one-off room ends it; ending a persistent room only clears the stage", async () => {
|
||||
const oneOff = await createRoom(false);
|
||||
await invite(oneOff, ["alice"]);
|
||||
const byMember = await as("alice", () =>
|
||||
invoke("post", "/rooms/:id/end", { params: { id: oneOff } }),
|
||||
);
|
||||
expect((byMember as { statusCode: number }).statusCode).toBe(403);
|
||||
await invoke("post", "/rooms/:id/end", { params: { id: oneOff } });
|
||||
expect(state.rooms.get(oneOff)!.endedAt).toBeTruthy();
|
||||
expect(state.broadcasts.at(-1)).toMatchObject({
|
||||
type: "collab_room_ended",
|
||||
});
|
||||
|
||||
const persistent = await createRoom(true);
|
||||
state.liveOwned.set("s1", "host-1");
|
||||
await present(persistent, "s1");
|
||||
await invoke("post", "/rooms/:id/end", { params: { id: persistent } });
|
||||
const room = state.rooms.get(persistent)!;
|
||||
expect(room.endedAt).toBeNull();
|
||||
expect(room.stageShareId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -218,6 +218,42 @@ describe("TerminalSessionManager - multiplayer participants", () => {
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("setRoomShareControl makes only the controller read-write and never touches the owner", () => {
|
||||
const id = createConnectedSession();
|
||||
const ownerWs = makeFakeWs();
|
||||
sessionManager.attachWs(id, "owner-1", ownerWs);
|
||||
const aliceWs = makeFakeWs();
|
||||
const bobWs = makeFakeWs();
|
||||
const session = sessionManager.joinAsParticipant(id, aliceWs, {
|
||||
userId: "alice",
|
||||
permissionLevel: "read-only",
|
||||
shareId: "stage-share",
|
||||
})!;
|
||||
sessionManager.joinAsParticipant(id, bobWs, {
|
||||
userId: "bob",
|
||||
permissionLevel: "read-only",
|
||||
shareId: "stage-share",
|
||||
});
|
||||
|
||||
sessionManager.setRoomShareControl(id, "stage-share", "alice");
|
||||
expect(
|
||||
sessionManager.getParticipantForWs(session, aliceWs)?.permissionLevel,
|
||||
).toBe("read-write");
|
||||
expect(
|
||||
sessionManager.getParticipantForWs(session, bobWs)?.permissionLevel,
|
||||
).toBe("read-only");
|
||||
expect(
|
||||
sessionManager.getParticipantForWs(session, ownerWs)?.permissionLevel,
|
||||
).toBe("read-write");
|
||||
|
||||
sessionManager.setRoomShareControl(id, "stage-share", null);
|
||||
expect(
|
||||
sessionManager.getParticipantForWs(session, aliceWs)?.permissionLevel,
|
||||
).toBe("read-only");
|
||||
|
||||
sessionManager.destroySession(id);
|
||||
});
|
||||
|
||||
it("joinAsParticipant returns null for a nonexistent or unconnected session", () => {
|
||||
expect(
|
||||
sessionManager.joinAsParticipant("does-not-exist", makeFakeWs(), {
|
||||
|
||||
@@ -77,6 +77,8 @@ const ElectronVersionCheck = lazy(() =>
|
||||
const SharedSessionView = lazy(
|
||||
() => import("@/features/session-sharing/SharedSessionView"),
|
||||
);
|
||||
// Anonymous guest view for collab rooms (?view=collab-guest&token=<guestLinkToken>).
|
||||
const CollabGuestView = lazy(() => import("@/features/collab/CollabGuestView"));
|
||||
|
||||
type Phase =
|
||||
"verifying" | "idle-auth" | "fading-in" | "idle-app" | "fading-out";
|
||||
@@ -408,6 +410,13 @@ function RootApp() {
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
if (searchParams.get("view") === "collab-guest") {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<CollabGuestView />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
if (isFullscreen) {
|
||||
return (
|
||||
|
||||
@@ -267,6 +267,7 @@ export type TabType =
|
||||
| "ssh-tools"
|
||||
| "automations"
|
||||
| "ai"
|
||||
| "collab"
|
||||
| "split-screen";
|
||||
|
||||
export type SerialConfig = {
|
||||
@@ -294,6 +295,8 @@ export type Tab = {
|
||||
initialPath?: string;
|
||||
/** Which fleet a fleet-inventory tab is currently showing (singleton tab, re-targeted on reopen). */
|
||||
fleetId?: number;
|
||||
/** Which collab room a collab tab is showing. */
|
||||
collabRoomId?: string;
|
||||
serialConfig?: SerialConfig;
|
||||
/** Present only on a split-screen container tab. Pane ids reference live child tabs. */
|
||||
splitConfig?: SplitTabConfig;
|
||||
|
||||
@@ -80,6 +80,9 @@ const AlertManager = lazy(() =>
|
||||
const SshToolsPanel = lazy(() =>
|
||||
import("@/sidebar/SshToolsPanel").then((m) => ({ default: m.SshToolsPanel })),
|
||||
);
|
||||
const CollabPanel = lazy(() =>
|
||||
import("@/sidebar/CollabPanel").then((m) => ({ default: m.CollabPanel })),
|
||||
);
|
||||
const SnippetsPanel = lazy(() =>
|
||||
import("@/sidebar/SnippetsPanel").then((m) => ({ default: m.SnippetsPanel })),
|
||||
);
|
||||
@@ -1572,6 +1575,7 @@ export function AppShell({
|
||||
serialConfig?: SerialConfig;
|
||||
joinSharedSessionId?: string | null;
|
||||
joinShareId?: string | null;
|
||||
collabRoomId?: string;
|
||||
},
|
||||
) {
|
||||
const tabId = `${host.name}-${type}-${Date.now()}`;
|
||||
@@ -1617,6 +1621,7 @@ export function AppShell({
|
||||
initialFilePath,
|
||||
initialPath,
|
||||
serialConfig,
|
||||
collabRoomId: restore?.collabRoomId,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1652,6 +1657,7 @@ export function AppShell({
|
||||
initialFilePath,
|
||||
initialPath,
|
||||
serialConfig,
|
||||
collabRoomId: restore?.collabRoomId,
|
||||
},
|
||||
];
|
||||
});
|
||||
@@ -1769,6 +1775,52 @@ export function AppShell({
|
||||
return id;
|
||||
}
|
||||
|
||||
// Invite awareness: rooms are discovered by polling, so a room that has
|
||||
// never been shown to this browser gets one toast with an Open action.
|
||||
useEffect(() => {
|
||||
const SEEN_KEY = "termix:collab-rooms-seen";
|
||||
let cancelled = false;
|
||||
const check = async () => {
|
||||
try {
|
||||
const { listCollabRooms } = await import("@/api/collab-api");
|
||||
const { rooms } = await listCollabRooms();
|
||||
if (cancelled) return;
|
||||
let seen: string[] = [];
|
||||
try {
|
||||
seen = JSON.parse(localStorage.getItem(SEEN_KEY) ?? "[]");
|
||||
} catch {
|
||||
seen = [];
|
||||
}
|
||||
const seenSet = new Set(seen);
|
||||
const fresh = rooms.filter((room) => !seenSet.has(room.id));
|
||||
if (fresh.length === 0) return;
|
||||
localStorage.setItem(
|
||||
SEEN_KEY,
|
||||
JSON.stringify([...seenSet, ...fresh.map((room) => room.id)]),
|
||||
);
|
||||
// The first poll after login only records what already exists.
|
||||
if (seen.length === 0) return;
|
||||
for (const room of fresh) {
|
||||
if (room.ownerUserId === userId) continue;
|
||||
toast(t("collab.invitedTo", { name: room.name }), {
|
||||
action: {
|
||||
label: t("collab.openRoom"),
|
||||
onClick: () => setRailView("collab"),
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* next poll */
|
||||
}
|
||||
};
|
||||
void check();
|
||||
const timer = setInterval(() => void check(), 60_000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openSingletonTab = useCallback(
|
||||
// --- tmux-monitor --- (added optional `host` so tmux_monitor can open
|
||||
// with a preselected host; existing callers are unaffected)
|
||||
@@ -2595,6 +2647,57 @@ export function AppShell({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{railView === "collab" && (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<CollabPanel
|
||||
onOpenRoom={(room) => {
|
||||
const roomHost: Host = {
|
||||
id: `collab-${room.id}`,
|
||||
name: room.name,
|
||||
username: "",
|
||||
ip: "",
|
||||
port: 0,
|
||||
folder: "",
|
||||
online: false,
|
||||
cpu: null,
|
||||
ram: null,
|
||||
lastAccess: new Date().toISOString(),
|
||||
authType: "none",
|
||||
enableTerminal: false,
|
||||
enableCommandHistory: false,
|
||||
enableTunnel: false,
|
||||
enableFileManager: false,
|
||||
enableDocker: false,
|
||||
enableProxmox: false,
|
||||
enableProxmoxStats: false,
|
||||
enableTmuxMonitor: false,
|
||||
enableTerminalToolbar: false,
|
||||
enableSsh: false,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
enableTelnet: false,
|
||||
sshPort: 22,
|
||||
rdpPort: 3389,
|
||||
vncPort: 5900,
|
||||
telnetPort: 23,
|
||||
serverTunnels: [],
|
||||
quickActions: [],
|
||||
};
|
||||
openTab(roomHost, "collab", {
|
||||
instanceId:
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
|
||||
restoredSessionId: null,
|
||||
savedLabel: room.name,
|
||||
collabRoomId: room.id,
|
||||
});
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{railView === "session-logs" && (
|
||||
<div className="relative flex-1 min-h-0 flex flex-col">
|
||||
<SessionLogsPanel />
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
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;
|
||||
guestLinkToken: string | null;
|
||||
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;
|
||||
}
|
||||
|
||||
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<void> {
|
||||
try {
|
||||
await authApi.post(`/collab/rooms/${roomId}/control/request`);
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "request stage control");
|
||||
}
|
||||
}
|
||||
|
||||
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 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;
|
||||
}
|
||||
@@ -35,7 +35,7 @@ const isDev = (): boolean =>
|
||||
// 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.
|
||||
async function resolveApiBaseUrl(): Promise<string> {
|
||||
export async function resolveApiBaseUrl(): Promise<string> {
|
||||
if (isDev()) {
|
||||
const protocol = window.location.protocol === "https:" ? "https" : "http";
|
||||
return `${protocol}://localhost:30001`;
|
||||
|
||||
@@ -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!, {
|
||||
|
||||
@@ -662,6 +662,7 @@
|
||||
"aiNote": "Nothing is sent anywhere until you add a provider and send a message."
|
||||
},
|
||||
"nav": {
|
||||
"collab": "Meetings",
|
||||
"home": "Home",
|
||||
"terminal": "Terminal",
|
||||
"localTerminal": "Local Terminal",
|
||||
@@ -1806,6 +1807,59 @@
|
||||
"join": "Join",
|
||||
"sharedSessionLabel": "{{hostName}} (shared)"
|
||||
},
|
||||
"collab": {
|
||||
"createRoom": "New room",
|
||||
"roomName": "Room name",
|
||||
"persistentRoom": "Persistent room",
|
||||
"persistentRoomHint": "Persistent rooms stay listed after the meeting ends and can be reused.",
|
||||
"noRooms": "No rooms yet. Create one to start a meeting.",
|
||||
"openRoom": "Open",
|
||||
"endRoom": "End meeting",
|
||||
"leaveRoom": "Leave",
|
||||
"deleteMember": "Remove",
|
||||
"invite": "Invite",
|
||||
"inviteTitle": "Invite members",
|
||||
"members": "Members",
|
||||
"online": "online",
|
||||
"hostBadge": "Host",
|
||||
"presenterBadge": "Presenting",
|
||||
"present": "Present",
|
||||
"presentTitle": "Take the stage",
|
||||
"presentHost": "Host",
|
||||
"presentProtocol": "Protocol",
|
||||
"stopPresenting": "Stop presenting",
|
||||
"takeOver": "Take over",
|
||||
"emptyStage": "Nobody is presenting. Take the stage to share a session.",
|
||||
"stageLoading": "Connecting to the stage...",
|
||||
"stageEnded": "The presentation ended",
|
||||
"roomEnded": "This meeting has ended",
|
||||
"presenterLabel": "{{name}} is presenting",
|
||||
"guestLink": "Guest link",
|
||||
"guestLinkOn": "Guest link is on. Anyone with the link can watch the stage.",
|
||||
"guestLinkOff": "Guest link is off.",
|
||||
"copyLink": "Copy link",
|
||||
"linkCopied": "Link copied",
|
||||
"roles": "Roles",
|
||||
"users": "Users",
|
||||
"invitedTo": "You were invited to \"{{name}}\"",
|
||||
"guest": {
|
||||
"title": "Meeting",
|
||||
"waiting": "Waiting for the presenter...",
|
||||
"linkInvalid": "This guest link is invalid or the meeting has ended"
|
||||
},
|
||||
"reopenFromPanel": "This meeting tab expired. Reopen the room from the Meetings panel.",
|
||||
"requestControl": "Request control",
|
||||
"releaseControl": "Release control",
|
||||
"grantControl": "Give control",
|
||||
"revokeControl": "Take back control",
|
||||
"controlRequestedBy": "{{name}} asked for control",
|
||||
"grant": "Grant",
|
||||
"controlBadge": "In control",
|
||||
"youArePresenting": "You are presenting in another tab. Stop presenting to hand the stage over.",
|
||||
"created": "Room created",
|
||||
"invited": "Members invited",
|
||||
"roomTab": "Meeting"
|
||||
},
|
||||
"sessionSharing": {
|
||||
"guestView": {
|
||||
"loading": "Connecting to shared session...",
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
Plug,
|
||||
ScrollText,
|
||||
Sparkles,
|
||||
Presentation,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
import { lazy, Suspense } from "react";
|
||||
@@ -57,6 +58,11 @@ const loadTerminalFeature = () =>
|
||||
default: m.Terminal,
|
||||
}));
|
||||
const TerminalFeature = lazy(loadTerminalFeature);
|
||||
const CollabRoomTab = lazy(() =>
|
||||
import("@/features/collab/CollabRoomTab").then((m) => ({
|
||||
default: m.CollabRoomTab,
|
||||
})),
|
||||
);
|
||||
const LocalTerminal = lazy(() =>
|
||||
import("@/features/local-terminal/LocalTerminal").then((m) => ({
|
||||
default: m.LocalTerminal,
|
||||
@@ -318,6 +324,8 @@ export function tabIcon(type: TabType) {
|
||||
return <LayoutGrid className="size-3.5" />;
|
||||
case "fleet-inventory":
|
||||
return <Boxes className="size-3.5" />;
|
||||
case "collab":
|
||||
return <Presentation className="size-3.5" />;
|
||||
case "termix-id":
|
||||
return <Fingerprint className="size-3.5" />;
|
||||
case "alerts":
|
||||
@@ -615,6 +623,11 @@ export function renderTabContent(
|
||||
<FleetInventoryTab fleetId={tab.fleetId} isVisible={isVisible} />,
|
||||
);
|
||||
|
||||
case "collab":
|
||||
return withTabSuspense(
|
||||
<CollabRoomTab roomId={tab.collabRoomId} isVisible={isVisible} />,
|
||||
);
|
||||
|
||||
case "termix-id":
|
||||
return withTabSuspense(
|
||||
<PanelTabFrame>
|
||||
|
||||
@@ -32,7 +32,8 @@ export type RailView =
|
||||
| "automations"
|
||||
| "ai"
|
||||
| "fleets"
|
||||
| "workspaces";
|
||||
| "workspaces"
|
||||
| "collab";
|
||||
|
||||
export type HideableRailView =
|
||||
| Exclude<RailView, "user-profile" | "admin-settings">
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, Plus, Presentation, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { Badge } from "@/components/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog";
|
||||
import {
|
||||
createCollabRoom,
|
||||
listCollabRooms,
|
||||
type CollabRoom,
|
||||
} from "@/api/collab-api";
|
||||
import { getErrorMessage } from "@/lib/error-message";
|
||||
|
||||
export function CollabPanel({
|
||||
onOpenRoom,
|
||||
}: {
|
||||
onOpenRoom: (room: CollabRoom) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [rooms, setRooms] = useState<CollabRoom[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [persistent, setPersistent] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const result = await listCollabRooms();
|
||||
setRooms(result.rooms);
|
||||
} catch {
|
||||
/* the list stays as-is */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
async function handleCreate() {
|
||||
if (!name.trim()) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const { room } = await createCollabRoom(name.trim(), persistent);
|
||||
toast.success(t("collab.created"));
|
||||
setCreateOpen(false);
|
||||
setName("");
|
||||
setPersistent(false);
|
||||
await refresh();
|
||||
onOpenRoom(room);
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs flex-1"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
{t("collab.createRoom")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-2"
|
||||
onClick={() => void refresh()}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-6">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : rooms.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground px-1 py-4 text-center">
|
||||
{t("collab.noRooms")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{rooms.map((room) => (
|
||||
<button
|
||||
key={room.id}
|
||||
type="button"
|
||||
onClick={() => onOpenRoom(room)}
|
||||
className="flex items-center gap-2 px-2 py-1.5 text-left border border-border hover:bg-muted/50"
|
||||
>
|
||||
<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" />
|
||||
)}
|
||||
{room.persistent && (
|
||||
<Badge variant="outline" className="text-[9px] px-1 py-0">
|
||||
{t("collab.persistentRoom")}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("collab.createRoom")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Input
|
||||
placeholder={t("collab.roomName")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleCreate();
|
||||
}}
|
||||
/>
|
||||
<label className="flex items-start gap-2 text-xs cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
checked={persistent}
|
||||
onChange={(e) => setPersistent(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">
|
||||
{t("collab.persistentRoom")}
|
||||
</span>
|
||||
<br />
|
||||
<span className="text-muted-foreground">
|
||||
{t("collab.persistentRoomHint")}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCreateOpen(false)}
|
||||
disabled={creating}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={creating || !name.trim()}
|
||||
>
|
||||
{creating && <Loader2 className="size-3.5 mr-1 animate-spin" />}
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
User,
|
||||
Workflow,
|
||||
Zap,
|
||||
Presentation,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
@@ -83,6 +84,12 @@ export const RAIL_ITEMS: RailItemDef[] = [
|
||||
separatorAfter: true,
|
||||
rightDockable: true,
|
||||
},
|
||||
{
|
||||
id: "collab",
|
||||
icon: Presentation,
|
||||
labelKey: "nav.collab",
|
||||
separatorAfter: true,
|
||||
},
|
||||
{
|
||||
id: "quick-connect",
|
||||
icon: Zap,
|
||||
|
||||
@@ -48,6 +48,7 @@ describe("RAIL_ITEMS", () => {
|
||||
"credentials",
|
||||
"termix-id",
|
||||
"connections",
|
||||
"collab",
|
||||
"quick-connect",
|
||||
"serial",
|
||||
"ssh-tools",
|
||||
|
||||
Reference in New Issue
Block a user