mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
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:
@@ -2,6 +2,7 @@
|
|||||||
import {
|
import {
|
||||||
useState,
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
type MouseEvent,
|
type MouseEvent,
|
||||||
@@ -404,7 +405,7 @@ export function HostItem({
|
|||||||
if (compactHostView) {
|
if (compactHostView) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
draggable={!selectionMode && !isTouchOnly}
|
draggable={!selectionMode && !isTouchOnly && canEditHost(host)}
|
||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
e.dataTransfer.effectAllowed = "move";
|
e.dataTransfer.effectAllowed = "move";
|
||||||
onDragStart?.();
|
onDragStart?.();
|
||||||
@@ -934,7 +935,7 @@ export function HostItem({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
draggable={!selectionMode && !isTouchOnly}
|
draggable={!selectionMode && !isTouchOnly && canEditHost(host)}
|
||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
e.dataTransfer.effectAllowed = "move";
|
e.dataTransfer.effectAllowed = "move";
|
||||||
onDragStart?.();
|
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) {
|
function handleDragHostStart(hostId: string) {
|
||||||
// When the dragged host is part of an active selection, move the whole set.
|
// When the dragged host is part of an active selection, move the whole set.
|
||||||
if (selectionMode && selectedHostIds.has(hostId)) {
|
if (selectionMode && selectedHostIds.has(hostId)) {
|
||||||
@@ -1875,12 +1882,19 @@ export function SidebarTree({
|
|||||||
targetPath: string,
|
targetPath: string,
|
||||||
) {
|
) {
|
||||||
setDraggedHostIds(null);
|
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 {
|
try {
|
||||||
await bulkUpdateSSHHosts(hostIds.map(Number), { folder: targetPath });
|
await bulkUpdateSSHHosts(movableIds.map(Number), { folder: targetPath });
|
||||||
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
|
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
|
||||||
toast.success(
|
toast.success(
|
||||||
t("hosts.movedToFolder", {
|
t("hosts.movedToFolder", {
|
||||||
count: hostIds.length,
|
count: movableIds.length,
|
||||||
folder: targetPath || t("hosts.folderPickerNone"),
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user