mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-09-24 15:49:09 +00:00
4e9e5da13c
Integrates all user-facing strings into the translation system and expands locale files for comprehensive coverage, enabling dynamic i18n across the interface. Replaces hardcoded labels, button texts, and prompts with translation keys, benefiting maintainability and future language support. Improves translation ergonomics in forms, dialogs, wizards, and settings, ensuring consistent user experience in both English and Chinese.
412 lines
14 KiB
TypeScript
Executable File
412 lines
14 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 { useI18n } from "../application/i18n/I18nProvider";
|
|
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 { t } = useI18n();
|
|
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"
|
|
? t("keychain.keyDialog.newTitle")
|
|
: t("keychain.keyDialog.editTitle")}
|
|
</DialogTitle>
|
|
<DialogDescription className="sr-only">
|
|
{panelMode === "new"
|
|
? t("keychain.keyDialog.newDesc")
|
|
: t("keychain.keyDialog.editDesc")}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label>{t("keychain.field.label")}</Label>
|
|
<Input
|
|
value={draftKey.label}
|
|
onChange={(e) =>
|
|
setDraftKey({ ...draftKey, label: e.target.value })
|
|
}
|
|
placeholder={t("keychain.field.labelPlaceholder")}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>{t("keychain.field.privateKeyRequired")}</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}
|
|
>
|
|
{t("keychain.generate.generate")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>{t("keychain.field.publicKey")}</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">
|
|
{t("terminal.auth.certificate")}{" "}
|
|
<span className="text-[10px] px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
|
|
{t("common.optional")}
|
|
</span>
|
|
</Label>
|
|
<Textarea
|
|
placeholder={t("keychain.field.certificatePlaceholder")}
|
|
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">
|
|
{t("keychain.import.dropHint")}
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() => {
|
|
// mock file import
|
|
setDraftKey({
|
|
...draftKey,
|
|
label: draftKey.label || t("keychain.import.importedKeyLabel"),
|
|
privateKey:
|
|
draftKey.privateKey ||
|
|
"-----BEGIN OPENSSH PRIVATE KEY-----\nAAAAC3NzaC1lZDI1NTE5AAAA\n-----END OPENSSH PRIVATE KEY-----",
|
|
});
|
|
}}
|
|
>
|
|
{t("keychain.import.importFromFile")}
|
|
</Button>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
{panelMode === "edit" && draftKey.id && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
className="text-destructive mr-auto"
|
|
onClick={() => handleDelete(draftKey.id!)}
|
|
>
|
|
{t("common.delete")}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => setIsDialogOpen(false)}
|
|
>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
<Button type="submit">
|
|
{panelMode === "new"
|
|
? t("keychain.import.saveKey")
|
|
: t("keychain.keyDialog.updateKey")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default KeyManager;
|