maestro/ui/src/components/userfolder/SshConnectionForm.tsx
oss-sync e00ea9fb0c
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (d8074a7)
2026-06-05 06:05:30 +00:00

411 lines
15 KiB
TypeScript

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<string, unknown>) => Promise<void>;
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<FormState>(() => initialFromExisting(existing));
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setState(initialFromExisting(existing));
setError(null);
}, [existing?.id]);
function update<K extends keyof FormState>(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<string, unknown> = {
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 (
<form onSubmit={handleSubmit} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<Field label="Label" required>
<input
type="text"
value={state.label}
onChange={e => update('label', e.target.value)}
className={inputCls}
placeholder="prod-db (任意の表示名)"
required
/>
</Field>
<Field label="Username" required>
<input
type="text"
value={state.username}
onChange={e => update('username', e.target.value)}
className={inputCls + ' font-mono'}
placeholder="agent"
required
/>
</Field>
<Field label="Host" required>
<input
type="text"
value={state.host}
onChange={e => update('host', e.target.value)}
className={inputCls + ' font-mono'}
placeholder="db.example.com"
required
/>
</Field>
<Field label="Port" required>
<input
type="number"
value={state.port}
onChange={e => update('port', e.target.value)}
className={inputCls + ' font-mono'}
min={1}
max={65535}
required
/>
</Field>
</div>
{isCreate && (
<fieldset className="rounded border border-hairline bg-surface/40 p-3 space-y-2">
<legend className="px-1 text-2xs font-semibold text-slate-600 uppercase tracking-wide">
</legend>
<label className="flex items-start gap-2 text-xs cursor-pointer">
<input
type="radio"
name="keypairSource"
value="provided"
checked={state.keypairSource === 'provided'}
onChange={() => update('keypairSource', 'provided')}
className="mt-0.5"
/>
<span>
<span className="font-semibold"></span>
<span className="block text-2xs text-slate-600">
<code className="font-mono">ssh-keygen</code> upload
</span>
</span>
</label>
<label className="flex items-start gap-2 text-xs cursor-pointer">
<input
type="radio"
name="keypairSource"
value="generate"
checked={state.keypairSource === 'generate'}
onChange={() => update('keypairSource', 'generate')}
className="mt-0.5"
/>
<span>
<span className="font-semibold">Orchestrator </span>
<span className="block text-2xs text-slate-600">
Orchestrator
<code className="font-mono"> ~/.ssh/authorized_keys</code>
</span>
</span>
</label>
{state.keypairSource === 'generate' && (
<Field label="Key type">
<select
value={state.generateKeyType}
onChange={e => update('generateKeyType', e.target.value as GeneratedKeyType)}
className={inputCls}
>
<option value="ed25519">Ed25519 (recommended, )</option>
<option value="rsa-4096">RSA 4096-bit ()</option>
</select>
</Field>
)}
</fieldset>
)}
{(!isCreate || state.keypairSource === 'provided') && (
<>
<Field
label={`Private Key (PEM)${isCreate ? '' : ' ← 空欄なら現在のキーを維持'}`}
required={isCreate && state.keypairSource === 'provided'}
>
<textarea
value={state.privateKeyPem}
onChange={e => update('privateKeyPem', e.target.value)}
className={inputCls + ' font-mono h-32 resize-y'}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;...&#10;-----END OPENSSH PRIVATE KEY-----"
spellCheck={false}
autoComplete="off"
required={isCreate && state.keypairSource === 'provided'}
/>
<p className="text-2xs text-slate-500 mt-1">
AES-256-GCM SHA1 RSA reject
</p>
</Field>
<Field label="Passphrase ← キーが encrypted な場合のみ">
<input
type="password"
value={state.passphrase}
onChange={e => update('passphrase', e.target.value)}
className={inputCls + ' font-mono'}
autoComplete="new-password"
/>
</Field>
</>
)}
{!state.allowRemoteUnrestricted && (
<Field label="Remote Path Prefix" required>
<input
type="text"
value={state.remotePathPrefix}
onChange={e => update('remotePathPrefix', e.target.value)}
className={inputCls + ' font-mono'}
placeholder="/srv/agent"
required
/>
<p className="text-2xs text-slate-500 mt-1">
(Upload/Download)
</p>
</Field>
)}
<div className="grid grid-cols-2 gap-3">
<Field label="Command Deny Patterns (1 行 1 regex)">
<textarea
value={state.commandDenyPatterns}
onChange={e => update('commandDenyPatterns', e.target.value)}
className={inputCls + ' font-mono h-20 resize-y text-2xs'}
placeholder="^rm\s+-rf\s+/&#10;^dd\s"
spellCheck={false}
/>
</Field>
<Field label="Command Allow Patterns (空 = 全許可)">
<textarea
value={state.commandAllowPatterns}
onChange={e => update('commandAllowPatterns', e.target.value)}
className={inputCls + ' font-mono h-20 resize-y text-2xs'}
placeholder="^psql\s&#10;^ls\b"
spellCheck={false}
/>
</Field>
</div>
{adminContext && (
<fieldset className="rounded border border-amber-200 bg-amber-50/50 p-3">
<legend className="px-1 text-2xs font-semibold text-amber-800 uppercase tracking-wide">
Admin-only flags
</legend>
<label className="flex items-start gap-2 text-xs cursor-pointer">
<input
type="checkbox"
checked={state.allowRemoteUnrestricted}
onChange={e => update('allowRemoteUnrestricted', e.target.checked)}
className="mt-0.5"
/>
<span>
<span className="font-semibold">Allow remote unrestricted</span>
<span className="block text-2xs text-slate-600">
Remote Path Prefix
</span>
</span>
</label>
<label className="flex items-start gap-2 text-xs cursor-pointer mt-2">
<input
type="checkbox"
checked={state.allowPrivateAddresses}
onChange={e => update('allowPrivateAddresses', e.target.checked)}
className="mt-0.5"
/>
<span>
<span className="font-semibold">Allow private addresses</span>
<span className="block text-2xs text-slate-600">
RFC1918 / localhost SSRF
</span>
</span>
</label>
</fieldset>
)}
{reasonNeeded && (
<Field label="Reason (≥ 8 chars; 監査ログに残ります)" required>
<input
type="text"
value={state.reason}
onChange={e => update('reason', e.target.value)}
className={inputCls}
placeholder="新規ステージング用 global 接続を追加"
required
/>
</Field>
)}
{error && <div className="text-xs text-red-600">{error}</div>}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-hairline">
<button
type="button"
onClick={onCancel}
disabled={submitting}
className="px-3 h-7 text-xs text-slate-700 border border-hairline bg-canvas rounded-md hover:bg-surface disabled:opacity-50"
>
</button>
<button
type="submit"
disabled={!canSubmit}
className="px-3 h-7 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep disabled:opacity-50"
>
{submitting ? '保存中…' : isCreate ? '作成' : '更新'}
</button>
</div>
</form>
);
}
const inputCls = 'w-full text-xs px-2 py-1.5 border border-hairline rounded';
function Field({ label, required, children }: { label: string; required?: boolean; children: React.ReactNode }) {
return (
<label className="block">
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</div>
{children}
</label>
);
}