Files
Netcatty/components/HostForm.tsx
T
2026-01-21 00:58:13 +08:00

383 lines
13 KiB
TypeScript
Executable File

import { Key, Lock, Plus, Save, Server, X } from "lucide-react";
import React, { useEffect, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { cn } from "../lib/utils";
import { Host, SSHKey } from "../types";
import { Button } from "./ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Switch } from "./ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./ui/select";
interface HostFormProps {
initialData?: Host | null;
availableKeys: SSHKey[];
groups: string[];
onSave: (host: Host) => void;
onCancel: () => void;
}
const HostForm: React.FC<HostFormProps> = ({
initialData,
availableKeys,
groups,
onSave,
onCancel,
}) => {
const { t } = useI18n();
const [formData, setFormData] = useState<Partial<Host>>(
initialData || {
label: "",
hostname: "",
port: 22,
username: "root",
tags: [],
os: "linux",
group: "General",
identityFileId: "",
},
);
const [authType, setAuthType] = useState<"password" | "key">(
initialData?.identityFileId ? "key" : "password",
);
const [tagInput, setTagInput] = useState("");
const handleAddTag = () => {
const tag = tagInput.trim();
if (tag && !formData.tags?.includes(tag)) {
setFormData((prev) => ({ ...prev, tags: [...(prev.tags || []), tag] }));
setTagInput("");
}
};
const handleRemoveTag = (tagToRemove: string) => {
setFormData((prev) => ({
...prev,
tags: (prev.tags || []).filter((t) => t !== tagToRemove),
}));
};
const handleTagKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddTag();
}
};
// Effect to ensure we have a valid auth state if switching back and forth
useEffect(() => {
if (authType === "password") {
setFormData((prev) => ({ ...prev, identityFileId: "" }));
} else if (
authType === "key" &&
!formData.identityFileId &&
availableKeys.length > 0
) {
// Default to first key if none selected
setFormData((prev) => ({ ...prev, identityFileId: availableKeys[0].id }));
}
}, [authType, availableKeys, formData.identityFileId]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (formData.label && formData.hostname && formData.username) {
onSave({
...formData,
id: initialData?.id || crypto.randomUUID(),
tags: formData.tags || [],
port: formData.port || 22,
group: formData.group || "General",
identityFileId:
authType === "key" ? formData.identityFileId : undefined,
createdAt: initialData?.createdAt || Date.now(),
} as Host);
}
};
return (
<Dialog open={true} onOpenChange={() => onCancel()}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Server className="h-5 w-5 text-primary" />
{initialData ? t("hostForm.title.edit") : t("hostForm.title.new")}
</DialogTitle>
<DialogDescription className="sr-only">
{initialData ? t("hostForm.desc.edit") : t("hostForm.desc.new")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="label">{t("hostForm.field.label")}</Label>
<Input
id="label"
placeholder={t("hostForm.placeholder.label")}
value={formData.label}
onChange={(e) =>
setFormData({ ...formData, label: e.target.value })
}
required
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 grid gap-2">
<Label htmlFor="hostname">{t("hostForm.field.hostname")}</Label>
<Input
id="hostname"
placeholder={t("hostForm.placeholder.hostname")}
value={formData.hostname}
onChange={(e) =>
setFormData({ ...formData, hostname: e.target.value })
}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="port">{t("hostForm.field.port")}</Label>
<Input
id="port"
type="number"
value={formData.port}
onChange={(e) =>
setFormData({ ...formData, port: parseInt(e.target.value) })
}
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="username">{t("hostForm.field.username")}</Label>
<Input
id="username"
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="os">{t("hostForm.field.osType")}</Label>
<Select
value={formData.os}
onValueChange={(val: "linux" | "windows" | "macos") =>
setFormData({ ...formData, os: val })
}
>
<SelectTrigger>
<SelectValue placeholder={t("hostForm.placeholder.selectOs")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="linux">Linux</SelectItem>
<SelectItem value="windows">Windows</SelectItem>
<SelectItem value="macos">macOS</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="group">{t("hostForm.field.group")}</Label>
<Input
id="group"
placeholder={t("hostForm.placeholder.group")}
value={formData.group}
onChange={(e) =>
setFormData({ ...formData, group: e.target.value })
}
list="group-suggestions"
autoComplete="off"
/>
<datalist id="group-suggestions">
{groups.map((g) => (
<option key={g} value={g} />
))}
</datalist>
</div>
<div className="grid gap-2">
<Label htmlFor="tags">{t("hostForm.field.tags")}</Label>
<div className="flex gap-2">
<Input
id="tags"
placeholder={t("hostForm.placeholder.addTag")}
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleTagKeyDown}
className="flex-1"
/>
<Button
type="button"
variant="secondary"
size="icon"
onClick={handleAddTag}
disabled={!tagInput.trim()}
>
<Plus size={16} />
</Button>
</div>
{formData.tags && formData.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-1">
{formData.tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-primary text-xs"
>
{tag}
<button
type="button"
onClick={() => handleRemoveTag(tag)}
className="hover:bg-primary/20 rounded-full p-0.5"
>
<X size={12} />
</button>
</span>
))}
</div>
)}
</div>
<div className="space-y-3 pt-2">
<div className="flex items-center justify-between space-x-2 border rounded-md p-3">
<div className="space-y-0.5">
<Label htmlFor="sftp-sudo" className="text-base">
{t("hostDetails.sftp.sudo")}
</Label>
<p className="text-xs text-muted-foreground">
{t("hostDetails.sftp.sudo.desc")}
</p>
{formData.sftpSudo && authType === "key" && (
<p className="text-xs text-amber-500 mt-1">
{t("hostDetails.sftp.sudo.passwordWarning")}
</p>
)}
</div>
<Switch
id="sftp-sudo"
checked={formData.sftpSudo || false}
onCheckedChange={(checked) =>
setFormData({ ...formData, sftpSudo: checked })
}
/>
</div>
<div className="space-y-1">
<Label htmlFor="sftp-encoding">
{t("hostDetails.sftp.encoding")}
</Label>
<Select
value={formData.sftpEncoding || "auto"}
onValueChange={(val) =>
setFormData({ ...formData, sftpEncoding: val as Host["sftpEncoding"] })
}
>
<SelectTrigger id="sftp-encoding">
<SelectValue placeholder={t("sftp.encoding.label")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t("sftp.encoding.auto")}</SelectItem>
<SelectItem value="utf-8">{t("sftp.encoding.utf8")}</SelectItem>
<SelectItem value="gb18030">{t("sftp.encoding.gb18030")}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("hostDetails.sftp.encoding.desc")}
</p>
</div>
<Label>{t("hostForm.auth.method")}</Label>
<div className="grid grid-cols-2 gap-4">
<div
className={cn(
"border rounded-md p-3 flex flex-col items-center justify-center gap-2 cursor-pointer transition-all hover:bg-accent/50",
authType === "password"
? "border-primary bg-primary/5 text-primary ring-1 ring-primary"
: "text-muted-foreground",
)}
onClick={() => setAuthType("password")}
>
<Lock size={20} />
<span className="text-xs font-medium">{t("hostForm.auth.password")}</span>
</div>
<div
className={cn(
"border rounded-md p-3 flex flex-col items-center justify-center gap-2 cursor-pointer transition-all hover:bg-accent/50",
authType === "key"
? "border-primary bg-primary/5 text-primary ring-1 ring-primary"
: "text-muted-foreground",
)}
onClick={() => setAuthType("key")}
>
<Key size={20} />
<span className="text-xs font-medium">{t("hostForm.auth.sshKey")}</span>
</div>
</div>
{authType === "key" && (
<div className="animate-in fade-in zoom-in-95 duration-200">
<Select
value={formData.identityFileId || ""}
onValueChange={(val) =>
setFormData({ ...formData, identityFileId: val })
}
>
<SelectTrigger>
<SelectValue placeholder={t("hostForm.auth.selectKey")} />
</SelectTrigger>
<SelectContent>
{availableKeys.map((key) => (
<SelectItem key={key.id} value={key.id}>
{key.label} ({key.type})
</SelectItem>
))}
{availableKeys.length === 0 && (
<SelectItem value="none" disabled>
{t("hostForm.auth.noKeys")}
</SelectItem>
)}
</SelectContent>
</Select>
{availableKeys.length === 0 && (
<p className="text-[10px] text-destructive mt-1">
{t("hostForm.auth.noKeysHint")}
</p>
)}
</div>
)}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onCancel}>
{t("common.cancel")}
</Button>
<Button type="submit">
<Save className="mr-2 h-4 w-4" /> {t("hostForm.saveHost")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
export default HostForm;