import { useState, useEffect } from 'react'; import type { SshConnection } from '../../lib/ssh-types'; interface SshConnectionFormProps { /** Existing connection for edit; null for create. */ existing: SshConnection | null; /** True when rendered in admin context — exposes admin-only flags. */ adminContext: boolean; /** Submit handler; receives the request body and returns a Promise. */ onSubmit: (body: Record) => Promise; onCancel: () => void; } type KeypairSource = 'provided' | 'generate'; type GeneratedKeyType = 'ed25519' | 'rsa-4096'; interface FormState { label: string; host: string; port: string; username: string; keypairSource: KeypairSource; generateKeyType: GeneratedKeyType; privateKeyPem: string; passphrase: string; remotePathPrefix: string; commandDenyPatterns: string; commandAllowPatterns: string; allowRemoteUnrestricted: boolean; allowPrivateAddresses: boolean; reason: string; } function initialFromExisting(existing: SshConnection | null): FormState { return { label: existing?.label ?? '', host: existing?.host ?? '', port: existing ? String(existing.port) : '22', username: existing?.username ?? '', keypairSource: 'provided', generateKeyType: 'ed25519', privateKeyPem: '', passphrase: '', remotePathPrefix: existing?.remotePathPrefix ?? '/srv/agent', commandDenyPatterns: existing?.commandDenyPatterns ?? '', commandAllowPatterns: existing?.commandAllowPatterns ?? '', allowRemoteUnrestricted: existing?.allowRemoteUnrestricted ?? false, allowPrivateAddresses: existing?.allowPrivateAddresses ?? false, reason: '', }; } /** * Create/edit form for an SSH connection. * * - Create: `existing === null`, all required fields visible, privateKeyPem required. * - Edit: `existing !== null`, privateKeyPem optional (omitted = keep current key). * - Admin context shows `allowRemoteUnrestricted` and `allowPrivateAddresses` toggles. * These flags are admin-only at the API layer; user-context renders them omitted. * - When `adminContext && isCreate`: also collect `reason` (required ≥ 8 chars) for the * audit row that POST /admin/globals will write. Edit reasons go via PATCH. */ export function SshConnectionForm({ existing, adminContext, onSubmit, onCancel }: SshConnectionFormProps) { const isCreate = existing === null; const [state, setState] = useState(() => initialFromExisting(existing)); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); useEffect(() => { setState(initialFromExisting(existing)); setError(null); }, [existing?.id]); function update(key: K, value: FormState[K]) { setState(prev => ({ ...prev, [key]: value })); } const portNum = Number(state.port); const portValid = Number.isInteger(portNum) && portNum >= 1 && portNum <= 65535; const remotePathOk = state.allowRemoteUnrestricted ? true : (() => { const p = state.remotePathPrefix.trim(); if (p.length === 0) return false; // Accept POSIX (`/srv/agent`), Windows drive (`C:\Users\agent`), // UNC (`\\server\share`), or no-leading-slash prefixes. Reject any // `..` parent-ref segment in either separator. return !p.split(/[\\/]/).includes('..'); })(); const needsUploadedKey = isCreate && state.keypairSource === 'provided'; const baseValid = state.label.trim().length > 0 && state.host.trim().length > 0 && state.username.trim().length > 0 && portValid && remotePathOk && (!needsUploadedKey || state.privateKeyPem.length > 0); const reasonNeeded = adminContext; const reasonValid = !reasonNeeded || state.reason.trim().length >= 8; const canSubmit = baseValid && reasonValid && !submitting; async function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (!canSubmit) return; setSubmitting(true); setError(null); const body: Record = { label: state.label.trim(), host: state.host.trim(), port: portNum, username: state.username.trim(), }; // Keypair handling. Only meaningful at create-time; on edit we never // re-key (a separate rotation flow handles that). if (isCreate && state.keypairSource === 'generate') { body.keypairSource = 'generate'; body.generateKeyType = state.generateKeyType; } else { if (state.privateKeyPem.length > 0) { body.privateKeyPem = state.privateKeyPem; } if (state.passphrase.length > 0) { body.passphrase = state.passphrase; } } if (state.allowRemoteUnrestricted && adminContext) { body.allowRemoteUnrestricted = true; } else { body.remotePathPrefix = state.remotePathPrefix.trim(); } if (adminContext && state.allowPrivateAddresses) { body.allowPrivateAddresses = true; } if (state.commandDenyPatterns.trim().length > 0) { body.commandDenyPatterns = state.commandDenyPatterns.trim(); } else if (existing?.commandDenyPatterns) { body.commandDenyPatterns = ''; } if (state.commandAllowPatterns.trim().length > 0) { body.commandAllowPatterns = state.commandAllowPatterns.trim(); } else if (existing?.commandAllowPatterns) { body.commandAllowPatterns = ''; } if (reasonNeeded) { body.reason = state.reason.trim(); } try { await onSubmit(body); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setSubmitting(false); } } return (
update('label', e.target.value)} className={inputCls} placeholder="prod-db (任意の表示名)" required /> update('username', e.target.value)} className={inputCls + ' font-mono'} placeholder="agent" required /> update('host', e.target.value)} className={inputCls + ' font-mono'} placeholder="db.example.com" required /> update('port', e.target.value)} className={inputCls + ' font-mono'} min={1} max={65535} required />
{isCreate && (
鍵の出所 {state.keypairSource === 'generate' && ( )}
)} {(!isCreate || state.keypairSource === 'provided') && ( <>