320 lines
12 KiB
TypeScript
Executable File
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;
|