mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
884 lines
32 KiB
TypeScript
884 lines
32 KiB
TypeScript
/* 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<string | null>;
|
|
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<Host | "new" | null>(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<Credential | null>(
|
|
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<Host[]>([]);
|
|
const [credentials, setCredentials] = useState<Credential[]>([]);
|
|
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<HostProtocols>(() =>
|
|
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<Host[]>([]);
|
|
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<HostDraft | undefined>).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<string>).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<string, number>();
|
|
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<string, boolean>;
|
|
// 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 (
|
|
<div className="flex flex-col flex-1 min-h-0">
|
|
{/* Back bar + tab strip */}
|
|
<div className="flex flex-col shrink-0 border-b border-border">
|
|
<button
|
|
onClick={() => {
|
|
if (isHost) {
|
|
requestCloseHostEditor();
|
|
} else if (credentialReturnHost) {
|
|
setEditingHost(credentialReturnHost);
|
|
setCredentialReturnHost(null);
|
|
setEditingCredential(null);
|
|
setActiveCredentialTab("general");
|
|
} else {
|
|
setEditingCredential(null);
|
|
setActiveCredentialTab("general");
|
|
}
|
|
}}
|
|
className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border-b border-border/50"
|
|
>
|
|
<ArrowLeft className="size-3.5 shrink-0" />
|
|
<span>
|
|
{isHost
|
|
? t("hosts.backToHosts")
|
|
: credentialReturnHost
|
|
? t("hosts.backToHost")
|
|
: t("credentials.backToCredentials")}
|
|
</span>
|
|
{isHost && editingHost !== "new" && (
|
|
<span
|
|
className="ml-auto font-semibold text-foreground truncate max-w-[200px]"
|
|
title={(editingHost as Host).name}
|
|
>
|
|
{(editingHost as Host).name}
|
|
</span>
|
|
)}
|
|
</button>
|
|
<TabStrip
|
|
tabs={tabs}
|
|
activeTab={activeTab}
|
|
onTabChange={(id) => {
|
|
if (isHost && id === "ssh") {
|
|
if (!isSshGroupTab(activeHostTab)) {
|
|
setActiveHostTab("ssh");
|
|
}
|
|
} else {
|
|
setActiveTab(id);
|
|
}
|
|
}}
|
|
isActive={
|
|
isHost
|
|
? (id) =>
|
|
id === "ssh"
|
|
? isSshGroupTab(activeHostTab)
|
|
: activeHostTab === id
|
|
: undefined
|
|
}
|
|
/>
|
|
{showSshSubTabs && (
|
|
<TabStrip
|
|
tabs={sshSubTabs}
|
|
activeTab={activeHostTab}
|
|
onTabChange={setActiveHostTab}
|
|
variant="secondary"
|
|
/>
|
|
)}
|
|
{isHost && simpleEditor && (
|
|
<button
|
|
onClick={() => {
|
|
if (showAdvancedEditor) setActiveHostTab("general");
|
|
setShowAdvancedEditor(!showAdvancedEditor);
|
|
}}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border-t border-border/50"
|
|
>
|
|
<ChevronDown
|
|
className={`size-3 transition-transform ${showAdvancedEditor ? "rotate-180" : ""}`}
|
|
/>
|
|
{showAdvancedEditor
|
|
? t("hosts.hideAdvancedSettings")
|
|
: t("hosts.showAdvancedSettings")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-3">
|
|
{isHost ? (
|
|
<HostEditor
|
|
key={
|
|
editingHost === "new"
|
|
? `new-host-${hostDraft?.key ?? ""}`
|
|
: (editingHost as Host).id
|
|
}
|
|
host={editingHost === "new" ? null : (editingHost as Host)}
|
|
draft={editingHost === "new" ? hostDraft?.draft : undefined}
|
|
activeTab={effectiveHostTab}
|
|
simpleMode={collapseAdvanced}
|
|
onBack={requestCloseHostEditor}
|
|
onSave={(saved) => {
|
|
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}
|
|
/>
|
|
) : (
|
|
<CredentialEditorView
|
|
key={
|
|
editingCredential === "new"
|
|
? "new-cred"
|
|
: (editingCredential as Credential).id
|
|
}
|
|
credential={
|
|
editingCredential === "new"
|
|
? null
|
|
: (editingCredential as Credential)
|
|
}
|
|
activeTab={activeCredentialTab}
|
|
existingFolders={Array.from(
|
|
new Set(
|
|
credentials
|
|
.map((c) => 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<Credential> & {
|
|
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");
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const isEditing = !!editingHost || !!editingCredential;
|
|
|
|
useEffect(() => {
|
|
if (active) onEditingChange?.(isEditing);
|
|
}, [isEditing, active]);
|
|
|
|
return (
|
|
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
|
|
{isEditing ? (
|
|
renderEditorView()
|
|
) : (
|
|
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
|
{/* Search bar — hidden when parent supplies its own */}
|
|
{!hideListHeader && (
|
|
<div className="px-2 py-1.5 shrink-0 border-b border-border/40">
|
|
<div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60">
|
|
<Search className="size-3 text-muted-foreground/60 shrink-0" />
|
|
<input
|
|
value={searchQuery}
|
|
onChange={(e) => 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 && (
|
|
<button
|
|
onClick={() => setSearchQuery("")}
|
|
className="text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
|
>
|
|
<X className="size-3" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!credentialsLoading && filteredCredentials.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-12 text-center px-4">
|
|
<span className="text-sm font-semibold text-muted-foreground/60">
|
|
{t("credentials.noCredentialsFound")}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="mt-3 h-7 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
|
|
onClick={() => {
|
|
setEditingCredential("new");
|
|
setActiveCredentialTab("general");
|
|
}}
|
|
>
|
|
{t("credentials.addCredentialBtn")}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<CredentialSidebarTree
|
|
folders={credentialFolderTree}
|
|
usedByCounts={usedByCounts}
|
|
query=""
|
|
loading={credentialsLoading}
|
|
arrangeLocked={externalArrangeLocked}
|
|
density={density}
|
|
trayTrigger={trayTrigger}
|
|
showTags={showTags}
|
|
editingFolderName={editingCredFolderName}
|
|
editingFolderValue={editingCredFolderValue}
|
|
onEditingFolderNameChange={setEditingCredFolderName}
|
|
onEditingFolderValueChange={setEditingCredFolderValue}
|
|
onRenameFolder={handleRenameCredentialFolder}
|
|
onMoveCredentialToFolder={handleMoveCredentialToFolder}
|
|
onDeployCredential={(cred) =>
|
|
setDeployDialog({ cred, hostId: "" })
|
|
}
|
|
onEditCredential={handleEditCredential}
|
|
onCloneCredential={handleCloneCredential}
|
|
onDeleteCredential={handleConfirmDeleteCredential}
|
|
onShareCredential={setShareCredential}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<CredentialShareModal
|
|
credential={shareCredential}
|
|
onClose={() => setShareCredential(null)}
|
|
/>
|
|
|
|
{/* Confirm dialog */}
|
|
{confirmDialog && (
|
|
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
|
|
<div className="bg-popover border border-border shadow-xl w-full max-w-xs flex flex-col gap-4 p-4">
|
|
<p className="text-sm text-foreground">{confirmDialog.message}</p>
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
onClick={() => setConfirmDialog(null)}
|
|
className="px-3 py-1.5 text-xs border border-border text-muted-foreground hover:text-foreground hover:bg-muted rounded transition-colors"
|
|
>
|
|
{t("hosts.cancelBtn")}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
confirmDialog.onConfirm();
|
|
setConfirmDialog(null);
|
|
}}
|
|
className="px-3 py-1.5 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded transition-colors"
|
|
>
|
|
{t("hosts.deleteConfirmBtn")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showUnsavedHostDialog && (
|
|
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
|
|
<div className="bg-popover border border-border shadow-xl w-full max-w-xs flex flex-col gap-4 p-4">
|
|
<div className="flex flex-col gap-1">
|
|
<p className="text-sm font-semibold text-foreground">
|
|
{t("common.unsavedChanges")}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("hosts.unsavedChangesDescription")}
|
|
</p>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
onClick={() => setShowUnsavedHostDialog(false)}
|
|
className="px-3 py-1.5 text-xs border border-border text-muted-foreground hover:text-foreground hover:bg-muted rounded transition-colors"
|
|
>
|
|
{t("hosts.keepEditing")}
|
|
</button>
|
|
<button
|
|
onClick={closeHostEditor}
|
|
className="px-3 py-1.5 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded transition-colors"
|
|
>
|
|
{t("hosts.discardChanges")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Deploy credential dialog */}
|
|
{deployDialog && (
|
|
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
|
|
<div className="bg-popover border border-border shadow-xl w-full max-w-sm flex flex-col gap-4 p-4">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-bold">
|
|
{t("credentials.deployDialogTitle")}
|
|
</span>
|
|
<button
|
|
onClick={() => setDeployDialog(null)}
|
|
className="text-muted-foreground hover:text-foreground"
|
|
>
|
|
<X className="size-4" />
|
|
</button>
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{t("credentials.deployDialogDesc", {
|
|
name: deployDialog.cred.name,
|
|
})}
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
{t("credentials.targetHostLabel")}
|
|
</label>
|
|
<Select2
|
|
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
|
value={deployDialog.hostId}
|
|
onChange={(e) =>
|
|
setDeployDialog({ ...deployDialog, hostId: e.target.value })
|
|
}
|
|
>
|
|
<option value="">{t("credentials.selectHostOption")}</option>
|
|
{allHosts
|
|
.filter(
|
|
(h) => h.enableSsh || enabledHostProtocols(h).length === 0,
|
|
)
|
|
.map((h) => (
|
|
<option key={h.id} value={h.id}>
|
|
{h.name || h.ip}
|
|
</option>
|
|
))}
|
|
</Select2>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setDeployDialog(null)}
|
|
disabled={deploying}
|
|
>
|
|
{t("hosts.cancelBtn")}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
|
|
disabled={!deployDialog.hostId || deploying}
|
|
onClick={async () => {
|
|
setDeploying(true);
|
|
try {
|
|
await deployCredentialToHost(
|
|
Number(deployDialog.cred.id),
|
|
Number(deployDialog.hostId),
|
|
);
|
|
toast.success(t("credentials.keyDeployedSuccess"));
|
|
setDeployDialog(null);
|
|
} catch {
|
|
toast.error(t("credentials.failedToDeployKey"));
|
|
} finally {
|
|
setDeploying(false);
|
|
}
|
|
}}
|
|
>
|
|
{deploying
|
|
? t("credentials.deployingBtn")
|
|
: t("credentials.deployBtn")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|