diff --git a/Overlord-Server/public/assets/cert-banner.js b/Overlord-Server/public/assets/cert-banner.js new file mode 100644 index 0000000..c50c8d2 --- /dev/null +++ b/Overlord-Server/public/assets/cert-banner.js @@ -0,0 +1,124 @@ +const DISMISS_KEY = "overlord_cert_banner_dismissed"; + + +export async function showCertBannerIfNeeded(anchor) { + if (sessionStorage.getItem(DISMISS_KEY) === "1") return; + if (localStorage.getItem(DISMISS_KEY) === "1") return; + + let source; + try { + const res = await fetch("/api/cert/info"); + if (!res.ok) return; + const data = await res.json(); + source = data.source; + } catch { + return; + } + + if (source !== "self-signed") return; + + if ("serviceWorker" in navigator && window.isSecureContext) { + try { + const reg = await navigator.serviceWorker.getRegistration("/"); + if (reg && reg.active) { + return; + } + } catch { } + } + + const os = detectOS(); + + const banner = document.createElement("div"); + banner.id = "cert-trust-banner"; + banner.className = + "mx-4 mt-3 mb-1 flex items-start gap-3 rounded-lg border border-amber-500/30 " + + "bg-amber-950/60 px-4 py-3 text-sm text-amber-200 shadow-lg"; + + banner.innerHTML = ` + ⚠️ +
+

Untrusted self-signed certificate

+

+ This server is using a self-signed TLS certificate. Your browser doesn't + trust it, which may block some features (desktop notifications, service workers). + Download the certificate and add it to your OS / browser trust store to fix this. +

+
+ + + Download Certificate + + +
+ +
+ + `; + + anchor.insertAdjacentElement("afterend", banner); + + document.getElementById("cert-banner-dismiss")?.addEventListener("click", () => { + banner.remove(); + localStorage.setItem(DISMISS_KEY, "1"); + sessionStorage.setItem(DISMISS_KEY, "1"); + }); + + document.getElementById("cert-banner-how")?.addEventListener("click", () => { + const el = document.getElementById("cert-banner-instructions"); + if (el) el.classList.toggle("hidden"); + }); +} + +function detectOS() { + const ua = navigator.userAgent.toLowerCase(); + if (ua.includes("win")) { + return { + label: "Windows", + instructions: +`1. Double-click the downloaded overlord-ca.crt file. +2. Click Install Certificate... +3. Choose Current User or Local Machine. +4. Select "Place all certificates in the following store". +5. Click Browse → choose Trusted Root Certification Authorities. +6. Click Next → Finish. +7. Restart your browser for the change to take effect.`, + }; + } + if (ua.includes("mac")) { + return { + label: "macOS", + instructions: +`1. Double-click the downloaded overlord-ca.crt file — it opens in Keychain Access. +2. If prompted, add it to the login or System keychain. +3. Find the certificate in the list, double-click it. +4. Expand Trust and set "When using this certificate" to Always Trust. +5. Close the dialog (you'll be prompted for your password). +6. Restart your browser.`, + }; + } + return { + label: "Linux", + instructions: +`Debian / Ubuntu: + sudo cp overlord-ca.crt /usr/local/share/ca-certificates/overlord-ca.crt + sudo update-ca-certificates + +Fedora / RHEL: + sudo cp overlord-ca.crt /etc/pki/ca-trust/source/anchors/overlord-ca.crt + sudo update-ca-trust + +Then restart your browser. +For Chrome/Chromium you may also need: Settings → Privacy → Manage Certificates → Authorities → Import.`, + }; +} diff --git a/Overlord-Server/public/assets/nav.js b/Overlord-Server/public/assets/nav.js index 1fda5de..9db86f5 100644 --- a/Overlord-Server/public/assets/nav.js +++ b/Overlord-Server/public/assets/nav.js @@ -8,10 +8,12 @@ import { import { mountNav } from "./nav/template.js"; import { createAdaptiveNavController } from "./nav/layout.js"; import { applyUserRoleUI } from "./nav/role-ui.js"; +import { showCertBannerIfNeeded } from "./cert-banner.js"; const host = document.getElementById("top-nav"); if (host) { const refs = mountNav(host); + showCertBannerIfNeeded(host); const { applyAdaptiveNavLayout } = createAdaptiveNavController(host, refs); const path = window.location.pathname; diff --git a/Overlord-Server/public/assets/notification-sw.js b/Overlord-Server/public/assets/notification-sw.js new file mode 100644 index 0000000..32648a1 --- /dev/null +++ b/Overlord-Server/public/assets/notification-sw.js @@ -0,0 +1,48 @@ +self.addEventListener("install", () => self.skipWaiting()); +self.addEventListener("activate", (event) => { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + + const targetUrl = (event.notification.data && event.notification.data.url) + ? event.notification.data.url + : "/notifications"; + + event.waitUntil( + self.clients + .matchAll({ type: "window", includeUncontrolled: true }) + .then((clientList) => { + for (const client of clientList) { + try { + const clientUrl = new URL(client.url); + if (clientUrl.origin === self.location.origin) { + if (clientUrl.pathname !== "/notifications") { + client.navigate(targetUrl); + } + return client.focus(); + } + } catch {} + } + return self.clients.openWindow(targetUrl); + }), + ); +}); + +self.addEventListener("message", (event) => { + const data = event.data; + if (!data || data.type !== "show_notification") return; + + const title = String(data.title || "Overlord Notification"); + const options = { + body: String(data.body || ""), + icon: data.icon || "/assets/overlord.png", + badge: "/assets/overlord.png", + tag: data.tag || `overlord-${Date.now()}`, + data: { url: "/notifications" }, + requireInteraction: false, + }; + + event.waitUntil(self.registration.showNotification(title, options)); +}); diff --git a/Overlord-Server/public/assets/notifications.js b/Overlord-Server/public/assets/notifications.js index ee48ea9..1361535 100644 --- a/Overlord-Server/public/assets/notifications.js +++ b/Overlord-Server/public/assets/notifications.js @@ -1,9 +1,12 @@ import { startNotificationClient, subscribeNotifications, + subscribeClientEvents, subscribeReady, subscribeStatus, markAllNotificationsRead, + getClientEventNotificationEnabled, + setClientEventNotificationEnabled, } from "./notify-client.js"; // ── DOM refs ────────────────────────────────────────────────────────────────── @@ -42,6 +45,11 @@ const resetTelegramTemplateBtn = document.getElementById("reset-telegram-templat const previewTelegramTemplateBtn = document.getElementById("preview-telegram-template"); const telegramTemplatePreview = document.getElementById("telegram-template-preview"); +const eventNotifOnlineInput = document.getElementById("event-notif-online"); +const eventNotifOfflineInput = document.getElementById("event-notif-offline"); +const eventNotifPurgatoryInput = document.getElementById("event-notif-purgatory"); +const saveEventNotifsBtn = document.getElementById("save-event-notifs"); + // Preview modal const panel = document.getElementById("notification-panel"); const previewModal = document.getElementById("notification-preview-modal"); @@ -541,6 +549,35 @@ function connect() { }); subscribeNotifications((item) => renderRow(item, true)); + subscribeClientEvents((item) => renderClientEventRow(item)); +} + +const CLIENT_EVENT_BADGE = { + client_online: ` online`, + client_offline: ` offline`, + client_purgatory: ` purgatory`, +}; + +function renderClientEventRow(item) { + if (!listEl) return; + const badge = CLIENT_EVENT_BADGE[item.event] || + `${escapeHtml(item.event)}`; + const row = document.createElement("tr"); + row.className = "border-t border-slate-800/60"; + row.innerHTML = ` + ${formatTime(item.ts)} + ${escapeHtml(item.clientId || "-")} + ${escapeHtml(item.user || "-")} + client event + ${escapeHtml(item.os || "-")} + - + ${badge} + + `; + listEl.prepend(row); + const rows = listEl.querySelectorAll("tr"); + if (rows.length > MAX_ROWS) rows[rows.length - 1].remove(); + if (emptyState) emptyState.classList.add("hidden"); } // ── Preview modal ───────────────────────────────────────────────────────────── @@ -564,11 +601,30 @@ document.addEventListener("visibilitychange", clearIfActive); window.addEventListener("focus", clearIfActive); clearIfActive(); +// ── Event notification toggles ─────────────────────────────────────────────── +function loadEventNotifPrefs() { + if (eventNotifOnlineInput) eventNotifOnlineInput.checked = getClientEventNotificationEnabled("client_online"); + if (eventNotifOfflineInput) eventNotifOfflineInput.checked = getClientEventNotificationEnabled("client_offline"); + if (eventNotifPurgatoryInput) eventNotifPurgatoryInput.checked = getClientEventNotificationEnabled("client_purgatory"); +} + +function wireEventNotifSave() { + if (!saveEventNotifsBtn) return; + saveEventNotifsBtn.addEventListener("click", () => { + if (eventNotifOnlineInput) setClientEventNotificationEnabled("client_online", eventNotifOnlineInput.checked); + if (eventNotifOfflineInput) setClientEventNotificationEnabled("client_offline", eventNotifOfflineInput.checked); + if (eventNotifPurgatoryInput) setClientEventNotificationEnabled("client_purgatory", eventNotifPurgatoryInput.checked); + window.showToast?.("Event notification preferences saved", "success", 3000); + }); +} + // ── Bootstrap ───────────────────────────────────────────────────────────────── initWebhookTemplateEditor(); wireKeywordSave(); wireWebhookSave(); wireTelegramSave(); loadMySettings(); +loadEventNotifPrefs(); +wireEventNotifSave(); initRoleUi(); connect(); diff --git a/Overlord-Server/public/assets/notify-client.js b/Overlord-Server/public/assets/notify-client.js index c551e82..59a0a99 100644 --- a/Overlord-Server/public/assets/notify-client.js +++ b/Overlord-Server/public/assets/notify-client.js @@ -2,6 +2,13 @@ import { decodeMsgpack } from "./msgpack-helpers.js"; const STORAGE_KEY = "overlord_notifications_enabled"; const UNREAD_KEY = "overlord_notifications_unread"; +const DESKTOP_NOTIF_KEY = "overlord_desktop_notifications_enabled"; +const CLIENT_EVENT_KEYS = { + client_online: "overlord_desktop_notify_client_online", + client_offline: "overlord_desktop_notify_client_offline", + client_purgatory: "overlord_desktop_notify_client_purgatory", +}; + let enabled = localStorage.getItem(STORAGE_KEY); if (enabled === null) { enabled = "1"; @@ -10,12 +17,21 @@ if (enabled === null) { if (localStorage.getItem(UNREAD_KEY) === null) { localStorage.setItem(UNREAD_KEY, "0"); } + +if ( + localStorage.getItem(DESKTOP_NOTIF_KEY) === null && + typeof Notification !== "undefined" && + Notification.permission === "granted" +) { + localStorage.setItem(DESKTOP_NOTIF_KEY, "1"); +} let ws = null; let started = false; let readyHandlers = new Set(); let notificationHandlers = new Set(); let statusHandlers = new Set(); let unreadHandlers = new Set(); +let clientEventHandlers = new Set(); let lastHistory = []; function emitStatus(status) { @@ -43,6 +59,14 @@ function emitNotification(item) { } } +function emitClientEvent(item) { + for (const handler of clientEventHandlers) { + try { + handler(item); + } catch {} + } +} + function shouldNotify() { return localStorage.getItem(STORAGE_KEY) === "1"; } @@ -65,6 +89,125 @@ function incrementUnread() { setUnreadCount(getUnreadCount() + 1); } +export function getDesktopNotificationsEnabled() { + const stored = localStorage.getItem(DESKTOP_NOTIF_KEY); + if (stored === null) { + return typeof Notification !== "undefined" && Notification.permission === "granted"; + } + return stored === "1"; +} + +export function setDesktopNotificationsEnabled(value) { + localStorage.setItem(DESKTOP_NOTIF_KEY, value ? "1" : "0"); +} + +export function getClientEventNotificationEnabled(eventType) { + const key = CLIENT_EVENT_KEYS[eventType]; + if (!key) return false; + const stored = localStorage.getItem(key); + return stored === null ? true : stored === "1"; +} + +export function setClientEventNotificationEnabled(eventType, value) { + const key = CLIENT_EVENT_KEYS[eventType]; + if (!key) return; + localStorage.setItem(key, value ? "1" : "0"); +} + +export async function requestDesktopNotificationPermission() { + if (!("Notification" in window)) return "denied"; + if (Notification.permission !== "default") return Notification.permission; + try { + return await Notification.requestPermission(); + } catch { + return "denied"; + } +} + +function fireNotification(title, body, tag, clickUrl) { + if ("serviceWorker" in navigator && navigator.serviceWorker.controller) { + navigator.serviceWorker.ready + .then((reg) => + reg.showNotification(title, { + body, + icon: "/assets/overlord.png", + tag, + data: { url: clickUrl || "/notifications" }, + requireInteraction: false, + }), + ) + .catch(() => fireNotificationFallback(title, body, tag, clickUrl)); + return; + } + fireNotificationFallback(title, body, tag, clickUrl); +} + +function fireNotificationFallback(title, body, tag, clickUrl) { + try { + const n = new Notification(title, { + body, + icon: "/assets/overlord.png", + tag, + silent: false, + }); + n.onclick = () => { + window.focus(); + n.close(); + if (clickUrl && window.location.pathname !== clickUrl) { + window.location.href = clickUrl; + } + }; + } catch (err) { + console.warn("[notifications] desktop notification failed", err); + } +} + +function showDesktopNotification(item) { + if (!getDesktopNotificationsEnabled()) return; + if (!("Notification" in window)) return; + if (Notification.permission !== "granted") return; + + const title = item.keyword + ? `Overlord \u2014 ${item.keyword}` + : "Overlord \u2014 Notification"; + const lines = [item.title]; + if (item.user) lines.push(`User: ${item.user}`); + if (item.host) lines.push(`Host: ${item.host}`); + if (item.process) lines.push(`Process: ${item.process}`); + const body = lines.filter(Boolean).join("\n"); + + fireNotification(title, body, `overlord-${item.id || Date.now()}`, "/notifications"); +} + +const CLIENT_EVENT_LABELS = { + client_online: "\u{1F7E2} Client Online", + client_offline: "\u{1F534} Client Offline", + client_purgatory: "\u{1F7E1} Client Awaiting Approval", +}; + +function showClientEventDesktopNotification(item) { + if (!getDesktopNotificationsEnabled()) return; + if (!getClientEventNotificationEnabled(item.event)) return; + if (!("Notification" in window)) return; + if (Notification.permission !== "granted") return; + + const title = CLIENT_EVENT_LABELS[item.event] || "Overlord \u2014 Client Event"; + const lines = []; + if (item.host) lines.push(`Host: ${item.host}`); + if (item.user) lines.push(`User: ${item.user}`); + if (item.os) lines.push(`OS: ${item.os}`); + if (item.clientId) lines.push(`ID: ${item.clientId}`); + const body = lines.join("\n") || item.clientId || ""; + const dest = item.event === "client_purgatory" ? "/purgatory" : "/"; + + fireNotification( + title, + body, + `overlord-client-${item.event}-${item.clientId || Date.now()}`, + dest, + ); +} + function handleMessage(payload) { if (!payload || typeof payload.type !== "string") return; if (payload.type === "ready") { @@ -77,6 +220,12 @@ function handleMessage(payload) { if (shouldNotify()) { incrementUnread(); } + showDesktopNotification(payload.item); + } + if (payload.type === "client_event" && payload.event) { + console.log("[notifications] client event", payload.event, payload.clientId); + emitClientEvent(payload); + showClientEventDesktopNotification(payload); } } @@ -159,6 +308,26 @@ export async function startNotificationClient() { } catch (err) { console.warn("[notifications] failed to load msgpackr", err); } + + if ("serviceWorker" in navigator && window.isSecureContext) { + fetch("/assets/notification-sw.js") + .then((res) => { + if (!res.ok) return; + navigator.serviceWorker + .register("/assets/notification-sw.js", { scope: "/" }) + .catch(() => { }); + }) + .catch(() => {/* SW script unreachable (e.g. self-signed cert) — skip quietly */}); + } + + if (getDesktopNotificationsEnabled()) { + requestDesktopNotificationPermission().then((perm) => { + if (perm !== "granted") { + setDesktopNotificationsEnabled(false); + } + }); + } + connect(); } @@ -167,6 +336,11 @@ export function subscribeNotifications(handler) { return () => notificationHandlers.delete(handler); } +export function subscribeClientEvents(handler) { + clientEventHandlers.add(handler); + return () => clientEventHandlers.delete(handler); +} + export function subscribeUnread(handler) { unreadHandlers.add(handler); try { diff --git a/Overlord-Server/public/assets/settings.js b/Overlord-Server/public/assets/settings.js index fe12630..e8025d6 100644 --- a/Overlord-Server/public/assets/settings.js +++ b/Overlord-Server/public/assets/settings.js @@ -1,6 +1,9 @@ import { getNotificationsEnabled, setNotificationsEnabled, + getDesktopNotificationsEnabled, + setDesktopNotificationsEnabled, + requestDesktopNotificationPermission, } from "./notify-client.js"; const PREF_REFRESH_KEY = "overlord_refresh_interval_seconds"; @@ -17,6 +20,8 @@ const passwordPolicyHint = document.getElementById("password-policy-hint"); const prefsForm = document.getElementById("prefs-form"); const prefNotificationsInput = document.getElementById("pref-notifications"); +const prefDesktopNotificationsInput = document.getElementById("pref-desktop-notifications"); +const prefDesktopNotificationsHint = document.getElementById("pref-desktop-notifications-hint"); const prefRefreshSecondsInput = document.getElementById("pref-refresh-seconds"); const myTelegramChatIdInput = document.getElementById("my-telegram-chat-id"); @@ -259,6 +264,9 @@ async function loadTlsSettings() { function loadPrefs() { prefNotificationsInput.checked = getNotificationsEnabled(); + if (prefDesktopNotificationsInput) { + prefDesktopNotificationsInput.checked = getDesktopNotificationsEnabled(); + } const refreshSeconds = Number(localStorage.getItem(PREF_REFRESH_KEY) || 8); prefRefreshSecondsInput.value = String(Math.min(120, Math.max(3, refreshSeconds))); } @@ -325,7 +333,28 @@ function savePrefs(event) { localStorage.setItem(PREF_REFRESH_KEY, String(refreshSeconds)); prefRefreshSecondsInput.value = String(refreshSeconds); - showMessage("Preferences saved."); + const wantsDesktop = prefDesktopNotificationsInput + ? prefDesktopNotificationsInput.checked + : false; + + if (wantsDesktop) { + requestDesktopNotificationPermission().then((perm) => { + if (perm === "granted") { + setDesktopNotificationsEnabled(true); + if (prefDesktopNotificationsHint) prefDesktopNotificationsHint.classList.add("hidden"); + showMessage("Preferences saved. Desktop notifications enabled."); + } else { + setDesktopNotificationsEnabled(false); + if (prefDesktopNotificationsInput) prefDesktopNotificationsInput.checked = false; + if (prefDesktopNotificationsHint) prefDesktopNotificationsHint.classList.remove("hidden"); + showMessage("Desktop notifications require browser permission — not granted.", "error"); + } + }); + } else { + setDesktopNotificationsEnabled(false); + if (prefDesktopNotificationsHint) prefDesktopNotificationsHint.classList.add("hidden"); + showMessage("Preferences saved."); + } } async function saveSecurityPolicy(event) { diff --git a/Overlord-Server/public/notifications.html b/Overlord-Server/public/notifications.html index 348992c..13dbf5f 100644 --- a/Overlord-Server/public/notifications.html +++ b/Overlord-Server/public/notifications.html @@ -251,6 +251,48 @@ + +
+
+
+

Desktop Event Notifications

+

+ Choose which client events trigger a desktop (OS) notification. Desktop notifications must be enabled in Settings first. +

+
+ +
+
+ + + +
+
+
Enable notification badge updates + +