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);
+ }
+ });
+});