Files
Homelab-manager/web/src/pages/settings/NotificationSettings.tsx
T
bobbanandClaude Sonnet 5 82ed25b63f Fix checkbox/label spacing on Notifications and other form checkboxes
Tabler's form-check-single modifier zeroes margin on .form-check-input,
which cancels the -2rem margin-inline-start the base .form-check rule
relies on to offset the checkbox into its label's padding gutter. That
left checkboxes floating almost flush against their label text.

Removed the form-check-single class from all 9 affected checkboxes:
6 in NotificationSettings, 1 each in DnsProviderForm, IntegrationForm,
and IntegrationEditForm.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 01:43:45 +02:00

543 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import {
api,
type AppSettings,
type GotifySettings,
type NtfySettings,
type SmtpSettings,
type WebhookSettings,
type NotificationEvents,
} from "../../api/client";
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,
tailscaleKeyCheck: 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 NotificationSettings() {
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 [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);
})
.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 });
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">
<h3 className="mb-0">Notifications</h3>
<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 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 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 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 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 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" },
{ key: "tailscaleKeyCheck" as const, label: "Tailscale key expiry reminder" },
].map(({ key, label }) => (
<label key={key} className="form-check 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>
))}
{(() => {
const dailyChecksEnabled = notifications.secretCheck || notifications.tailscaleKeyCheck;
return (
<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={!dailyChecksEnabled}
onChange={(e) => setNotifications((n) => ({ ...n, secretCheckTime: e.target.value }))}
/>
<div className="form-hint">Shared by the secret and Tailscale key expiry reminders above.</div>
</div>
<div className="col-6">
<label className="form-label">Timezone</label>
<select
className="form-select"
value={notifications.timezone}
disabled={!dailyChecksEnabled}
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>
</>
);
}