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:
1 parent
b9409d3095
commit
f5d3c25c89
5 files changed
+253
-5
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user