From 6bdd38159c3e783aca64ddcac7423470c92343f3 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Tue, 28 Jul 2026 01:48:52 +0800 Subject: [PATCH] stop read-only shared hosts from being dragged into folders (#1119) Shared hosts hide their edit, share and delete actions based on the recipient's permission level, but the sidebar row stays draggable regardless. Dropping one on a folder issues a bulk folder update the server rejects, so a recipient without edit rights gets a failure toast for an action the UI offered them. Gate draggable on canEditHost, and skip hosts the recipient cannot edit in the move handler so a mixed selection moves what it can instead of failing whole. Closes Termix-SSH/Support#1011 --- src/ui/sidebar/SidebarTree.tsx | 22 +++++- src/ui/tests/sidebar/host-permissions.test.ts | 76 +++++++++++++++++++ 2 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 src/ui/tests/sidebar/host-permissions.test.ts diff --git a/src/ui/sidebar/SidebarTree.tsx b/src/ui/sidebar/SidebarTree.tsx index 880c3c63..bfcac53b 100644 --- a/src/ui/sidebar/SidebarTree.tsx +++ b/src/ui/sidebar/SidebarTree.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, + useMemo, useRef, useLayoutEffect, type MouseEvent, @@ -404,7 +405,7 @@ export function HostItem({ if (compactHostView) { return (
{ e.dataTransfer.effectAllowed = "move"; onDragStart?.(); @@ -934,7 +935,7 @@ export function HostItem({ return (
{ e.dataTransfer.effectAllowed = "move"; onDragStart?.(); @@ -1861,6 +1862,12 @@ export function SidebarTree({ }; }, []); + const hostsById = useMemo(() => { + const map = new Map(); + for (const host of collectAllHosts(children)) map.set(host.id, host); + return map; + }, [children]); + function handleDragHostStart(hostId: string) { // When the dragged host is part of an active selection, move the whole set. if (selectionMode && selectedHostIds.has(hostId)) { @@ -1875,12 +1882,19 @@ export function SidebarTree({ targetPath: string, ) { setDraggedHostIds(null); + // A selection can mix owned hosts with shared ones the recipient may not + // edit; moving those would fail server-side and take the whole batch down. + const movableIds = hostIds.filter((id) => { + const host = hostsById.get(id); + return !host || canEditHost(host); + }); + if (movableIds.length === 0) return; try { - await bulkUpdateSSHHosts(hostIds.map(Number), { folder: targetPath }); + await bulkUpdateSSHHosts(movableIds.map(Number), { folder: targetPath }); window.dispatchEvent(new CustomEvent("termix:hosts-changed")); toast.success( t("hosts.movedToFolder", { - count: hostIds.length, + count: movableIds.length, folder: targetPath || t("hosts.folderPickerNone"), }), ); diff --git a/src/ui/tests/sidebar/host-permissions.test.ts b/src/ui/tests/sidebar/host-permissions.test.ts new file mode 100644 index 00000000..9507b9b4 --- /dev/null +++ b/src/ui/tests/sidebar/host-permissions.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import type { Host, SharePermissionLevel } from "@/types/ui-types"; +import { + canDeleteHost, + canEditHost, + canShareHost, + canViewHostConfig, +} from "../../sidebar/host-permissions"; + +function ownHost(): Host { + return { id: "1", name: "own", isShared: false } as Host; +} + +function sharedHost(permissionLevel?: SharePermissionLevel): Host { + return { id: "2", name: "shared", isShared: true, permissionLevel } as Host; +} + +describe("host permissions", () => { + it("grants every action on a host you own", () => { + const host = ownHost(); + + expect(canViewHostConfig(host)).toBe(true); + expect(canEditHost(host)).toBe(true); + expect(canShareHost(host)).toBe(true); + expect(canDeleteHost(host)).toBe(true); + }); + + it("limits a connect-level recipient to connecting", () => { + const host = sharedHost("connect"); + + expect(canViewHostConfig(host)).toBe(false); + expect(canEditHost(host)).toBe(false); + expect(canShareHost(host)).toBe(false); + }); + + it("lets a view-level recipient read the config but not change it", () => { + const host = sharedHost("view"); + + expect(canViewHostConfig(host)).toBe(true); + expect(canEditHost(host)).toBe(false); + expect(canShareHost(host)).toBe(false); + }); + + it("lets an edit-level recipient edit but not re-share", () => { + const host = sharedHost("edit"); + + expect(canEditHost(host)).toBe(true); + expect(canShareHost(host)).toBe(false); + }); + + it("lets a manage-level recipient edit and re-share", () => { + const host = sharedHost("manage"); + + expect(canEditHost(host)).toBe(true); + expect(canShareHost(host)).toBe(true); + }); + + it("treats a shared host with no level as connect-only", () => { + const host = sharedHost(undefined); + + expect(canViewHostConfig(host)).toBe(false); + expect(canEditHost(host)).toBe(false); + expect(canShareHost(host)).toBe(false); + }); + + it("never lets a recipient delete a shared host", () => { + for (const level of [ + "connect", + "view", + "edit", + "manage", + ] as SharePermissionLevel[]) { + expect(canDeleteHost(sharedHost(level))).toBe(false); + } + }); +});