Files
Netcatty/components/KeychainManager.tsx
T
LAPTOP-O016UC3M\Qi Chen b8c221d112 Organize and normalize imports across project
Reorder and normalize import statements, group icon imports, and add
missing utility imports (cn). Fix export/newline formatting and minor
type import tweaks. Add ESLint scripts to package.json and update the
lockfile.
2025-12-11 16:01:03 +08:00

1246 lines
56 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 { cn } from '../lib/utils';
import { Host,Identity,KeyType,SSHKey } from '../types';
import SelectHostPanel from './SelectHostPanel';
import { AsidePanel,AsidePanelContent } from './ui/aside-panel';
import { Button } from './ui/button';
import { Collapsible,CollapsibleContent,CollapsibleTrigger } from './ui/collapsible';
import { Dropdown,DropdownContent,DropdownTrigger } from './ui/dropdown';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Textarea } from './ui/textarea';
import { toast } from './ui/toast';
// Import utilities and components from keychain module
import {
createBiometricCredential,
createFido2Credential,
type FilterTab,
GenerateBiometricPanel,
GenerateFido2Panel,
GenerateStandardPanel,
IdentityCard,
IdentityPanel,
ImportKeyPanel,
isMacOS,
KeyCard,
type PanelMode,
ViewKeyPanel
} from './keychain';
interface KeychainManagerProps {
keys: SSHKey[];
identities?: Identity[];
hosts?: Host[];
customGroups?: string[];
onSave: (key: SSHKey) => void;
onUpdate: (key: SSHKey) => void;
onDelete: (id: string) => void;
onSaveIdentity?: (identity: Identity) => void;
onDeleteIdentity?: (id: string) => void;
onNewHost?: () => void;
onSaveHost?: (host: Host) => void;
onCreateGroup?: (groupPath: string) => void;
}
const KeychainManager: React.FC<KeychainManagerProps> = ({
keys,
identities = [],
hosts = [],
customGroups = [],
onSave,
onUpdate,
onDelete,
onSaveIdentity,
onDeleteIdentity,
onNewHost,
onSaveHost,
onCreateGroup,
}) => {
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 = panelStack.length > 0 ? panelStack[panelStack.length - 1] : { type: 'closed' } as PanelMode;
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) {
console.error('Failed to copy public key:', err);
}
}
}, []);
// Open panel for new identity
const openNewIdentity = useCallback(() => {
setPanelStack([{ type: 'identity' }]);
setDraftIdentity({
id: '',
label: '',
username: '',
authMethod: 'password',
created: Date.now(),
});
setError(null);
}, []);
// Open generate panel
const openGenerate = useCallback((keyType: 'standard' | 'biometric' | 'fido2') => {
const defaultType = (keyType === 'biometric' || keyType === 'fido2') ? 'ECDSA' : 'ED25519';
// Set default keySize based on type: ED25519 doesn't need size, RSA defaults to 4096, ECDSA to 256
const getDefaultKeySize = (type: string) => {
if (type === 'ED25519') return undefined;
if (type === 'RSA') return 4096;
return 256; // ECDSA
};
const getSource = () => {
if (keyType === 'biometric') return 'biometric';
if (keyType === 'fido2') return 'fido2';
return 'generated';
};
setPanelStack([{ type: 'generate', keyType }]);
setDraftKey({
id: '',
label: '',
type: defaultType,
keySize: getDefaultKeySize(defaultType),
privateKey: '',
publicKey: '',
source: getSource(),
category: 'key',
created: Date.now(),
});
setError(null);
}, []);
// Open import panel
const openImport = useCallback(() => {
setPanelStack([{ type: 'import' }]);
setDraftKey({
id: '',
label: '',
type: 'ED25519',
privateKey: '',
publicKey: '',
source: 'imported',
category: 'key',
created: Date.now(),
});
setError(null);
}, []);
// Handle standard key generation
const handleGenerateStandard = useCallback(async () => {
if (!draftKey.label?.trim()) {
setError('Please enter a label for the key');
return;
}
setIsGenerating(true);
setError(null);
try {
const keyType = draftKey.type as KeyType || 'ED25519';
const keySize = draftKey.keySize;
// Use real key generation via Electron backend
if (window.nebula?.generateKeyPair) {
const result = await window.nebula.generateKeyPair({
type: keyType,
bits: keySize,
comment: `${draftKey.label.trim()}@netcatty`,
});
if (!result.success || !result.privateKey || !result.publicKey) {
throw new Error(result.error || 'Failed to generate key pair');
}
const newKey: SSHKey = {
id: crypto.randomUUID(),
label: draftKey.label.trim(),
type: keyType,
keySize: keyType !== 'ED25519' ? keySize : undefined,
privateKey: result.privateKey,
publicKey: result.publicKey,
passphrase: draftKey.passphrase,
savePassphrase: draftKey.savePassphrase,
source: 'generated',
category: 'key',
created: Date.now(),
};
onSave(newKey);
closePanel();
} else {
throw new Error('Key generation not available - please ensure the app is running in Electron');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate key');
} finally {
setIsGenerating(false);
}
}, [draftKey, onSave, closePanel]);
// Handle biometric key generation (Windows Hello)
const handleGenerateBiometric = useCallback(async () => {
if (!draftKey.label?.trim()) {
setError('Please enter a label for the key');
return;
}
setIsGenerating(true);
setError(null);
try {
const result = await createBiometricCredential(draftKey.label.trim());
if (!result) {
throw new Error('Credential creation was cancelled');
}
const newKey: SSHKey = {
id: crypto.randomUUID(),
label: draftKey.label.trim(),
type: 'ECDSA',
privateKey: '', // Biometric keys don't have exportable private keys
publicKey: result.publicKey,
credentialId: result.credentialId,
rpId: result.rpId,
source: 'biometric',
category: 'key',
created: Date.now(),
};
onSave(newKey);
closePanel();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create biometric credential');
} finally {
setIsGenerating(false);
}
}, [draftKey, onSave, closePanel]);
// Handle FIDO2 hardware key registration
const handleGenerateFido2 = useCallback(async () => {
if (!draftKey.label?.trim()) {
setError('Please enter a label for the security key');
return;
}
setIsGenerating(true);
setError(null);
try {
const result = await createFido2Credential(draftKey.label.trim());
if (!result) {
throw new Error('Security key registration was cancelled');
}
const newKey: SSHKey = {
id: crypto.randomUUID(),
label: draftKey.label.trim(),
type: 'ECDSA',
privateKey: '', // Hardware keys don't expose private keys
publicKey: result.publicKey,
credentialId: result.credentialId,
rpId: result.rpId,
source: 'fido2',
category: 'key',
created: Date.now(),
};
onSave(newKey);
closePanel();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to register security key');
} finally {
setIsGenerating(false);
}
}, [draftKey, onSave, closePanel]);
// Handle key import
const handleImport = useCallback(() => {
if (!draftKey.label?.trim() || !draftKey.privateKey?.trim()) {
setError('Label and private key are required');
return;
}
// Detect key type from private key content
let detectedType: KeyType = 'ED25519';
const pk = draftKey.privateKey.toLowerCase();
if (pk.includes('rsa')) detectedType = 'RSA';
else if (pk.includes('ecdsa') || pk.includes('ec ')) detectedType = 'ECDSA';
else if (pk.includes('ed25519')) detectedType = 'ED25519';
const newKey: SSHKey = {
id: crypto.randomUUID(),
label: draftKey.label.trim(),
type: draftKey.type as KeyType || detectedType,
privateKey: draftKey.privateKey.trim(),
publicKey: draftKey.publicKey?.trim() || undefined,
certificate: draftKey.certificate?.trim() || undefined,
passphrase: draftKey.passphrase,
savePassphrase: draftKey.savePassphrase,
source: 'imported',
category: draftKey.certificate ? 'certificate' : 'key',
created: Date.now(),
};
onSave(newKey);
closePanel();
}, [draftKey, onSave, closePanel]);
// Handle save identity
const handleSaveIdentity = useCallback(() => {
if (!draftIdentity.label?.trim() || !draftIdentity.username?.trim()) {
setError('Label and username are required');
return;
}
if (!onSaveIdentity) return;
const newIdentity: Identity = {
id: draftIdentity.id || crypto.randomUUID(),
label: draftIdentity.label.trim(),
username: draftIdentity.username.trim(),
authMethod: draftIdentity.authMethod || 'password',
password: draftIdentity.password,
keyId: draftIdentity.keyId,
created: draftIdentity.created || Date.now(),
};
onSaveIdentity(newIdentity);
closePanel();
}, [draftIdentity, onSaveIdentity, closePanel]);
// Handle delete
const handleDelete = useCallback((id: string) => {
onDelete(id);
if (panel.type === 'view' && panel.key.id === id) {
closePanel();
}
}, [onDelete, panel, closePanel]);
// Handle delete identity
const handleDeleteIdentity = useCallback((id: string) => {
onDeleteIdentity?.(id);
if (panel.type === 'identity' && panel.identity?.id === id) {
closePanel();
}
}, [onDeleteIdentity, panel, closePanel]);
// Copy to clipboard
const copyToClipboard = useCallback((text: string) => {
navigator.clipboard.writeText(text);
}, []);
// Get icon for key source
const getKeyIcon = (key: SSHKey) => {
if (key.source === 'biometric') return <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 window.nebula?.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;