Files
ProxmoxVE-Local/src/app/_components/ServerForm.tsx

452 lines
16 KiB
TypeScript

'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<CreateServerData>(
initialData ?? {
name: '',
ip: '',
user: '',
password: '',
auth_type: 'password',
ssh_key: '',
ssh_key_passphrase: '',
ssh_port: 22,
color: '#3b82f6',
}
);
const [errors, setErrors] = useState<Partial<Record<keyof CreateServerData, string>>>({});
const [sshKeyError, setSshKeyError] = useState<string>('');
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<number | null>(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 validateForm = (): boolean => {
const newErrors: Partial<Record<keyof CreateServerData, string>> = {};
if (!formData.name.trim()) {
newErrors.name = 'Server name is required';
}
if (!formData.ip.trim()) {
newErrors.ip = 'IP address is required';
} else {
// Basic IP validation
const ipRegex = /^(?:(?: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 (!ipRegex.test(formData.ip)) {
newErrors.ip = 'Please enter a valid IP address';
}
}
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<HTMLInputElement | HTMLSelectElement>
) => {
// 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 (
<>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-muted-foreground mb-1">
Server Name *
</label>
<input
type="text"
id="name"
value={formData.name}
onChange={handleChange('name')}
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.name ? 'border-destructive' : 'border-border'
}`}
placeholder="e.g., Production Server"
/>
{errors.name && <p className="mt-1 text-sm text-destructive">{errors.name}</p>}
</div>
<div>
<label htmlFor="ip" className="block text-sm font-medium text-muted-foreground mb-1">
IP Address *
</label>
<input
type="text"
id="ip"
value={formData.ip}
onChange={handleChange('ip')}
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.ip ? 'border-destructive' : 'border-border'
}`}
placeholder="e.g., 192.168.1.100"
/>
{errors.ip && <p className="mt-1 text-sm text-destructive">{errors.ip}</p>}
</div>
<div>
<label htmlFor="user" className="block text-sm font-medium text-muted-foreground mb-1">
Username *
</label>
<input
type="text"
id="user"
value={formData.user}
onChange={handleChange('user')}
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.user ? 'border-destructive' : 'border-border'
}`}
placeholder="e.g., root"
/>
{errors.user && <p className="mt-1 text-sm text-destructive">{errors.user}</p>}
</div>
<div>
<label htmlFor="ssh_port" className="block text-sm font-medium text-muted-foreground mb-1">
SSH Port
</label>
<input
type="number"
id="ssh_port"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="off"
value={formData.ssh_port ?? 22}
onChange={handleChange('ssh_port')}
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.ssh_port ? 'border-destructive' : 'border-border'
}`}
placeholder="22"
min={1}
max={65535}
/>
{errors.ssh_port && <p className="mt-1 text-sm text-destructive">{errors.ssh_port}</p>}
</div>
<div>
<label htmlFor="auth_type" className="block text-sm font-medium text-muted-foreground mb-1">
Authentication Type *
</label>
<select
id="auth_type"
value={formData.auth_type ?? 'password'}
onChange={handleChange('auth_type')}
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
>
<option value="password">Password Only</option>
<option value="key">SSH Key Only</option>
</select>
</div>
{colorCodingEnabled && (
<div>
<label htmlFor="color" className="block text-sm font-medium text-muted-foreground mb-1">
Server Color
</label>
<div className="flex items-center gap-3">
<input
type="color"
id="color"
value={formData.color ?? '#3b82f6'}
onChange={handleChange('color')}
className="w-20 h-10 rounded cursor-pointer border border-border"
/>
<span className="text-sm text-muted-foreground">
Choose a color to identify this server
</span>
</div>
</div>
)}
</div>
{/* Password Authentication */}
{formData.auth_type === 'password' && (
<div>
<label htmlFor="password" className="block text-sm font-medium text-muted-foreground mb-1">
Password *
</label>
<input
type="password"
id="password"
value={formData.password ?? ''}
onChange={handleChange('password')}
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
errors.password ? 'border-destructive' : 'border-border'
}`}
placeholder="Enter password"
/>
{errors.password && <p className="mt-1 text-sm text-destructive">{errors.password}</p>}
</div>
)}
{/* SSH Key Authentication */}
{formData.auth_type === 'key' && (
<div className="space-y-4">
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium text-muted-foreground">
SSH Private Key *
</label>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleGenerateKeyPair}
disabled={isGeneratingKey}
className="gap-2"
>
<Key className="h-4 w-4" />
{isGeneratingKey ? 'Generating...' : 'Generate Key Pair'}
</Button>
</div>
{/* Show manual key input only if no key has been generated */}
{!formData.key_generated && (
<>
<SSHKeyInput
value={formData.ssh_key ?? ''}
onChange={handleSSHKeyChange}
onError={setSshKeyError}
/>
{errors.ssh_key && <p className="mt-1 text-sm text-destructive">{errors.ssh_key}</p>}
{sshKeyError && <p className="mt-1 text-sm text-destructive">{sshKeyError}</p>}
</>
)}
{/* Show generated key status */}
{formData.key_generated && (
<div className="p-3 bg-success/10 border border-success/20 rounded-md">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-sm font-medium text-success-foreground">
SSH key pair generated successfully
</span>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowPublicKeyModal(true)}
className="gap-2 border-info/20 text-info bg-info/10 hover:bg-info/20"
>
<Key className="h-4 w-4" />
View Public Key
</Button>
</div>
<p className="text-xs text-success/80 mt-1">
The private key has been generated and will be saved with the server.
</p>
</div>
)}
</div>
<div>
<label htmlFor="ssh_key_passphrase" className="block text-sm font-medium text-muted-foreground mb-1">
SSH Key Passphrase (Optional)
</label>
<input
type="password"
id="ssh_key_passphrase"
value={formData.ssh_key_passphrase ?? ''}
onChange={handleChange('ssh_key_passphrase')}
className="w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring border-border"
placeholder="Enter passphrase for encrypted key"
/>
<p className="mt-1 text-xs text-muted-foreground">
Only required if your SSH key is encrypted with a passphrase
</p>
</div>
</div>
)}
<div className="flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-3 pt-4">
{isEditing && onCancel && (
<Button
type="button"
onClick={onCancel}
variant="outline"
size="default"
className="w-full sm:w-auto order-2 sm:order-1"
>
Cancel
</Button>
)}
<Button
type="submit"
variant="default"
size="default"
className="w-full sm:w-auto order-1 sm:order-2"
>
{isEditing ? 'Update Server' : 'Add Server'}
</Button>
</div>
</form>
{/* Public Key Modal */}
<PublicKeyModal
isOpen={showPublicKeyModal}
onClose={() => setShowPublicKeyModal(false)}
publicKey={generatedPublicKey}
serverName={formData.name || 'New Server'}
serverIp={formData.ip}
/>
</>
);
}