Build the Settings module: notification channels, event toggles, DNS badge colors

The Settings page was a "coming soon" placeholder. Port Sloth Manager's
settings feature set: Gotify/ntfy/SMTP/webhook notification channels
(each with its own test-send button), per-event toggles (DNS record
added/updated/deleted, a daily secret-expiry digest with configurable
time/timezone), and per-provider DNS badge color customization.

Settings persist in the existing `settings` key/value table via a new
settingsStore service; a notify service fans a message out to every
enabled channel. DNS record add/update/delete now fire notifications,
and a node-schedule job re-arms itself whenever the notification
settings change. Removed the now-superseded GOTIFY_URL/GOTIFY_TOKEN
env vars in favor of in-app configuration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-15 12:14:05 +02:00
1 parent 3a53a86ce0
commit 3255314402
16 files changed
+1272 -23

No files matched your search

+33
View File
@@ -14,6 +14,16 @@ import {
import { createDnsAdapter } from "../dns/registry.js";
import { loadDnsProviderConfig, getDnsAdapterForProvider } from "../dns/loadProvider.js";
import { asyncHandler } from "../utils/asyncHandler.js";
import { notifyDnsRecordAdded, notifyDnsRecordUpdated, notifyDnsRecordDeleted } from "../services/notify.js";
async function zoneNameFor(providerId: number, zoneId: string): Promise<string> {
const [zone] = await db
.select()
.from(dnsZonesCache)
.where(and(eq(dnsZonesCache.providerId, providerId), eq(dnsZonesCache.zoneId, zoneId)))
.limit(1);
return zone?.zoneName ?? zoneId;
}
export const dnsRouter = Router();
@@ -383,6 +393,9 @@ dnsRouter.post("/providers/:id/zones/:zoneId/records", requireRole("operator"),
targetId: result.id,
detail: { providerId, zoneId, name: result.name, type: result.type },
});
notifyDnsRecordAdded(found.provider.name, await zoneNameFor(providerId, zoneId), result).catch((err) =>
console.error("[dns] add-record notification failed:", err),
);
res.status(201).json({ record: result });
} catch (err) {
@@ -433,6 +446,9 @@ dnsRouter.put("/providers/:id/zones/:zoneId/records/:recordId", requireRole("ope
targetId: result.id,
detail: { providerId, zoneId, name: result.name, type: result.type },
});
notifyDnsRecordUpdated(found.provider.name, await zoneNameFor(providerId, zoneId), result).catch((err) =>
console.error("[dns] update-record notification failed:", err),
);
res.json({ record: result });
} catch (err) {
@@ -448,6 +464,18 @@ dnsRouter.delete("/providers/:id/zones/:zoneId/records/:recordId", requireRole("
if (!found) return res.status(404).json({ error: "not_found" });
try {
const [existingCached] = await db
.select()
.from(dnsRecordsCache)
.where(
and(
eq(dnsRecordsCache.providerId, providerId),
eq(dnsRecordsCache.zoneId, zoneId),
eq(dnsRecordsCache.recordId, recordId),
),
)
.limit(1);
await found.adapter.deleteRecord(zoneId, recordId);
await db
.delete(dnsRecordsCache)
@@ -467,6 +495,11 @@ dnsRouter.delete("/providers/:id/zones/:zoneId/records/:recordId", requireRole("
targetId: recordId,
detail: { providerId, zoneId },
});
if (existingCached) {
notifyDnsRecordDeleted(found.provider.name, await zoneNameFor(providerId, zoneId), existingCached).catch((err) =>
console.error("[dns] delete-record notification failed:", err),
);
}
res.status(204).end();
} catch (err) {
+133
View File
@@ -0,0 +1,133 @@
import { Router } from "express";
import { z } from "zod";
import { requireAuth, requireRole } from "../auth/middleware.js";
import { recordAudit } from "../services/audit.js";
import { getSettings, updateSettings } from "../services/settingsStore.js";
import { scheduleSecretExpiryCheck } from "../services/secretExpiryScheduler.js";
import { testGotify, testNtfy, testSmtp, testWebhook } from "../services/notify.js";
import { asyncHandler } from "../utils/asyncHandler.js";
export const settingsRouter = Router();
settingsRouter.use(requireAuth);
settingsRouter.get("/", requireRole("admin"), asyncHandler(async (_req, res) => {
res.json({ settings: await getSettings() });
}));
// Non-secret subset any signed-in user can read, so badge colors can be applied
// throughout the app without exposing Gotify/SMTP/webhook credentials.
settingsRouter.get("/provider-colors", asyncHandler(async (_req, res) => {
const { providerColors } = await getSettings();
res.json({ providerColors });
}));
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(),
smtp: z
.object({
enabled: z.boolean(),
host: z.string(),
port: z.number(),
secure: z.boolean(),
username: z.string(),
password: z.string(),
from: z.string(),
to: z.string(),
})
.partial()
.optional(),
webhook: z.object({ enabled: z.boolean(), url: z.string(), secret: z.string() }).partial().optional(),
notifications: z
.object({
dnsAdd: z.boolean(),
dnsUpdate: z.boolean(),
dnsDelete: z.boolean(),
secretCheck: z.boolean(),
secretCheckTime: z.string().regex(/^\d{2}:\d{2}$/),
timezone: z.string(),
})
.partial()
.optional(),
providerColors: z.record(z.string()).optional(),
});
settingsRouter.put("/", requireRole("admin"), asyncHandler(async (req, res) => {
const parsed = updateSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
}
const updated = await updateSettings(parsed.data);
if (parsed.data.notifications) {
await scheduleSecretExpiryCheck();
}
await recordAudit({
actor: req.currentUser!,
category: "settings",
action: "update",
targetType: "settings",
detail: { sections: Object.keys(parsed.data) },
});
res.json({ settings: updated });
}));
settingsRouter.post("/test-gotify", requireRole("admin"), asyncHandler(async (req, res) => {
const schema = z.object({ url: z.string().min(1), token: z.string().min(1), priority: z.number().optional() });
const parsed = schema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
try {
await testGotify(parsed.data);
res.json({ ok: true });
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}));
settingsRouter.post("/test-ntfy", requireRole("admin"), asyncHandler(async (req, res) => {
const schema = z.object({ url: z.string().min(1), topic: z.string().min(1), token: z.string().optional(), priority: z.number().optional() });
const parsed = schema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
try {
await testNtfy(parsed.data);
res.json({ ok: true });
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}));
settingsRouter.post("/test-smtp", requireRole("admin"), asyncHandler(async (req, res) => {
const schema = z.object({
host: z.string().min(1),
port: z.number().optional(),
secure: z.boolean().optional(),
username: z.string().optional(),
password: z.string().optional(),
from: z.string().min(1),
to: z.string().min(1),
});
const parsed = schema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
try {
await testSmtp(parsed.data);
res.json({ ok: true });
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}));
settingsRouter.post("/test-webhook", requireRole("admin"), asyncHandler(async (req, res) => {
const schema = z.object({ url: z.string().min(1), secret: z.string().optional() });
const parsed = schema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
try {
await testWebhook(parsed.data);
res.json({ ok: true });
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}));