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
@@ -51,7 +51,9 @@ All modules from the original plan are built:
|
|||||||
configurable time/timezone), and DNS provider badge-color customization.
|
configurable time/timezone), and DNS provider badge-color customization.
|
||||||
|
|
||||||
All six integrations follow the same config-in-UI + encrypted-credentials
|
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
|
**Verified for real, end to end**: every module above — including all six
|
||||||
integrations, both their read-only views and their write actions
|
integrations, both their read-only views and their write actions
|
||||||
|
|||||||
@@ -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) => {
|
integrationsRouter.delete("/:id", requireRole("admin"), asyncHandler(async (req, res) => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const [existing] = await db.select().from(integrations).where(eq(integrations.id, id)).limit(1);
|
const [existing] = await db.select().from(integrations).where(eq(integrations.id, id)).limit(1);
|
||||||
|
|||||||
@@ -576,6 +576,16 @@ export const api = {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}),
|
}),
|
||||||
|
getConfig: (id: number) =>
|
||||||
|
request<{
|
||||||
|
integration: { id: number; type: IntegrationType; name: string; enabled: boolean };
|
||||||
|
config: Record<string, string | boolean>;
|
||||||
|
}>(`/api/integrations/${id}/config`),
|
||||||
|
testExisting: (id: number, config: Record<string, string | boolean>) =>
|
||||||
|
request<{ ok: boolean; latencyMs?: number; error?: string }>(`/api/integrations/${id}/test`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ config }),
|
||||||
|
}),
|
||||||
tailscale: {
|
tailscale: {
|
||||||
devices: (integrationId: number) =>
|
devices: (integrationId: number) =>
|
||||||
request<TailscaleDevicesResponse>(`/api/integrations/${integrationId}/tailscale/devices`),
|
request<TailscaleDevicesResponse>(`/api/integrations/${integrationId}/tailscale/devices`),
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
type TailscaleDevicesResponse,
|
type TailscaleDevicesResponse,
|
||||||
} from "../api/client";
|
} from "../api/client";
|
||||||
import IntegrationForm from "../components/IntegrationForm";
|
import IntegrationForm from "../components/IntegrationForm";
|
||||||
|
import IntegrationEditForm from "../components/IntegrationEditForm";
|
||||||
|
|
||||||
function proxmoxStatusBadge(status: string) {
|
function proxmoxStatusBadge(status: string) {
|
||||||
return status === "running" ? (
|
return status === "running" ? (
|
||||||
@@ -135,6 +136,7 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
|||||||
|
|
||||||
const [integrations, setIntegrations] = useState<IntegrationSummary[] | null>(null);
|
const [integrations, setIntegrations] = useState<IntegrationSummary[] | null>(null);
|
||||||
const [managing, setManaging] = useState(false);
|
const [managing, setManaging] = useState(false);
|
||||||
|
const [editingIntegration, setEditingIntegration] = useState<IntegrationSummary | null>(null);
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -388,7 +390,18 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
|||||||
|
|
||||||
{managing ? (
|
{managing ? (
|
||||||
<>
|
<>
|
||||||
<IntegrationForm onCreated={loadIntegrations} onCancel={() => setManaging(false)} />
|
{editingIntegration ? (
|
||||||
|
<IntegrationEditForm
|
||||||
|
integration={editingIntegration}
|
||||||
|
onSaved={() => {
|
||||||
|
setEditingIntegration(null);
|
||||||
|
loadIntegrations();
|
||||||
|
}}
|
||||||
|
onCancel={() => setEditingIntegration(null)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<IntegrationForm onCreated={loadIntegrations} onCancel={() => setManaging(false)} />
|
||||||
|
)}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-vcenter card-table">
|
<table className="table table-vcenter card-table">
|
||||||
@@ -414,9 +427,14 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button className="btn btn-sm btn-outline-danger" onClick={() => removeIntegration(i)}>
|
<div className="btn-list flex-nowrap">
|
||||||
Delete
|
<button className="btn btn-sm" onClick={() => setEditingIntegration(i)}>
|
||||||
</button>
|
Edit
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-sm btn-outline-danger" onClick={() => removeIntegration(i)}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in new issue
Block a user