1039 lines
34 KiB
TypeScript
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;
|