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:
1 parent
3a53a86ce0
commit
3255314402
16 files changed
+1272
-23
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
}));
|
||||
Reference in new issue
Block a user