From f5d3c25c89887f31f5455b0d1336544efc61f96e Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Tue, 15 Sep 2026 13:12:17 +0200 Subject: [PATCH] Add editing existing integrations, so an expired secret can be rotated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 4 +- server/src/routes/integrations.ts | 48 ++++++ web/src/api/client.ts | 10 ++ web/src/components/IntegrationEditForm.tsx | 170 +++++++++++++++++++++ web/src/pages/Integrations.tsx | 26 +++- 5 files changed, 253 insertions(+), 5 deletions(-) create mode 100644 web/src/components/IntegrationEditForm.tsx diff --git a/README.md b/README.md index 995ee42..3a063b9 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,9 @@ All modules from the original plan are built: configurable time/timezone), and DNS provider badge-color customization. All six integrations follow the same config-in-UI + encrypted-credentials -pattern, added through **Integrations → Manage integrations**. +pattern, added (and edited — e.g. to rotate an expired API token without +recreating the whole integration) through **Integrations → Manage +integrations**. **Verified for real, end to end**: every module above — including all six integrations, both their read-only views and their write actions diff --git a/server/src/routes/integrations.ts b/server/src/routes/integrations.ts index 72d77f1..b377aa7 100644 --- a/server/src/routes/integrations.ts +++ b/server/src/routes/integrations.ts @@ -199,6 +199,54 @@ integrationsRouter.patch("/:id", requireRole("admin"), asyncHandler(async (req, }); })); +integrationsRouter.get("/:id/config", requireRole("admin"), asyncHandler(async (req, res) => { + const id = Number(req.params.id); + const loaded = await loadIntegrationConfig(id); + if (!loaded) { + return res.status(404).json({ error: "not_found" }); + } + + // Never return secret fields (API tokens/passwords) to the browser — the edit + // form pre-fills only non-secret fields and leaves secret inputs blank. + const nonSecretConfig: Record = { ...loaded.config }; + for (const field of INTEGRATION_FIELDS[loaded.integration.type] ?? []) { + if (field.secret) delete nonSecretConfig[field.key]; + } + + res.json({ + integration: { + id: loaded.integration.id, + type: loaded.integration.type, + name: loaded.integration.name, + enabled: loaded.integration.enabled, + }, + config: nonSecretConfig, + }); +})); + +const testExistingIntegrationSchema = z.object({ config: z.record(configValueSchema).optional() }); + +integrationsRouter.post("/:id/test", requireRole("admin"), asyncHandler(async (req, res) => { + const id = Number(req.params.id); + const parsed = testExistingIntegrationSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() }); + } + + const loaded = await loadIntegrationConfig(id); + if (!loaded) { + return res.status(404).json({ error: "not_found" }); + } + + // Merge any freshly-typed fields (e.g. a replacement token) over the + // already-stored, decrypted config — lets "Test connection" work during an + // edit without ever sending the current secret value back to the browser. + const mergedConfig = { ...loaded.config, ...(parsed.data.config ?? {}) }; + const adapter = createIntegrationAdapter(loaded.integration.type, mergedConfig); + const result = await adapter.ping(); + res.json(result); +})); + integrationsRouter.delete("/:id", requireRole("admin"), asyncHandler(async (req, res) => { const id = Number(req.params.id); const [existing] = await db.select().from(integrations).where(eq(integrations.id, id)).limit(1); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 7340543..7d4db4f 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -576,6 +576,16 @@ export const api = { method: "POST", body: JSON.stringify(data), }), + getConfig: (id: number) => + request<{ + integration: { id: number; type: IntegrationType; name: string; enabled: boolean }; + config: Record; + }>(`/api/integrations/${id}/config`), + testExisting: (id: number, config: Record) => + request<{ ok: boolean; latencyMs?: number; error?: string }>(`/api/integrations/${id}/test`, { + method: "POST", + body: JSON.stringify({ config }), + }), tailscale: { devices: (integrationId: number) => request(`/api/integrations/${integrationId}/tailscale/devices`), diff --git a/web/src/components/IntegrationEditForm.tsx b/web/src/components/IntegrationEditForm.tsx new file mode 100644 index 0000000..a6adcaa --- /dev/null +++ b/web/src/components/IntegrationEditForm.tsx @@ -0,0 +1,170 @@ +import { useEffect, useState } from "react"; +import { api, type IntegrationField, type IntegrationSummary, type IntegrationType } from "../api/client"; + +const TYPE_LABELS: Record = { + 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(null); + const [name, setName] = useState(integration.name); + const [values, setValues] = useState>({}); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(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(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 { + const overrides: Record = {}; + 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 ( +
+
Loading…
+
+ ); + } + + return ( +
+
+

+ Edit {integration.name} ({TYPE_LABELS[integration.type]}) +

+
+
+
+ {(loadError || error) && ( +
+
{loadError ?? error}
+
+ )} +
+ + setName(e.target.value)} /> +
+ + {fields?.map((field) => ( +
+ {field.type === "checkbox" ? ( + + ) : ( + <> + + setField(field.key, e.target.value)} + /> + + )} +
+ ))} + + {testResult && ( +
+ {testResult.ok ? ( +
+ Connection OK{testResult.latencyMs !== undefined ? ` (${testResult.latencyMs}ms)` : ""}. +
+ ) : ( +
Connection failed: {testResult.error}
+ )} +
+ )} +
+
+ + + +
+
+
+ ); +} diff --git a/web/src/pages/Integrations.tsx b/web/src/pages/Integrations.tsx index 1b75d1d..2fc28a3 100644 --- a/web/src/pages/Integrations.tsx +++ b/web/src/pages/Integrations.tsx @@ -16,6 +16,7 @@ import { type TailscaleDevicesResponse, } from "../api/client"; import IntegrationForm from "../components/IntegrationForm"; +import IntegrationEditForm from "../components/IntegrationEditForm"; function proxmoxStatusBadge(status: string) { return status === "running" ? ( @@ -135,6 +136,7 @@ export default function Integrations({ user }: { user: CurrentUser }) { const [integrations, setIntegrations] = useState(null); const [managing, setManaging] = useState(false); + const [editingIntegration, setEditingIntegration] = useState(null); const [selectedId, setSelectedId] = useState(null); const [error, setError] = useState(null); @@ -388,7 +390,18 @@ export default function Integrations({ user }: { user: CurrentUser }) { {managing ? ( <> - setManaging(false)} /> + {editingIntegration ? ( + { + setEditingIntegration(null); + loadIntegrations(); + }} + onCancel={() => setEditingIntegration(null)} + /> + ) : ( + setManaging(false)} /> + )}
@@ -414,9 +427,14 @@ export default function Integrations({ user }: { user: CurrentUser }) { ))}
- +
+ + +