diff --git a/packages/plugin-sdk/src/testing.ts b/packages/plugin-sdk/src/testing.ts index 59cff4b37..f1d811755 100644 --- a/packages/plugin-sdk/src/testing.ts +++ b/packages/plugin-sdk/src/testing.ts @@ -1293,6 +1293,9 @@ export interface MockContextOptions { permissions?: string[]; /** Hosts ctx.hosts.list/get/checkAccess serve. See FakeContextOptions. */ hosts?: PluginHostSummary[]; + /** Users and roles ctx.hosts.listUsers/listRoles serve. See FakeContextOptions. */ + shareableUsers?: PluginShareableUser[]; + shareableRoles?: PluginShareableRole[]; /** Other plugins' services. See FakeContextOptions. */ services?: Record; /** Hosts ctx.ssh.resolveHost serves. See FakeContextOptions. */ @@ -1361,6 +1364,8 @@ export function createMockCtx( router: options.router, permissions: options.permissions, hosts: options.hosts, + shareableUsers: options.shareableUsers, + shareableRoles: options.shareableRoles, sshHosts: options.sshHosts, services: options.services, protocolTargets: options.protocolTargets, diff --git a/plugins/snippets/locales/en.json b/plugins/snippets/locales/en.json index d452244fa..cdca75ada 100644 --- a/plugins/snippets/locales/en.json +++ b/plugins/snippets/locales/en.json @@ -149,5 +149,51 @@ "clearSearch": "Clear search", "noSearchResults": "No snippets match your search", "emptyTitle": "No snippets yet", - "emptyDescription": "Save commands you run often, then run them in a terminal with one click." + "emptyDescription": "Save commands you run often, then run them in a terminal with one click.", + "shareSnippet": "Share snippet", + "shareFolder": "Share folder", + "shareExpiry": "Expires", + "shareExpiryOptions": { + "h0": "Never", + "h1": "In 1 hour", + "h24": "In 1 day", + "h168": "In 7 days", + "h720": "In 30 days" + }, + "shareButton": "Share", + "shareSuccess": "Shared with {{count}}", + "shareFailed": "Failed to share", + "shareLoadFailed": "Failed to load sharing details", + "currentAccess": "Current access", + "notSharedYet": "Not shared with anyone yet", + "revokeAccess": "Revoke", + "revokeSuccess": "Access revoked", + "revokeFailed": "Failed to revoke access", + "close": "Close", + "targetHostsLabel": "Target hosts", + "targetHostsHint": "Pick hosts to run this command on directly, without a terminal open. Leave empty to run it in the active terminal.", + "clearTargetHosts": "Clear", + "noHostsAvailable": "No hosts available", + "searchHosts": "Search hosts", + "runOnTargets": "Run on target hosts", + "executionResultTitle": "Results for \"{{name}}\"", + "executionResultDescription": "Output from each target host.", + "executionSuccess": "Success", + "executionFailed": "Failed", + "directRunSuccess": "Ran \"{{name}}\" on {{count}} host(s)", + "directRunPartialFail": "\"{{name}}\" failed on some hosts", + "reorderFailed": "Failed to reorder snippets", + "dropToRemoveFromFolder": "Drop here to move out of the folder", + "shareSnippetTitle": "Share \"{{name}}\"", + "shareFolderTitle": "Share folder \"{{name}}\"", + "shareWith": "Share with", + "usersTab": "Users", + "rolesTab": "Roles", + "shareSearchPlaceholder": "Search users or roles", + "shareNoMatches": "No matches", + "shareWithCount": "Share with {{count}}", + "shareFolderHint": "Shares every snippet in this folder. Snippets added later are not shared automatically.", + "shareFolderSummary": "Shared {{count}} snippet(s) in this folder", + "grantedBy": "Granted by", + "expired": "Expired" } diff --git a/plugins/snippets/manifest.json b/plugins/snippets/manifest.json index 6b257870f..6ef88d7fb 100644 --- a/plugins/snippets/manifest.json +++ b/plugins/snippets/manifest.json @@ -19,6 +19,7 @@ "network:serve", "ssh:connect", "credentials:use", + "hosts:write", "ui:surface", "events:core" ], diff --git a/plugins/snippets/src/backend/routes.ts b/plugins/snippets/src/backend/routes.ts index 27d80dad0..84ef3e72e 100644 --- a/plugins/snippets/src/backend/routes.ts +++ b/plugins/snippets/src/backend/routes.ts @@ -926,6 +926,68 @@ export function registerSnippetRoutes( // Sharing (moved from core's /rbac/snippet* routes) + /** + * @openapi + * /plugin-api/snippets/share-targets/users: + * get: + * summary: Users the caller may share snippets with + * tags: + * - Snippets + * responses: + * 200: + * description: Users list. + * 500: + * description: Failed to list users. + */ + router.get( + "/share-targets/users", + ctx.rbac.require("share") as never, + async (_req: Request, res: Response) => { + try { + res.json({ users: await ctx.hosts.listUsers() }); + } catch (err) { + logError( + ctx, + "Failed to list share target users", + err, + "snippet_share_targets_users_failed", + ); + res.status(500).json({ error: "Failed to list users" }); + } + }, + ); + + /** + * @openapi + * /plugin-api/snippets/share-targets/roles: + * get: + * summary: Non-system roles the caller may share snippets with + * tags: + * - Snippets + * responses: + * 200: + * description: Roles list. + * 500: + * description: Failed to list roles. + */ + router.get( + "/share-targets/roles", + ctx.rbac.require("share") as never, + async (_req: Request, res: Response) => { + try { + res.json({ roles: await ctx.hosts.listRoles() }); + } catch (err) { + logError( + ctx, + "Failed to list share target roles", + err, + "snippet_share_targets_roles_failed", + ); + res.status(500).json({ error: "Failed to list roles" }); + } + }, + ); + /** * @openapi * /plugin-api/snippets/{id}/share: diff --git a/plugins/snippets/src/frontend/ExecutionResultsDialog.tsx b/plugins/snippets/src/frontend/ExecutionResultsDialog.tsx new file mode 100644 index 000000000..96aa5804f --- /dev/null +++ b/plugins/snippets/src/frontend/ExecutionResultsDialog.tsx @@ -0,0 +1,85 @@ +import { useTranslation } from "@termix/plugin-sdk/frontend"; +import { Loader2, Server } from "lucide-react"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@termix/plugin-sdk/ui"; + +export interface HostExecutionResult { + hostId: number; + hostLabel: string; + /** Null while the command is still running on this host. */ + success: boolean | null; + output: string; + error?: string; +} + +export function ExecutionResultsDialog({ + snippetName, + results, + onClose, +}: { + snippetName: string; + results: HostExecutionResult[]; + onClose: () => void; +}) { + const { t } = useTranslation(); + + return ( + !open && onClose()}> + + + + {t("executionResultTitle", { name: snippetName })} + + + {t("executionResultDescription")} + + +
+ {results.map((result) => ( +
+
+ + + {result.hostLabel} + + {result.success === null ? ( + + ) : ( + + {t(result.success ? "executionSuccess" : "executionFailed")} + + )} +
+ {(result.output || result.error) && ( +
+                  {result.error || result.output}
+                
+ )} +
+ ))} +
+ + + +
+
+ ); +} diff --git a/plugins/snippets/src/frontend/SnippetEditor.tsx b/plugins/snippets/src/frontend/SnippetEditor.tsx index 29f4b086e..80b6d0612 100644 --- a/plugins/snippets/src/frontend/SnippetEditor.tsx +++ b/plugins/snippets/src/frontend/SnippetEditor.tsx @@ -1,8 +1,8 @@ -import { useState } from "react"; -import { useTranslation } from "@termix/plugin-sdk/frontend"; -import { ArrowLeft, FileText } from "lucide-react"; +import { useMemo, useState } from "react"; +import { useHosts, useTranslation } from "@termix/plugin-sdk/frontend"; +import { ArrowLeft, Check, FileText, Search, Server, Zap } from "lucide-react"; import { Button, Input, SectionCard, Select2 } from "@termix/plugin-sdk/ui"; -import type { Snippet, SnippetFolder } from "./types"; +import { parseHostFilter, type Snippet, type SnippetFolder } from "./types"; export interface SnippetFormValues { name: string; @@ -10,6 +10,7 @@ export interface SnippetFormValues { description: string; folder: string; isNote: boolean; + hostIds: number[]; } const labelClass = @@ -36,7 +37,10 @@ export function SnippetEditor({ description: snippet?.description ?? "", folder: snippet?.folder ?? defaultFolder ?? "", isNote: snippet?.isNote ?? false, + hostIds: parseHostFilter(snippet?.hostFilter), })); + const { hosts } = useHosts(); + const [hostSearch, setHostSearch] = useState(""); const [saving, setSaving] = useState(false); const set = ( @@ -53,6 +57,29 @@ export function SnippetEditor({ ]), ).sort((a, b) => a.localeCompare(b)); + const hostQuery = hostSearch.trim().toLowerCase(); + const visibleHosts = useMemo( + () => + hosts + .filter( + (host) => + !hostQuery || + host.name.toLowerCase().includes(hostQuery) || + host.ip.toLowerCase().includes(hostQuery), + ) + .sort((a, b) => a.name.localeCompare(b.name)), + [hosts, hostQuery], + ); + + function toggleHost(id: number) { + set( + "hostIds", + values.hostIds.includes(id) + ? values.hostIds.filter((h) => h !== id) + : [...values.hostIds, id], + ); + } + async function handleSave() { if (!canSave) return; setSaving(true); @@ -168,6 +195,80 @@ export function SnippetEditor({ + {!values.isNote && ( + } + action={ + values.hostIds.length > 0 ? ( + + ) : undefined + } + > +
+ + {t("targetHostsHint")} + + {hosts.length === 0 ? ( + + {t("noHostsAvailable")} + + ) : ( +
+
+ + setHostSearch(e.target.value)} + placeholder={t("searchHosts")} + className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 min-w-0" + /> +
+
+ {visibleHosts.map((host) => { + const id = Number(host.id); + const selected = values.hostIds.includes(id); + return ( + + ); + })} +
+
+ )} +
+
+ )} +
+ + {loadError && ( +
+ +
{t("shareLoadFailed")}
+
+ )} + +
+
+
+ + {t("shareWith")} +
+ + {t("docsLink")} + +
+ +
+ {(["user", "role"] as const).map((key) => { + const count = + key === "user" ? selectedUsers.size : selectedRoles.size; + return ( + + ); + })} +
+ +
+ + setSearch(e.target.value)} + className="pl-8" + /> +
+ +
+ {tab === "user" && + (filteredUsers.length === 0 ? ( +
+ {t("shareNoMatches")} +
+ ) : ( + filteredUsers.map((user) => { + const selected = selectedUsers.has(user.id); + return ( + + ); + }) + ))} + {tab === "role" && + (filteredRoles.length === 0 ? ( +
+ {t("shareNoMatches")} +
+ ) : ( + filteredRoles.map((role) => { + const selected = selectedRoles.has(role.id); + return ( + + ); + }) + ))} +
+ +
+ + + + + + {EXPIRY_HOURS.map((hours) => ( + setExpiryHours(hours)} + > + {expiryHours === hours ? ( + + ) : ( + + )} + {t(`shareExpiryOptions.h${hours}`)} + + ))} + + + + +
+ + {target.kind === "folder" && ( +

+ {t("shareFolderHint")} +

+ )} +
+ + {target.kind === "folder" && folderSummary !== null && ( +
+ + {t("shareFolderSummary", { count: folderSummary })} +
+ )} + + {target.kind === "snippet" && ( +
+
+ + {t("currentAccess")} + {access.length > 0 && ( + + ({access.length}) + + )} +
+
+ {access.length === 0 && ( +
+ {t("notSharedYet")} +
+ )} + {access.map((entry) => { + const expired = + entry.expiresAt && new Date(entry.expiresAt) < new Date(); + return ( +
+
+
+ {entry.targetType === "user" ? ( + + ) : ( + + )} + + {entry.username ?? + entry.roleDisplayName ?? + entry.roleName ?? + entry.userId ?? + entry.roleId} + +
+ +
+
+ + {t("grantedBy")}:{" "} + + {entry.grantedByUsername ?? "?"} + + + + {t("shareExpiry")}:{" "} + {expired ? ( + + + {t("expired")} + + ) : ( + + {entry.expiresAt + ? new Date(entry.expiresAt).toLocaleString() + : t("shareExpiryOptions.h0")} + + )} + +
+
+ ); + })} +
+
+ )} +
+ ); +} diff --git a/plugins/snippets/src/frontend/SnippetTree.tsx b/plugins/snippets/src/frontend/SnippetTree.tsx index 189cce25d..7b66e0621 100644 --- a/plugins/snippets/src/frontend/SnippetTree.tsx +++ b/plugins/snippets/src/frontend/SnippetTree.tsx @@ -5,14 +5,17 @@ import { ClipboardPaste, Copy, FolderInput, + GripVertical, MoreHorizontal, Pencil, Play, Plus, + Share2, StickyNote, Terminal, Trash2, Users, + Zap, } from "lucide-react"; import { DropdownMenu, @@ -30,42 +33,93 @@ import type { Snippet, SnippetFolder } from "./types"; const trayButtonClass = "flex items-center justify-center size-[22.75px] text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors"; +export type DropPosition = "above" | "below"; + export function SnippetRow({ snippet, stripeIndex, showCommand, folderNames, + targetHostNames, canEdit, canDelete, + canShare, + draggable, + dragActive, + isDragging, + dropIndicator, onRun, onCopy, onEdit, + onShare, onMove, onDelete, + onDragStart, + onDragEnd, + onDragOverRow, + onDropRow, }: { snippet: Snippet; stripeIndex: number; showCommand: boolean; folderNames: string[]; + /** Hosts a command snippet runs on directly, instead of the active terminal. */ + targetHostNames: string[]; canEdit: boolean; canDelete: boolean; + canShare: boolean; + draggable: boolean; + /** True while any snippet is being dragged, so this row is a drop target. */ + dragActive: boolean; + isDragging: boolean; + dropIndicator: DropPosition | null; onRun: () => void; onCopy: () => void; onEdit: () => void; + onShare: () => void; onMove: (folder: string | null) => void; onDelete: () => void; + onDragStart: () => void; + onDragEnd: () => void; + onDragOverRow: (position: DropPosition) => void; + onDropRow: () => void; }) { const { t } = useTranslation(); const [menuOpen, setMenuOpen] = useState(false); const owned = !snippet.isShared; const editable = canEdit && owned; const deletable = canDelete && owned; + const shareable = canShare && owned; + const hasTargets = !snippet.isNote && targetHostNames.length > 0; const TypeIcon = snippet.isNote ? StickyNote : Terminal; - const RunIcon = snippet.isNote ? ClipboardPaste : Play; - const runLabel = t(snippet.isNote ? "pasteToTerminal" : "run"); + const RunIcon = hasTargets ? Zap : snippet.isNote ? ClipboardPaste : Play; + const runLabel = t( + hasTargets ? "runOnTargets" : snippet.isNote ? "pasteToTerminal" : "run", + ); return (
{ + e.dataTransfer.effectAllowed = "move"; + onDragStart(); + }} + onDragEnd={onDragEnd} + onDragOver={(e) => { + if (!dragActive) return; + e.preventDefault(); + e.stopPropagation(); + const rect = e.currentTarget.getBoundingClientRect(); + onDragOverRow( + e.clientY < rect.top + rect.height / 2 ? "above" : "below", + ); + }} + onDrop={(e) => { + if (!dragActive) return; + e.preventDefault(); + e.stopPropagation(); + onDropRow(); + }} onDoubleClick={onRun} onContextMenu={(e) => { e.preventDefault(); @@ -73,12 +127,24 @@ export function SnippetRow({ }} title={t("doubleClickToRun")} className={`group relative flex items-stretch select-none border-b border-border/40 transition-colors hover:bg-muted/50 ${ + draggable ? "cursor-grab active:cursor-grabbing" : "" + } ${ menuOpen ? "bg-muted/50" : stripeIndex % 2 === 1 ? "bg-muted/15" : "" - }`} + } ${isDragging ? "opacity-40" : ""}`} > + {dropIndicator && ( +
+ )}
+ {draggable && ( +
+ +
+ )}
@@ -107,6 +173,24 @@ export function SnippetRow({ {snippet.content.split("\n")[0]} )} + {hasTargets && ( +
+ + {targetHostNames.slice(0, 3).map((name) => ( + + {name} + + ))} + {targetHostNames.length > 3 && ( + + +{targetHostNames.length - 3} + + )} +
+ )}
)} + {shareable && ( + + )}