Files
Netcatty/components/keychain/ExportKeyPanel.tsx
bincxz fb35f989b8 Refactors to enforce backend access via application hooks
Replaces all direct usage of browser globals and infrastructure service imports in UI components with dedicated application/state backend hooks. Introduces lint rules to prevent direct access to backend bridges and localStorage from components, promoting a cleaner separation of concerns and improved maintainability.

Moves user preferences (e.g., port forwarding form mode) to persistent state hooks, updates port forwarding and SFTP logic to rely on backend hooks, and centralizes logging through a logger utility. Cleans up debug code and removes obsolete scripts from HTML.

Improves testability, prepares for alternative backend implementations, and enforces architectural boundaries.
2025-12-13 01:38:44 +08:00

228 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 { useKeychainBackend } from '../../application/state/useKeychainBackend';
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 { execCommand } = useKeychainBackend();
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 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>
</>
);
};