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
This commit is contained in:
ZacharyZcR
2026-07-28 01:48:52 +08:00
committed by GitHub
parent ac5da581e2
commit 6bdd38159c
2 changed files with 94 additions and 4 deletions
+18 -4
View File
@@ -2,6 +2,7 @@
import {
useState,
useEffect,
useMemo,
useRef,
useLayoutEffect,
type MouseEvent,
@@ -404,7 +405,7 @@ export function HostItem({
if (compactHostView) {
return (
<div
draggable={!selectionMode && !isTouchOnly}
draggable={!selectionMode && !isTouchOnly && canEditHost(host)}
onDragStart={(e) => {
e.dataTransfer.effectAllowed = "move";
onDragStart?.();
@@ -934,7 +935,7 @@ export function HostItem({
return (
<div
draggable={!selectionMode && !isTouchOnly}
draggable={!selectionMode && !isTouchOnly && canEditHost(host)}
onDragStart={(e) => {
e.dataTransfer.effectAllowed = "move";
onDragStart?.();
@@ -1861,6 +1862,12 @@ export function SidebarTree({
};
}, []);
const hostsById = useMemo(() => {
const map = new Map<string, Host>();
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"),
}),
);
@@ -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);
}
});
});