Files
Netcatty/components/KeyManager.tsx
2025-12-07 03:25:07 +08:00

320 lines
12 KiB
TypeScript
Executable File

import React, { useMemo, useState } from 'react';
import { SSHKey } from '../types';
import { Key, Plus, Trash2, Shield, Search, LayoutGrid, List as ListIcon, Pencil } from 'lucide-react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
import { Label } from './ui/label';
import { Textarea } from './ui/textarea';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from './ui/dialog';
import { cn } from '../lib/utils';
interface KeyManagerProps {
keys: SSHKey[];
onSave: (key: SSHKey) => void;
onDelete: (id: string) => void;
}
const KeyManager: React.FC<KeyManagerProps> = ({ keys, onSave, onDelete }) => {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [panelMode, setPanelMode] = useState<'new' | 'edit'>('new');
const [draftKey, setDraftKey] = useState<Partial<SSHKey>>({
id: '',
label: '',
type: 'RSA',
privateKey: '',
publicKey: '',
created: Date.now(),
});
const [generateMode, setGenerateMode] = useState(false);
const [search, setSearch] = useState('');
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const handleGenerate = () => {
// Simulate Key Generation
const mockKey = `-----BEGIN ${draftKey.type} PRIVATE KEY-----\n` +
`MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC${Math.random().toString(36).substring(7)}\n` +
`... (simulated generated content) ...\n` +
`-----END ${draftKey.type} PRIVATE KEY-----`;
setDraftKey({ ...draftKey, privateKey: mockKey });
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!draftKey.label || !draftKey.privateKey) return;
const payload: SSHKey = {
id: draftKey.id || crypto.randomUUID(),
label: draftKey.label,
type: (draftKey.type as any) || 'RSA',
privateKey: draftKey.privateKey,
publicKey: draftKey.publicKey?.trim() || undefined,
created: draftKey.created || Date.now(),
};
onSave(payload);
setIsDialogOpen(false);
setGenerateMode(false);
};
const openPanelForKey = (key: SSHKey) => {
setPanelMode('edit');
setDraftKey({ ...key });
setIsDialogOpen(true);
setGenerateMode(false);
};
const openPanelNew = (isGenerate = false) => {
setPanelMode('new');
setGenerateMode(isGenerate);
setDraftKey({
id: '',
label: '',
type: 'RSA',
privateKey: isGenerate ? 'Click generate to create a new key pair...' : '',
publicKey: '',
created: Date.now(),
});
setIsDialogOpen(true);
};
const handleDelete = (id: string) => {
onDelete(id);
if (draftKey.id === id) {
setIsDialogOpen(false);
setDraftKey({ id: '', label: '', type: 'RSA', privateKey: '', publicKey: '', created: Date.now() });
}
};
const filteredKeys = useMemo(() => {
const term = search.trim().toLowerCase();
return keys.filter(k => {
if (!term) return true;
return (
k.label.toLowerCase().includes(term) ||
(k.type || '').toString().toLowerCase().includes(term)
);
});
}, [keys, search]);
const derivedPublicKey = useMemo(() => {
if (draftKey.publicKey) return draftKey.publicKey;
if (!draftKey.label) return 'Generated By netcatty';
return `ssh-${(draftKey.type || 'ed25519').toLowerCase()} AAAAC3NzaC1lZDI1NTE5AAAA${(draftKey.label || 'nebula')
.replace(/\s+/g, '')
.slice(0, 8)} Generated By netcatty`;
}, [draftKey.label, draftKey.type, draftKey.publicKey]);
return (
<div className="px-2.5 py-2.5 lg:px-3 lg:py-3 h-full overflow-y-auto space-y-3.5 relative">
<div className="flex flex-wrap items-center gap-3 bg-secondary/60 border border-border/70 rounded-xl px-2 py-1.5 shadow-sm">
<Button
size="sm"
variant="secondary"
className="h-8 px-3 gap-2"
disabled
>
Key
<span className="text-[10px] px-2 rounded-full h-5 min-w-[22px] flex items-center justify-center bg-primary/10 text-primary border border-border/70">
{keys.length}
</span>
</Button>
<div className="ml-auto flex items-center gap-2">
<div className="relative">
<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 keys..."
className="h-9 pl-8 w-44 md:w-56"
/>
</div>
<Button
size="icon"
variant={viewMode === 'grid' ? 'secondary' : 'ghost'}
className="h-9 w-9"
onClick={() => setViewMode('grid')}
>
<LayoutGrid size={16} />
</Button>
<Button
size="icon"
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
className="h-9 w-9"
onClick={() => setViewMode('list')}
>
<ListIcon size={16} />
</Button>
<Button size="sm" onClick={() => openPanelNew(false)}>
<Plus size={14} className="mr-2" /> Import
</Button>
<Button size="sm" variant="secondary" onClick={() => openPanelNew(true)}>
Generate
</Button>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between px-1">
<h2 className="text-base font-semibold text-muted-foreground">Keys</h2>
</div>
<div className="space-y-3">
{filteredKeys.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground border-2 border-dashed rounded-xl">
<Shield size={48} className="mb-3 opacity-60" />
<p className="text-sm">No keys found. Import or generate to get started.</p>
</div>
)}
<div className={viewMode === 'grid' ? "grid gap-2.5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" : "space-y-2"}>
{filteredKeys.map((key) => (
<Card
key={key.id}
className={cn(
"group relative overflow-hidden bg-secondary/60 border transition-shadow cursor-pointer",
viewMode === 'grid' ? "h-[72px] px-3 py-2" : "h-[72px] px-3 py-2 w-full",
"border-border/60 shadow-sm hover:shadow-[0_0_0_2px_var(--ring)]"
)}
onClick={() => openPanelForKey(key)}
>
<div className="flex items-center gap-3 h-full">
<div className="h-9 w-9 rounded-md bg-primary/15 text-primary flex items-center justify-center">
<Key size={16} />
</div>
<div className="min-w-0 flex-1">
<CardTitle className="text-sm font-semibold truncate">{key.label}</CardTitle>
<CardDescription className="text-[11px] font-mono text-muted-foreground truncate">
Type {key.type}
</CardDescription>
<div className="text-[10px] text-muted-foreground/80 font-mono truncate">SHA256:{key.id.substring(0, 16)}...</div>
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
openPanelForKey(key);
}}
>
<Pencil size={14} />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDelete(key.id);
}}
>
<Trash2 size={14} />
</Button>
</div>
</div>
</Card>
))}
</div>
</div>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{panelMode === 'new' ? 'New Key' : 'Edit Key'}</DialogTitle>
<DialogDescription className="sr-only">
{panelMode === 'new' ? 'Create a new SSH key entry' : 'Edit the selected SSH key entry'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label>Label</Label>
<Input
value={draftKey.label}
onChange={e => setDraftKey({ ...draftKey, label: e.target.value })}
placeholder="Key label"
required
/>
</div>
<div className="space-y-2">
<Label>Private key *</Label>
<Textarea
value={draftKey.privateKey}
onChange={e => setDraftKey({ ...draftKey, privateKey: e.target.value })}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
className="min-h-[160px] font-mono text-xs"
required
/>
{generateMode && (
<Button type="button" size="sm" variant="secondary" onClick={handleGenerate}>
Generate
</Button>
)}
</div>
<div className="space-y-2">
<Label>Public key</Label>
<Textarea
value={derivedPublicKey}
onChange={e => setDraftKey({ ...draftKey, publicKey: e.target.value })}
placeholder="ssh-ed25519 AAAAC3... user@host"
className="min-h-[90px] font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label className="flex items-center gap-2">
Certificate <span className="text-[10px] px-2 py-0.5 rounded-full bg-muted text-muted-foreground">Optional</span>
</Label>
<Textarea
placeholder="Paste certificate..."
className="min-h-[80px] text-xs"
/>
</div>
<div className="border border-dashed border-border/80 rounded-xl p-4 text-center space-y-2 bg-background/60">
<div className="text-sm text-muted-foreground">Drag and drop a private key file to import</div>
<Button
type="button"
variant="secondary"
onClick={() => {
// mock file import
setDraftKey({
...draftKey,
label: draftKey.label || 'Imported Key',
privateKey:
draftKey.privateKey ||
'-----BEGIN OPENSSH PRIVATE KEY-----\nAAAAC3NzaC1lZDI1NTE5AAAA\n-----END OPENSSH PRIVATE KEY-----',
});
}}
>
Import from key file
</Button>
</div>
<DialogFooter>
{panelMode === 'edit' && draftKey.id && (
<Button
type="button"
variant="ghost"
className="text-destructive mr-auto"
onClick={() => handleDelete(draftKey.id!)}
>
Delete
</Button>
)}
<Button type="button" variant="ghost" onClick={() => setIsDialogOpen(false)}>Cancel</Button>
<Button type="submit">{panelMode === 'new' ? 'Save Key' : 'Update Key'}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
};
export default KeyManager;