"use client"; import { useState, useEffect } from "react"; import type { CreateServerData } from "../../types/server"; import { Button } from "./ui/button"; import { SSHKeyInput } from "./SSHKeyInput"; import { PublicKeyModal } from "./PublicKeyModal"; import { Key } from "lucide-react"; interface ServerFormProps { onSubmit: (data: CreateServerData) => void; initialData?: CreateServerData; isEditing?: boolean; onCancel?: () => void; } export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel, }: ServerFormProps) { const [formData, setFormData] = useState( initialData ?? { name: "", ip: "", user: "", password: "", auth_type: "password", ssh_key: "", ssh_key_passphrase: "", ssh_port: 22, color: "#3b82f6", }, ); const [errors, setErrors] = useState< Partial> >({}); const [sshKeyError, setSshKeyError] = useState(""); const [colorCodingEnabled, setColorCodingEnabled] = useState(false); const [isGeneratingKey, setIsGeneratingKey] = useState(false); const [showPublicKeyModal, setShowPublicKeyModal] = useState(false); const [generatedPublicKey, setGeneratedPublicKey] = useState(""); const [, setIsGeneratedKey] = useState(false); const [, setGeneratedServerId] = useState(null); useEffect(() => { const loadColorCodingSetting = async () => { try { const response = await fetch("/api/settings/color-coding"); if (response.ok) { const data = await response.json(); setColorCodingEnabled(Boolean(data.enabled)); } } catch (error) { console.error("Error loading color coding setting:", error); } }; void loadColorCodingSetting(); }, []); const validateServerAddress = (address: string): boolean => { const trimmed = address.trim(); if (!trimmed) return false; // IPv4 validation const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; if (ipv4Regex.test(trimmed)) { return true; } // Check for IPv6 with zone identifier (link-local addresses like fe80::...%eth0) let ipv6Address = trimmed; const zoneIdMatch = /^(.+)%([a-zA-Z0-9_\-]+)$/.exec(trimmed); if (zoneIdMatch?.[1] && zoneIdMatch[2]) { ipv6Address = zoneIdMatch[1]; // Zone identifier should be a valid interface name (alphanumeric, underscore, hyphen) const zoneId = zoneIdMatch[2]; if (!/^[a-zA-Z0-9_\-]+$/.test(zoneId)) { return false; } } // IPv6 validation (supports compressed format like ::1 and full format) // Matches: 2001:0db8:85a3:0000:0000:8a2e:0370:7334, ::1, 2001:db8::1, etc. // Also supports IPv4-mapped IPv6 addresses like ::ffff:192.168.1.1 // Simplified validation: check for valid hex segments separated by colons const ipv6Pattern = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::1$|^::$|^(?:[0-9a-fA-F]{1,4}:)*::(?:[0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4}$|^(?:[0-9a-fA-F]{1,4}:)*::[0-9a-fA-F]{1,4}$|^::(?:[0-9a-fA-F]{1,4}:)+[0-9a-fA-F]{1,4}$|^::ffff:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(?:[0-9a-fA-F]{1,4}:){1,4}:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; if (ipv6Pattern.test(ipv6Address)) { // Additional validation: ensure only one :: compression exists const compressionCount = (ipv6Address.match(/::/g) ?? []).length; if (compressionCount <= 1) { return true; } } // FQDN/hostname validation (RFC 1123 compliant) // Allows letters, numbers, hyphens, dots; must start and end with alphanumeric // Max length 253 characters, each label max 63 characters const hostnameRegex = /^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$/; if (hostnameRegex.test(trimmed) && trimmed.length <= 253) { // Additional check: each label (between dots) must be max 63 chars const labels = trimmed.split("."); if (labels.every((label) => label.length > 0 && label.length <= 63)) { return true; } } // Also allow simple hostnames without dots (like 'localhost') const simpleHostnameRegex = /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$/; if (simpleHostnameRegex.test(trimmed) && trimmed.length <= 63) { return true; } return false; }; const validateForm = (): boolean => { const newErrors: Partial> = {}; if (!formData.name.trim()) { newErrors.name = "Server name is required"; } if (!formData.ip.trim()) { newErrors.ip = "Server address is required"; } else { if (!validateServerAddress(formData.ip)) { newErrors.ip = "Please enter a valid IP address (IPv4/IPv6) or hostname"; } } if (!formData.user.trim()) { newErrors.user = "Username is required"; } // Validate SSH port if ( formData.ssh_port !== undefined && (formData.ssh_port < 1 || formData.ssh_port > 65535) ) { newErrors.ssh_port = "SSH port must be between 1 and 65535"; } // Validate authentication based on auth_type const authType = formData.auth_type ?? "password"; if (authType === "password") { if (!formData.password?.trim()) { newErrors.password = "Password is required for password authentication"; } } if (authType === "key") { if (!formData.ssh_key?.trim()) { newErrors.ssh_key = "SSH key is required for key authentication"; } } setErrors(newErrors); return Object.keys(newErrors).length === 0 && !sshKeyError; }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (validateForm()) { onSubmit(formData); if (!isEditing) { setFormData({ name: "", ip: "", user: "", password: "", auth_type: "password", ssh_key: "", ssh_key_passphrase: "", ssh_port: 22, color: "#3b82f6", }); } } }; const handleChange = (field: keyof CreateServerData) => (e: React.ChangeEvent) => { // Special handling for numeric ssh_port: keep it strictly numeric if (field === "ssh_port") { const raw = (e.target as HTMLInputElement).value ?? ""; const digitsOnly = raw.replace(/\D+/g, ""); setFormData((prev) => ({ ...prev, ssh_port: digitsOnly ? parseInt(digitsOnly, 10) : undefined, })); if (errors.ssh_port) { setErrors((prev) => ({ ...prev, ssh_port: undefined })); } return; } setFormData((prev) => ({ ...prev, [field]: (e.target as HTMLInputElement).value, })); // Clear error when user starts typing if (errors[field]) { setErrors((prev) => ({ ...prev, [field]: undefined })); } // Reset generated key state when switching auth types if (field === "auth_type") { setIsGeneratedKey(false); setGeneratedPublicKey(""); } }; const handleGenerateKeyPair = async () => { setIsGeneratingKey(true); try { const response = await fetch("/api/servers/generate-keypair", { method: "POST", headers: { "Content-Type": "application/json", }, }); if (!response.ok) { throw new Error("Failed to generate key pair"); } const data = (await response.json()) as { success: boolean; privateKey?: string; publicKey?: string; serverId?: number; error?: string; }; if (data.success) { const serverId = data.serverId ?? 0; const keyPath = `data/ssh-keys/server_${serverId}_key`; setFormData((prev) => ({ ...prev, ssh_key: data.privateKey ?? "", ssh_key_path: keyPath, key_generated: true, })); setGeneratedPublicKey(data.publicKey ?? ""); setGeneratedServerId(serverId); setIsGeneratedKey(true); setShowPublicKeyModal(true); setSshKeyError(""); } else { throw new Error(data.error ?? "Failed to generate key pair"); } } catch (error) { console.error("Error generating key pair:", error); setSshKeyError( error instanceof Error ? error.message : "Failed to generate key pair", ); } finally { setIsGeneratingKey(false); } }; const handleSSHKeyChange = (value: string) => { setFormData((prev) => ({ ...prev, ssh_key: value })); if (errors.ssh_key) { setErrors((prev) => ({ ...prev, ssh_key: undefined })); } }; return ( <>
{errors.name && (

{errors.name}

)}
{errors.ip && (

{errors.ip}

)}
{errors.user && (

{errors.user}

)}
{errors.ssh_port && (

{errors.ssh_port}

)}
{colorCodingEnabled && (
Choose a color to identify this server
)}
{/* Password Authentication */} {formData.auth_type === "password" && (
{errors.password && (

{errors.password}

)}
)} {/* SSH Key Authentication */} {formData.auth_type === "key" && (
{/* Show manual key input only if no key has been generated */} {!formData.key_generated && ( <> {errors.ssh_key && (

{errors.ssh_key}

)} {sshKeyError && (

{sshKeyError}

)} )} {/* Show generated key status */} {formData.key_generated && (
SSH key pair generated successfully

The private key has been generated and will be saved with the server.

)}

Only required if your SSH key is encrypted with a passphrase

)}
{isEditing && onCancel && ( )}
{/* Public Key Modal */} setShowPublicKeyModal(false)} publicKey={generatedPublicKey} serverName={formData.name || "New Server"} serverIp={formData.ip} /> ); }