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:
+2
-3
@@ -23,6 +23,5 @@ AUTHENTIK_ISSUER_URL=https://authentik.example.lan/application/o/homelab-manager
|
||||
AUTHENTIK_CLIENT_ID=
|
||||
AUTHENTIK_CLIENT_SECRET=
|
||||
|
||||
# --- Optional: Gotify notifications (secret expiry, integration offline, etc.) ---
|
||||
GOTIFY_URL=
|
||||
GOTIFY_TOKEN=
|
||||
# Notification channels (Gotify, ntfy, SMTP, webhook) are configured in-app
|
||||
# under Settings, not here.
|
||||
|
||||
@@ -41,6 +41,10 @@ All modules from the original plan are built:
|
||||
Supports self-signed certificates (common in homelab Proxmox setups).
|
||||
- **Integrations → Synology** — volume and disk health (read-only by
|
||||
design). Supports self-signed certificates.
|
||||
- **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.
|
||||
|
||||
All six integrations follow the same config-in-UI + encrypted-credentials
|
||||
pattern, added through **Integrations → Manage integrations**.
|
||||
|
||||
Generated
+72
@@ -1979,6 +1979,26 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-schedule": {
|
||||
"version": "2.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-schedule/-/node-schedule-2.1.8.tgz",
|
||||
"integrity": "sha512-k00g6Yj/oUg/CDC+MeLHUzu0+OFxWbIqrFfDiLi6OPKxTujvpv29mHGM8GtKr7B+9Vv92FcK/8mRqi1DK5f3hA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "6.4.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.24.tgz",
|
||||
"integrity": "sha512-Ww4u0rT9wQNXh4JiQaIwx3QWdcOFXzOjQA2zc+jtFYNmQiT4mIUqcDin51bDFdkzKubFnQCZNK7FIHlPKQ/q9w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -3214,6 +3234,12 @@
|
||||
"@libsql/win32-x64-msvc": "0.4.7"
|
||||
}
|
||||
},
|
||||
"node_modules/long-timeout": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/long-timeout/-/long-timeout-0.1.1.tgz",
|
||||
"integrity": "sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
@@ -3402,6 +3428,42 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/node-schedule": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-schedule/-/node-schedule-2.1.1.tgz",
|
||||
"integrity": "sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cron-parser": "^4.2.0",
|
||||
"long-timeout": "0.1.1",
|
||||
"sorted-array-functions": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/node-schedule/node_modules/cron-parser": {
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
|
||||
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
|
||||
"deprecated": "v4 is no longer maintained, upgrade to v5",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"luxon": "^3.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "6.10.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
|
||||
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/oauth4webapi": {
|
||||
"version": "3.8.8",
|
||||
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.8.tgz",
|
||||
@@ -3968,6 +4030,12 @@
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sorted-array-functions": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz",
|
||||
"integrity": "sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
@@ -4825,6 +4893,8 @@
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"express": "^4.21.2",
|
||||
"express-session": "^1.18.1",
|
||||
"node-schedule": "^2.1.1",
|
||||
"nodemailer": "^6.9.14",
|
||||
"openid-client": "^6.1.7",
|
||||
"session-file-store": "^1.5.0",
|
||||
"xml2js": "^0.6.2",
|
||||
@@ -4834,6 +4904,8 @@
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-session": "^1.18.1",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/node-schedule": "^2.1.7",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/session-file-store": "^1.2.5",
|
||||
"@types/xml2js": "^0.4.14",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"express": "^4.21.2",
|
||||
"express-session": "^1.18.1",
|
||||
"node-schedule": "^2.1.1",
|
||||
"nodemailer": "^6.9.14",
|
||||
"openid-client": "^6.1.7",
|
||||
"session-file-store": "^1.5.0",
|
||||
"xml2js": "^0.6.2",
|
||||
@@ -25,6 +27,8 @@
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-session": "^1.18.1",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/node-schedule": "^2.1.7",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/session-file-store": "^1.2.5",
|
||||
"@types/xml2js": "^0.4.14",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
|
||||
@@ -11,10 +11,6 @@ export const env = {
|
||||
clientId: process.env.AUTHENTIK_CLIENT_ID ?? "",
|
||||
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET ?? "",
|
||||
},
|
||||
gotify: {
|
||||
url: process.env.GOTIFY_URL ?? "",
|
||||
token: process.env.GOTIFY_TOKEN ?? "",
|
||||
},
|
||||
get authEnabled() {
|
||||
return Boolean(this.authentik.issuerUrl && this.authentik.clientId && this.authentik.clientSecret);
|
||||
},
|
||||
|
||||
@@ -19,9 +19,12 @@ import { serversRouter } from "./routes/servers.js";
|
||||
import { tasksRouter } from "./routes/tasks.js";
|
||||
import { agentReportRouter } from "./routes/agentReport.js";
|
||||
import { integrationsRouter } from "./routes/integrations.js";
|
||||
import { settingsRouter } from "./routes/settings.js";
|
||||
import { initSecretExpiryScheduler } from "./services/secretExpiryScheduler.js";
|
||||
|
||||
warnIfAuthNotConfigured();
|
||||
await runMigrations();
|
||||
await initSecretExpiryScheduler();
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const webDist = join(__dirname, "..", "..", "web", "dist");
|
||||
@@ -68,6 +71,7 @@ app.use("/api/servers", serversRouter);
|
||||
app.use("/api/tasks", tasksRouter);
|
||||
app.use("/api/agent/report", agentReportRouter);
|
||||
app.use("/api/integrations", integrationsRouter);
|
||||
app.use("/api/settings", settingsRouter);
|
||||
|
||||
if (existsSync(webDist)) {
|
||||
app.use(express.static(webDist));
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
}));
|
||||
@@ -0,0 +1,179 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { getSettings, type NotificationEvents } from "./settingsStore.js";
|
||||
|
||||
// ─── Channel senders (best-effort — failures are logged, never thrown) ──────
|
||||
|
||||
async function sendGotify(title: string, message: string) {
|
||||
const { gotify } = await getSettings();
|
||||
if (!gotify.enabled || !gotify.url || !gotify.token) return;
|
||||
const base = gotify.url.replace(/\/$/, "");
|
||||
try {
|
||||
const res = await fetch(`${base}/message?token=${encodeURIComponent(gotify.token)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, message, priority: gotify.priority ?? 5 }),
|
||||
});
|
||||
if (!res.ok) console.error(`[notify] Gotify error ${res.status}: ${await res.text().catch(() => "")}`);
|
||||
} catch (err) {
|
||||
console.error("[notify] Gotify failed:", err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendNtfy(title: string, message: string) {
|
||||
const { ntfy } = await getSettings();
|
||||
if (!ntfy.enabled || !ntfy.url || !ntfy.topic) return;
|
||||
const base = ntfy.url.replace(/\/$/, "");
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "text/plain",
|
||||
Title: title,
|
||||
Priority: String(ntfy.priority ?? 3),
|
||||
};
|
||||
if (ntfy.token) headers.Authorization = `Bearer ${ntfy.token}`;
|
||||
try {
|
||||
const res = await fetch(`${base}/${encodeURIComponent(ntfy.topic)}`, { method: "POST", headers, body: message });
|
||||
if (!res.ok) console.error(`[notify] ntfy error ${res.status}: ${await res.text().catch(() => "")}`);
|
||||
} catch (err) {
|
||||
console.error("[notify] ntfy failed:", err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendSmtp(title: string, message: string) {
|
||||
const { smtp } = await getSettings();
|
||||
if (!smtp.enabled || !smtp.host || !smtp.to || !smtp.from) return;
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtp.host,
|
||||
port: Number(smtp.port) || 587,
|
||||
secure: !!smtp.secure,
|
||||
auth: smtp.username ? { user: smtp.username, pass: smtp.password } : undefined,
|
||||
});
|
||||
await transporter.sendMail({ from: smtp.from, to: smtp.to, subject: title, text: message });
|
||||
} catch (err) {
|
||||
console.error("[notify] SMTP failed:", err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendWebhook(title: string, message: string) {
|
||||
const { webhook } = await getSettings();
|
||||
if (!webhook.enabled || !webhook.url) return;
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (webhook.secret) headers["X-Webhook-Secret"] = webhook.secret;
|
||||
try {
|
||||
const res = await fetch(webhook.url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ content: `**${title}**\n${message}` }),
|
||||
});
|
||||
if (!res.ok) console.error(`[notify] Webhook error ${res.status}: ${await res.text().catch(() => "")}`);
|
||||
} catch (err) {
|
||||
console.error("[notify] Webhook failed:", err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function notify(title: string, message: string): Promise<void> {
|
||||
await Promise.all([sendGotify(title, message), sendNtfy(title, message), sendSmtp(title, message), sendWebhook(title, message)]);
|
||||
}
|
||||
|
||||
// ─── Test senders — throw on failure so the route can report it ────────────
|
||||
|
||||
export async function testGotify(cfg: { url: string; token: string; priority?: number }): Promise<void> {
|
||||
const base = cfg.url.replace(/\/$/, "");
|
||||
const res = await fetch(`${base}/message?token=${encodeURIComponent(cfg.token)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: "Homelab Manager — Test",
|
||||
message: "Gotify notifications are working correctly.",
|
||||
priority: cfg.priority ?? 5,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Gotify returned ${res.status}: ${await res.text().catch(() => "")}`);
|
||||
}
|
||||
|
||||
export async function testNtfy(cfg: { url: string; topic: string; token?: string; priority?: number }): Promise<void> {
|
||||
const base = cfg.url.replace(/\/$/, "");
|
||||
const headers: Record<string, string> = { "Content-Type": "text/plain", Title: "Homelab Manager — Test", Priority: String(cfg.priority ?? 3) };
|
||||
if (cfg.token) headers.Authorization = `Bearer ${cfg.token}`;
|
||||
const res = await fetch(`${base}/${encodeURIComponent(cfg.topic)}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: "ntfy notifications are working correctly.",
|
||||
});
|
||||
if (!res.ok) throw new Error(`ntfy returned ${res.status}: ${await res.text().catch(() => "")}`);
|
||||
}
|
||||
|
||||
export async function testSmtp(cfg: {
|
||||
host: string;
|
||||
port?: number;
|
||||
secure?: boolean;
|
||||
username?: string;
|
||||
password?: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}): Promise<void> {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: cfg.host,
|
||||
port: Number(cfg.port) || 587,
|
||||
secure: !!cfg.secure,
|
||||
auth: cfg.username ? { user: cfg.username, pass: cfg.password } : undefined,
|
||||
});
|
||||
await transporter.verify();
|
||||
await transporter.sendMail({ from: cfg.from, to: cfg.to, subject: "Homelab Manager — Test", text: "SMTP notifications are working correctly." });
|
||||
}
|
||||
|
||||
export async function testWebhook(cfg: { url: string; secret?: string }): Promise<void> {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (cfg.secret) headers["X-Webhook-Secret"] = cfg.secret;
|
||||
const res = await fetch(cfg.url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ content: "**Homelab Manager — Test**\nWebhook notifications are working correctly." }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Webhook returned ${res.status}: ${await res.text().catch(() => "")}`);
|
||||
}
|
||||
|
||||
// ─── Event helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
async function eventEnabled(key: keyof NotificationEvents): Promise<boolean> {
|
||||
const { notifications } = await getSettings();
|
||||
return notifications[key] !== false;
|
||||
}
|
||||
|
||||
export async function notifyDnsRecordAdded(
|
||||
providerLabel: string,
|
||||
zoneName: string,
|
||||
record: { type: string; name: string; content: string },
|
||||
): Promise<void> {
|
||||
if (!(await eventEnabled("dnsAdd"))) return;
|
||||
await notify("DNS Record Added", `[${providerLabel}] ${zoneName}\n+ ${record.type} ${record.name} → ${record.content}`);
|
||||
}
|
||||
|
||||
export async function notifyDnsRecordUpdated(
|
||||
providerLabel: string,
|
||||
zoneName: string,
|
||||
record: { type: string; name: string; content: string },
|
||||
): Promise<void> {
|
||||
if (!(await eventEnabled("dnsUpdate"))) return;
|
||||
await notify("DNS Record Updated", `[${providerLabel}] ${zoneName}\n✎ ${record.type} ${record.name} → ${record.content}`);
|
||||
}
|
||||
|
||||
export async function notifyDnsRecordDeleted(
|
||||
providerLabel: string,
|
||||
zoneName: string,
|
||||
record: { type: string; name: string; content: string },
|
||||
): Promise<void> {
|
||||
if (!(await eventEnabled("dnsDelete"))) return;
|
||||
await notify("DNS Record Deleted", `[${providerLabel}] ${zoneName}\n− ${record.type} ${record.name} ${record.content}`);
|
||||
}
|
||||
|
||||
export async function notifySecretExpiry(
|
||||
expiring: { name: string; status: "expired" | "expiring"; daysLeft: number }[],
|
||||
): Promise<void> {
|
||||
if (expiring.length === 0) return;
|
||||
if (!(await eventEnabled("secretCheck"))) return;
|
||||
const lines = expiring.map((s) => (s.status === "expired" ? `✕ EXPIRED — ${s.name}` : `⚠ ${s.daysLeft}d left — ${s.name}`));
|
||||
await notify(
|
||||
"Homelab Manager — Secrets Alert",
|
||||
`${expiring.length} secret${expiring.length !== 1 ? "s" : ""} need attention:\n\n${lines.join("\n")}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import schedule from "node-schedule";
|
||||
import { db } from "../db/client.js";
|
||||
import { secrets } from "../db/schema.js";
|
||||
import { computeSecretStatus } from "./secretStatus.js";
|
||||
import { notifySecretExpiry } from "./notify.js";
|
||||
import { getSettings, getInternalFlag, setInternalFlag } from "./settingsStore.js";
|
||||
|
||||
const LAST_RUN_FLAG = "secretCheckLastRunDate";
|
||||
|
||||
async function checkSecretExpiry(): Promise<void> {
|
||||
const rows = await db.select().from(secrets);
|
||||
const expiring = rows
|
||||
.map((s) => ({ name: s.name, ...computeSecretStatus(s.expiryDate, s.warnDays) }))
|
||||
.filter((s): s is typeof s & { status: "expired" | "expiring" } => s.status === "expired" || s.status === "expiring");
|
||||
await notifySecretExpiry(expiring);
|
||||
}
|
||||
|
||||
async function checkSecretExpiryOnce(): Promise<void> {
|
||||
const today = new Date().toDateString();
|
||||
const lastRun = await getInternalFlag(LAST_RUN_FLAG);
|
||||
if (lastRun === today) return;
|
||||
await setInternalFlag(LAST_RUN_FLAG, today);
|
||||
await checkSecretExpiry();
|
||||
}
|
||||
|
||||
function cronFromTime(time: string): string {
|
||||
const [h, m] = time.split(":").map(Number);
|
||||
return `${Number.isFinite(m) ? m : 0} ${Number.isFinite(h) ? h : 8} * * *`;
|
||||
}
|
||||
|
||||
let currentJob: schedule.Job | null = null;
|
||||
|
||||
/** (Re)schedules the daily secret-expiry check per the current notification settings. Call again after settings change. */
|
||||
export async function scheduleSecretExpiryCheck(): Promise<void> {
|
||||
if (currentJob) {
|
||||
currentJob.cancel();
|
||||
currentJob = null;
|
||||
}
|
||||
const { notifications } = await getSettings();
|
||||
currentJob = schedule.scheduleJob({ rule: cronFromTime(notifications.secretCheckTime), tz: notifications.timezone }, () => {
|
||||
setInternalFlag(LAST_RUN_FLAG, "").catch(() => {});
|
||||
getSettings().then(({ notifications: n }) => {
|
||||
if (n.secretCheck) checkSecretExpiry().catch((err) => console.error("[secretExpiry] check failed:", err));
|
||||
});
|
||||
});
|
||||
console.log(`Secret expiry check scheduled at ${notifications.secretCheckTime} (${notifications.timezone})`);
|
||||
}
|
||||
|
||||
/** Runs once at startup (skipped if already run today), then arms the daily schedule. */
|
||||
export async function initSecretExpiryScheduler(): Promise<void> {
|
||||
await checkSecretExpiryOnce();
|
||||
await scheduleSecretExpiryCheck();
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { settings } from "../db/schema.js";
|
||||
|
||||
export interface GotifySettings {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
token: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface NtfySettings {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
topic: string;
|
||||
token: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface SmtpSettings {
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
username: string;
|
||||
password: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface WebhookSettings {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
secret: string;
|
||||
}
|
||||
|
||||
export interface NotificationEvents {
|
||||
dnsAdd: boolean;
|
||||
dnsUpdate: boolean;
|
||||
dnsDelete: boolean;
|
||||
secretCheck: boolean;
|
||||
secretCheckTime: string; // "HH:MM"
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
export type ProviderColors = Record<string, string>;
|
||||
|
||||
export interface AppSettings {
|
||||
gotify: GotifySettings;
|
||||
ntfy: NtfySettings;
|
||||
smtp: SmtpSettings;
|
||||
webhook: WebhookSettings;
|
||||
notifications: NotificationEvents;
|
||||
providerColors: ProviderColors;
|
||||
}
|
||||
|
||||
const DEFAULTS: AppSettings = {
|
||||
gotify: { enabled: false, url: "", token: "", priority: 5 },
|
||||
ntfy: { enabled: false, url: "https://ntfy.sh", topic: "", token: "", priority: 3 },
|
||||
smtp: { enabled: false, host: "", port: 587, secure: false, username: "", password: "", from: "", to: "" },
|
||||
webhook: { enabled: false, url: "", secret: "" },
|
||||
notifications: {
|
||||
dnsAdd: true,
|
||||
dnsUpdate: true,
|
||||
dnsDelete: true,
|
||||
secretCheck: true,
|
||||
secretCheckTime: "08:00",
|
||||
timezone: "UTC",
|
||||
},
|
||||
providerColors: {},
|
||||
};
|
||||
|
||||
const KEYS = Object.keys(DEFAULTS) as (keyof AppSettings)[];
|
||||
|
||||
export async function getSettings(): Promise<AppSettings> {
|
||||
const rows = await db.select().from(settings);
|
||||
const byKey = new Map(rows.map((r) => [r.key, r.value]));
|
||||
|
||||
const result = {} as AppSettings;
|
||||
for (const key of KEYS) {
|
||||
const raw = byKey.get(key);
|
||||
let parsed: Record<string, unknown> = {};
|
||||
if (raw) {
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
parsed = {};
|
||||
}
|
||||
}
|
||||
(result[key] as Record<string, unknown>) = { ...(DEFAULTS[key] as object), ...parsed };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export type AppSettingsPatch = { [K in keyof AppSettings]?: Partial<AppSettings[K]> };
|
||||
|
||||
export async function updateSettings(partial: AppSettingsPatch): Promise<AppSettings> {
|
||||
const current = await getSettings();
|
||||
|
||||
for (const key of Object.keys(partial) as (keyof AppSettings)[]) {
|
||||
if (!KEYS.includes(key)) continue;
|
||||
const merged = { ...(current[key] as object), ...(partial[key] as object) };
|
||||
const value = JSON.stringify(merged);
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({ key, value })
|
||||
.onConflictDoUpdate({ target: settings.key, set: { value, updatedAt: sql`(current_timestamp)` } });
|
||||
}
|
||||
|
||||
return getSettings();
|
||||
}
|
||||
|
||||
/** Small internal key/value slot for scheduler bookkeeping, outside the AppSettings shape. */
|
||||
export async function getInternalFlag(key: string): Promise<string | null> {
|
||||
const [row] = await db.select().from(settings).where(sql`${settings.key} = ${`_internal:${key}`}`).limit(1);
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
export async function setInternalFlag(key: string, value: string): Promise<void> {
|
||||
const fullKey = `_internal:${key}`;
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({ key: fullKey, value })
|
||||
.onConflictDoUpdate({ target: settings.key, set: { value, updatedAt: sql`(current_timestamp)` } });
|
||||
}
|
||||
+2
-2
@@ -10,7 +10,7 @@ import Ipam from "./pages/Ipam";
|
||||
import Dns from "./pages/Dns";
|
||||
import ServersTasks from "./pages/ServersTasks";
|
||||
import Integrations from "./pages/Integrations";
|
||||
import ComingSoon from "./pages/ComingSoon";
|
||||
import Settings from "./pages/Settings";
|
||||
import AppShell from "./layout/AppShell";
|
||||
|
||||
const roleRank: Record<UserRole, number> = { viewer: 0, operator: 1, admin: 2 };
|
||||
@@ -84,7 +84,7 @@ export default function App() {
|
||||
path="/settings"
|
||||
element={
|
||||
<RequireRole user={user} minRole="admin">
|
||||
<ComingSoon title="Settings" />
|
||||
<Settings />
|
||||
</RequireRole>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -78,6 +78,58 @@ export interface IpamInput {
|
||||
|
||||
export type DnsProviderType = "cloudflare" | "loopia" | "pihole" | "azure" | "cpanel" | "technitium";
|
||||
|
||||
export interface GotifySettings {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
token: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface NtfySettings {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
topic: string;
|
||||
token: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface SmtpSettings {
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
username: string;
|
||||
password: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface WebhookSettings {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
secret: string;
|
||||
}
|
||||
|
||||
export interface NotificationEvents {
|
||||
dnsAdd: boolean;
|
||||
dnsUpdate: boolean;
|
||||
dnsDelete: boolean;
|
||||
secretCheck: boolean;
|
||||
secretCheckTime: string;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
gotify: GotifySettings;
|
||||
ntfy: NtfySettings;
|
||||
smtp: SmtpSettings;
|
||||
webhook: WebhookSettings;
|
||||
notifications: NotificationEvents;
|
||||
providerColors: Record<string, string>;
|
||||
}
|
||||
|
||||
export type AppSettingsPatch = { [K in keyof AppSettings]?: Partial<AppSettings[K]> };
|
||||
|
||||
export interface DnsProviderField {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -534,4 +586,18 @@ export const api = {
|
||||
storage: (integrationId: number) => request<SynologyStorageResponse>(`/api/integrations/${integrationId}/synology/storage`),
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
get: () => request<{ settings: AppSettings }>("/api/settings"),
|
||||
providerColors: () => request<{ providerColors: Record<string, string> }>("/api/settings/provider-colors"),
|
||||
update: (data: AppSettingsPatch) =>
|
||||
request<{ settings: AppSettings }>("/api/settings", { method: "PUT", body: JSON.stringify(data) }),
|
||||
testGotify: (data: { url: string; token: string; priority?: number }) =>
|
||||
request<{ ok: true }>("/api/settings/test-gotify", { method: "POST", body: JSON.stringify(data) }),
|
||||
testNtfy: (data: { url: string; topic: string; token?: string; priority?: number }) =>
|
||||
request<{ ok: true }>("/api/settings/test-ntfy", { method: "POST", body: JSON.stringify(data) }),
|
||||
testSmtp: (data: { host: string; port?: number; secure?: boolean; username?: string; password?: string; from: string; to: string }) =>
|
||||
request<{ ok: true }>("/api/settings/test-smtp", { method: "POST", body: JSON.stringify(data) }),
|
||||
testWebhook: (data: { url: string; secret?: string }) =>
|
||||
request<{ ok: true }>("/api/settings/test-webhook", { method: "POST", body: JSON.stringify(data) }),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
export default function ComingSoon({ title }: { title: string }) {
|
||||
return (
|
||||
<>
|
||||
<h2 className="page-title mb-3">{title}</h2>
|
||||
<div className="card">
|
||||
<div className="card-body text-secondary">
|
||||
This module hasn't been built yet — it's on the roadmap for a follow-up pass.
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+17
-2
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, type CSSProperties } from "react";
|
||||
import {
|
||||
api,
|
||||
type CurrentUser,
|
||||
@@ -21,6 +21,12 @@ const PROVIDER_LABELS: Record<DnsProviderType, string> = {
|
||||
|
||||
const emptyRecordForm: DnsRecordInput = { type: "A", name: "", content: "", ttl: 3600, priority: undefined, proxied: undefined };
|
||||
|
||||
function providerBadgeStyle(colors: Record<string, string>, providerType: DnsProviderType): CSSProperties {
|
||||
const color = colors[providerType];
|
||||
if (!color) return {};
|
||||
return { backgroundColor: `${color}22`, color, border: `1px solid ${color}55` };
|
||||
}
|
||||
|
||||
export default function Dns({ user }: { user: CurrentUser }) {
|
||||
const isAdmin = user.role === "admin";
|
||||
const canEdit = user.role === "admin" || user.role === "operator";
|
||||
@@ -29,6 +35,11 @@ export default function Dns({ user }: { user: CurrentUser }) {
|
||||
const [managingProviders, setManagingProviders] = useState(false);
|
||||
const [selectedProviderId, setSelectedProviderId] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [providerColors, setProviderColors] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
api.settings.providerColors().then((res) => setProviderColors(res.providerColors)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const [zones, setZones] = useState<DnsZone[] | null>(null);
|
||||
const [zonesError, setZonesError] = useState<string | null>(null);
|
||||
@@ -212,7 +223,11 @@ export default function Dns({ user }: { user: CurrentUser }) {
|
||||
{providers?.map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td>{p.name}</td>
|
||||
<td>{PROVIDER_LABELS[p.providerType]}</td>
|
||||
<td>
|
||||
<span className="badge" style={providerBadgeStyle(providerColors, p.providerType)}>
|
||||
{PROVIDER_LABELS[p.providerType]}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className={`badge ${p.enabled ? "bg-green-lt text-green" : "bg-secondary-lt text-secondary"} border-0`}
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
api,
|
||||
type AppSettings,
|
||||
type GotifySettings,
|
||||
type NtfySettings,
|
||||
type SmtpSettings,
|
||||
type WebhookSettings,
|
||||
type NotificationEvents,
|
||||
} from "../api/client";
|
||||
|
||||
const PROVIDER_NAMES: Record<string, string> = {
|
||||
cloudflare: "Cloudflare",
|
||||
loopia: "Loopia",
|
||||
pihole: "Pi-hole",
|
||||
azure: "Azure DNS",
|
||||
cpanel: "cPanel",
|
||||
technitium: "Technitium",
|
||||
};
|
||||
|
||||
const TIMEZONES = [
|
||||
"UTC",
|
||||
"Europe/Stockholm",
|
||||
"Europe/London",
|
||||
"Europe/Berlin",
|
||||
"Europe/Paris",
|
||||
"Europe/Oslo",
|
||||
"Europe/Helsinki",
|
||||
"Europe/Amsterdam",
|
||||
"Europe/Copenhagen",
|
||||
"America/New_York",
|
||||
"America/Chicago",
|
||||
"America/Denver",
|
||||
"America/Los_Angeles",
|
||||
"America/Toronto",
|
||||
"America/Sao_Paulo",
|
||||
"Asia/Tokyo",
|
||||
"Asia/Shanghai",
|
||||
"Asia/Singapore",
|
||||
"Asia/Dubai",
|
||||
"Australia/Sydney",
|
||||
];
|
||||
|
||||
const DEFAULT_GOTIFY: GotifySettings = { enabled: false, url: "", token: "", priority: 5 };
|
||||
const DEFAULT_NTFY: NtfySettings = { enabled: false, url: "https://ntfy.sh", topic: "", token: "", priority: 3 };
|
||||
const DEFAULT_SMTP: SmtpSettings = { enabled: false, host: "", port: 587, secure: false, username: "", password: "", from: "", to: "" };
|
||||
const DEFAULT_WEBHOOK: WebhookSettings = { enabled: false, url: "", secret: "" };
|
||||
const DEFAULT_NOTIFICATIONS: NotificationEvents = {
|
||||
dnsAdd: true,
|
||||
dnsUpdate: true,
|
||||
dnsDelete: true,
|
||||
secretCheck: true,
|
||||
secretCheckTime: "08:00",
|
||||
timezone: "UTC",
|
||||
};
|
||||
|
||||
type TestResult = { ok: boolean; message: string } | null;
|
||||
|
||||
function TestBadge({ result }: { result: TestResult }) {
|
||||
if (!result) return null;
|
||||
return (
|
||||
<div className={`alert ${result.ok ? "alert-success" : "alert-danger"} mb-0 mt-2 py-2`}>
|
||||
{result.ok ? "✓" : "✕"} {result.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const [gotify, setGotify] = useState<GotifySettings>(DEFAULT_GOTIFY);
|
||||
const [ntfy, setNtfy] = useState<NtfySettings>(DEFAULT_NTFY);
|
||||
const [smtp, setSmtp] = useState<SmtpSettings>(DEFAULT_SMTP);
|
||||
const [webhook, setWebhook] = useState<WebhookSettings>(DEFAULT_WEBHOOK);
|
||||
const [notifications, setNotifications] = useState<NotificationEvents>(DEFAULT_NOTIFICATIONS);
|
||||
const [providerColors, setProviderColors] = useState<Record<string, string>>({});
|
||||
|
||||
const [testingGotify, setTestingGotify] = useState(false);
|
||||
const [testGotifyResult, setTestGotifyResult] = useState<TestResult>(null);
|
||||
const [testingNtfy, setTestingNtfy] = useState(false);
|
||||
const [testNtfyResult, setTestNtfyResult] = useState<TestResult>(null);
|
||||
const [testingSmtp, setTestingSmtp] = useState(false);
|
||||
const [testSmtpResult, setTestSmtpResult] = useState<TestResult>(null);
|
||||
const [testingWebhook, setTestingWebhook] = useState(false);
|
||||
const [testWebhookResult, setTestWebhookResult] = useState<TestResult>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.settings
|
||||
.get()
|
||||
.then((res: { settings: AppSettings }) => {
|
||||
setGotify(res.settings.gotify);
|
||||
setNtfy(res.settings.ntfy);
|
||||
setSmtp(res.settings.smtp);
|
||||
setWebhook(res.settings.webhook);
|
||||
setNotifications(res.settings.notifications);
|
||||
setProviderColors(res.settings.providerColors);
|
||||
})
|
||||
.catch((err) => setLoadError(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
setSaved(false);
|
||||
try {
|
||||
await api.settings.update({ gotify, ntfy, smtp, webhook, notifications, providerColors });
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestGotify() {
|
||||
setTestingGotify(true);
|
||||
setTestGotifyResult(null);
|
||||
try {
|
||||
await api.settings.testGotify(gotify);
|
||||
setTestGotifyResult({ ok: true, message: "Notification sent successfully." });
|
||||
} catch (err) {
|
||||
setTestGotifyResult({ ok: false, message: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
setTestingGotify(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestNtfy() {
|
||||
setTestingNtfy(true);
|
||||
setTestNtfyResult(null);
|
||||
try {
|
||||
await api.settings.testNtfy(ntfy);
|
||||
setTestNtfyResult({ ok: true, message: "Notification sent successfully." });
|
||||
} catch (err) {
|
||||
setTestNtfyResult({ ok: false, message: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
setTestingNtfy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestSmtp() {
|
||||
setTestingSmtp(true);
|
||||
setTestSmtpResult(null);
|
||||
try {
|
||||
await api.settings.testSmtp(smtp);
|
||||
setTestSmtpResult({ ok: true, message: "Test email sent successfully." });
|
||||
} catch (err) {
|
||||
setTestSmtpResult({ ok: false, message: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
setTestingSmtp(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestWebhook() {
|
||||
setTestingWebhook(true);
|
||||
setTestWebhookResult(null);
|
||||
try {
|
||||
await api.settings.testWebhook(webhook);
|
||||
setTestWebhookResult({ ok: true, message: "Webhook delivered successfully." });
|
||||
} catch (err) {
|
||||
setTestWebhookResult({ ok: false, message: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
setTestingWebhook(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-secondary">Loading…</div>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="d-flex align-items-center justify-content-between mb-3">
|
||||
<h2 className="page-title mb-0">Settings</h2>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
{saveError && <span className="text-danger small">{saveError}</span>}
|
||||
{saved && <span className="text-success small">✓ Settings saved</span>}
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
|
||||
{saving ? "Saving…" : "Save Settings"}
|
||||
</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">Gotify</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<p className="text-secondary">Send push notifications to a self-hosted Gotify instance.</p>
|
||||
<label className="form-check form-check-single mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
checked={gotify.enabled}
|
||||
onChange={(e) => setGotify((g) => ({ ...g, enabled: e.target.checked }))}
|
||||
/>
|
||||
<span className="form-check-label">Enable Gotify notifications</span>
|
||||
</label>
|
||||
<div className="mb-2">
|
||||
<label className="form-label">Gotify URL</label>
|
||||
<input
|
||||
className="form-control"
|
||||
placeholder="http://gotify.example.com"
|
||||
value={gotify.url}
|
||||
disabled={!gotify.enabled}
|
||||
onChange={(e) => setGotify((g) => ({ ...g, url: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label className="form-label">App token</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
placeholder="Your Gotify app token"
|
||||
value={gotify.token}
|
||||
disabled={!gotify.enabled}
|
||||
onChange={(e) => setGotify((g) => ({ ...g, token: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label className="form-label">Priority (1–10)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
className="form-control"
|
||||
value={gotify.priority}
|
||||
disabled={!gotify.enabled}
|
||||
onChange={(e) => setGotify((g) => ({ ...g, priority: Number(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
<TestBadge result={testGotifyResult} />
|
||||
</div>
|
||||
<div className="card-footer">
|
||||
<button
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={handleTestGotify}
|
||||
disabled={testingGotify || !gotify.url || !gotify.token}
|
||||
>
|
||||
{testingGotify ? "Sending…" : "Send test"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-md-6">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">ntfy</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<p className="text-secondary">
|
||||
Send notifications via <a href="https://ntfy.sh" target="_blank" rel="noreferrer">ntfy.sh</a> or a self-hosted instance.
|
||||
</p>
|
||||
<label className="form-check form-check-single mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
checked={ntfy.enabled}
|
||||
onChange={(e) => setNtfy((n) => ({ ...n, enabled: e.target.checked }))}
|
||||
/>
|
||||
<span className="form-check-label">Enable ntfy notifications</span>
|
||||
</label>
|
||||
<div className="row g-2 mb-2">
|
||||
<div className="col-8">
|
||||
<label className="form-label">Server URL</label>
|
||||
<input
|
||||
className="form-control"
|
||||
placeholder="https://ntfy.sh"
|
||||
value={ntfy.url}
|
||||
disabled={!ntfy.enabled}
|
||||
onChange={(e) => setNtfy((n) => ({ ...n, url: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-4">
|
||||
<label className="form-label">Priority (1–5)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={5}
|
||||
className="form-control"
|
||||
value={ntfy.priority}
|
||||
disabled={!ntfy.enabled}
|
||||
onChange={(e) => setNtfy((n) => ({ ...n, priority: Number(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label className="form-label">Topic</label>
|
||||
<input
|
||||
className="form-control"
|
||||
placeholder="your-topic"
|
||||
value={ntfy.topic}
|
||||
disabled={!ntfy.enabled}
|
||||
onChange={(e) => setNtfy((n) => ({ ...n, topic: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label className="form-label">Access token (optional, for protected topics)</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
value={ntfy.token}
|
||||
disabled={!ntfy.enabled}
|
||||
onChange={(e) => setNtfy((n) => ({ ...n, token: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<TestBadge result={testNtfyResult} />
|
||||
</div>
|
||||
<div className="card-footer">
|
||||
<button
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={handleTestNtfy}
|
||||
disabled={testingNtfy || !ntfy.url || !ntfy.topic}
|
||||
>
|
||||
{testingNtfy ? "Sending…" : "Send test"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-md-6">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Email (SMTP)</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<p className="text-secondary">Send notifications via email using any SMTP server.</p>
|
||||
<label className="form-check form-check-single mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
checked={smtp.enabled}
|
||||
onChange={(e) => setSmtp((s) => ({ ...s, enabled: e.target.checked }))}
|
||||
/>
|
||||
<span className="form-check-label">Enable SMTP notifications</span>
|
||||
</label>
|
||||
<div className="row g-2 mb-2">
|
||||
<div className="col-8">
|
||||
<label className="form-label">SMTP host</label>
|
||||
<input
|
||||
className="form-control"
|
||||
placeholder="smtp.example.com"
|
||||
value={smtp.host}
|
||||
disabled={!smtp.enabled}
|
||||
onChange={(e) => setSmtp((s) => ({ ...s, host: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-4">
|
||||
<label className="form-label">Port</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
placeholder="587"
|
||||
value={smtp.port}
|
||||
disabled={!smtp.enabled}
|
||||
onChange={(e) => setSmtp((s) => ({ ...s, port: Number(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label className="form-check form-check-single mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
checked={smtp.secure}
|
||||
disabled={!smtp.enabled}
|
||||
onChange={(e) => setSmtp((s) => ({ ...s, secure: e.target.checked }))}
|
||||
/>
|
||||
<span className="form-check-label">Use TLS/SSL (port 465)</span>
|
||||
</label>
|
||||
<div className="row g-2 mb-2">
|
||||
<div className="col-6">
|
||||
<label className="form-label">Username (optional)</label>
|
||||
<input
|
||||
className="form-control"
|
||||
value={smtp.username}
|
||||
disabled={!smtp.enabled}
|
||||
onChange={(e) => setSmtp((s) => ({ ...s, username: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-6">
|
||||
<label className="form-label">Password (optional)</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
value={smtp.password}
|
||||
disabled={!smtp.enabled}
|
||||
onChange={(e) => setSmtp((s) => ({ ...s, password: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row g-2 mb-2">
|
||||
<div className="col-6">
|
||||
<label className="form-label">From</label>
|
||||
<input
|
||||
type="email"
|
||||
className="form-control"
|
||||
placeholder="homelab@example.com"
|
||||
value={smtp.from}
|
||||
disabled={!smtp.enabled}
|
||||
onChange={(e) => setSmtp((s) => ({ ...s, from: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-6">
|
||||
<label className="form-label">To</label>
|
||||
<input
|
||||
type="email"
|
||||
className="form-control"
|
||||
placeholder="you@example.com"
|
||||
value={smtp.to}
|
||||
disabled={!smtp.enabled}
|
||||
onChange={(e) => setSmtp((s) => ({ ...s, to: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TestBadge result={testSmtpResult} />
|
||||
</div>
|
||||
<div className="card-footer">
|
||||
<button
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={handleTestSmtp}
|
||||
disabled={testingSmtp || !smtp.host || !smtp.from || !smtp.to}
|
||||
>
|
||||
{testingSmtp ? "Sending…" : "Send test"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-md-6">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Webhook</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<p className="text-secondary">
|
||||
POST a JSON payload to any HTTP endpoint. Uses Discord/Slack-compatible <code>{"{ \"content\": \"…\" }"}</code> format.
|
||||
</p>
|
||||
<label className="form-check form-check-single mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
checked={webhook.enabled}
|
||||
onChange={(e) => setWebhook((w) => ({ ...w, enabled: e.target.checked }))}
|
||||
/>
|
||||
<span className="form-check-label">Enable webhook notifications</span>
|
||||
</label>
|
||||
<div className="mb-2">
|
||||
<label className="form-label">Webhook URL</label>
|
||||
<input
|
||||
type="url"
|
||||
className="form-control"
|
||||
placeholder="https://discord.com/api/webhooks/…"
|
||||
value={webhook.url}
|
||||
disabled={!webhook.enabled}
|
||||
onChange={(e) => setWebhook((w) => ({ ...w, url: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label className="form-label">Secret (optional, sent as X-Webhook-Secret header)</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
value={webhook.secret}
|
||||
disabled={!webhook.enabled}
|
||||
onChange={(e) => setWebhook((w) => ({ ...w, secret: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<TestBadge result={testWebhookResult} />
|
||||
</div>
|
||||
<div className="card-footer">
|
||||
<button className="btn btn-outline-secondary" onClick={handleTestWebhook} disabled={testingWebhook || !webhook.url}>
|
||||
{testingWebhook ? "Sending…" : "Send test"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-md-6">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Notify on</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<p className="text-secondary">
|
||||
Controls which events trigger a notification on every enabled channel above.
|
||||
</p>
|
||||
{[
|
||||
{ key: "dnsAdd" as const, label: "DNS record added" },
|
||||
{ key: "dnsUpdate" as const, label: "DNS record updated" },
|
||||
{ key: "dnsDelete" as const, label: "DNS record deleted" },
|
||||
{ key: "secretCheck" as const, label: "Secret expiry reminder" },
|
||||
].map(({ key, label }) => (
|
||||
<label key={key} className="form-check form-check-single mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
checked={notifications[key]}
|
||||
onChange={(e) => setNotifications((n) => ({ ...n, [key]: e.target.checked }))}
|
||||
/>
|
||||
<span className="form-check-label">{label}</span>
|
||||
</label>
|
||||
))}
|
||||
<div className="row g-2 mt-2">
|
||||
<div className="col-6">
|
||||
<label className="form-label">Daily reminder time</label>
|
||||
<input
|
||||
type="time"
|
||||
className="form-control"
|
||||
value={notifications.secretCheckTime}
|
||||
disabled={!notifications.secretCheck}
|
||||
onChange={(e) => setNotifications((n) => ({ ...n, secretCheckTime: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-6">
|
||||
<label className="form-label">Timezone</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={notifications.timezone}
|
||||
disabled={!notifications.secretCheck}
|
||||
onChange={(e) => setNotifications((n) => ({ ...n, timezone: e.target.value }))}
|
||||
>
|
||||
{TIMEZONES.map((tz) => (
|
||||
<option key={tz} value={tz}>
|
||||
{tz}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user