Files
Termix/src/ui/sidebar/HostManager.tsx
T

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