Extend badge color settings to cover integration types too

Settings → DNS Badges only let you customize DNS provider badge
colors. Expand it into a general Badges page with a second section
for the six integration types (Tailscale, Proxmox, Synology,
Semaphore, Gitea, Dockhand), applied to the type badge in the Manage
integrations table.

- New integrationColors key on AppSettings, stored/merged the same way
  as providerColors via the existing settingsStore.
- New GET /api/settings/integration-colors — non-secret, any signed-in
  user, mirroring /provider-colors — so the badge color can be read
  without needing admin access to the full settings payload.
- Renamed DnsBadgeSettings.tsx -> BadgeSettings.tsx (route
  /settings/dns-badges -> /settings/badges, sub-nav label "DNS
  Badges" -> "Badges") with both color sections saved together.

Verified end-to-end against the real dev server: PUT persists
integration colors independently of provider colors, the public
integration-colors endpoint reflects updates immediately, and the
change is audit-logged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-15 21:28:26 +02:00
1 parent 7fed1dbfa4
commit ad6783b6a1
9 files changed
+175 -100

No files matched your search

+142
View File
@@ -0,0 +1,142 @@
import { useEffect, useState } from "react";
import { api } from "../../api/client";
const PROVIDER_NAMES: Record<string, string> = {
cloudflare: "Cloudflare",
loopia: "Loopia",
pihole: "Pi-hole",
azure: "Azure DNS",
cpanel: "cPanel",
technitium: "Technitium",
};
const INTEGRATION_NAMES: Record<string, string> = {
tailscale: "Tailscale",
proxmox: "Proxmox",
synology: "Synology",
semaphore: "Semaphore",
gitea: "Gitea",
dockhand: "Dockhand",
};
function ColorList({
names,
colors,
onChange,
}: {
names: Record<string, string>;
colors: Record<string, string>;
onChange: (id: string, color: string) => void;
}) {
return (
<div className="d-flex flex-column gap-2">
{Object.entries(names).map(([id, name]) => {
const color = colors[id] ?? "#6366f1";
return (
<div key={id} className="d-flex align-items-center gap-2">
<span className="badge" style={{ backgroundColor: `${color}22`, color, border: `1px solid ${color}55`, minWidth: 110 }}>
{name}
</span>
<input
type="color"
value={color}
className="form-control form-control-color"
onChange={(e) => onChange(id, e.target.value)}
/>
<span className="text-secondary small">{color}</span>
</div>
);
})}
</div>
);
}
export default function BadgeSettings() {
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [providerColors, setProviderColors] = useState<Record<string, string>>({});
const [integrationColors, setIntegrationColors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
useEffect(() => {
Promise.all([api.settings.providerColors(), api.settings.integrationColors()])
.then(([providerRes, integrationRes]) => {
setProviderColors(providerRes.providerColors);
setIntegrationColors(integrationRes.integrationColors);
})
.catch((err) => setLoadError(err instanceof Error ? err.message : String(err)))
.finally(() => setLoading(false));
}, []);
async function handleSave() {
setSaving(true);
setSaveError(null);
setSaved(false);
try {
const res = await api.settings.update({ providerColors, integrationColors });
setProviderColors(res.settings.providerColors);
setIntegrationColors(res.settings.integrationColors);
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch (err) {
setSaveError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
if (loading) return <div className="text-secondary">Loading…</div>;
return (
<>
<div className="d-flex align-items-center justify-content-between mb-3">
<h3 className="mb-0">Badges</h3>
<div className="d-flex align-items-center gap-2">
{saveError && <span className="text-danger small">{saveError}</span>}
{saved && <span className="text-success small">✓ Colors saved</span>}
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? "Saving…" : "Save Colors"}
</button>
</div>
</div>
{loadError && <div className="alert alert-danger">{loadError}</div>}
<div className="row row-cards">
<div className="col-md-6">
<div className="card">
<div className="card-header">
<h3 className="card-title">DNS provider badge colors</h3>
</div>
<div className="card-body">
<p className="text-secondary">Customize the color of each DNS provider's badge throughout the app.</p>
<ColorList
names={PROVIDER_NAMES}
colors={providerColors}
onChange={(id, color) => setProviderColors((c) => ({ ...c, [id]: color }))}
/>
</div>
</div>
</div>
<div className="col-md-6">
<div className="card">
<div className="card-header">
<h3 className="card-title">Integration badge colors</h3>
</div>
<div className="card-body">
<p className="text-secondary">Customize the color of each integration type's badge throughout the app.</p>
<ColorList
names={INTEGRATION_NAMES}
colors={integrationColors}
onChange={(id, color) => setIntegrationColors((c) => ({ ...c, [id]: color }))}
/>
</div>
</div>
</div>
</div>
</>
);
}