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:
1 parent
7fed1dbfa4
commit
ad6783b6a1
9 files changed
+175
-100
No files matched your search
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user