mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
e00fa2733b
Updates all code references, IPC channels, CSS classes, and storage keys from "nebula" to "netcatty" to align with new branding and application identity. Ensures consistency across frontend, Electron backend, global types, and package metadata.
226 lines
8.9 KiB
TypeScript
226 lines
8.9 KiB
TypeScript
/**
|
|
* Export Key Panel - Export SSH key to remote host
|
|
*/
|
|
|
|
import { ChevronRight, Info } from 'lucide-react';
|
|
import React, { useState } from 'react';
|
|
import { cn } from '../../lib/utils';
|
|
import { Host, SSHKey } from '../../types';
|
|
import { Button } from '../ui/button';
|
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible';
|
|
import { Input } from '../ui/input';
|
|
import { Label } from '../ui/label';
|
|
import { Textarea } from '../ui/textarea';
|
|
import { toast } from '../ui/toast';
|
|
import { getKeyIcon, getKeyTypeDisplay, isMacOS } from './utils';
|
|
|
|
interface ExportKeyPanelProps {
|
|
keyItem: SSHKey;
|
|
_hosts: Host[]; // Reserved for future inline host list/validation
|
|
keys: SSHKey[];
|
|
exportHost: Host | null;
|
|
_setExportHost: (host: Host | null) => void; // Host selection handled by onShowHostSelector callback
|
|
onShowHostSelector: () => void;
|
|
onSaveHost?: (host: Host) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
const DEFAULT_EXPORT_SCRIPT = `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"`;
|
|
|
|
export const ExportKeyPanel: React.FC<ExportKeyPanelProps> = ({
|
|
keyItem,
|
|
_hosts, // Reserved for future inline host list/validation
|
|
keys,
|
|
exportHost,
|
|
_setExportHost, // Host selection handled by onShowHostSelector callback
|
|
onShowHostSelector,
|
|
onSaveHost,
|
|
onClose,
|
|
}) => {
|
|
const [exportLocation, setExportLocation] = useState('.ssh');
|
|
const [exportFilename, setExportFilename] = useState('authorized_keys');
|
|
const [exportAdvancedOpen, setExportAdvancedOpen] = useState(false);
|
|
const [exportScript, setExportScript] = useState(DEFAULT_EXPORT_SCRIPT);
|
|
const [isExporting, setIsExporting] = useState(false);
|
|
|
|
const isMac = isMacOS();
|
|
|
|
const handleExport = async () => {
|
|
if (!exportHost || !keyItem.publicKey) return;
|
|
|
|
setIsExporting(true);
|
|
|
|
try {
|
|
// Check for authentication method
|
|
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
|
|
const escapedPublicKey = keyItem.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}'`);
|
|
|
|
const command = scriptWithVars;
|
|
|
|
// Execute via SSH
|
|
const result = await window.netcatty?.execCommand({
|
|
hostname: exportHost.hostname,
|
|
username: exportHost.username,
|
|
port: exportHost.port || 22,
|
|
password: exportHost.password,
|
|
privateKey: hostPrivateKey,
|
|
command,
|
|
timeout: 30000,
|
|
});
|
|
|
|
// Check result
|
|
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: keyItem.id,
|
|
authMethod: 'key',
|
|
};
|
|
onSaveHost(updatedHost);
|
|
}
|
|
toast.success(`Public key exported and attached to ${exportHost.label}`, 'Export Successful');
|
|
onClose();
|
|
} 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);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* 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",
|
|
keyItem.source === 'biometric'
|
|
? "bg-blue-500/15 text-blue-500"
|
|
: keyItem.source === 'fido2'
|
|
? "bg-amber-500/15 text-amber-500"
|
|
: "bg-primary/15 text-primary"
|
|
)}>
|
|
{getKeyIcon(keyItem)}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-semibold truncate">{keyItem.label}</p>
|
|
<p className="text-xs text-muted-foreground">Type {getKeyTypeDisplay(keyItem, isMac)}</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={onShowHostSelector}
|
|
>
|
|
Select Host
|
|
</Button>
|
|
</div>
|
|
<Input
|
|
value={exportHost?.label || ''}
|
|
readOnly
|
|
placeholder="Select a host..."
|
|
className="bg-muted/50 cursor-pointer"
|
|
onClick={onShowHostSelector}
|
|
/>
|
|
</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={handleExport}
|
|
>
|
|
{isExporting ? 'Exporting...' : 'Export and Attach'}
|
|
</Button>
|
|
</>
|
|
);
|
|
};
|