Files
Netcatty/components/KeychainManager.tsx
bincxz fb35f989b8 Refactors to enforce backend access via application hooks
Replaces all direct usage of browser globals and infrastructure service imports in UI components with dedicated application/state backend hooks. Introduces lint rules to prevent direct access to backend bridges and localStorage from components, promoting a cleaner separation of concerns and improved maintainability.

Moves user preferences (e.g., port forwarding form mode) to persistent state hooks, updates port forwarding and SFTP logic to rely on backend hooks, and centralizes logging through a logger utility. Cleans up debug code and removes obsolete scripts from HTML.

Improves testability, prepares for alternative backend implementations, and enforces architectural boundaries.
2025-12-13 01:38:44 +08:00

1404 lines
47 KiB
TypeScript

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 { logger } from "../lib/logger";
import { cn } from "../lib/utils";
import { Host, Identity, KeyType, SSHKey } from "../types";
import { useKeychainBackend } from "../application/state/useKeychainBackend";
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<KeychainManagerProps> = ({
keys,
identities = [],
hosts = [],
customGroups = [],
onSave,
onUpdate,
onDelete,
onSaveIdentity,
onDeleteIdentity,
onNewHost: _onNewHost,
onSaveHost,
onCreateGroup,
}) => {
const { generateKeyPair, execCommand } = useKeychainBackend();
const [activeFilter, setActiveFilter] = useState<FilterTab>("key");
const [search, setSearch] = useState("");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
// Panel stack for navigation (supports back navigation)
const [panelStack, setPanelStack] = useState<PanelMode[]>([]);
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<Host | null>(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<Partial<SSHKey>>({});
const [draftIdentity, setDraftIdentity] = useState<Partial<Identity>>({});
const [showPassphrase, setShowPassphrase] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(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) {
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(),
});
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
const result = await generateKeyPair({
type: keyType,
bits: keySize,
comment: `${draftKey.label.trim()}@netcatty`,
});
if (!result) {
throw new Error(
"Key generation not available - please ensure the app is running in Electron",
);
}
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();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to generate key");
} finally {
setIsGenerating(false);
}
}, [draftKey, onSave, closePanel, generateKeyPair]);
// 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 <Fingerprint size={16} />;
if (key.source === "fido2") return <Shield size={16} />;
if (key.certificate) return <BadgeCheck size={16} />;
return <Key size={16} />;
};
// 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<HTMLInputElement>(null);
// Handle file import
const handleFileImport = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
},
[],
);
return (
<div className="h-full flex relative">
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept=".pem,.key,.pub,.ppk,*"
className="hidden"
onChange={handleFileImport}
/>
{/* Main Content */}
<div
className={cn(
"flex-1 overflow-y-auto transition-all duration-200",
panel.type !== "closed" && "mr-[380px]",
)}
>
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3 bg-secondary/60 border-b border-border/70 px-3 py-1.5">
{/* Filter Tabs */}
<div className="flex items-center gap-1">
{/* KEY button with split interaction: left=switch view, right=dropdown */}
<Dropdown>
<div
className={cn(
"flex items-center rounded-md transition-colors",
activeFilter === "key" ? "bg-primary/15" : "hover:bg-accent",
)}
>
<Button
size="sm"
variant="ghost"
className={cn(
"h-8 px-3 gap-2 rounded-r-none hover:bg-transparent",
activeFilter === "key" && "text-primary",
)}
onClick={() => setActiveFilter("key")}
>
<Key size={14} />
KEY
</Button>
<DropdownTrigger asChild>
<Button
size="sm"
variant="ghost"
className={cn(
"h-8 px-1.5 rounded-l-none hover:bg-transparent",
activeFilter === "key" && "text-primary",
)}
>
<ChevronDown size={12} />
</Button>
</DropdownTrigger>
</div>
<DropdownContent className="w-44" align="start" alignToParent>
<Button
variant="ghost"
className="w-full justify-start gap-2"
onClick={() => openGenerate("standard")}
>
<Plus size={14} /> Generate Key
</Button>
<Button
variant="ghost"
className="w-full justify-start gap-2"
onClick={openImport}
>
<Upload size={14} /> Import Key
</Button>
{onSaveIdentity && (
<Button
variant="ghost"
className="w-full justify-start gap-2"
onClick={openNewIdentity}
>
<UserPlus size={14} /> New Identity
</Button>
)}
</DropdownContent>
</Dropdown>
{/* CERTIFICATE button with split interaction */}
<Dropdown>
<div
className={cn(
"flex items-center rounded-md transition-colors",
activeFilter === "certificate"
? "bg-primary/15"
: "hover:bg-accent",
)}
>
<Button
size="sm"
variant="ghost"
className={cn(
"h-8 px-3 gap-2 rounded-r-none hover:bg-transparent",
activeFilter === "certificate" && "text-primary",
)}
onClick={() => setActiveFilter("certificate")}
>
<BadgeCheck size={14} />
CERTIFICATE
<span className="text-[10px] px-1.5 rounded-full bg-muted text-muted-foreground">
{keys.filter((k) => k.certificate).length}
</span>
</Button>
<DropdownTrigger asChild>
<Button
size="sm"
variant="ghost"
className={cn(
"h-8 px-1.5 rounded-l-none hover:bg-transparent",
activeFilter === "certificate" && "text-primary",
)}
>
<ChevronDown size={12} />
</Button>
</DropdownTrigger>
</div>
<DropdownContent className="w-44" align="start" alignToParent>
<Button
variant="ghost"
className="w-full justify-start gap-2"
onClick={openImport}
>
<Upload size={14} /> Import Certificate
</Button>
</DropdownContent>
</Dropdown>
<Button
size="sm"
variant={activeFilter === "biometric" ? "secondary" : "ghost"}
className={cn(
"h-8 px-3 gap-2",
activeFilter === "biometric" && "bg-primary/15 text-primary",
)}
onClick={() => setActiveFilter("biometric")}
>
<Fingerprint size={14} />
{biometricLabel}
</Button>
<Button
size="sm"
variant={activeFilter === "fido2" ? "secondary" : "ghost"}
className={cn(
"h-8 px-3 gap-2",
activeFilter === "fido2" && "bg-primary/15 text-primary",
)}
onClick={() => setActiveFilter("fido2")}
>
<Shield size={14} />
FIDO2
</Button>
</div>
{/* Search and View Mode - hide search when panel is open */}
<div className="ml-auto flex items-center gap-2 min-w-0 flex-shrink">
{panel.type === "closed" && (
<div className="relative flex-shrink min-w-[100px]">
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search..."
className="h-9 pl-8 w-full"
/>
</div>
)}
<Dropdown>
<DropdownTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 flex-shrink-0"
>
{viewMode === "grid" ? (
<LayoutGrid size={16} />
) : (
<ListIcon size={16} />
)}
<ChevronDown size={10} className="ml-0.5" />
</Button>
</DropdownTrigger>
<DropdownContent className="w-32" align="end">
<Button
variant={viewMode === "grid" ? "secondary" : "ghost"}
className="w-full justify-start gap-2 h-9"
onClick={() => setViewMode("grid")}
>
<LayoutGrid size={14} /> Grid
</Button>
<Button
variant={viewMode === "list" ? "secondary" : "ghost"}
className="w-full justify-start gap-2 h-9"
onClick={() => setViewMode("list")}
>
<ListIcon size={14} /> List
</Button>
</DropdownContent>
</Dropdown>
</div>
</div>
{/* Keys Section */}
<div className="space-y-3 p-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-muted-foreground">
Keys
</h2>
<span className="text-xs text-muted-foreground">
{filteredKeys.length} items
</span>
</div>
{filteredKeys.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
<Shield size={32} className="opacity-60" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">
{activeFilter === "biometric"
? `Set up ${isMac ? "Touch ID" : "Windows Hello"}`
: activeFilter === "fido2"
? "Add a security key"
: "Set up your keys"}
</h3>
<p className="text-sm text-center max-w-sm mb-4">
{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."}
</p>
{activeFilter === "biometric" && (
<Button onClick={() => openGenerate("biometric")}>
<Fingerprint size={14} className="mr-2" />
Create Biometric Key
</Button>
)}
{activeFilter === "fido2" && (
<Button onClick={() => openGenerate("fido2")}>
<Shield size={14} className="mr-2" />
Register Security Key
</Button>
)}
{(activeFilter === "key" || activeFilter === "certificate") && (
<div className="flex gap-2">
<Button variant="secondary" onClick={openImport}>
<Upload size={14} className="mr-2" />
Import
</Button>
<Button onClick={() => openGenerate("standard")}>
<Plus size={14} className="mr-2" />
Generate
</Button>
</div>
)}
</div>
) : (
<div
className={
viewMode === "grid"
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
: "flex flex-col gap-0"
}
>
{filteredKeys.map((key) => (
<KeyCard
key={key.id}
keyItem={key}
viewMode={viewMode}
isSelected={
(panel.type === "view" && panel.key.id === key.id) ||
(panel.type === "export" && panel.key.id === key.id)
}
isMac={isMacOS()}
onClick={() => openKeyView(key)}
onEdit={() => openKeyEdit(key)}
onExport={() => openKeyExport(key)}
onCopyPublicKey={() => copyPublicKey(key)}
onDelete={() => handleDelete(key.id)}
/>
))}
</div>
)}
</div>
{/* Identities Section */}
{activeFilter === "key" && filteredIdentities.length > 0 && (
<div className="space-y-3 px-3 pb-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-muted-foreground">
Identities
</h2>
<span className="text-xs text-muted-foreground">
{filteredIdentities.length} items
</span>
</div>
<div
className={
viewMode === "grid"
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
: "flex flex-col gap-0"
}
>
{filteredIdentities.map((identity) => (
<IdentityCard
key={identity.id}
identity={identity}
viewMode={viewMode}
isSelected={
panel.type === "identity" &&
panel.identity?.id === identity.id
}
onClick={() => {
setPanelStack([{ type: "identity", identity }]);
setDraftIdentity({ ...identity });
}}
/>
))}
</div>
</div>
)}
</div>
{/* Slide-out Panel */}
{panel.type !== "closed" && (
<AsidePanel
open={true}
onClose={closePanel}
title={
panel.type === "generate" && panel.keyType === "biometric"
? "Generate Biometric Key"
: panel.type === "generate" && panel.keyType === "standard"
? "Generate Key"
: panel.type === "generate" && panel.keyType === "fido2"
? "Register Security Key"
: panel.type === "import"
? "New Key"
: panel.type === "view"
? panel.key.source === "biometric"
? "Biometric Key"
: panel.key.source === "fido2"
? "Security Key"
: "Key Details"
: panel.type === "edit"
? "Edit Key"
: panel.type === "identity"
? panel.identity
? "Edit Identity"
: "New Identity"
: panel.type === "export"
? "Key Export"
: ""
}
showBackButton={panelStack.length > 1}
onBack={popPanel}
actions={
panel.type === "view" || panel.type === "identity" ? (
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal size={16} />
</Button>
) : undefined
}
>
<AsidePanelContent>
{/* Error Display */}
{error && (
<div className="p-3 bg-destructive/10 border border-destructive/30 rounded-lg text-sm text-destructive">
{error}
</div>
)}
{/* Generate Biometric Key */}
{panel.type === "generate" && panel.keyType === "biometric" && (
<GenerateBiometricPanel
draftKey={draftKey}
setDraftKey={setDraftKey}
isGenerating={isGenerating}
onGenerate={handleGenerateBiometric}
/>
)}
{/* Register FIDO2 Hardware Key */}
{panel.type === "generate" && panel.keyType === "fido2" && (
<GenerateFido2Panel
draftKey={draftKey}
setDraftKey={setDraftKey}
isGenerating={isGenerating}
onGenerate={handleGenerateFido2}
/>
)}
{/* Generate Standard Key */}
{panel.type === "generate" && panel.keyType === "standard" && (
<GenerateStandardPanel
draftKey={draftKey}
setDraftKey={setDraftKey}
showPassphrase={showPassphrase}
setShowPassphrase={setShowPassphrase}
isGenerating={isGenerating}
onGenerate={handleGenerateStandard}
/>
)}
{/* Import Key */}
{panel.type === "import" && (
<ImportKeyPanel
draftKey={draftKey}
setDraftKey={setDraftKey}
onImport={handleImport}
/>
)}
{/* View Key */}
{panel.type === "view" && (
<ViewKeyPanel
keyItem={panel.key}
onExport={() => openKeyExport(panel.key)}
/>
)}
{/* Identity Panel */}
{panel.type === "identity" && (
<IdentityPanel
draftIdentity={draftIdentity}
setDraftIdentity={setDraftIdentity}
keys={keys}
showPassphrase={showPassphrase}
setShowPassphrase={setShowPassphrase}
isNew={!panel.identity}
onSave={handleSaveIdentity}
/>
)}
{/* Key Export Panel */}
{panel.type === "export" && !showHostSelector && (
<>
{/* Key info card */}
<div className="flex items-center gap-3 p-3 bg-card border border-border/80 rounded-lg">
<div
className={cn(
"h-10 w-10 rounded-md flex items-center justify-center",
panel.key.source === "biometric"
? "bg-blue-500/15 text-blue-500"
: panel.key.source === "fido2"
? "bg-amber-500/15 text-amber-500"
: "bg-primary/15 text-primary",
)}
>
{getKeyIcon(panel.key)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold truncate">
{panel.key.label}
</p>
<p className="text-xs text-muted-foreground">
Type {getKeyTypeDisplay(panel.key)}
</p>
</div>
</div>
{/* Export to field */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-muted-foreground">Export to *</Label>
<Button
variant="link"
className="h-auto p-0 text-primary text-sm"
onClick={() => setShowHostSelector(true)}
>
Select Host
</Button>
</div>
<Input
value={exportHost?.label || ""}
readOnly
placeholder="Select a host..."
className="bg-muted/50 cursor-pointer"
onClick={() => setShowHostSelector(true)}
/>
</div>
{/* Location field */}
<div className="space-y-2">
<Label className="text-muted-foreground">
Location ~ $1 *
</Label>
<Input
value={exportLocation}
onChange={(e) => setExportLocation(e.target.value)}
placeholder=".ssh"
/>
</div>
{/* Filename field */}
<div className="space-y-2">
<Label className="text-muted-foreground">
Filename ~ $2 *
</Label>
<Input
value={exportFilename}
onChange={(e) => setExportFilename(e.target.value)}
placeholder="authorized_keys"
/>
</div>
{/* Info note */}
<div className="flex items-start gap-2 p-3 bg-muted/50 border border-border/60 rounded-lg">
<Info
size={14}
className="mt-0.5 text-muted-foreground shrink-0"
/>
<p className="text-xs text-muted-foreground">
Key export currently supports only{" "}
<span className="font-semibold text-foreground">UNIX</span>{" "}
systems. Use{" "}
<span className="font-semibold text-foreground">
Advanced
</span>{" "}
section to customize the export script.
</p>
</div>
{/* Advanced collapsible */}
<Collapsible
open={exportAdvancedOpen}
onOpenChange={setExportAdvancedOpen}
>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="w-full justify-between px-0 h-10 hover:bg-transparent hover:text-current"
>
<span className="font-medium">Advanced</span>
<ChevronRight
size={16}
className={cn(
"transition-transform",
exportAdvancedOpen && "rotate-90",
)}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 pt-2">
<Label className="text-muted-foreground">Script *</Label>
<Textarea
value={exportScript}
onChange={(e) => setExportScript(e.target.value)}
className="min-h-[180px] font-mono text-xs"
placeholder="Export script..."
/>
</CollapsibleContent>
</Collapsible>
{/* Export button */}
<Button
className="w-full h-11"
disabled={
!exportHost ||
!exportLocation ||
!exportFilename ||
isExporting
}
onClick={async () => {
if (!exportHost || !panel.key.publicKey) return;
setIsExporting(true);
setError("");
try {
// Check for authentication method - prefer password for key export
// Since we're exporting a key to a host, we need password auth
if (!exportHost.password && !exportHost.identityFileId) {
throw new Error(
"Host has no saved password or key. Please add password credentials to the host first.",
);
}
// Get private key for authentication if host uses key auth
const hostPrivateKey = exportHost.identityFileId
? keys.find((k) => k.id === exportHost.identityFileId)
?.privateKey
: undefined;
// Escape the public key for shell (single quotes, escape existing quotes)
const escapedPublicKey = panel.key.publicKey.replace(
/'/g,
"'\\''",
);
// Build the command by replacing $1, $2, $3
const scriptWithVars = exportScript
.replace(/\$1/g, exportLocation)
.replace(/\$2/g, exportFilename)
.replace(/\$3/g, `'${escapedPublicKey}'`);
// Execute the script directly - SSH exec handles multiline commands
const command = scriptWithVars;
// Execute via SSH
const result = await execCommand({
hostname: exportHost.hostname,
username: exportHost.username,
port: exportHost.port || 22,
password: exportHost.password,
privateKey: hostPrivateKey,
command,
timeout: 30000,
});
// Check result - code 0, null, or undefined with no stderr is success
const exitCode = result?.code;
const hasError = result?.stderr?.trim();
if (exitCode === 0 || (exitCode == null && !hasError)) {
// Update host to use this key for authentication
if (onSaveHost) {
const updatedHost: Host = {
...exportHost,
identityFileId: panel.key.id,
authMethod: "key",
};
onSaveHost(updatedHost);
}
toast.success(
`Public key exported and attached to ${exportHost.label}`,
"Export Successful",
);
closePanel();
} else {
const errorMsg =
hasError ||
result?.stdout?.trim() ||
`Command exited with code ${exitCode}`;
toast.error(
`Failed to export key: ${errorMsg}`,
"Export Failed",
);
}
} catch (err) {
const message =
err instanceof Error ? err.message : String(err);
toast.error(`Export failed: ${message}`, "Export Failed");
} finally {
setIsExporting(false);
}
}}
>
{isExporting ? "Exporting..." : "Export and Attach"}
</Button>
</>
)}
{/* Edit Key Panel */}
{panel.type === "edit" && (
<>
<div className="space-y-2">
<Label>Label *</Label>
<Input
value={draftKey.label || ""}
onChange={(e) =>
setDraftKey({ ...draftKey, label: e.target.value })
}
placeholder="Key label"
/>
</div>
<div className="space-y-2">
<Label className="text-destructive">Private key *</Label>
<Textarea
value={draftKey.privateKey || ""}
onChange={(e) =>
setDraftKey({ ...draftKey, privateKey: e.target.value })
}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
className="min-h-[180px] font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Public key</Label>
<Textarea
value={draftKey.publicKey || ""}
onChange={(e) =>
setDraftKey({ ...draftKey, publicKey: e.target.value })
}
placeholder="ssh-ed25519 AAAA..."
className="min-h-[80px] font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Certificate</Label>
<Textarea
value={draftKey.certificate || ""}
onChange={(e) =>
setDraftKey({ ...draftKey, certificate: e.target.value })
}
placeholder="Certificate content (optional)"
className="min-h-[60px] font-mono text-xs"
/>
</div>
{/* Key Export section */}
<div className="pt-4 mt-4 border-t border-border/60">
<div className="flex items-center gap-2 mb-3">
<span className="text-sm font-medium">Key export</span>
<div className="h-4 w-4 rounded-full bg-muted flex items-center justify-center">
<Info size={10} className="text-muted-foreground" />
</div>
</div>
<Button
className="w-full h-11"
onClick={() => openKeyExport(panel.key)}
>
Export to host
</Button>
</div>
{/* Save button */}
<Button
className="w-full h-11 mt-4"
disabled={
!draftKey.label?.trim() || !draftKey.privateKey?.trim()
}
onClick={() => {
if (draftKey.id) {
onUpdate({
...panel.key,
...(draftKey as SSHKey),
});
closePanel();
}
}}
>
Save Changes
</Button>
</>
)}
</AsidePanelContent>
{/* Host Selector Overlay for Export */}
{showHostSelector && panel.type === "export" && (
<SelectHostPanel
hosts={hosts}
customGroups={customGroups}
selectedHostIds={exportHost?.id ? [exportHost.id] : []}
multiSelect={false}
onSelect={(host) => {
setExportHost(host);
setShowHostSelector(false);
}}
onBack={() => setShowHostSelector(false)}
onContinue={() => setShowHostSelector(false)}
availableKeys={keys}
onSaveHost={onSaveHost}
onCreateGroup={onCreateGroup}
/>
)}
</AsidePanel>
)}
</div>
);
};
export default KeychainManager;