Add editing existing integrations, so an expired secret can be rotated

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>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-15 13:12:17 +02:00
1 parent b9409d3095
commit f5d3c25c89
5 files changed
+253 -5

No files matched your search

+48
View File
@@ -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<string, string | boolean | undefined> = { ...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);