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.
406 lines
13 KiB
TypeScript
Executable File
406 lines
13 KiB
TypeScript
Executable File
import {
|
|
Key,
|
|
LayoutGrid,
|
|
List as ListIcon,
|
|
Pencil,
|
|
Plus,
|
|
Search,
|
|
Shield,
|
|
Trash2,
|
|
} from "lucide-react";
|
|
import React, { useMemo, useState } from "react";
|
|
import { KeyType } from "../domain/models";
|
|
import { cn } from "../lib/utils";
|
|
import { SSHKey } from "../types";
|
|
import { Button } from "./ui/button";
|
|
import { Card, CardDescription, CardTitle } from "./ui/card";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "./ui/dialog";
|
|
import { Input } from "./ui/input";
|
|
import { Label } from "./ui/label";
|
|
import { Textarea } from "./ui/textarea";
|
|
|
|
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 KeyType) || "RSA",
|
|
privateKey: draftKey.privateKey,
|
|
publicKey: draftKey.publicKey?.trim() || undefined,
|
|
created: draftKey.created || Date.now(),
|
|
source: draftKey.source || (generateMode ? "generated" : "imported"),
|
|
category: draftKey.category || "key",
|
|
};
|
|
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 || "netcatty"
|
|
)
|
|
.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 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">
|
|
Set up your keys
|
|
</h3>
|
|
<p className="text-sm text-center max-w-sm">
|
|
Import or generate SSH keys for secure authentication.
|
|
</p>
|
|
</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) => (
|
|
<Card
|
|
key={key.id}
|
|
className={cn(
|
|
"group cursor-pointer soft-card elevate rounded-xl",
|
|
viewMode === "grid"
|
|
? "h-[68px] px-3 py-2"
|
|
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
|
|
)}
|
|
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;
|