Manage integrations only supported add/toggle/delete — fixing an expired API token meant deleting and recreating the whole integration. - New GET /api/integrations/:id/config returns only the non-secret config fields (never the decrypted secret) so an edit form can pre-fill URL/tailnet/etc. fields. - New POST /api/integrations/:id/test merges the stored, decrypted config with any freshly-typed overrides and pings the real adapter — lets "Test connection" work during an edit without ever sending the current secret back to the browser. - New IntegrationEditForm component: secret fields render blank with a "leave blank to keep the current value" placeholder; submitting only sends the fields that were actually filled in, so a name/URL edit can't accidentally wipe a secret and a secret rotation can't touch anything else. Reuses the existing PATCH /:id route, which already merged partial config updates correctly. Verified end-to-end against the real dev server: confirmed via direct DB decryption that a non-secret-only edit leaves the stored secret byte-for-byte unchanged, and that a secret-only edit rotates it without touching other config; the test route was confirmed to make a real network call (got a genuine "API token invalid" from Tailscale's API against a fake key). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
171 lines
5.9 KiB
TypeScript
171 lines
5.9 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { api, type IntegrationField, type IntegrationSummary, type IntegrationType } from "../api/client";
|
|
|
|
const TYPE_LABELS: Record<IntegrationType, string> = {
|
|
tailscale: "Tailscale",
|
|
proxmox: "Proxmox",
|
|
synology: "Synology",
|
|
semaphore: "Semaphore",
|
|
gitea: "Gitea",
|
|
dockhand: "Dockhand",
|
|
};
|
|
|
|
export default function IntegrationEditForm({
|
|
integration,
|
|
onSaved,
|
|
onCancel,
|
|
}: {
|
|
integration: IntegrationSummary;
|
|
onSaved: () => void;
|
|
onCancel: () => void;
|
|
}) {
|
|
const [fields, setFields] = useState<IntegrationField[] | null>(null);
|
|
const [name, setName] = useState(integration.name);
|
|
const [values, setValues] = useState<Record<string, string | boolean>>({});
|
|
const [loading, setLoading] = useState(true);
|
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
const [testResult, setTestResult] = useState<{ ok: boolean; latencyMs?: number; error?: string } | null>(null);
|
|
const [testing, setTesting] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
Promise.all([api.integrations.fields(), api.integrations.getConfig(integration.id)])
|
|
.then(([fieldsRes, configRes]) => {
|
|
setFields(fieldsRes.fields[integration.type] ?? []);
|
|
setValues(configRes.config);
|
|
setName(configRes.integration.name);
|
|
})
|
|
.catch((err) => setLoadError(err instanceof Error ? err.message : String(err)))
|
|
.finally(() => setLoading(false));
|
|
}, [integration.id, integration.type]);
|
|
|
|
function setField(key: string, value: string | boolean) {
|
|
setValues((v) => ({ ...v, [key]: value }));
|
|
setTestResult(null);
|
|
}
|
|
|
|
/** Only fields the admin actually typed a value for — a blank secret input means "keep the current value". */
|
|
function collectOverrides(): Record<string, string | boolean> {
|
|
const overrides: Record<string, string | boolean> = {};
|
|
for (const f of fields ?? []) {
|
|
const v = values[f.key];
|
|
if (f.type === "checkbox") {
|
|
overrides[f.key] = Boolean(v);
|
|
} else if (typeof v === "string" && v !== "") {
|
|
overrides[f.key] = v;
|
|
}
|
|
}
|
|
return overrides;
|
|
}
|
|
|
|
async function testConnection() {
|
|
setTesting(true);
|
|
setTestResult(null);
|
|
try {
|
|
const result = await api.integrations.testExisting(integration.id, collectOverrides());
|
|
setTestResult(result);
|
|
} catch (err) {
|
|
setTestResult({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
} finally {
|
|
setTesting(false);
|
|
}
|
|
}
|
|
|
|
async function submit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setSaving(true);
|
|
setError(null);
|
|
try {
|
|
await api.integrations.update(integration.id, { name, config: collectOverrides() });
|
|
onSaved();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="card mb-3">
|
|
<div className="card-body text-secondary">Loading…</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="card mb-3">
|
|
<div className="card-header">
|
|
<h3 className="card-title">
|
|
Edit {integration.name} ({TYPE_LABELS[integration.type]})
|
|
</h3>
|
|
</div>
|
|
<form onSubmit={submit}>
|
|
<div className="card-body row g-3">
|
|
{(loadError || error) && (
|
|
<div className="col-12">
|
|
<div className="alert alert-danger">{loadError ?? error}</div>
|
|
</div>
|
|
)}
|
|
<div className="col-md-4">
|
|
<label className="form-label">Display name</label>
|
|
<input className="form-control" required value={name} onChange={(e) => setName(e.target.value)} />
|
|
</div>
|
|
|
|
{fields?.map((field) => (
|
|
<div className="col-md-4" key={field.key}>
|
|
{field.type === "checkbox" ? (
|
|
<label className="form-check form-check-single mt-4">
|
|
<input
|
|
type="checkbox"
|
|
className="form-check-input"
|
|
checked={Boolean(values[field.key])}
|
|
onChange={(e) => setField(field.key, e.target.checked)}
|
|
/>
|
|
<span className="form-check-label">{field.label}</span>
|
|
</label>
|
|
) : (
|
|
<>
|
|
<label className="form-label">{field.label}</label>
|
|
<input
|
|
type={field.type === "password" ? "password" : "text"}
|
|
className="form-control"
|
|
placeholder={field.secret ? "Leave blank to keep the current value" : field.placeholder}
|
|
required={!field.secret}
|
|
value={(values[field.key] as string) ?? ""}
|
|
onChange={(e) => setField(field.key, e.target.value)}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{testResult && (
|
|
<div className="col-12">
|
|
{testResult.ok ? (
|
|
<div className="alert alert-success mb-0">
|
|
Connection OK{testResult.latencyMs !== undefined ? ` (${testResult.latencyMs}ms)` : ""}.
|
|
</div>
|
|
) : (
|
|
<div className="alert alert-danger mb-0">Connection failed: {testResult.error}</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="card-footer d-flex gap-2">
|
|
<button type="button" className="btn btn-outline-secondary" onClick={testConnection} disabled={testing}>
|
|
{testing ? "Testing…" : "Test connection"}
|
|
</button>
|
|
<button type="submit" className="btn btn-primary" disabled={saving}>
|
|
{saving ? "Saving…" : "Save changes"}
|
|
</button>
|
|
<button type="button" className="btn" onClick={onCancel}>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|