import React, { useState, useEffect, useMemo, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; import { Sheet, SheetContent } from "@/components/ui/sheet"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { Search, Key, Folder, Edit, Trash2, Shield, Tag, Info, FolderMinus, Pencil, X, Check, Upload, Server, User, } from "lucide-react"; import { getCredentials, deleteCredential, updateCredential, renameCredentialFolder, deployCredentialToHost, getSSHHosts, } from "@/ui/main-axios"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import { useConfirmation } from "@/hooks/use-confirmation.ts"; import CredentialViewer from "./CredentialViewer"; import type { Credential, CredentialsManagerProps, } from "../../../../types/index.js"; export function CredentialsManager({ onEditCredential, }: CredentialsManagerProps) { const { t } = useTranslation(); const { confirmWithToast } = useConfirmation(); const [credentials, setCredentials] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [showViewer, setShowViewer] = useState(false); const [viewingCredential] = useState(null); const [draggedCredential, setDraggedCredential] = useState( null, ); const [dragOverFolder, setDragOverFolder] = useState(null); const [editingFolder, setEditingFolder] = useState(null); const [editingFolderName, setEditingFolderName] = useState(""); const [operationLoading, setOperationLoading] = useState(false); const [showDeployDialog, setShowDeployDialog] = useState(false); const [deployingCredential, setDeployingCredential] = useState(null); const [availableHosts, setAvailableHosts] = useState< Array<{ id: number; name: string; ip: string; port: number; username: string; }> >([]); const [selectedHostId, setSelectedHostId] = useState(""); const [deployLoading, setDeployLoading] = useState(false); const [hostSearchQuery, setHostSearchQuery] = useState(""); const [dropdownOpen, setDropdownOpen] = useState(false); const dropdownRef = useRef(null); const dragCounter = useRef(0); useEffect(() => { fetchCredentials(); fetchHosts(); }, []); useEffect(() => { if (showDeployDialog) { setDropdownOpen(false); setHostSearchQuery(""); setSelectedHostId(""); setTimeout(() => { if ( document.activeElement && (document.activeElement as HTMLElement).blur ) { (document.activeElement as HTMLElement).blur(); } }, 50); } }, [showDeployDialog]); useEffect(() => { function handleClickOutside(event: MouseEvent) { if ( dropdownRef.current && !dropdownRef.current.contains(event.target as Node) ) { setDropdownOpen(false); } } if (dropdownOpen) { document.addEventListener("mousedown", handleClickOutside); } else { document.removeEventListener("mousedown", handleClickOutside); } return () => { document.removeEventListener("mousedown", handleClickOutside); }; }, [dropdownOpen]); const fetchHosts = async () => { try { const hosts = await getSSHHosts(); setAvailableHosts(hosts); } catch (err) { console.error("Failed to fetch hosts:", err); } }; const fetchCredentials = async () => { try { setLoading(true); const data = await getCredentials(); setCredentials(data); setError(null); } catch { setError(t("credentials.failedToFetchCredentials")); } finally { setLoading(false); } }; const handleEdit = (credential: Credential) => { if (onEditCredential) { onEditCredential(credential); } }; const handleDeploy = (credential: Credential) => { if (credential.authType !== "key") { toast.error("Only SSH key-based credentials can be deployed"); return; } if (!credential.publicKey) { toast.error("Public key is required for deployment"); return; } setDeployingCredential(credential); setSelectedHostId(""); setHostSearchQuery(""); setDropdownOpen(false); setShowDeployDialog(true); }; const performDeploy = async () => { if (!deployingCredential || !selectedHostId) { toast.error("Please select a target host"); return; } setDeployLoading(true); try { const result = await deployCredentialToHost( deployingCredential.id, parseInt(selectedHostId), ); if (result.success) { toast.success(result.message || "SSH key deployed successfully"); setShowDeployDialog(false); setDeployingCredential(null); setSelectedHostId(""); } else { toast.error(result.error || "Deployment failed"); } } catch (error) { console.error("Deployment error:", error); toast.error("Failed to deploy SSH key"); } finally { setDeployLoading(false); } }; const handleDelete = async (credentialId: number, credentialName: string) => { confirmWithToast( t("credentials.confirmDeleteCredential", { name: credentialName }), async () => { try { await deleteCredential(credentialId); toast.success( t("credentials.credentialDeletedSuccessfully", { name: credentialName, }), ); await fetchCredentials(); window.dispatchEvent(new CustomEvent("credentials:changed")); } catch (err: unknown) { const error = err as { response?: { data?: { error?: string; details?: string } }; }; if (error.response?.data?.details) { toast.error( `${error.response.data.error}\n${error.response.data.details}`, ); } else { toast.error(t("credentials.failedToDeleteCredential")); } } }, "destructive", ); }; const handleRemoveFromFolder = async (credential: Credential) => { confirmWithToast( t("credentials.confirmRemoveFromFolder", { name: credential.name || credential.username, folder: credential.folder, }), async () => { try { setOperationLoading(true); const updatedCredential = { ...credential, folder: "" }; await updateCredential(credential.id, updatedCredential); toast.success( t("credentials.removedFromFolder", { name: credential.name || credential.username, }), ); await fetchCredentials(); window.dispatchEvent(new CustomEvent("credentials:changed")); } catch { toast.error(t("credentials.failedToRemoveFromFolder")); } finally { setOperationLoading(false); } }, ); }; const handleFolderRename = async (oldName: string) => { if (!editingFolderName.trim() || editingFolderName === oldName) { setEditingFolder(null); setEditingFolderName(""); return; } try { setOperationLoading(true); await renameCredentialFolder(oldName, editingFolderName.trim()); toast.success( t("credentials.folderRenamed", { oldName, newName: editingFolderName.trim(), }), ); await fetchCredentials(); window.dispatchEvent(new CustomEvent("credentials:changed")); setEditingFolder(null); setEditingFolderName(""); } catch { toast.error(t("credentials.failedToRenameFolder")); } finally { setOperationLoading(false); } }; const startFolderEdit = (folderName: string) => { setEditingFolder(folderName); setEditingFolderName(folderName); }; const cancelFolderEdit = () => { setEditingFolder(null); setEditingFolderName(""); }; const handleDragStart = (e: React.DragEvent, credential: Credential) => { setDraggedCredential(credential); e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", ""); }; const handleDragEnd = () => { setDraggedCredential(null); setDragOverFolder(null); dragCounter.current = 0; }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; }; const handleDragEnter = (e: React.DragEvent, folderName: string) => { e.preventDefault(); dragCounter.current++; setDragOverFolder(folderName); }; const handleDragLeave = () => { dragCounter.current--; if (dragCounter.current === 0) { setDragOverFolder(null); } }; const handleDrop = async (e: React.DragEvent, targetFolder: string) => { e.preventDefault(); dragCounter.current = 0; setDragOverFolder(null); if (!draggedCredential) return; const newFolder = targetFolder === t("credentials.uncategorized") ? "" : targetFolder; if (draggedCredential.folder === newFolder) { setDraggedCredential(null); return; } try { setOperationLoading(true); const updatedCredential = { ...draggedCredential, folder: newFolder }; await updateCredential(draggedCredential.id, updatedCredential); toast.success( t("credentials.movedToFolder", { name: draggedCredential.name || draggedCredential.username, folder: targetFolder, }), ); await fetchCredentials(); window.dispatchEvent(new CustomEvent("credentials:changed")); } catch { toast.error(t("credentials.failedToMoveToFolder")); } finally { setOperationLoading(false); setDraggedCredential(null); } }; const filteredAndSortedCredentials = useMemo(() => { let filtered = credentials; if (searchQuery.trim()) { const query = searchQuery.toLowerCase(); filtered = credentials.filter((credential) => { const searchableText = [ credential.name || "", credential.username, credential.description || "", ...(credential.tags || []), credential.authType, credential.keyType || "", ] .join(" ") .toLowerCase(); return searchableText.includes(query); }); } return filtered.sort((a, b) => { const aName = a.name || a.username; const bName = b.name || b.username; return aName.localeCompare(bName); }); }, [credentials, searchQuery]); const credentialsByFolder = useMemo(() => { const grouped: { [key: string]: Credential[] } = {}; filteredAndSortedCredentials.forEach((credential) => { const folder = credential.folder || t("credentials.uncategorized"); if (!grouped[folder]) { grouped[folder] = []; } grouped[folder].push(credential); }); const sortedFolders = Object.keys(grouped).sort((a, b) => { if (a === t("credentials.uncategorized")) return -1; if (b === t("credentials.uncategorized")) return 1; return a.localeCompare(b); }); const sortedGrouped: { [key: string]: Credential[] } = {}; sortedFolders.forEach((folder) => { sortedGrouped[folder] = grouped[folder]; }); return sortedGrouped; }, [filteredAndSortedCredentials, t]); if (loading) { return (

{t("credentials.loadingCredentials")}

); } if (error) { return (

{error}

); } if (credentials.length === 0) { return (

{t("credentials.sshCredentials")}

{t("credentials.credentialsCount", { count: 0 })}

{t("credentials.noCredentials")}

{t("credentials.noCredentialsMessage")}

); } return (

{t("credentials.sshCredentials")}

{t("credentials.credentialsCount", { count: filteredAndSortedCredentials.length, })}

setSearchQuery(e.target.value)} className="pl-10" />
{Object.entries(credentialsByFolder).map( ([folder, folderCredentials]) => (
handleDragEnter(e, folder)} onDragLeave={handleDragLeave} onDrop={(e) => handleDrop(e, folder)} >
{editingFolder === folder ? (
e.stopPropagation()} > setEditingFolderName(e.target.value) } onKeyDown={(e) => { if (e.key === "Enter") handleFolderRename(folder); if (e.key === "Escape") cancelFolderEdit(); }} className="h-6 text-sm px-2 flex-1" autoFocus disabled={operationLoading} />
) : ( <> { e.stopPropagation(); if (folder !== t("credentials.uncategorized")) { startFolderEdit(folder); } }} title={ folder !== t("credentials.uncategorized") ? "Click to rename folder" : "" } > {folder} {folder !== t("credentials.uncategorized") && ( )} )} {folderCredentials.length}
{folderCredentials.map((credential) => (
handleDragStart(e, credential) } onDragEnd={handleDragEnd} className={`bg-dark-bg-input border border-input rounded-lg cursor-pointer hover:shadow-lg hover:border-blue-400/50 hover:bg-dark-hover-alt transition-all duration-200 p-3 group relative ${ draggedCredential?.id === credential.id ? "opacity-50 scale-95" : "" }`} onClick={() => handleEdit(credential)} >

{credential.name || `${credential.username}`}

{credential.username}

{credential.authType === "password" ? t("credentials.password") : t("credentials.sshKey")}

{credential.folder && credential.folder !== "" && (

Remove from folder " {credential.folder}"

)}

Edit credential

{credential.authType === "key" && (

Deploy SSH key to host

)}

Delete credential

{credential.tags && credential.tags.length > 0 && (
{credential.tags .slice(0, 6) .map((tag, index) => ( {tag} ))} {credential.tags.length > 6 && ( +{credential.tags.length - 6} )}
)}
{credential.authType === "password" ? ( ) : ( )} {credential.authType} {credential.authType === "key" && credential.keyType && ( {credential.keyType} )}

Click to edit credential

Drag to move between folders

))}
), )}
{showViewer && viewingCredential && ( setShowViewer(false)} onEdit={() => { setShowViewer(false); handleEdit(viewingCredential); }} /> )}
{t("credentials.deploySSHKey")}
{t("credentials.deploySSHKeyDescription")}
{deployingCredential && (

{t("credentials.sourceCredential")}

{t("common.name")}
{deployingCredential.name || deployingCredential.username}
{t("common.username")}
{deployingCredential.username}
{t("credentials.keyType")}
{deployingCredential.keyType || "SSH Key"}
)}
{ setHostSearchQuery(e.target.value); }} onClick={() => { setDropdownOpen(true); }} className="w-full" autoFocus={false} tabIndex={0} /> {dropdownOpen && (
{availableHosts.length === 0 ? (
{t("credentials.noHostsAvailable")}
) : availableHosts.filter( (host) => !hostSearchQuery || host.name ?.toLowerCase() .includes(hostSearchQuery.toLowerCase()) || host.ip ?.toLowerCase() .includes(hostSearchQuery.toLowerCase()) || host.username ?.toLowerCase() .includes(hostSearchQuery.toLowerCase()), ).length === 0 ? (
{t("credentials.noHostsMatchSearch")}
) : ( availableHosts .filter( (host) => !hostSearchQuery || host.name ?.toLowerCase() .includes(hostSearchQuery.toLowerCase()) || host.ip ?.toLowerCase() .includes(hostSearchQuery.toLowerCase()) || host.username ?.toLowerCase() .includes(hostSearchQuery.toLowerCase()), ) .map((host) => (
{ setSelectedHostId(host.id.toString()); setHostSearchQuery(host.name || host.ip); setDropdownOpen(false); }} >
{host.name || host.ip}
{host.username}@{host.ip}:{host.port}
)) )}
)}

{t("credentials.deploymentProcess")}

{t("credentials.deploymentProcessDescription")}

); }