mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-09-24 15:49:09 +00:00
951a89e91e
Add execCommand options to opt into keyboard-interactive auth and wire MFA only for export-key flows, preserving non-interactive exec usage elsewhere. Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1304 lines
44 KiB
TypeScript
1304 lines
44 KiB
TypeScript
import {
|
|
BadgeCheck,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Edit2,
|
|
Info,
|
|
Key,
|
|
LayoutGrid,
|
|
List as ListIcon,
|
|
MoreHorizontal,
|
|
Plus,
|
|
Search,
|
|
Shield,
|
|
Trash2,
|
|
Upload,
|
|
UserPlus,
|
|
} from "lucide-react";
|
|
import React, { useCallback, useMemo, useState } from "react";
|
|
import { useI18n } from "../application/i18n/I18nProvider";
|
|
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
|
import { resolveHostAuth } from "../domain/sshAuth";
|
|
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, 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 {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from "./ui/collapsible";
|
|
import {
|
|
ContextMenu,
|
|
ContextMenuContent,
|
|
ContextMenuItem,
|
|
ContextMenuSeparator,
|
|
ContextMenuTrigger,
|
|
} from "./ui/context-menu";
|
|
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 {
|
|
type FilterTab,
|
|
GenerateStandardPanel,
|
|
IdentityCard,
|
|
IdentityPanel,
|
|
ImportKeyPanel,
|
|
isMacOS,
|
|
KeyCard,
|
|
type PanelMode,
|
|
ViewKeyPanel,
|
|
} from "./keychain";
|
|
|
|
interface KeychainManagerProps {
|
|
keys: SSHKey[];
|
|
identities?: Identity[];
|
|
hosts?: Host[];
|
|
customGroups?: string[];
|
|
managedSources?: ManagedSource[];
|
|
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 = [],
|
|
managedSources = [],
|
|
onSave,
|
|
onUpdate,
|
|
onDelete,
|
|
onSaveIdentity,
|
|
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);
|
|
|
|
// 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 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",
|
|
);
|
|
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 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]);
|
|
}, []);
|
|
|
|
// 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],
|
|
);
|
|
|
|
// Copy to clipboard
|
|
const _copyToClipboard = useCallback((_text: string) => {
|
|
navigator.clipboard.writeText(_text);
|
|
}, []);
|
|
|
|
// 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 = "";
|
|
},
|
|
[],
|
|
);
|
|
|
|
// 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} />
|
|
{t("keychain.filter.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}
|
|
>
|
|
<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-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} />
|
|
{t("keychain.filter.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} /> {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" && (
|
|
<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={t("common.searchPlaceholder")}
|
|
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} /> {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>
|
|
</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">
|
|
{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()}
|
|
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">
|
|
{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
|
|
}
|
|
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>
|
|
|
|
{/* 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" ? (
|
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
|
<MoreHorizontal size={16} />
|
|
</Button>
|
|
) : 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}
|
|
/>
|
|
)}
|
|
|
|
{/* 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.certificate
|
|
? "bg-emerald-500/15 text-emerald-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">
|
|
{t("auth.keyType", { 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">
|
|
{t("keychain.export.exportTo")}
|
|
</Label>
|
|
<Button
|
|
variant="link"
|
|
className="h-auto p-0 text-primary text-sm"
|
|
onClick={() => setShowHostSelector(true)}
|
|
>
|
|
{t("keychain.export.selectHost")}
|
|
</Button>
|
|
</div>
|
|
<Input
|
|
value={exportHost?.label || ""}
|
|
readOnly
|
|
placeholder={t("common.selectAHostPlaceholder")}
|
|
className="bg-muted/50 cursor-pointer"
|
|
onClick={() => setShowHostSelector(true)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Location field */}
|
|
<div className="space-y-2">
|
|
<Label className="text-muted-foreground">
|
|
{t("keychain.export.location")}
|
|
</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">
|
|
{t("keychain.export.filename")}
|
|
</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">
|
|
{t("keychain.export.note", {
|
|
unix: "UNIX",
|
|
advanced: t("common.advanced"),
|
|
})}
|
|
</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">{t("common.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">
|
|
{t("keychain.export.script")}
|
|
</Label>
|
|
<Textarea
|
|
value={exportScript}
|
|
onChange={(e) => setExportScript(e.target.value)}
|
|
className="min-h-[180px] font-mono text-xs"
|
|
placeholder={t("keychain.export.scriptPlaceholder")}
|
|
/>
|
|
</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);
|
|
|
|
try {
|
|
const exportAuth = resolveHostAuth({
|
|
host: exportHost,
|
|
keys,
|
|
identities,
|
|
});
|
|
|
|
// Need either password or a usable key to run remote command.
|
|
if (!exportAuth.password && !exportAuth.key?.privateKey) {
|
|
throw new Error(
|
|
t("keychain.export.missingCredentials"),
|
|
);
|
|
}
|
|
|
|
const hostPrivateKey = exportAuth.key?.privateKey;
|
|
|
|
// 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: exportAuth.username,
|
|
port: exportHost.port || 22,
|
|
password: exportAuth.password,
|
|
privateKey: hostPrivateKey,
|
|
command,
|
|
timeout: 30000,
|
|
enableKeyboardInteractive: true,
|
|
sessionId: `export-key:${exportHost.id}:${panel.key.id}`,
|
|
});
|
|
|
|
// 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 identity (preferred) or host to use this key for authentication
|
|
if (exportHost.identityId && onSaveIdentity) {
|
|
const existing = identities.find(
|
|
(i) => i.id === exportHost.identityId,
|
|
);
|
|
if (existing) {
|
|
onSaveIdentity({
|
|
...existing,
|
|
authMethod: "key",
|
|
keyId: panel.key.id,
|
|
});
|
|
}
|
|
} else if (onSaveHost) {
|
|
onSaveHost({
|
|
...exportHost,
|
|
identityFileId: panel.key.id,
|
|
authMethod: "key",
|
|
});
|
|
}
|
|
toast.success(
|
|
t("keychain.export.successMessage", {
|
|
host: exportHost.label,
|
|
}),
|
|
t("keychain.export.successTitle"),
|
|
);
|
|
closePanel();
|
|
} else {
|
|
const errorMsg =
|
|
hasError ||
|
|
result?.stdout?.trim() ||
|
|
t("keychain.export.exitCode", { code: exitCode });
|
|
toast.error(
|
|
t("keychain.export.failedMessage", { error: errorMsg }),
|
|
t("keychain.export.failedTitle"),
|
|
);
|
|
}
|
|
} catch (err) {
|
|
const message =
|
|
err instanceof Error ? err.message : String(err);
|
|
toast.error(
|
|
t("keychain.export.failedPrefix", { error: message }),
|
|
t("keychain.export.failedTitle"),
|
|
);
|
|
} finally {
|
|
setIsExporting(false);
|
|
}
|
|
}}
|
|
>
|
|
{isExporting
|
|
? t("keychain.export.exporting")
|
|
: t("keychain.export.exportAndAttach")}
|
|
</Button>
|
|
</>
|
|
)}
|
|
|
|
{/* Edit Key Panel */}
|
|
{panel.type === "edit" && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<Label>{t("keychain.edit.labelRequired")}</Label>
|
|
<Input
|
|
value={draftKey.label || ""}
|
|
onChange={(e) =>
|
|
setDraftKey({ ...draftKey, label: e.target.value })
|
|
}
|
|
placeholder={t("keychain.edit.keyLabelPlaceholder")}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-destructive">
|
|
{t("keychain.edit.privateKeyRequired")}
|
|
</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">
|
|
{t("keychain.edit.publicKey")}
|
|
</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">
|
|
{t("keychain.edit.certificate")}
|
|
</Label>
|
|
<Textarea
|
|
value={draftKey.certificate || ""}
|
|
onChange={(e) =>
|
|
setDraftKey({ ...draftKey, certificate: e.target.value })
|
|
}
|
|
placeholder={t("keychain.edit.certificatePlaceholder")}
|
|
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">
|
|
{t("keychain.edit.keyExport")}
|
|
</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)}
|
|
>
|
|
{t("keychain.edit.exportToHost")}
|
|
</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();
|
|
}
|
|
}}
|
|
>
|
|
{t("common.saveChanges")}
|
|
</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}
|
|
managedSources={managedSources}
|
|
onSaveHost={onSaveHost}
|
|
onCreateGroup={onCreateGroup}
|
|
/>
|
|
)}
|
|
</AsidePanel>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default KeychainManager;
|