/* eslint-disable react-hooks/exhaustive-deps */ import { enabledHostProtocols, hostProtocolFlags, type HostProtocols, } from "./host-protocols"; import React, { useState, useEffect, useRef, type MutableRefObject, } from "react"; import { useTranslation } from "react-i18next"; import type { HostDraft } from "@termix/plugin-sdk/frontend"; import { CredentialShareModal } from "./CredentialShareModal"; import { Button } from "@/components/button"; import { ArrowLeft, ChevronDown, Search, X } from "lucide-react"; import { useUiPreference } from "@/contexts/UiPreferencesContext"; import { toast } from "sonner"; import { getSSHHosts, getCredentials, getCredentialDetails, deleteCredential, duplicateCredential, updateCredential, deployCredentialToHost, renameCredentialFolder, } from "@/main-axios"; import type { Host, Credential } from "@/types/ui-types"; import type { SSHHostWithStatus } from "@/main-axios"; import type { CredentialSidebarFilterState, CredentialSortKey, } from "@/types/credential-sidebar-preferences"; import { CredentialEditorView } from "./CredentialEditorView"; import { HostEditor } from "./HostEditor"; import { mapCredentials, sshHostToHost } from "./HostManagerData"; import { CredentialSidebarTree } from "./credential-tree"; import { sortCredentials, credentialPassesFilters } from "./credential-sort"; import { makeCredentialTabs, makeHostTabs, makeHostSshSubTabs, isSshGroupTab, useHostEditorSections, TabStrip, } from "./HostManagerTabs"; import { Select2 } from "@/components/select2"; export function HostManager({ pendingEditId, pendingAction, onEditingChange, hideListHeader, externalSearch, externalSort, externalArrangeLocked = true, externalFilter, density = "comfortable", trayTrigger = "hover", showTags = true, onTagsChange, active = true, }: { pendingEditId?: MutableRefObject; pendingAction?: MutableRefObject<"add-host" | "add-credential" | null>; onEditingChange?: (editing: boolean) => void; hideListHeader?: boolean; externalSearch?: string; externalSort?: CredentialSortKey; externalArrangeLocked?: boolean; externalFilter?: CredentialSidebarFilterState; density?: "comfortable" | "compact"; trayTrigger?: "always" | "hover" | "click" | "actionsOnly"; showTags?: boolean; onTagsChange?: (tags: string[]) => void; active?: boolean; } = {}) { // Re-render when a plugin adds or removes a host editor tab. useHostEditorSections(); const { t } = useTranslation(); const [editingHost, setEditingHost] = useState(null); // Fields a plugin filled in for a new host, keyed so a second draft remounts // the editor. const [hostDraft, setHostDraft] = useState<{ key: number; draft: HostDraft; } | null>(null); const [shareCredential, setShareCredential] = useState( null, ); const [editingCredential, setEditingCredential] = useState< Credential | "new" | null >(null); const [activeHostTab, setActiveHostTab] = useState("general"); const [activeCredentialTab, setActiveCredentialTab] = useState("general"); const [searchQuery, setSearchQuery] = useState(""); const effectiveSearch = externalSearch ?? searchQuery; const [hosts, setHosts] = useState([]); const [credentials, setCredentials] = useState([]); const [credentialsLoading, setCredentialsLoading] = useState(true); const [deployDialog, setDeployDialog] = useState<{ cred: Credential; hostId: string; } | null>(null); const [deploying, setDeploying] = useState(false); const [confirmDialog, setConfirmDialog] = useState<{ message: string; onConfirm: () => void; } | null>(null); const [hostEditorDirty, setHostEditorDirty] = useState(false); const [showUnsavedHostDialog, setShowUnsavedHostDialog] = useState(false); const [editingProtocols, setEditingProtocols] = useState(() => hostProtocolFlags(null), ); const simpleEditor = useUiPreference("hostEditor", "mode") === "simple"; // Expanding advanced is a per-session choice; fixing one host's SSH options // shouldn't quietly move the whole app off the Simple preset. const [showAdvancedEditor, setShowAdvancedEditor] = useState(false); const hostsRef = useRef([]); useEffect(() => { hostsRef.current = hosts; }, [hosts]); const [editingCredFolderName, setEditingCredFolderName] = useState< string | null >(null); const [editingCredFolderValue, setEditingCredFolderValue] = useState(""); // Remembers the host being edited when its credential is opened for // editing, so "back" can return to that host instead of the list. const [credentialReturnHost, setCredentialReturnHost] = useState< Host | "new" | null >(null); useEffect(() => { onTagsChange?.([...new Set(credentials.flatMap((c) => c.tags ?? []))]); }, [credentials]); const applyPendingEdit = (hostList: Host[]) => { if (pendingEditId?.current) { const id = pendingEditId.current; pendingEditId.current = null; const host = hostList.find((h) => h.id === id); if (host) { setEditingHost(host); setEditingCredential(null); setActiveHostTab("general"); setEditingProtocols(hostProtocolFlags(host)); return true; } } return false; }; const reloadHosts = () => { getSSHHosts() .then((raw) => { const converted = raw.map(sshHostToHost); setHosts(converted); applyPendingEdit(converted); }) .catch(() => {}); }; const reloadCredentials = () => { getCredentials() .then((res) => setCredentials(mapCredentials(res))) .catch(() => {}) .finally(() => setCredentialsLoading(false)); }; useEffect(() => { reloadHosts(); reloadCredentials(); window.addEventListener("termix:hosts-changed", reloadHosts); window.addEventListener("ssh-hosts:changed", reloadHosts); window.addEventListener("hosts:refresh", reloadHosts); window.addEventListener("termix:credentials-changed", reloadCredentials); return () => { window.removeEventListener("termix:hosts-changed", reloadHosts); window.removeEventListener("ssh-hosts:changed", reloadHosts); window.removeEventListener("hosts:refresh", reloadHosts); window.removeEventListener( "termix:credentials-changed", reloadCredentials, ); }; }, []); useEffect(() => { if (pendingAction?.current) { const action = pendingAction.current; pendingAction.current = null; if (action === "add-host") { setEditingHost("new"); setHostDraft(null); setEditingCredential(null); setEditingProtocols(hostProtocolFlags(null)); setActiveHostTab("general"); } else if (action === "add-credential") { setEditingCredential("new"); setEditingHost(null); setActiveCredentialTab("general"); } } }, [pendingEditId, pendingAction]); useEffect(() => { if (!active) return; const handleAddHost = (e: Event) => { const draft = (e as CustomEvent).detail; setEditingHost("new"); setHostDraft(draft ? { key: Date.now(), draft } : null); setEditingCredential(null); setEditingProtocols(hostProtocolFlags(null)); setActiveHostTab("general"); }; const handleAddCredential = () => { setEditingCredential("new"); setEditingHost(null); setActiveCredentialTab("general"); }; const handleEditHost = (e: Event) => { const id = (e as CustomEvent).detail; const host = hostsRef.current.find((h) => h.id === id); if (host) { setEditingHost(host); setEditingCredential(null); setActiveHostTab("general"); setEditingProtocols(hostProtocolFlags(host)); } }; window.addEventListener("host-manager:add-host", handleAddHost); window.addEventListener("host-manager:add-credential", handleAddCredential); window.addEventListener("host-manager:edit-host", handleEditHost); return () => { window.removeEventListener("host-manager:add-host", handleAddHost); window.removeEventListener( "host-manager:add-credential", handleAddCredential, ); window.removeEventListener("host-manager:edit-host", handleEditHost); }; }, [active]); const allHosts = hosts; const searchedCredentials = credentials.filter( (c) => c.name.toLowerCase().includes(effectiveSearch.toLowerCase()) || c.username.toLowerCase().includes(effectiveSearch.toLowerCase()), ); const filteredCredentials = sortCredentials( externalFilter ? searchedCredentials.filter((c) => credentialPassesFilters(c, externalFilter), ) : searchedCredentials, externalSort ?? "default", ); const credentialFolderNames = Array.from( new Set(filteredCredentials.map((c) => c.folder || "Uncategorized")), ).sort(); const credentialFolderTree = credentialFolderNames.map((name) => ({ name, children: filteredCredentials.filter( (c) => (c.folder || "Uncategorized") === name, ), })); const usedByCounts = new Map(); for (const cred of filteredCredentials) { usedByCounts.set( cred.id, allHosts.filter((h) => h.credentialId === cred.id).length, ); } const handleRenameCredentialFolder = async ( folder: string, newName: string, ) => { try { await renameCredentialFolder(folder, newName); const res = await getCredentials(); setCredentials(mapCredentials(res)); toast.success(t("credentials.folderRenamedTo", { name: newName })); } catch { toast.error(t("credentials.failedToRenameFolder")); } }; const handleMoveCredentialToFolder = async ( credentialId: string, targetFolder: string, ) => { const cred = credentials.find((c) => c.id === credentialId); if (!cred || (cred.folder || "Uncategorized") === targetFolder) return; const folderValue = targetFolder === "Uncategorized" ? "" : targetFolder; try { await updateCredential(Number(credentialId), { folder: folderValue }); setCredentials((prev) => prev.map((c) => c.id === credentialId ? { ...c, folder: folderValue } : c, ), ); } catch { toast.error(t("credentials.failedToMoveCredential")); } }; const handleCloneCredential = async (cred: Credential) => { try { await duplicateCredential(Number(cred.id), { name: t("credentials.clonedCredentialName", { name: cred.name }), }); const res = await getCredentials(); setCredentials(mapCredentials(res)); window.dispatchEvent(new CustomEvent("termix:credentials-changed")); toast.success(t("credentials.clonedCredential", { name: cred.name })); } catch (err) { const msg = err instanceof Error ? err.message : null; toast.error(msg || t("credentials.failedToCloneCredential")); } }; const handleDeleteCredential = async (cred: Credential) => { await deleteCredential(Number(cred.id)); setCredentials((prev) => prev.filter((c) => c.id !== cred.id)); }; function handleConfirmDeleteCredential(cred: Credential) { setConfirmDialog({ message: t("credentials.deleteCredentialConfirm", { name: cred.name }), onConfirm: async () => { try { await handleDeleteCredential(cred); toast.success( t("credentials.deletedCredential", { name: cred.name }), ); } catch { toast.error(t("credentials.failedToDeleteCredential")); } }, }); } async function handleEditCredential(cred: Credential) { try { const full = (await getCredentialDetails(Number(cred.id))) as { hasKey?: boolean; hasKeyPassword?: boolean; password?: string; certPublicKey?: string; }; setEditingCredential({ ...cred, value: full.hasKey ? "existing_key" : (full.password ?? ""), password: full.password ?? "", passphrase: full.hasKeyPassword ? "existing_key_password" : "", publicKey: full.certPublicKey ?? cred.publicKey, }); setActiveCredentialTab("general"); } catch { setEditingCredential(cred); setActiveCredentialTab("general"); } } // Opens a host's credential for editing from within the host editor, // remembering the host so "back" returns to it instead of the list. async function handleEditCredentialFromHost(credentialId: string) { const cred = credentials.find((c) => String(c.id) === String(credentialId)); if (!cred) return; setCredentialReturnHost(editingHost); await handleEditCredential(cred); // The editor view keys off editingHost, so it has to be cleared or the // host editor keeps rendering over the credential we just opened. setEditingHost(null); } // Editor view: full-width with top tab bar instead of side nav const closeHostEditor = () => { setHostEditorDirty(false); setShowUnsavedHostDialog(false); setEditingHost(null); setActiveHostTab("general"); }; const requestCloseHostEditor = () => { if (hostEditorDirty) { setShowUnsavedHostDialog(true); return; } closeHostEditor(); }; const renderEditorView = () => { const isHost = !!editingHost; // Simple mode keeps General and SSH -- between them they hold everything // needed to reach a host (name, address, username, auth) -- and hides the // other protocol tabs plus the seven SSH sub-tabs. Nothing is lost: // createHostEditorForm still materializes every field with its default and // buildHostEditorPayload still serializes all of them, so hiding a tab // hides its inputs, not its values. const collapseAdvanced = isHost && simpleEditor && !showAdvancedEditor; const protocols = editingProtocols as unknown as Record; // Plugin tabs decide their own visibility from the host's protocols. const tabs = isHost ? makeHostTabs(t, protocols).filter((tab) => { if (tab.id === "general") return true; if (tab.id === "ssh") return editingProtocols.enableSsh; return !collapseAdvanced; }) : makeCredentialTabs(t); // Collapsing while on a now-hidden tab would leave nothing selected. The // SSH group collapses to its own "ssh" tab, everything else to General. // The top-level strip only lists general/ssh/rdp/vnc/telnet -- the SSH // sub-tabs live in the secondary strip, so they count as visible whenever // the SSH group is expanded. const sshSubTabs = makeHostSshSubTabs(t, protocols); // Registered SSH-group tabs count too: checking a fixed list here is what // left plugin tabs unreachable. const hostTabVisible = tabs.some((tab) => tab.id === activeHostTab) || (!collapseAdvanced && editingProtocols.enableSsh && sshSubTabs.some((tab) => tab.id === activeHostTab)); const effectiveHostTab = hostTabVisible ? activeHostTab : collapseAdvanced && isSshGroupTab(activeHostTab) ? "ssh" : "general"; const activeTab = isHost ? effectiveHostTab : activeCredentialTab; const setActiveTab = isHost ? setActiveHostTab : setActiveCredentialTab; const showSshSubTabs = isHost && !collapseAdvanced && editingProtocols.enableSsh && isSshGroupTab(activeHostTab); return (
{/* Back bar + tab strip */}
{ if (isHost && id === "ssh") { if (!isSshGroupTab(activeHostTab)) { setActiveHostTab("ssh"); } } else { setActiveTab(id); } }} isActive={ isHost ? (id) => id === "ssh" ? isSshGroupTab(activeHostTab) : activeHostTab === id : undefined } /> {showSshSubTabs && ( )} {isHost && simpleEditor && ( )}
{isHost ? ( { const updated = sshHostToHost( saved as unknown as SSHHostWithStatus, ); setHosts((prev) => { const idx = prev.findIndex((h) => h.id === updated.id); if (idx >= 0) { const next = [...prev]; next[idx] = updated; return next; } return [...prev, updated]; }); window.dispatchEvent(new CustomEvent("termix:hosts-changed")); closeHostEditor(); }} protocols={editingProtocols} onProtocolChange={(p) => setEditingProtocols((prev) => ({ ...prev, ...p })) } onDirtyChange={setHostEditorDirty} onTabChange={setActiveHostTab} hosts={hosts} credentials={credentials} onEditCredential={handleEditCredentialFromHost} /> ) : ( c.folder) .filter((f): f is string => !!f), ), ).sort()} saveAsNewHost={ credentialReturnHost ? credentialReturnHost : undefined } onBack={() => { if (credentialReturnHost) { setEditingHost(credentialReturnHost); setCredentialReturnHost(null); setEditingCredential(null); setActiveCredentialTab("general"); return; } setEditingCredential(null); setActiveCredentialTab("general"); }} onSave={(saved, options) => { // The save endpoints are typed as an untyped record; these are // the fields this view reads back off the response. const result = saved as Partial & { id: number | string; authType?: string; }; setCredentials((prev) => { const idx = prev.findIndex((c) => c.id === String(result.id)); const updated: Credential = { id: String(result.id), name: result.name ?? "", username: result.username ?? "", type: result.authType === "key" ? "key" : "password", value: result.value, password: result.password, publicKey: result.publicKey, passphrase: result.passphrase, description: result.description, folder: result.folder ?? "", tags: result.tags ?? [], }; if (idx >= 0) { const next = [...prev]; next[idx] = updated; return next; } return [...prev, updated]; }); if (options?.assignToHost && credentialReturnHost) { const returnHost = credentialReturnHost; setCredentialReturnHost(null); if (returnHost !== "new") { setHosts((prev) => prev.map((h) => h.id === returnHost.id ? { ...h, credentialId: String(result.id) } : h, ), ); } setEditingHost( returnHost === "new" ? "new" : { ...returnHost, credentialId: String(result.id) }, ); setActiveHostTab("ssh"); setEditingCredential(null); return; } setEditingCredential(null); setActiveCredentialTab("general"); }} /> )}
); }; const isEditing = !!editingHost || !!editingCredential; useEffect(() => { if (active) onEditingChange?.(isEditing); }, [isEditing, active]); return (
{isEditing ? ( renderEditorView() ) : (
{/* Search bar — hidden when parent supplies its own */} {!hideListHeader && (
setSearchQuery(e.target.value)} placeholder={t("credentials.searchCredentialsPlaceholder")} className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0" /> {searchQuery && ( )}
)} {!credentialsLoading && filteredCredentials.length === 0 ? (
{t("credentials.noCredentialsFound")}
) : ( setDeployDialog({ cred, hostId: "" }) } onEditCredential={handleEditCredential} onCloneCredential={handleCloneCredential} onDeleteCredential={handleConfirmDeleteCredential} onShareCredential={setShareCredential} /> )}
)} setShareCredential(null)} /> {/* Confirm dialog */} {confirmDialog && (

{confirmDialog.message}

)} {showUnsavedHostDialog && (

{t("common.unsavedChanges")}

{t("hosts.unsavedChangesDescription")}

)} {/* Deploy credential dialog */} {deployDialog && (
{t("credentials.deployDialogTitle")}
{t("credentials.deployDialogDesc", { name: deployDialog.cred.name, })}
setDeployDialog({ ...deployDialog, hostId: e.target.value }) } > {allHosts .filter( (h) => h.enableSsh || enabledHostProtocols(h).length === 0, ) .map((h) => ( ))}
)}
); }