Files
Netcatty/components/KeychainManager.tsx
2026-06-11 16:05:17 +08:00

1039 lines
34 KiB
TypeScript

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,
vaultSectionTitleClass,
} from "./vault/VaultPageHeader";
import { useVaultItemReorder } from "./vault/vaultReorderDrag";
// Import utilities and components from keychain module
import {
type FilterTab,
GenerateStandardPanel,
IdentityCard,
IdentityPanel,
ImportKeyPanel,
isMacOS,
KeyCard,
type PanelMode,
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<KeychainManagerProps> = ({
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 [activeFilter, setActiveFilter] = useState<FilterTab>("key");
const [search, setSearch] = useState("");
const [viewMode, setViewMode] = useStoredViewMode(
STORAGE_KEY_VAULT_KEYS_VIEW_MODE,
"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 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<HTMLDivElement | null>(null);
// 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"`);
// 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 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]);
// 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" || k.source === "reference",
);
break;
case "certificate":
result = result.filter(
(k) => k.category === "certificate" || k.certificate,
);
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 sortByVaultOrder(result);
}, [keys, activeFilter, 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]);
// 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(
async (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],
);
// Get icon for key source
const getKeyIcon = (key: SSHKey) => {
if (key.certificate) return <BadgeCheck size={16} />;
return <Key size={16} />;
};
// Get key type display
const getKeyTypeDisplay = (key: SSHKey) => {
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 = "";
},
[],
);
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 flex flex-col min-h-0 transition-all duration-200",
panel.type !== "closed" && "mr-[380px]",
)}
>
<VaultPageHeader>
{/* 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-foreground/10 text-foreground hover:bg-foreground/15"
: "bg-foreground/5 text-foreground hover:bg-foreground/10",
)}
>
<Button
size="sm"
variant="ghost"
className="h-10 px-3 gap-2 rounded-r-none hover:bg-transparent text-inherit"
onClick={() => setActiveFilter("key")}
>
<Key size={14} />
{t("keychain.filter.key")}
</Button>
<DropdownTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-10 px-1.5 rounded-l-none hover:bg-transparent text-inherit"
>
<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}
>
<Plus size={14} /> {t("keychain.action.generateKey")}
</Button>
<Button
variant="ghost"
className="w-full justify-start gap-2"
onClick={openImport}
>
<Upload size={14} /> {t("keychain.action.importKey")}
</Button>
{onSaveIdentity && (
<Button
variant="ghost"
className="w-full justify-start gap-2"
onClick={openNewIdentity}
>
<UserPlus size={14} /> {t("keychain.action.newIdentity")}
</Button>
)}
</DropdownContent>
</Dropdown>
{/* CERTIFICATE button with split interaction */}
<Dropdown>
<div
className={cn(
"flex items-center rounded-md transition-colors",
activeFilter === "certificate"
? "bg-foreground/10 text-foreground hover:bg-foreground/15"
: "bg-foreground/5 text-foreground hover:bg-foreground/10",
)}
>
<Button
size="sm"
variant="ghost"
className="h-10 px-3 gap-2 rounded-r-none hover:bg-transparent text-inherit"
onClick={() => setActiveFilter("certificate")}
>
<BadgeCheck size={14} />
{t("keychain.filter.certificate")}
</Button>
<DropdownTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-10 px-1.5 rounded-l-none hover:bg-transparent text-inherit"
>
<ChevronDown size={12} />
</Button>
</DropdownTrigger>
</div>
<DropdownContent className="w-48" align="start" alignToParent>
<Button
variant="ghost"
className="w-full justify-start gap-2"
onClick={openImport}
>
<Upload size={14} /> {t("keychain.action.importCertificate")}
</Button>
</DropdownContent>
</Dropdown>
</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" && (
<VaultHeaderSearch
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("common.searchPlaceholder")}
className="flex-shrink w-64"
/>
)}
<Dropdown>
<DropdownTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(vaultHeaderIconButtonClass, "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} /> {t("keychain.view.grid")}
</Button>
<Button
variant={viewMode === "list" ? "secondary" : "ghost"}
className="w-full justify-start gap-2 h-9"
onClick={() => setViewMode("list")}
>
<ListIcon size={14} /> {t("keychain.view.list")}
</Button>
</DropdownContent>
</Dropdown>
</div>
</VaultPageHeader>
{/* Scrollable Content */}
<div
ref={listRef}
className="flex-1 overflow-y-auto"
onDragOverCapture={(event) => {
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 */}
<div className="space-y-3 p-3">
<div className="flex items-center justify-between">
<h2 className={vaultSectionTitleClass}>
{t("keychain.section.keys")}
</h2>
<span className="text-xs text-muted-foreground">
{t("keychain.count.items", { count: filteredKeys.length })}
</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">
{t("keychain.empty.title")}
</h3>
<p className="text-sm text-center max-w-sm mb-4">
{t("keychain.empty.desc")}
</p>
{(activeFilter === "key" || activeFilter === "certificate") && (
<div className="flex gap-2">
<Button variant="secondary" onClick={openImport}>
<Upload size={14} className="mr-2" />
{t("common.import")}
</Button>
<Button onClick={openGenerate}>
<Plus size={14} className="mr-2" />
{t("common.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()}
reorderProps={keyReorder.getItemReorderProps(key.id, `key:${key.id}`)}
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={vaultSectionTitleClass}>
{t("keychain.section.identities")}
</h2>
<span className="text-xs text-muted-foreground">
{t("keychain.count.items", { count: filteredIdentities.length })}
</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) => (
<ContextMenu key={identity.id}>
<ContextMenuTrigger>
<IdentityCard
identity={identity}
viewMode={viewMode}
isSelected={
panel.type === "identity" &&
panel.identity?.id === identity.id
}
reorderProps={identityReorder.getItemReorderProps(identity.id, `identity:${identity.id}`)}
onClick={() => {
setPanelStack([{ type: "identity", identity }]);
setDraftIdentity({ ...identity });
}}
/>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() => {
setPanelStack([{ type: "identity", identity }]);
setDraftIdentity({ ...identity });
}}
>
<Edit2 className="mr-2 h-4 w-4" /> {t("action.edit")}
</ContextMenuItem>
{onDeleteIdentity && (
<>
<ContextMenuSeparator />
<ContextMenuItem
className="text-destructive"
onClick={() => {
const ok = window.confirm(
t("confirm.deleteIdentity", {
name: identity.label || "",
}),
);
if (!ok) return;
_handleDeleteIdentity(identity.id);
}}
>
<Trash2 className="mr-2 h-4 w-4" />{" "}
{t("action.delete")}
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
))}
</div>
</div>
)}
</div>
</div>
{/* Slide-out Panel */}
{panel.type !== "closed" && (
<AsidePanel
open={true}
onClose={closePanel}
title={panelTitle}
showBackButton={panelStack.length > 1}
onBack={popPanel}
actions={
panel.type === "identity" && panel.identity && onDeleteIdentity ? (
<AsideActionMenu>
<AsideActionMenuItem
variant="destructive"
icon={<Trash2 size={14} />}
onClick={() => {
const ok = window.confirm(
t("confirm.deleteIdentity", {
name: panel.identity?.label || "",
}),
);
if (!ok || !panel.identity) return;
_handleDeleteIdentity(panel.identity.id);
}}
>
{t("common.delete")}
</AsideActionMenuItem>
</AsideActionMenu>
) : panel.type === "view" ? (
<AsideActionMenu>
{panel.key.publicKey ? (
<AsideActionMenuItem
icon={<Copy size={14} />}
onClick={() => copyPublicKey(panel.key)}
>
{t("action.copyPublicKey")}
</AsideActionMenuItem>
) : null}
<AsideActionMenuItem
icon={<ExternalLink size={14} />}
onClick={() => openKeyExport(panel.key)}
>
{t("action.keyExport")}
</AsideActionMenuItem>
<AsideActionMenuItem
icon={<Edit2 size={14} />}
onClick={() => openKeyEdit(panel.key)}
>
{t("action.edit")}
</AsideActionMenuItem>
<AsideActionMenuItem
variant="destructive"
icon={<Trash2 size={14} />}
onClick={() => handleDelete(panel.key.id)}
>
{t("action.delete")}
</AsideActionMenuItem>
</AsideActionMenu>
) : undefined
}
>
<AsidePanelContent>
{/* 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}
showPassphrase={showPassphrase}
setShowPassphrase={setShowPassphrase}
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}
/>
)}
{panel.type === "export" && !showHostSelector && (
<KeychainExportPanel
panel={panel}
t={t}
getKeyIcon={getKeyIcon}
getKeyTypeDisplay={getKeyTypeDisplay}
setShowHostSelector={setShowHostSelector}
exportHost={exportHost}
exportLocation={exportLocation}
setExportLocation={setExportLocation}
exportFilename={exportFilename}
setExportFilename={setExportFilename}
exportAdvancedOpen={exportAdvancedOpen}
setExportAdvancedOpen={setExportAdvancedOpen}
exportScript={exportScript}
setExportScript={setExportScript}
isExporting={isExporting}
setIsExporting={setIsExporting}
keys={keys}
identities={identities}
groupConfigs={groupConfigs}
execCommand={execCommand}
onSaveIdentity={onSaveIdentity}
onSaveHost={onSaveHost}
closePanel={closePanel}
/>
)}
{panel.type === "edit" && (
<KeychainEditPanel
panel={panel}
t={t}
draftKey={draftKey}
setDraftKey={setDraftKey}
showPassphrase={showPassphrase}
setShowPassphrase={setShowPassphrase}
openKeyExport={openKeyExport}
onUpdate={onUpdate}
closePanel={closePanel}
/>
)}
</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}
proxyProfiles={proxyProfiles}
managedSources={managedSources}
onSaveHost={onSaveHost}
onCreateGroup={onCreateGroup}
/>
)}
</AsidePanel>
)}
</div>
);
};
export default KeychainManager;