From da840b925c3d11ad602cc20ec697d9a4149b9f37 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Sun, 20 Sep 2026 18:12:09 -0500 Subject: [PATCH] feat: Add Fleet Sharing functionality to the UI (#1228) https://github.com/Termix-SSH/Support/issues/1228 --- src/ui/api/fleets-api.ts | 36 ++- src/ui/locales/en.json | 9 +- src/ui/sidebar/FleetsPanel.tsx | 466 +++++++++++++++++++++++++++++++++ 3 files changed, 509 insertions(+), 2 deletions(-) diff --git a/src/ui/api/fleets-api.ts b/src/ui/api/fleets-api.ts index c54cc6676..d15ececbf 100644 --- a/src/ui/api/fleets-api.ts +++ b/src/ui/api/fleets-api.ts @@ -1,4 +1,9 @@ -import { authApi, handleApiError } from "@/main-axios"; +import { + authApi, + handleApiError, + type SharePermissionLevel, + type ShareTarget, +} from "@/main-axios"; export interface FleetRow { id: number; @@ -245,3 +250,32 @@ export async function runFleetPackageAction( throw handleApiError(error, "run fleet package action"); } } + +export interface FleetShareHostResult { + hostId: number; + shared: boolean; + reason?: string; +} + +export async function shareFleet( + fleetId: number, + shareData: { + targets: ShareTarget[]; + permissionLevel: SharePermissionLevel; + durationHours?: number; + }, +): Promise<{ + success: boolean; + permissionLevel: SharePermissionLevel; + expiresAt: string | null; + hostsShared: number; + hostsTotal: number; + hostResults: FleetShareHostResult[]; +}> { + try { + const response = await authApi.post(`/fleets/${fleetId}/share`, shareData); + return response.data; + } catch (error) { + throw handleApiError(error, "share fleet"); + } +} diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index eea4c7edb..66ba836a6 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -4370,7 +4370,14 @@ "packageUpgradeAll": "Upgrade all packages", "packageNameLabel": "Package name", "packageNameRequired": "A package name is required", - "packageHint": "Requires manage-level access per host and elevates using the host's stored sudo password." + "packageHint": "Requires manage-level access per host and elevates using the host's stored sudo password.", + "shareFleet": "Share Fleet", + "shareFleetTitle": "Share \"{{name}}\"", + "shareFleetDescription": "Shares this fleet's current member hosts with the selected users or roles. Hosts added to the fleet later are not shared automatically - share again to include them.", + "shareSelectionRequired": "Select at least one user or role", + "fleetSharedSuccessfully": "Shared {{shared}} of {{total}} host(s) in this fleet", + "failedToShareFleet": "Failed to share fleet", + "noMembersToShare": "This fleet has no member hosts to share yet." }, "workspaces": { "title": "Workspaces", diff --git a/src/ui/sidebar/FleetsPanel.tsx b/src/ui/sidebar/FleetsPanel.tsx index 28ef390f9..f322d265c 100644 --- a/src/ui/sidebar/FleetsPanel.tsx +++ b/src/ui/sidebar/FleetsPanel.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { Boxes, + Check, ChevronLeft, Download, ExternalLink, @@ -15,8 +16,11 @@ import { Search, Server, Settings2, + Share2, + Shield, Trash2, Upload, + User, X, } from "lucide-react"; import { Button } from "@/components/button"; @@ -34,6 +38,15 @@ import { } from "@/components/dialog"; import { FOLDER_COLORS } from "@/lib/theme"; import { getSSHHosts } from "@/api/ssh-host-management-api"; +import { getUserList } from "@/api/user-management-api"; +import { getRoles, type SharePermissionLevel, type ShareTarget } from "@/api/rbac-api"; +import { Select2 } from "@/components/select2"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/dropdown-menu"; import { extractSnippetInputs, type SnippetInput, @@ -52,14 +65,34 @@ import { getFleetInventory, refreshFleetInventory, runFleetPackageAction, + shareFleet, type FleetRow, type FleetMemberRow, type FleetHostResult, type FleetInventoryEntry, type FleetPackageAction, + type FleetShareHostResult, } from "@/api/fleets-api"; import type { SSHHost } from "@/types/index"; +const SHARE_PERMISSION_LEVELS: SharePermissionLevel[] = [ + "connect", + "view", + "edit", + "manage", +]; + +const SHARE_EXPIRY_PRESETS = [ + { key: "never", hours: undefined }, + { key: "oneHour", hours: 1 }, + { key: "oneDay", hours: 24 }, + { key: "sevenDays", hours: 24 * 7 }, + { key: "thirtyDays", hours: 24 * 30 }, + { key: "custom", hours: undefined }, +] as const; + +type ShareExpiryPresetKey = (typeof SHARE_EXPIRY_PRESETS)[number]["key"]; + function formatUptime(seconds: number | null): string { if (seconds === null) return "-"; const d = Math.floor(seconds / 86400); @@ -373,6 +406,406 @@ function MemberPickerDialog({ ); } +function FleetShareDialog({ + open, + onClose, + fleet, +}: { + open: boolean; + onClose: () => void; + fleet: FleetRow | null; +}) { + const { t } = useTranslation(); + const [targetTab, setTargetTab] = useState<"user" | "role">("user"); + const [search, setSearch] = useState(""); + const [shareUsers, setShareUsers] = useState< + { id: string; username: string }[] + >([]); + const [shareRoles, setShareRoles] = useState< + { id: number; name: string; displayName?: string }[] + >([]); + const [selectedUserIds, setSelectedUserIds] = useState>( + new Set(), + ); + const [selectedRoleIds, setSelectedRoleIds] = useState>( + new Set(), + ); + const [permissionLevel, setPermissionLevel] = + useState("connect"); + const [expiryPreset, setExpiryPreset] = + useState("never"); + const [customHours, setCustomHours] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [summary, setSummary] = useState<{ + hostsShared: number; + hostsTotal: number; + hostResults: FleetShareHostResult[]; + } | null>(null); + + useEffect(() => { + if (!open) return; + setSearch(""); + setSelectedUserIds(new Set()); + setSelectedRoleIds(new Set()); + setPermissionLevel("connect"); + setExpiryPreset("never"); + setCustomHours(""); + setTargetTab("user"); + setSummary(null); + Promise.all([ + getUserList().catch(() => ({ users: [] })), + getRoles().catch(() => ({ roles: [] })), + ]).then(([usersRes, rolesRes]) => { + setShareUsers( + (usersRes.users ?? []).map((u) => ({ + id: String(u.userId), + username: u.username, + })), + ); + setShareRoles( + (rolesRes.roles ?? []) + .filter((r) => !r.isSystem) + .map((r) => ({ + id: Number(r.id), + name: r.name, + displayName: r.displayName, + })), + ); + }); + }, [open]); + + const filteredUsers = useMemo(() => { + const q = search.trim().toLowerCase(); + return q + ? shareUsers.filter((u) => u.username.toLowerCase().includes(q)) + : shareUsers; + }, [shareUsers, search]); + + const filteredRoles = useMemo(() => { + const q = search.trim().toLowerCase(); + return q + ? shareRoles.filter( + (r) => + r.name.toLowerCase().includes(q) || + (r.displayName ?? "").toLowerCase().includes(q), + ) + : shareRoles; + }, [shareRoles, search]); + + const selectedCount = selectedUserIds.size + selectedRoleIds.size; + + const durationHours = (() => { + if (expiryPreset === "never") return undefined; + if (expiryPreset === "custom") { + const hours = Number(customHours); + return Number.isFinite(hours) && hours > 0 ? hours : undefined; + } + return ( + SHARE_EXPIRY_PRESETS.find((p) => p.key === expiryPreset)?.hours ?? + undefined + ); + })(); + + async function handleShare() { + if (!fleet || selectedCount === 0) return; + const targets: ShareTarget[] = [ + ...[...selectedUserIds].map( + (id) => ({ type: "user", id }) as ShareTarget, + ), + ...[...selectedRoleIds].map( + (id) => ({ type: "role", id }) as ShareTarget, + ), + ]; + + setSubmitting(true); + try { + const result = await shareFleet(fleet.id, { + targets, + permissionLevel, + ...(durationHours ? { durationHours } : {}), + }); + setSummary({ + hostsShared: result.hostsShared, + hostsTotal: result.hostsTotal, + hostResults: result.hostResults, + }); + setSelectedUserIds(new Set()); + setSelectedRoleIds(new Set()); + toast.success( + t("newUi.sidebar.fleets.fleetSharedSuccessfully", { + shared: result.hostsShared, + total: result.hostsTotal, + }), + ); + } catch (error) { + const message = getErrorMessage(error, ""); + toast.error(message || t("newUi.sidebar.fleets.failedToShareFleet")); + } finally { + setSubmitting(false); + } + } + + return ( + !next && onClose()}> + + + + {t("newUi.sidebar.fleets.shareFleetTitle", { + name: fleet?.name ?? "", + })} + + + {t("newUi.sidebar.fleets.shareFleetDescription")} + + + +
+ {fleet && fleet.memberCount === 0 ? ( +
+ {t("newUi.sidebar.fleets.noMembersToShare")} +
+ ) : ( + <> +
+ {(["user", "role"] as const).map((tab) => ( + + ))} +
+ +
+ + setSearch(e.target.value)} + className="pl-8" + /> +
+ +
+ {targetTab === "user" && + (filteredUsers.length === 0 ? ( +
+ {t("hosts.sharing.noMatches")} +
+ ) : ( + filteredUsers.map((user) => { + const isSelected = selectedUserIds.has(user.id); + return ( + + ); + }) + ))} + {targetTab === "role" && + (filteredRoles.length === 0 ? ( +
+ {t("hosts.sharing.noMatches")} +
+ ) : ( + filteredRoles.map((role) => { + const isSelected = selectedRoleIds.has(role.id); + return ( + + ); + }) + ))} +
+ +
+
+ + {t("hosts.sharing.permissionLevelLabel")} + + + setPermissionLevel( + e.target.value as SharePermissionLevel, + ) + } + className="h-8 w-full px-2.5 text-xs border border-border bg-background hover:bg-muted/40 transition-colors" + > + {SHARE_PERMISSION_LEVELS.map((level) => ( + + ))} + +
+ + + + + + + {SHARE_EXPIRY_PRESETS.map((preset) => ( + setExpiryPreset(preset.key)} + > + {expiryPreset === preset.key ? ( + + ) : ( + + )} + {t(`hosts.sharing.expiry.${preset.key}`)} + + ))} + + +
+ +

+ {t(`hosts.sharing.levels.${permissionLevel}.description`)} +

+ + {expiryPreset === "custom" && ( + setCustomHours(e.target.value)} + className="shrink-0 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> + )} + + {summary && ( +
+ + {t("newUi.sidebar.fleets.fleetSharedSuccessfully", { + shared: summary.hostsShared, + total: summary.hostsTotal, + })} + + {summary.hostResults.some((r) => !r.shared) && ( +
+ {summary.hostResults + .filter((r) => !r.shared) + .map((r) => ( + + {t("newUi.sidebar.fleets.resultFailed")}: # + {r.hostId} ({r.reason ?? "unknown"}) + + ))} +
+ )} +
+ )} + + )} +
+ + + + {fleet && fleet.memberCount > 0 && ( + + )} + +
+
+ ); +} + function RunCommandTab({ fleetId }: { fleetId: number }) { const { t } = useTranslation(); const [command, setCommand] = useState(""); @@ -821,6 +1254,7 @@ function FleetDetail({ const [members, setMembers] = useState([]); const [loadingMembers, setLoadingMembers] = useState(true); const [pickerOpen, setPickerOpen] = useState(false); + const [shareOpen, setShareOpen] = useState(false); const [actionView, setActionView] = useState("run"); const loadMembers = useCallback(async () => { @@ -865,6 +1299,14 @@ function FleetDetail({ variant="outline" size="sm" className="ml-auto" + onClick={() => setShareOpen(true)} + > + + {t("newUi.sidebar.fleets.shareFleet")} + +