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

+2 -1
View File
@@ -49,7 +49,8 @@ All modules from the original plan are built:
- **Settings** (admin-only) — notification channels (Gotify, ntfy, SMTP,
generic webhook) with per-channel test buttons, per-event toggles (DNS
record added/updated/deleted, daily secret-expiry reminder with a
configurable time/timezone), and DNS provider badge-color customization.
configurable time/timezone), and badge-color customization for both DNS
providers and integration types.
All six integrations follow the same config-in-UI + encrypted-credentials
pattern, added (and edited — e.g. to rotate an expired API token without
+6
View File
@@ -22,6 +22,11 @@ settingsRouter.get("/provider-colors", asyncHandler(async (_req, res) => {
res.json({ providerColors });
}));
settingsRouter.get("/integration-colors", asyncHandler(async (_req, res) => {
const { integrationColors } = await getSettings();
res.json({ integrationColors });
}));
const updateSchema = z.object({
gotify: z.object({ enabled: z.boolean(), url: z.string(), token: z.string(), priority: z.number() }).partial().optional(),
ntfy: z.object({ enabled: z.boolean(), url: z.string(), topic: z.string(), token: z.string(), priority: z.number() }).partial().optional(),
@@ -51,6 +56,7 @@ const updateSchema = z.object({
.partial()
.optional(),
providerColors: z.record(z.string()).optional(),
integrationColors: z.record(z.string()).optional(),
});
settingsRouter.put("/", requireRole("admin"), asyncHandler(async (req, res) => {
+3
View File
@@ -44,6 +44,7 @@ export interface NotificationEvents {
}
export type ProviderColors = Record<string, string>;
export type IntegrationColors = Record<string, string>;
export interface AppSettings {
gotify: GotifySettings;
@@ -52,6 +53,7 @@ export interface AppSettings {
webhook: WebhookSettings;
notifications: NotificationEvents;
providerColors: ProviderColors;
integrationColors: IntegrationColors;
}
const DEFAULTS: AppSettings = {
@@ -68,6 +70,7 @@ const DEFAULTS: AppSettings = {
timezone: "UTC",
},
providerColors: {},
integrationColors: {},
};
const KEYS = Object.keys(DEFAULTS) as (keyof AppSettings)[];
+2 -2
View File
@@ -13,7 +13,7 @@ import ServerDetail from "./pages/ServerDetail";
import Integrations from "./pages/Integrations";
import Settings from "./pages/Settings";
import NotificationSettings from "./pages/settings/NotificationSettings";
import DnsBadgeSettings from "./pages/settings/DnsBadgeSettings";
import BadgeSettings from "./pages/settings/BadgeSettings";
import CacheSettings from "./pages/settings/CacheSettings";
import AppShell from "./layout/AppShell";
@@ -95,7 +95,7 @@ export default function App() {
>
<Route index element={<Navigate to="notifications" replace />} />
<Route path="notifications" element={<NotificationSettings />} />
<Route path="dns-badges" element={<DnsBadgeSettings />} />
<Route path="badges" element={<BadgeSettings />} />
<Route path="cache" element={<CacheSettings />} />
</Route>
</Routes>
+2
View File
@@ -126,6 +126,7 @@ export interface AppSettings {
webhook: WebhookSettings;
notifications: NotificationEvents;
providerColors: Record<string, string>;
integrationColors: Record<string, string>;
}
export type AppSettingsPatch = { [K in keyof AppSettings]?: Partial<AppSettings[K]> };
@@ -653,6 +654,7 @@ export const api = {
settings: {
get: () => request<{ settings: AppSettings }>("/api/settings"),
providerColors: () => request<{ providerColors: Record<string, string> }>("/api/settings/provider-colors"),
integrationColors: () => request<{ integrationColors: Record<string, string> }>("/api/settings/integration-colors"),
update: (data: AppSettingsPatch) =>
request<{ settings: AppSettings }>("/api/settings", { method: "PUT", body: JSON.stringify(data) }),
testGotify: (data: { url: string; token: string; priority?: number }) =>
+17 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useState, type CSSProperties } from "react";
import {
api,
type CurrentUser,
@@ -130,6 +130,12 @@ const TYPE_LABELS: Record<IntegrationType, string> = {
dockhand: "Dockhand",
};
function typeBadgeStyle(colors: Record<string, string>, type: IntegrationType): CSSProperties {
const color = colors[type];
if (!color) return {};
return { backgroundColor: `${color}22`, color, border: `1px solid ${color}55` };
}
export default function Integrations({ user }: { user: CurrentUser }) {
const isAdmin = user.role === "admin";
const canEdit = user.role === "admin" || user.role === "operator";
@@ -139,6 +145,11 @@ export default function Integrations({ user }: { user: CurrentUser }) {
const [editingIntegration, setEditingIntegration] = useState<IntegrationSummary | null>(null);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [integrationColors, setIntegrationColors] = useState<Record<string, string>>({});
useEffect(() => {
api.settings.integrationColors().then((res) => setIntegrationColors(res.integrationColors)).catch(() => {});
}, []);
const [tailscaleData, setTailscaleData] = useState<TailscaleDevicesResponse | null>(null);
const [tailscaleError, setTailscaleError] = useState<string | null>(null);
@@ -418,7 +429,11 @@ export default function Integrations({ user }: { user: CurrentUser }) {
{integrations?.map((i) => (
<tr key={i.id}>
<td>{i.name}</td>
<td>{TYPE_LABELS[i.type]}</td>
<td>
<span className="badge" style={typeBadgeStyle(integrationColors, i.type)}>
{TYPE_LABELS[i.type]}
</span>
</td>
<td>
<button
className={`badge ${i.enabled ? "bg-green-lt text-green" : "bg-secondary-lt text-secondary"} border-0`}
+1 -1
View File
@@ -2,7 +2,7 @@ import { NavLink, Outlet } from "react-router-dom";
const SUB_NAV = [
{ to: "/settings/notifications", label: "Notifications" },
{ to: "/settings/dns-badges", label: "DNS Badges" },
{ to: "/settings/badges", label: "Badges" },
{ to: "/settings/cache", label: "Cache" },
];
+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>
</>
);
}
@@ -1,94 +0,0 @@
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",
};
export default function DnsBadgeSettings() {
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [providerColors, setProviderColors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
useEffect(() => {
api.settings
.providerColors()
.then((res) => setProviderColors(res.providerColors))
.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 });
setProviderColors(res.settings.providerColors);
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">DNS 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="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>
<div className="d-flex flex-column gap-2">
{Object.entries(PROVIDER_NAMES).map(([id, name]) => {
const color = providerColors[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) => setProviderColors((c) => ({ ...c, [id]: e.target.value }))}
/>
<span className="text-secondary small">{color}</span>
</div>
);
})}
</div>
</div>
</div>
</>
);
}