import { BadgeCheck, ChevronDown, Copy, Edit2, ExternalLink, Key, LayoutGrid, List as ListIcon, Plus, Shield, Trash2, Upload, UserPlus, } from "lucide-react"; import React, { useCallback, useMemo, useRef, useState } from "react"; import { useI18n } from "../application/i18n/I18nProvider"; import { useStoredViewMode } from "../application/state/useStoredViewMode"; import type { GroupConfig } from "../domain/models"; import { reorderVaultItems, sortByVaultOrder } from "../domain/vaultOrder"; import { STORAGE_KEY_VAULT_KEYS_VIEW_MODE } from "../infrastructure/config/storageKeys"; import { logger } from "../lib/logger"; import { cn } from "../lib/utils"; import { Host, Identity, KeyType, ProxyProfile, SSHKey } from "../types"; import { ManagedSource } from "../domain/models"; import { useKeychainBackend } from "../application/state/useKeychainBackend"; import SelectHostPanel from "./SelectHostPanel"; import { AsideActionMenu, AsideActionMenuItem, AsidePanel, AsidePanelContent, } from "./ui/aside-panel"; import { Button } from "./ui/button"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from "./ui/context-menu"; import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown"; import { toast } from "./ui/toast"; import { KeychainExportPanel } from "./KeychainExportPanel"; import { KeychainEditPanel } from "./KeychainEditPanel"; import { VaultHeaderSearch, VaultPageHeader, vaultHeaderIconButtonClass, vaultHeaderSecondaryButtonClass, vaultSectionTitleClass, } from "./vault/VaultPageHeader"; import { VaultDeleteConfirmDialog } from "./vault/VaultDeleteConfirmDialog"; import { useVaultItemReorder } from "./vault/vaultReorderDrag"; // Import utilities and components from keychain module import { GenerateStandardPanel, IdentityCard, IdentityPanel, ImportKeyPanel, isMacOS, KeyCard, type PanelMode, shouldShowIdentitySection, shouldShowKeySection, shouldShowSearchNoResults, ViewKeyPanel, } from "./keychain"; interface KeychainManagerProps { keys: SSHKey[]; identities?: Identity[]; hosts?: Host[]; proxyProfiles?: ProxyProfile[]; customGroups?: string[]; /** * Group default configurations. Needed by the "export public key to * host" flow so per-host SSH algorithm settings (legacy / skipEcdsa / * overrides) that the host inherits from its group are honored when * the export opens its one-off SSH connection. */ groupConfigs?: GroupConfig[]; managedSources?: ManagedSource[]; onSave: (key: SSHKey) => void; onUpdate: (key: SSHKey) => void; onReorderKeys?: (keys: SSHKey[]) => void; onDelete: (id: string) => void; onSaveIdentity?: (identity: Identity) => void; onReorderIdentities?: (identities: Identity[]) => void; onDeleteIdentity?: (id: string) => void; onNewHost?: () => void; onSaveHost?: (host: Host) => void; onCreateGroup?: (groupPath: string) => void; } const KeychainManager: React.FC = ({ keys, identities = [], hosts = [], proxyProfiles = [], customGroups = [], groupConfigs = [], managedSources = [], onSave, onUpdate, onReorderKeys, onDelete, onSaveIdentity, onReorderIdentities, onDeleteIdentity, onNewHost: _onNewHost, onSaveHost, onCreateGroup, }) => { const { t } = useI18n(); const { generateKeyPair, execCommand } = useKeychainBackend(); const [search, setSearch] = useState(""); const [deleteTarget, setDeleteTarget] = useState<{ type: "key" | "identity"; id: string; name: string; } | null>(null); const [viewMode, setViewMode] = useStoredViewMode( STORAGE_KEY_VAULT_KEYS_VIEW_MODE, "grid", ); // Panel stack for navigation (supports back navigation) const [panelStack, setPanelStack] = useState([]); const panel = useMemo( () => panelStack.length > 0 ? panelStack[panelStack.length - 1] : ({ type: "closed" } as PanelMode), [panelStack], ); const panelTitle = useMemo(() => { switch (panel.type) { case "generate": return t("keychain.panel.generateKey"); case "import": return t("keychain.panel.newKey"); case "view": return t("keychain.panel.keyDetails"); case "edit": return t("keychain.panel.editKey"); case "identity": return panel.identity ? t("keychain.panel.editIdentity") : t("keychain.panel.newIdentity"); case "export": return t("keychain.panel.keyExport"); default: return ""; } }, [panel, t]); const [showHostSelector, setShowHostSelector] = useState(false); const [isExporting, setIsExporting] = useState(false); const listRef = useRef(null); // Export panel state const [exportLocation, setExportLocation] = useState(".ssh"); const [exportFilename, setExportFilename] = useState("authorized_keys"); const [exportHost, setExportHost] = useState(null); const [exportAdvancedOpen, setExportAdvancedOpen] = useState(false); const [exportScript, setExportScript] = useState(`DIR="$HOME/$1" FILE="$DIR/$2" if [ ! -d "$DIR" ]; then mkdir -p "$DIR" chmod 700 "$DIR" fi if [ ! -f "$FILE" ]; then touch "$FILE" chmod 600 "$FILE" fi echo $3 >> "$FILE"`); // Draft state for forms const [draftKey, setDraftKey] = useState>({}); const [draftIdentity, setDraftIdentity] = useState>({}); const [showPassphrase, setShowPassphrase] = useState(false); const [isGenerating, setIsGenerating] = useState(false); const keyReorder = useVaultItemReorder({ containerRef: listRef, viewMode, dragType: "key-id", targetAttribute: "data-key-id", disabled: !onReorderKeys || search.trim().length > 0, onReorder: (sourceId, targetId, position) => { onReorderKeys?.(reorderVaultItems(keys, sourceId, targetId, position)); }, }); const identityReorder = useVaultItemReorder({ containerRef: listRef, viewMode, dragType: "identity-id", targetAttribute: "data-identity-id", disabled: !onReorderIdentities || search.trim().length > 0, onReorder: (sourceId, targetId, position) => { onReorderIdentities?.(reorderVaultItems(identities, sourceId, targetId, position)); }, }); const showError = useCallback((message: string, title = t("common.error")) => { toast.error(message, title); }, [t]); // Ordered key collection (keys and certificates on one page) const orderedKeys = useMemo(() => sortByVaultOrder(keys), [keys]); // Filter keys by search const filteredKeys = useMemo(() => { if (!search.trim()) return orderedKeys; const s = search.toLowerCase(); return orderedKeys.filter( (k) => k.label.toLowerCase().includes(s) || k.type.toLowerCase().includes(s) || k.publicKey?.toLowerCase().includes(s), ); }, [orderedKeys, search]); // Filter identities based on search const filteredIdentities = useMemo(() => { if (!search.trim()) return sortByVaultOrder(identities); const s = search.toLowerCase(); return sortByVaultOrder(identities.filter( (i) => i.label.toLowerCase().includes(s) || i.username.toLowerCase().includes(s), )); }, [identities, search]); const showIdentitySection = shouldShowIdentitySection({ identityCount: identities.length, filteredIdentityCount: filteredIdentities.length, filteredKeyCount: filteredKeys.length, search, }); const showKeySection = shouldShowKeySection({ identityCount: identities.length, filteredKeyCount: filteredKeys.length, search, }); // Push a new panel onto the stack const pushPanel = useCallback((newPanel: PanelMode) => { setPanelStack((prev) => [...prev, newPanel]); }, []); // Pop the top panel from the stack (go back) const popPanel = useCallback(() => { setPanelStack((prev) => { if (prev.length <= 1) { // Last panel, close everything setDraftKey({}); setDraftIdentity({}); setShowPassphrase(false); setExportHost(null); setExportAdvancedOpen(false); return []; } return prev.slice(0, -1); }); }, []); // Close all panels const closePanel = useCallback(() => { setPanelStack([]); setDraftKey({}); setDraftIdentity({}); setShowPassphrase(false); setExportHost(null); setExportAdvancedOpen(false); }, []); // Open panel for viewing key (replaces stack with single panel) const openKeyView = useCallback((key: SSHKey) => { setPanelStack([{ type: "view", key }]); setDraftKey({ ...key }); }, []); // Open panel for exporting key (pushes onto stack) const openKeyExport = useCallback( (key: SSHKey) => { pushPanel({ type: "export", key }); setExportHost(null); setExportLocation(".ssh"); setExportFilename("authorized_keys"); }, [pushPanel], ); // Open panel for editing key (replaces stack) const openKeyEdit = useCallback((key: SSHKey) => { setPanelStack([{ type: "edit", key }]); setDraftKey({ ...key }); }, []); // Copy public key to clipboard const copyPublicKey = useCallback(async (key: SSHKey) => { if (key.publicKey) { try { await navigator.clipboard.writeText(key.publicKey); // Could add toast notification here } catch (err) { logger.error("Failed to copy public key:", err); } } }, []); // Open panel for new identity const openNewIdentity = useCallback(() => { setPanelStack([{ type: "identity" }]); setDraftIdentity({ id: "", label: "", username: "", authMethod: "password", created: Date.now(), }); }, []); // Open generate panel const openGenerate = useCallback(() => { const defaultType: KeyType = "ED25519"; setPanelStack([{ type: "generate", keyType: "standard" }]); setDraftKey({ id: "", label: "", type: defaultType, keySize: undefined, privateKey: "", publicKey: "", source: "generated", category: "key", created: Date.now(), }); }, []); // Open import panel const openImport = useCallback(() => { setPanelStack([{ type: "import" }]); setDraftKey({ id: "", label: "", type: "ED25519", privateKey: "", publicKey: "", source: "imported", category: "key", created: Date.now(), }); }, []); // Handle standard key generation const handleGenerateStandard = useCallback(async () => { if (!draftKey.label?.trim()) { showError(t("keychain.validation.labelRequired"), t("common.validation")); return; } setIsGenerating(true); try { const keyType = (draftKey.type as KeyType) || "ED25519"; const keySize = draftKey.keySize; // Use real key generation via Electron backend const result = await generateKeyPair({ type: keyType, bits: keySize, comment: `${draftKey.label.trim()}@netcatty`, }); if (!result) { throw new Error( t("keychain.error.generationUnavailable"), ); } if (!result.success || !result.privateKey || !result.publicKey) { throw new Error(result.error || t("keychain.error.generateKeyPairFailed")); } const newKey: SSHKey = { id: crypto.randomUUID(), label: draftKey.label.trim(), type: keyType, keySize: keyType !== "ED25519" ? keySize : undefined, privateKey: result.privateKey, publicKey: result.publicKey, passphrase: draftKey.passphrase, savePassphrase: draftKey.savePassphrase, source: "generated", category: "key", created: Date.now(), }; onSave(newKey); closePanel(); } catch (err) { showError( err instanceof Error ? err.message : t("keychain.error.generateKeyFailed"), t("keychain.error.keyGenerationTitle"), ); } finally { setIsGenerating(false); } }, [draftKey, onSave, closePanel, generateKeyPair, showError, t]); // Handle key import const handleImport = useCallback(() => { if (!draftKey.label?.trim() || !draftKey.privateKey?.trim()) { showError(t("keychain.validation.labelAndPrivateKeyRequired"), t("common.validation")); return; } // Detect key type from private key content let detectedType: KeyType = "ED25519"; const pk = draftKey.privateKey.toLowerCase(); if (pk.includes("rsa")) detectedType = "RSA"; else if (pk.includes("ecdsa") || pk.includes("ec ")) detectedType = "ECDSA"; else if (pk.includes("ed25519")) detectedType = "ED25519"; const newKey: SSHKey = { id: crypto.randomUUID(), label: draftKey.label.trim(), type: (draftKey.type as KeyType) || detectedType, privateKey: draftKey.privateKey.trim(), publicKey: draftKey.publicKey?.trim() || undefined, certificate: draftKey.certificate?.trim() || undefined, passphrase: draftKey.passphrase, savePassphrase: draftKey.savePassphrase, source: "imported", category: draftKey.certificate ? "certificate" : "key", created: Date.now(), }; onSave(newKey); closePanel(); }, [draftKey, onSave, closePanel, showError, t]); // Handle save identity const handleSaveIdentity = useCallback(() => { if (!draftIdentity.label?.trim() || !draftIdentity.username?.trim()) { showError(t("keychain.validation.labelAndUsernameRequired"), t("common.validation")); return; } if (!onSaveIdentity) return; const newIdentity: Identity = { id: draftIdentity.id || crypto.randomUUID(), label: draftIdentity.label.trim(), username: draftIdentity.username.trim(), authMethod: draftIdentity.authMethod || "password", password: draftIdentity.password, keyId: draftIdentity.keyId, created: draftIdentity.created || Date.now(), }; onSaveIdentity(newIdentity); closePanel(); }, [draftIdentity, onSaveIdentity, closePanel, showError, t]); // Handle delete const handleDelete = useCallback( (id: string) => { const key = keys.find((item) => item.id === id); setDeleteTarget({ type: "key", id, name: key?.label || t("keychain.panel.keyDetails"), }); }, [keys, t], ); // Handle delete identity const _handleDeleteIdentity = useCallback( (id: string) => { const identity = identities.find((item) => item.id === id); setDeleteTarget({ type: "identity", id, name: identity?.label || t("keychain.panel.editIdentity"), }); }, [identities, t], ); const confirmDeleteTarget = useCallback( () => { if (!deleteTarget) return; if (deleteTarget.type === "key") { onDelete(deleteTarget.id); if (panel.type === "view" && panel.key.id === deleteTarget.id) { closePanel(); } } else { onDeleteIdentity?.(deleteTarget.id); if (panel.type === "identity" && panel.identity?.id === deleteTarget.id) { closePanel(); } } setDeleteTarget(null); }, [closePanel, deleteTarget, onDelete, onDeleteIdentity, panel], ); // Get icon for key source const getKeyIcon = (key: SSHKey) => { if (key.certificate) return ; return ; }; // Get key type display const getKeyTypeDisplay = (key: SSHKey) => { return key.type; }; // File input ref for import const fileInputRef = React.useRef(null); // Handle file import const handleFileImport = useCallback( (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { const content = e.target?.result as string; if (content) { // Try to detect key type from content let detectedType: KeyType = "ED25519"; const lc = content.toLowerCase(); if (lc.includes("rsa")) detectedType = "RSA"; else if (lc.includes("ecdsa") || lc.includes("ec private")) detectedType = "ECDSA"; else if (lc.includes("ed25519")) detectedType = "ED25519"; // Extract label from filename (remove extension) const label = file.name.replace(/\.(pem|key|pub|ppk)$/i, ""); setDraftKey((prev) => ({ ...prev, privateKey: content, label: prev.label || label, type: detectedType, })); } }; reader.readAsText(file); // Reset input so same file can be selected again event.target.value = ""; }, [], ); return (
{/* Hidden file input */} {/* Main Content */}
{/* Action buttons: New Key (split) | Import Certificate | New Identity */}
{/* New Key split button — same secondary style as sibling header actions */}
{/* Import Certificate - single action button */} {onSaveIdentity && ( )}
{/* Search and View Mode - hide search when panel is open */}
{panel.type === "closed" && ( setSearch(e.target.value)} placeholder={t("common.searchPlaceholder")} className="flex-shrink w-64" /> )}
{/* Scrollable Content */}
{ keyReorder.handleDragOverCapture(event); identityReorder.handleDragOverCapture(event); }} onDragOver={(event) => { keyReorder.handleDragOver(event); identityReorder.handleDragOver(event); }} onDropCapture={(event) => { keyReorder.handleDropCapture(event); identityReorder.handleDropCapture(event); }} onDragEndCapture={() => { keyReorder.handleDragEndCapture(); identityReorder.handleDragEndCapture(); }} > {/* Keys Section */} {showKeySection && (

{t("keychain.section.keys")}

{t("keychain.count.items", { count: filteredKeys.length })}
{orderedKeys.length === 0 ? (

{t("keychain.empty.title")}

{t("keychain.empty.desc")}

) : shouldShowSearchNoResults( search, filteredKeys.length, orderedKeys.length, ) ? (
{t("common.noResultsFound")}
) : (
{filteredKeys.map((key) => ( openKeyView(key)} onEdit={() => openKeyEdit(key)} onExport={() => openKeyExport(key)} onCopyPublicKey={() => copyPublicKey(key)} onDelete={() => handleDelete(key.id)} /> ))}
)}
)} {/* Identities Section */} {showIdentitySection && (

{t("keychain.section.identities")}

{t("keychain.count.items", { count: filteredIdentities.length })}
{shouldShowSearchNoResults( search, filteredIdentities.length, identities.length, ) ? (
{t("common.noResultsFound")}
) : (
{filteredIdentities.map((identity) => ( { setPanelStack([{ type: "identity", identity }]); setDraftIdentity({ ...identity }); }} /> { setPanelStack([{ type: "identity", identity }]); setDraftIdentity({ ...identity }); }} > {t("action.edit")} {onDeleteIdentity && ( <> _handleDeleteIdentity(identity.id)} > {" "} {t("action.delete")} )} ))}
)}
)}
{/* Slide-out Panel */} {panel.type !== "closed" && ( 1} onBack={popPanel} actions={ panel.type === "identity" && panel.identity && onDeleteIdentity ? ( } onClick={() => { if (panel.identity) _handleDeleteIdentity(panel.identity.id); }} > {t("common.delete")} ) : panel.type === "view" ? ( {panel.key.publicKey ? ( } onClick={() => copyPublicKey(panel.key)} > {t("action.copyPublicKey")} ) : null} } onClick={() => openKeyExport(panel.key)} > {t("action.keyExport")} } onClick={() => openKeyEdit(panel.key)} > {t("action.edit")} } onClick={() => handleDelete(panel.key.id)} > {t("action.delete")} ) : undefined } > {/* Generate Standard Key */} {panel.type === "generate" && panel.keyType === "standard" && ( )} {/* Import Key */} {panel.type === "import" && ( )} {/* View Key */} {panel.type === "view" && ( openKeyExport(panel.key)} /> )} {/* Identity Panel */} {panel.type === "identity" && ( )} {panel.type === "export" && !showHostSelector && ( )} {panel.type === "edit" && ( )} {/* Host Selector Overlay for Export */} {showHostSelector && panel.type === "export" && ( { setExportHost(host); setShowHostSelector(false); }} onBack={() => setShowHostSelector(false)} onContinue={() => setShowHostSelector(false)} availableKeys={keys} proxyProfiles={proxyProfiles} managedSources={managedSources} onSaveHost={onSaveHost} onCreateGroup={onCreateGroup} /> )} )} { if (!open) setDeleteTarget(null); }} onConfirm={confirmDeleteTarget} />
); }; export default KeychainManager;