import { BadgeCheck, ChevronDown, ChevronRight, Fingerprint, Info, Key, LayoutGrid, List as ListIcon, MoreHorizontal, Plus, Search, Shield, Upload, UserPlus, } from "lucide-react"; import React, { useCallback, useMemo, useState } from "react"; import { cn } from "../lib/utils"; import { Host, Identity, KeyType, SSHKey } from "../types"; import SelectHostPanel from "./SelectHostPanel"; import { AsidePanel, AsidePanelContent } from "./ui/aside-panel"; import { Button } from "./ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "./ui/collapsible"; import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown"; import { Input } from "./ui/input"; import { Label } from "./ui/label"; import { Textarea } from "./ui/textarea"; import { toast } from "./ui/toast"; // Import utilities and components from keychain module import { createBiometricCredential, createFido2Credential, type FilterTab, GenerateBiometricPanel, GenerateFido2Panel, GenerateStandardPanel, IdentityCard, IdentityPanel, ImportKeyPanel, isMacOS, KeyCard, type PanelMode, ViewKeyPanel, } from "./keychain"; interface KeychainManagerProps { keys: SSHKey[]; identities?: Identity[]; hosts?: Host[]; customGroups?: string[]; onSave: (key: SSHKey) => void; onUpdate: (key: SSHKey) => void; onDelete: (id: string) => void; onSaveIdentity?: (identity: Identity) => void; onDeleteIdentity?: (id: string) => void; onNewHost?: () => void; onSaveHost?: (host: Host) => void; onCreateGroup?: (groupPath: string) => void; } const KeychainManager: React.FC = ({ keys, identities = [], hosts = [], customGroups = [], onSave, onUpdate, onDelete, onSaveIdentity, onDeleteIdentity, onNewHost: _onNewHost, onSaveHost, onCreateGroup, }) => { const [activeFilter, setActiveFilter] = useState("key"); const [search, setSearch] = useState(""); const [viewMode, setViewMode] = useState<"grid" | "list">("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 [showHostSelector, setShowHostSelector] = useState(false); const [isExporting, setIsExporting] = useState(false); // 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"`); // Detect if running on macOS const isMac = useMemo(() => { return ( navigator.platform.toLowerCase().includes("mac") || navigator.userAgent.toLowerCase().includes("mac") ); }, []); // Biometric authentication label based on platform const biometricLabel = isMac ? "TOUCH ID" : "WINDOWS HELLO"; // Draft state for forms const [draftKey, setDraftKey] = useState>({}); const [draftIdentity, setDraftIdentity] = useState>({}); const [showPassphrase, setShowPassphrase] = useState(false); const [isGenerating, setIsGenerating] = useState(false); const [error, setError] = useState(null); // Filter keys based on active tab and search const filteredKeys = useMemo(() => { let result = keys; // Filter by tab switch (activeFilter) { case "key": result = result.filter( (k) => k.source === "generated" || k.source === "imported", ); break; case "certificate": result = result.filter( (k) => k.category === "certificate" || k.certificate, ); break; case "biometric": result = result.filter((k) => k.source === "biometric"); break; case "fido2": result = result.filter((k) => k.source === "fido2"); break; } // Filter by search if (search.trim()) { const s = search.toLowerCase(); result = result.filter( (k) => k.label.toLowerCase().includes(s) || k.type.toLowerCase().includes(s) || k.publicKey?.toLowerCase().includes(s), ); } return result; }, [keys, activeFilter, search]); // Filter identities based on search const filteredIdentities = useMemo(() => { if (!search.trim()) return identities; const s = search.toLowerCase(); return identities.filter( (i) => i.label.toLowerCase().includes(s) || i.username.toLowerCase().includes(s), ); }, [identities, search]); // Push a new panel onto the stack const pushPanel = useCallback((newPanel: PanelMode) => { setPanelStack((prev) => [...prev, newPanel]); setError(null); }, []); // Pop the top panel from the stack (go back) const popPanel = useCallback(() => { setPanelStack((prev) => { if (prev.length <= 1) { // Last panel, close everything setDraftKey({}); setDraftIdentity({}); setError(null); setShowPassphrase(false); setExportHost(null); setExportAdvancedOpen(false); return []; } return prev.slice(0, -1); }); }, []); // Close all panels const closePanel = useCallback(() => { setPanelStack([]); setDraftKey({}); setDraftIdentity({}); setError(null); 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 }); setError(null); }, []); // 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 }); setError(null); }, []); // 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) { console.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(), }); setError(null); }, []); // Open generate panel const openGenerate = useCallback( (keyType: "standard" | "biometric" | "fido2") => { const defaultType = keyType === "biometric" || keyType === "fido2" ? "ECDSA" : "ED25519"; // Set default keySize based on type: ED25519 doesn't need size, RSA defaults to 4096, ECDSA to 256 const getDefaultKeySize = (type: string) => { if (type === "ED25519") return undefined; if (type === "RSA") return 4096; return 256; // ECDSA }; const getSource = () => { if (keyType === "biometric") return "biometric"; if (keyType === "fido2") return "fido2"; return "generated"; }; setPanelStack([{ type: "generate", keyType }]); setDraftKey({ id: "", label: "", type: defaultType, keySize: getDefaultKeySize(defaultType), privateKey: "", publicKey: "", source: getSource(), category: "key", created: Date.now(), }); setError(null); }, [], ); // Open import panel const openImport = useCallback(() => { setPanelStack([{ type: "import" }]); setDraftKey({ id: "", label: "", type: "ED25519", privateKey: "", publicKey: "", source: "imported", category: "key", created: Date.now(), }); setError(null); }, []); // Handle standard key generation const handleGenerateStandard = useCallback(async () => { if (!draftKey.label?.trim()) { setError("Please enter a label for the key"); return; } setIsGenerating(true); setError(null); try { const keyType = (draftKey.type as KeyType) || "ED25519"; const keySize = draftKey.keySize; // Use real key generation via Electron backend if (window.netcatty?.generateKeyPair) { const result = await window.netcatty.generateKeyPair({ type: keyType, bits: keySize, comment: `${draftKey.label.trim()}@netcatty`, }); if (!result.success || !result.privateKey || !result.publicKey) { throw new Error(result.error || "Failed to generate key pair"); } 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(); } else { throw new Error( "Key generation not available - please ensure the app is running in Electron", ); } } catch (err) { setError(err instanceof Error ? err.message : "Failed to generate key"); } finally { setIsGenerating(false); } }, [draftKey, onSave, closePanel]); // Handle biometric key generation (Windows Hello) const handleGenerateBiometric = useCallback(async () => { if (!draftKey.label?.trim()) { setError("Please enter a label for the key"); return; } setIsGenerating(true); setError(null); try { const result = await createBiometricCredential(draftKey.label.trim()); if (!result) { throw new Error("Credential creation was cancelled"); } const newKey: SSHKey = { id: crypto.randomUUID(), label: draftKey.label.trim(), type: "ECDSA", privateKey: "", // Biometric keys don't have exportable private keys publicKey: result.publicKey, credentialId: result.credentialId, rpId: result.rpId, source: "biometric", category: "key", created: Date.now(), }; onSave(newKey); closePanel(); } catch (err) { setError( err instanceof Error ? err.message : "Failed to create biometric credential", ); } finally { setIsGenerating(false); } }, [draftKey, onSave, closePanel]); // Handle FIDO2 hardware key registration const handleGenerateFido2 = useCallback(async () => { if (!draftKey.label?.trim()) { setError("Please enter a label for the security key"); return; } setIsGenerating(true); setError(null); try { const result = await createFido2Credential(draftKey.label.trim()); if (!result) { throw new Error("Security key registration was cancelled"); } const newKey: SSHKey = { id: crypto.randomUUID(), label: draftKey.label.trim(), type: "ECDSA", privateKey: "", // Hardware keys don't expose private keys publicKey: result.publicKey, credentialId: result.credentialId, rpId: result.rpId, source: "fido2", category: "key", created: Date.now(), }; onSave(newKey); closePanel(); } catch (err) { setError( err instanceof Error ? err.message : "Failed to register security key", ); } finally { setIsGenerating(false); } }, [draftKey, onSave, closePanel]); // Handle key import const handleImport = useCallback(() => { if (!draftKey.label?.trim() || !draftKey.privateKey?.trim()) { setError("Label and private key are required"); 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]); // Handle save identity const handleSaveIdentity = useCallback(() => { if (!draftIdentity.label?.trim() || !draftIdentity.username?.trim()) { setError("Label and username are required"); 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]); // Handle delete const handleDelete = useCallback( (id: string) => { onDelete(id); if (panel.type === "view" && panel.key.id === id) { closePanel(); } }, [onDelete, panel, closePanel], ); // Handle delete identity const _handleDeleteIdentity = useCallback( (id: string) => { onDeleteIdentity?.(id); if (panel.type === "identity" && panel.identity?.id === id) { closePanel(); } }, [onDeleteIdentity, panel, closePanel], ); // Copy to clipboard const _copyToClipboard = useCallback((_text: string) => { navigator.clipboard.writeText(_text); }, []); // Get icon for key source const getKeyIcon = (key: SSHKey) => { if (key.source === "biometric") return ; if (key.source === "fido2") return ; if (key.certificate) return ; return ; }; // Get key type display const getKeyTypeDisplay = (key: SSHKey) => { if (key.source === "biometric") return isMac ? "Touch ID" : "Windows Hello"; if (key.source === "fido2") return "FIDO2"; 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 = ""; }, [], ); // Handle drag and drop const _handleDrop = useCallback((event: React.DragEvent) => { event.preventDefault(); event.stopPropagation(); const file = event.dataTransfer.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { const content = e.target?.result as string; if (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"; const label = file.name.replace(/\.(pem|key|pub|ppk)$/i, ""); setDraftKey((prev) => ({ ...prev, privateKey: content, label: prev.label || label, type: detectedType, })); } }; reader.readAsText(file); }, []); const _handleDragOver = useCallback( (event: React.DragEvent) => { event.preventDefault(); event.stopPropagation(); }, [], ); return (
{/* Hidden file input */} {/* Main Content */}
{/* Toolbar */}
{/* Filter Tabs */}
{/* KEY button with split interaction: left=switch view, right=dropdown */}
{onSaveIdentity && ( )}
{/* CERTIFICATE button with split interaction */}
{/* Search and View Mode - hide search when panel is open */}
{panel.type === "closed" && (
setSearch(e.target.value)} placeholder="Search..." className="h-9 pl-8 w-full" />
)}
{/* Keys Section */}

Keys

{filteredKeys.length} items
{filteredKeys.length === 0 ? (

{activeFilter === "biometric" ? `Set up ${isMac ? "Touch ID" : "Windows Hello"}` : activeFilter === "fido2" ? "Add a security key" : "Set up your keys"}

{activeFilter === "biometric" ? `Create biometric SSH keys secured by ${isMac ? "Touch ID" : "Windows Hello"} for passwordless authentication.` : activeFilter === "fido2" ? "Connect a hardware security key (YubiKey, etc.) for enhanced security." : "Import or generate SSH keys for secure authentication."}

{activeFilter === "biometric" && ( )} {activeFilter === "fido2" && ( )} {(activeFilter === "key" || activeFilter === "certificate") && (
)}
) : (
{filteredKeys.map((key) => ( openKeyView(key)} onEdit={() => openKeyEdit(key)} onExport={() => openKeyExport(key)} onCopyPublicKey={() => copyPublicKey(key)} onDelete={() => handleDelete(key.id)} /> ))}
)}
{/* Identities Section */} {activeFilter === "key" && filteredIdentities.length > 0 && (

Identities

{filteredIdentities.length} items
{filteredIdentities.map((identity) => ( { setPanelStack([{ type: "identity", identity }]); setDraftIdentity({ ...identity }); }} /> ))}
)}
{/* Slide-out Panel */} {panel.type !== "closed" && ( 1} onBack={popPanel} actions={ panel.type === "view" || panel.type === "identity" ? ( ) : undefined } > {/* Error Display */} {error && (
{error}
)} {/* Generate Biometric Key */} {panel.type === "generate" && panel.keyType === "biometric" && ( )} {/* Register FIDO2 Hardware Key */} {panel.type === "generate" && panel.keyType === "fido2" && ( )} {/* 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" && ( )} {/* Key Export Panel */} {panel.type === "export" && !showHostSelector && ( <> {/* Key info card */}
{getKeyIcon(panel.key)}

{panel.key.label}

Type {getKeyTypeDisplay(panel.key)}

{/* Export to field */}
setShowHostSelector(true)} />
{/* Location field */}
setExportLocation(e.target.value)} placeholder=".ssh" />
{/* Filename field */}
setExportFilename(e.target.value)} placeholder="authorized_keys" />
{/* Info note */}

Key export currently supports only{" "} UNIX{" "} systems. Use{" "} Advanced {" "} section to customize the export script.

{/* Advanced collapsible */}