new notification system
This commit is contained in:
@@ -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 = `
|
||||
<span class="mt-0.5 text-lg select-none">⚠️</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-semibold text-amber-100 mb-1">Untrusted self-signed certificate</p>
|
||||
<p class="mb-2 text-amber-200/80 leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<a href="/api/cert/download"
|
||||
download="overlord-ca.crt"
|
||||
class="inline-flex items-center gap-1.5 rounded bg-amber-600 hover:bg-amber-500
|
||||
px-3 py-1 text-xs font-medium text-white transition-colors">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2"
|
||||
viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5 5m0 0l5-5m-5 5V3"/></svg>
|
||||
Download Certificate
|
||||
</a>
|
||||
<button id="cert-banner-how" type="button"
|
||||
class="text-xs text-amber-300 underline underline-offset-2 hover:text-amber-100 transition-colors">
|
||||
How to install (${os.label})
|
||||
</button>
|
||||
</div>
|
||||
<div id="cert-banner-instructions" class="hidden mt-2 rounded bg-slate-900/80
|
||||
border border-slate-700/50 p-3 text-xs text-slate-300 leading-relaxed whitespace-pre-line">${os.instructions}</div>
|
||||
</div>
|
||||
<button id="cert-banner-dismiss" type="button"
|
||||
class="ml-2 mt-0.5 text-amber-400 hover:text-amber-200 transition-colors"
|
||||
aria-label="Dismiss">×</button>
|
||||
`;
|
||||
|
||||
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 <strong>overlord-ca.crt</strong> file.
|
||||
2. Click <strong>Install Certificate...</strong>
|
||||
3. Choose <strong>Current User</strong> or <strong>Local Machine</strong>.
|
||||
4. Select <strong>"Place all certificates in the following store"</strong>.
|
||||
5. Click <strong>Browse</strong> → choose <strong>Trusted Root Certification Authorities</strong>.
|
||||
6. Click <strong>Next</strong> → <strong>Finish</strong>.
|
||||
7. <strong>Restart your browser</strong> for the change to take effect.`,
|
||||
};
|
||||
}
|
||||
if (ua.includes("mac")) {
|
||||
return {
|
||||
label: "macOS",
|
||||
instructions:
|
||||
`1. Double-click the downloaded <strong>overlord-ca.crt</strong> file — it opens in Keychain Access.
|
||||
2. If prompted, add it to the <strong>login</strong> or <strong>System</strong> keychain.
|
||||
3. Find the certificate in the list, double-click it.
|
||||
4. Expand <strong>Trust</strong> and set <strong>"When using this certificate"</strong> to <strong>Always Trust</strong>.
|
||||
5. Close the dialog (you'll be prompted for your password).
|
||||
6. <strong>Restart your browser</strong>.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: "Linux",
|
||||
instructions:
|
||||
`<strong>Debian / Ubuntu:</strong>
|
||||
sudo cp overlord-ca.crt /usr/local/share/ca-certificates/overlord-ca.crt
|
||||
sudo update-ca-certificates
|
||||
|
||||
<strong>Fedora / RHEL:</strong>
|
||||
sudo cp overlord-ca.crt /etc/pki/ca-trust/source/anchors/overlord-ca.crt
|
||||
sudo update-ca-trust
|
||||
|
||||
Then <strong>restart your browser</strong>.
|
||||
For Chrome/Chromium you may also need: Settings → Privacy → Manage Certificates → Authorities → Import.`,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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: `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-emerald-900/60 text-emerald-300 border border-emerald-700/50"><i class="fa-solid fa-circle text-xs"></i> online</span>`,
|
||||
client_offline: `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-rose-900/60 text-rose-300 border border-rose-700/50"><i class="fa-solid fa-circle text-xs"></i> offline</span>`,
|
||||
client_purgatory: `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-amber-900/60 text-amber-300 border border-amber-700/50"><i class="fa-solid fa-hourglass-half text-xs"></i> purgatory</span>`,
|
||||
};
|
||||
|
||||
function renderClientEventRow(item) {
|
||||
if (!listEl) return;
|
||||
const badge = CLIENT_EVENT_BADGE[item.event] ||
|
||||
`<span class="text-xs text-slate-400">${escapeHtml(item.event)}</span>`;
|
||||
const row = document.createElement("tr");
|
||||
row.className = "border-t border-slate-800/60";
|
||||
row.innerHTML = `
|
||||
<td class="py-2 pr-4 whitespace-nowrap text-slate-400">${formatTime(item.ts)}</td>
|
||||
<td class="py-2 pr-4 whitespace-nowrap">${escapeHtml(item.clientId || "-")}</td>
|
||||
<td class="py-2 pr-4 whitespace-nowrap">${escapeHtml(item.user || "-")}</td>
|
||||
<td class="py-2 pr-4 max-w-xl truncate italic text-slate-400">client event</td>
|
||||
<td class="py-2 pr-4 whitespace-nowrap">${escapeHtml(item.os || "-")}</td>
|
||||
<td class="py-2 pr-4 whitespace-nowrap">-</td>
|
||||
<td class="py-2 pr-4 whitespace-nowrap">${badge}</td>
|
||||
<td class="py-2 pr-4"></td>
|
||||
`;
|
||||
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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -251,6 +251,48 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop Event Notifications -->
|
||||
<div class="bg-slate-900/60 border border-slate-800 rounded-xl p-4">
|
||||
<div class="flex items-center justify-between flex-wrap gap-3 mb-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Desktop Event Notifications</h2>
|
||||
<p class="text-sm text-slate-400">
|
||||
Choose which client events trigger a desktop (OS) notification. Desktop notifications must be enabled in <a href="/settings" class="text-sky-400 hover:underline">Settings</a> first.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
id="save-event-notifs"
|
||||
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
>
|
||||
<i class="fa-solid fa-save"></i>
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
|
||||
<label class="flex items-center gap-3 rounded-lg bg-slate-950 border border-slate-800 px-4 py-3 cursor-pointer hover:border-slate-600 transition-colors">
|
||||
<input id="event-notif-online" type="checkbox" class="h-4 w-4 accent-emerald-500 shrink-0" />
|
||||
<span class="flex flex-col gap-0.5">
|
||||
<span class="text-slate-200 font-medium">🟢 Client Online</span>
|
||||
<span class="text-slate-500 text-xs">Fire when a client connects</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-3 rounded-lg bg-slate-950 border border-slate-800 px-4 py-3 cursor-pointer hover:border-slate-600 transition-colors">
|
||||
<input id="event-notif-offline" type="checkbox" class="h-4 w-4 accent-red-500 shrink-0" />
|
||||
<span class="flex flex-col gap-0.5">
|
||||
<span class="text-slate-200 font-medium">🔴 Client Offline</span>
|
||||
<span class="text-slate-500 text-xs">Fire when a client disconnects</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-3 rounded-lg bg-slate-950 border border-slate-800 px-4 py-3 cursor-pointer hover:border-slate-600 transition-colors">
|
||||
<input id="event-notif-purgatory" type="checkbox" class="h-4 w-4 accent-yellow-500 shrink-0" />
|
||||
<span class="flex flex-col gap-0.5">
|
||||
<span class="text-slate-200 font-medium">🟡 Client in Purgatory</span>
|
||||
<span class="text-slate-500 text-xs">Fire when a client awaits approval</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification feed -->
|
||||
<div
|
||||
id="notification-panel"
|
||||
|
||||
@@ -86,6 +86,14 @@
|
||||
<input id="pref-notifications" type="checkbox" class="h-4 w-4" />
|
||||
<span>Enable notification badge updates</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input id="pref-desktop-notifications" type="checkbox" class="h-4 w-4 accent-emerald-500" />
|
||||
<span>Enable desktop (OS) notifications</span>
|
||||
</label>
|
||||
<p id="pref-desktop-notifications-hint" class="hidden text-xs text-amber-400">
|
||||
<i class="fa-solid fa-triangle-exclamation mr-1"></i>
|
||||
Browser permission was denied. Reset notification permissions for this site in your browser settings, then try again.
|
||||
</p>
|
||||
<label class="block">
|
||||
<span class="text-sm text-slate-300">Client refresh interval (seconds)</span>
|
||||
<input
|
||||
|
||||
@@ -358,6 +358,8 @@ async function startServer() {
|
||||
getRdSessionCount: sessionManager.getRdSessionCount,
|
||||
getFileBrowserSessionCount: sessionManager.getFileBrowserSessionCount,
|
||||
getProcessSessionCount: sessionManager.getProcessSessionCount,
|
||||
tlsCertPath: tls?.certPathUsed,
|
||||
tlsSource: tls?.source,
|
||||
},
|
||||
assets: {
|
||||
PUBLIC_ROOT,
|
||||
@@ -471,6 +473,7 @@ async function startServer() {
|
||||
notifyRemoteDesktopStatus,
|
||||
handleBuildTagConnection,
|
||||
notifyDashboard: sessionManager.notifyDashboardViewers,
|
||||
broadcastClientEvent: notificationPluginHandlers.broadcastClientLifecycleEvent,
|
||||
};
|
||||
|
||||
const server = Bun.serve<SocketData>({
|
||||
|
||||
@@ -5,7 +5,7 @@ export const CORS_HEADERS = {
|
||||
};
|
||||
|
||||
export const SECURITY_HEADERS = {
|
||||
"Content-Security-Policy": "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://unpkg.com; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://fonts.googleapis.com; img-src 'self' data: blob: https://cdn.jsdelivr.net https://a.tile.openstreetmap.org https://b.tile.openstreetmap.org https://c.tile.openstreetmap.org https://tile.openstreetmap.org https://a.basemaps.cartocdn.com https://b.basemaps.cartocdn.com https://c.basemaps.cartocdn.com https://d.basemaps.cartocdn.com; font-src 'self' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://fonts.gstatic.com; connect-src 'self' wss: ws: https://cdn.jsdelivr.net",
|
||||
"Content-Security-Policy": "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://unpkg.com; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://fonts.googleapis.com; img-src 'self' data: blob: https://cdn.jsdelivr.net https://a.tile.openstreetmap.org https://b.tile.openstreetmap.org https://c.tile.openstreetmap.org https://tile.openstreetmap.org https://a.basemaps.cartocdn.com https://b.basemaps.cartocdn.com https://c.basemaps.cartocdn.com https://d.basemaps.cartocdn.com; font-src 'self' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://fonts.gstatic.com; connect-src 'self' wss: ws: https://cdn.jsdelivr.net",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-XSS-Protection": "1; mode=block",
|
||||
|
||||
@@ -45,7 +45,11 @@ export async function handleAssetsRoutes(
|
||||
|
||||
const file = Bun.file(resolvedPath);
|
||||
if (await file.exists()) {
|
||||
return new Response(file, { headers: deps.secureHeaders(deps.mimeType(url.pathname)) });
|
||||
const headers: Record<string, string> = { ...deps.secureHeaders(deps.mimeType(url.pathname)) };
|
||||
if (relativePath === "notification-sw.js") {
|
||||
headers["Service-Worker-Allowed"] = "/";
|
||||
}
|
||||
return new Response(file, { headers });
|
||||
}
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ type MiscRouteDeps = {
|
||||
getRdSessionCount: () => number;
|
||||
getFileBrowserSessionCount: () => number;
|
||||
getProcessSessionCount: () => number;
|
||||
tlsCertPath?: string;
|
||||
tlsSource?: "certbot" | "configured" | "self-signed";
|
||||
};
|
||||
|
||||
export async function handleMiscRoutes(
|
||||
@@ -388,5 +390,28 @@ export async function handleMiscRoutes(
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/cert/info") {
|
||||
return Response.json(
|
||||
{ source: deps.tlsSource || "unknown" },
|
||||
{ headers: { "Content-Type": "application/json", ...deps.CORS_HEADERS } },
|
||||
);
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/cert/download" && deps.tlsCertPath) {
|
||||
try {
|
||||
const file = Bun.file(deps.tlsCertPath);
|
||||
if (await file.exists()) {
|
||||
return new Response(file, {
|
||||
headers: {
|
||||
"Content-Type": "application/x-pem-file",
|
||||
"Content-Disposition": 'attachment; filename="overlord-ca.crt"',
|
||||
...deps.CORS_HEADERS,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch { }
|
||||
return new Response("Certificate not available", { status: 404 });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -86,6 +86,10 @@ type WsLifecycleDeps = {
|
||||
notifyRemoteDesktopStatus: (clientId: string, status: string, reason?: string) => void;
|
||||
handleBuildTagConnection: (clientId: string, buildTag: string) => void;
|
||||
notifyDashboard: () => void;
|
||||
broadcastClientEvent: (
|
||||
event: "client_online" | "client_offline" | "client_purgatory",
|
||||
info: { id: string; host?: string; user?: string; os?: string; ip?: string; country?: string },
|
||||
) => void;
|
||||
};
|
||||
|
||||
const ENROLLMENT_TIMEOUT_MS = 30_000;
|
||||
@@ -296,6 +300,14 @@ export async function handleWebSocketMessage(
|
||||
logger.info(`[purgatory] client ${resolvedId} is pending approval`);
|
||||
ws.send(encodeMessage({ type: "enrollment_status", status: "pending" }));
|
||||
deps.notifyDashboard();
|
||||
deps.broadcastClientEvent("client_purgatory", {
|
||||
id: resolvedId,
|
||||
host: (payload as any).host || undefined,
|
||||
user: (payload as any).user || undefined,
|
||||
os: (payload as any).os || undefined,
|
||||
ip: ip || undefined,
|
||||
country,
|
||||
});
|
||||
try { ws.close(4001, "pending"); } catch {}
|
||||
return;
|
||||
}
|
||||
@@ -349,8 +361,14 @@ export async function handleWebSocketMessage(
|
||||
clientManager.addClient(infoObj.id, infoObj);
|
||||
|
||||
deps.dispatchAutoScriptsForConnection(infoObj, ws);
|
||||
deps.notifyDashboard();
|
||||
|
||||
deps.notifyDashboard(); deps.broadcastClientEvent("client_online", {
|
||||
id: infoObj.id,
|
||||
host: infoObj.host,
|
||||
user: infoObj.user,
|
||||
os: infoObj.os,
|
||||
ip: infoObj.ip,
|
||||
country: infoObj.country,
|
||||
});
|
||||
if (infoObj.role === "client") {
|
||||
deps.notifyRemoteDesktopStatus(resolvedId, "online");
|
||||
metrics.recordConnection();
|
||||
@@ -642,6 +660,17 @@ export function handleWebSocketClose(
|
||||
return;
|
||||
}
|
||||
|
||||
if (role === "client" && currentClient) {
|
||||
deps.broadcastClientEvent("client_offline", {
|
||||
id: clientId,
|
||||
host: currentClient.host,
|
||||
user: currentClient.user,
|
||||
os: currentClient.os,
|
||||
ip: currentClient.ip,
|
||||
country: currentClient.country,
|
||||
});
|
||||
}
|
||||
|
||||
clientManager.deleteClient(clientId);
|
||||
stopAllProxiesForClient(clientId);
|
||||
clearClientSyncState(clientId);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { certificatesExist, generateSelfSignedCert, getLocalIPs, isOpenSSLAvailable } from "../certGenerator";
|
||||
import { logger } from "../logger";
|
||||
import { X509Certificate } from "crypto";
|
||||
import path from "path";
|
||||
|
||||
type TlsBootstrapParams = {
|
||||
@@ -119,6 +120,15 @@ export async function prepareTlsOptions(params: TlsBootstrapParams): Promise<Tls
|
||||
}
|
||||
}
|
||||
|
||||
if (source === "configured" && tlsOptions.cert) {
|
||||
try {
|
||||
const x509 = new X509Certificate(tlsOptions.cert);
|
||||
if (x509.issuer === x509.subject) {
|
||||
source = "self-signed";
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
return {
|
||||
tlsOptions,
|
||||
certPathUsed: params.certPath,
|
||||
|
||||
@@ -304,6 +304,27 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
|
||||
}
|
||||
},
|
||||
|
||||
broadcastClientLifecycleEvent(
|
||||
event: "client_online" | "client_offline" | "client_purgatory",
|
||||
info: { id: string; host?: string; user?: string; os?: string; ip?: string; country?: string },
|
||||
) {
|
||||
const item = { type: "client_event", event, clientId: info.id, host: info.host, user: info.user, os: info.os, ip: info.ip, country: info.country, ts: Date.now() };
|
||||
for (const session of sessionManager.getAllNotificationSessions().values()) {
|
||||
const sRole = session.userRole ?? session.viewer.data.userRole ?? "";
|
||||
const sUserId = session.userId ?? session.viewer.data.userId;
|
||||
if (event === "client_purgatory") {
|
||||
if (sRole !== "admin" && sRole !== "operator") continue;
|
||||
} else {
|
||||
if (sRole !== "admin") {
|
||||
if (sUserId === undefined || !deps.canUserAccessClient(sUserId, sRole, info.id)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
safeSendViewer(session.viewer, item);
|
||||
}
|
||||
},
|
||||
|
||||
markPluginLoaded,
|
||||
|
||||
isPluginLoaded(clientId: string, pluginId: string): boolean {
|
||||
|
||||
Reference in New Issue
Block a user