better notification system
This commit is contained in:
@@ -6,19 +6,42 @@ import {
|
||||
markAllNotificationsRead,
|
||||
} from "./notify-client.js";
|
||||
|
||||
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
||||
const wsStatus = document.getElementById("ws-status");
|
||||
const listEl = document.getElementById("notification-list");
|
||||
const emptyState = document.getElementById("empty-state");
|
||||
const notificationScopeHint = document.getElementById("notification-scope-hint");
|
||||
|
||||
// Keyword section
|
||||
const keywordSection = document.getElementById("keyword-section");
|
||||
const keywordInput = document.getElementById("keyword-input");
|
||||
const saveKeywordsBtn = document.getElementById("save-keywords");
|
||||
const keywordHint = document.getElementById("keyword-hint");
|
||||
|
||||
// Webhook section
|
||||
const webhookEnabledInput = document.getElementById("webhook-enabled");
|
||||
const webhookUrlInput = document.getElementById("webhook-url");
|
||||
const webhookTemplateInput = document.getElementById("webhook-template");
|
||||
const saveWebhookBtn = document.getElementById("save-webhook");
|
||||
const webhookUrlToggle = document.getElementById("webhook-url-toggle");
|
||||
const resetWebhookTemplateBtn = document.getElementById("reset-webhook-template");
|
||||
const formatWebhookTemplateBtn = document.getElementById("format-webhook-template");
|
||||
const previewWebhookTemplateBtn = document.getElementById("preview-webhook-template");
|
||||
const webhookTemplatePreview = document.getElementById("webhook-template-preview");
|
||||
|
||||
// Telegram section
|
||||
const telegramEnabledInput = document.getElementById("telegram-enabled");
|
||||
const telegramBotTokenInput = document.getElementById("telegram-bot-token");
|
||||
const telegramChatIdInput = document.getElementById("telegram-chat-id");
|
||||
const telegramTemplateInput = document.getElementById("telegram-template");
|
||||
const saveTelegramBtn = document.getElementById("save-telegram");
|
||||
const telegramTokenToggle = document.getElementById("telegram-token-toggle");
|
||||
const telegramChatidToggle = document.getElementById("telegram-chatid-toggle");
|
||||
const resetTelegramTemplateBtn = document.getElementById("reset-telegram-template");
|
||||
const previewTelegramTemplateBtn = document.getElementById("preview-telegram-template");
|
||||
const telegramTemplatePreview = document.getElementById("telegram-template-preview");
|
||||
|
||||
// Preview modal
|
||||
const panel = document.getElementById("notification-panel");
|
||||
const previewModal = document.getElementById("notification-preview-modal");
|
||||
const previewModalImg = document.getElementById("notification-preview-image");
|
||||
@@ -26,6 +49,87 @@ const previewModalClose = document.getElementById("notification-preview-close");
|
||||
|
||||
const MAX_ROWS = 200;
|
||||
|
||||
// Defaults loaded from /api/notifications/my-settings
|
||||
let defaultWebhookTemplate = "";
|
||||
let defaultTelegramTemplate = "";
|
||||
let webhookTemplateEditor = null;
|
||||
|
||||
function tryFormatJsonText(text) {
|
||||
const source = String(text || "").trim();
|
||||
if (!source) return "";
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(source), null, 2);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function initWebhookTemplateEditor() {
|
||||
if (!webhookTemplateInput || typeof window.ace === "undefined") return;
|
||||
try {
|
||||
webhookTemplateEditor = window.ace.edit("webhook-template");
|
||||
webhookTemplateEditor.setTheme("ace/theme/tomorrow_night");
|
||||
webhookTemplateEditor.session.setMode("ace/mode/json");
|
||||
webhookTemplateEditor.session.setUseWrapMode(true);
|
||||
webhookTemplateEditor.setShowPrintMargin(false);
|
||||
webhookTemplateEditor.setOption("fontSize", "12px");
|
||||
webhookTemplateEditor.setOption("tabSize", 2);
|
||||
webhookTemplateEditor.setOption("useSoftTabs", true);
|
||||
webhookTemplateEditor.on("blur", () => {
|
||||
const formatted = tryFormatJsonText(webhookTemplateEditor.getValue());
|
||||
if (formatted !== null && formatted !== webhookTemplateEditor.getValue()) {
|
||||
const position = webhookTemplateEditor.getCursorPosition();
|
||||
webhookTemplateEditor.setValue(formatted, -1);
|
||||
webhookTemplateEditor.moveCursorToPosition(position);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
webhookTemplateEditor = null;
|
||||
}
|
||||
}
|
||||
|
||||
function getWebhookTemplateValue() {
|
||||
if (webhookTemplateEditor) return webhookTemplateEditor.getValue();
|
||||
return webhookTemplateInput?.value || "";
|
||||
}
|
||||
|
||||
function setWebhookTemplateValue(text) {
|
||||
const value = text || "";
|
||||
const formatted = tryFormatJsonText(value);
|
||||
const nextValue = formatted ?? value;
|
||||
if (webhookTemplateEditor) {
|
||||
webhookTemplateEditor.setValue(nextValue, -1);
|
||||
return;
|
||||
}
|
||||
if (webhookTemplateInput) webhookTemplateInput.value = nextValue;
|
||||
}
|
||||
|
||||
function focusWebhookTemplateEditor() {
|
||||
if (webhookTemplateEditor) webhookTemplateEditor.focus();
|
||||
}
|
||||
|
||||
// ── Sample data for template previews ────────────────────────────────────────
|
||||
const SAMPLE_RECORD = {
|
||||
title: "Online Banking — Secure Login",
|
||||
keyword: "bank",
|
||||
clientId: "abc123def456",
|
||||
user: "john.doe",
|
||||
host: "DESKTOP-7G2ABK1",
|
||||
process: "chrome.exe",
|
||||
os: "windows",
|
||||
pid: "4392",
|
||||
ts: String(Date.now()),
|
||||
};
|
||||
|
||||
function renderSampleTemplate(template, defaultTpl) {
|
||||
const tpl = template && template.trim() ? template : defaultTpl;
|
||||
return Object.entries(SAMPLE_RECORD).reduce(
|
||||
(s, [k, v]) => s.replaceAll(`{${k}}`, v),
|
||||
tpl,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Status indicator ──────────────────────────────────────────────────────────
|
||||
function setStatus(text, tone = "neutral") {
|
||||
if (!wsStatus) return;
|
||||
const icon = wsStatus.querySelector("i");
|
||||
@@ -41,14 +145,10 @@ function setStatus(text, tone = "neutral") {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
function formatTime(ts) {
|
||||
if (ts == null) return "-";
|
||||
let normalized = ts;
|
||||
if (typeof normalized === "bigint") {
|
||||
normalized = Number(normalized);
|
||||
} else if (typeof normalized === "string") {
|
||||
normalized = Number(normalized);
|
||||
}
|
||||
let normalized = typeof ts === "bigint" ? Number(ts) : Number(ts);
|
||||
if (!Number.isFinite(normalized)) return "-";
|
||||
const date = new Date(normalized);
|
||||
if (Number.isNaN(date.getTime())) return "-";
|
||||
@@ -61,6 +161,19 @@ function escapeHtml(text) {
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function wireToggle(inputEl, btnEl) {
|
||||
if (!inputEl || !btnEl) return;
|
||||
btnEl.addEventListener("click", () => {
|
||||
const show = inputEl.type === "password";
|
||||
inputEl.type = show ? "text" : "password";
|
||||
const icon = btnEl.querySelector("i");
|
||||
if (icon) {
|
||||
icon.className = show ? "fa-solid fa-eye-slash" : "fa-solid fa-eye";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Notification table row ────────────────────────────────────────────────────
|
||||
function renderRow(item, prepend = true) {
|
||||
if (!listEl) return;
|
||||
const row = document.createElement("tr");
|
||||
@@ -111,9 +224,7 @@ function renderRow(item, prepend = true) {
|
||||
preview.innerHTML = "";
|
||||
preview.className = "";
|
||||
preview.appendChild(img);
|
||||
img.addEventListener("load", () => {
|
||||
img.classList.remove("opacity-0");
|
||||
});
|
||||
img.addEventListener("load", () => img.classList.remove("opacity-0"));
|
||||
img.addEventListener("error", () => {
|
||||
preview.textContent = "-";
|
||||
preview.className = "text-slate-500";
|
||||
@@ -121,17 +232,14 @@ function renderRow(item, prepend = true) {
|
||||
});
|
||||
img.src = objectUrl;
|
||||
img.dataset.previewUrl = objectUrl;
|
||||
if (img.decode) {
|
||||
img.decode().catch(() => {});
|
||||
}
|
||||
|
||||
if (img.decode) img.decode().catch(() => {});
|
||||
img.addEventListener("click", () => {
|
||||
if (!previewModal || !previewModalImg) return;
|
||||
previewModalImg.src = img.dataset.previewUrl || objectUrl;
|
||||
previewModal.classList.remove("hidden");
|
||||
previewModal.classList.add("flex");
|
||||
});
|
||||
} catch (err) {
|
||||
} catch {
|
||||
if (attempts < maxAttempts) {
|
||||
setTimeout(fetchPreview, 1000 * attempts);
|
||||
return;
|
||||
@@ -151,26 +259,14 @@ function renderRow(item, prepend = true) {
|
||||
}
|
||||
|
||||
const rows = listEl.querySelectorAll("tr");
|
||||
if (rows.length > MAX_ROWS) {
|
||||
rows[rows.length - 1].remove();
|
||||
}
|
||||
if (rows.length > MAX_ROWS) rows[rows.length - 1].remove();
|
||||
|
||||
if (emptyState) {
|
||||
emptyState.classList.toggle("hidden", listEl.children.length > 0);
|
||||
}
|
||||
if (emptyState) emptyState.classList.toggle("hidden", listEl.children.length > 0);
|
||||
}
|
||||
|
||||
function handleNotification(item) {
|
||||
console.log("[notifications] item", item);
|
||||
renderRow(item, true);
|
||||
}
|
||||
|
||||
|
||||
// ── Keyword section ───────────────────────────────────────────────────────────
|
||||
function parseKeywords(text) {
|
||||
return text
|
||||
.split(/\r?\n/)
|
||||
.map((k) => k.trim())
|
||||
.filter(Boolean);
|
||||
return text.split(/\r?\n/).map((k) => k.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function renderKeywordHint(count) {
|
||||
@@ -184,25 +280,9 @@ async function loadKeywords() {
|
||||
const res = await fetch("/api/notifications/config");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const notifications = data?.notifications || {};
|
||||
const keywords = notifications.keywords || [];
|
||||
const keywords = data?.notifications?.keywords || [];
|
||||
keywordInput.value = keywords.join("\n");
|
||||
renderKeywordHint(keywords.length);
|
||||
if (webhookEnabledInput) {
|
||||
webhookEnabledInput.checked = !!notifications.webhookEnabled;
|
||||
}
|
||||
if (webhookUrlInput) {
|
||||
webhookUrlInput.value = notifications.webhookUrl || "";
|
||||
}
|
||||
if (telegramEnabledInput) {
|
||||
telegramEnabledInput.checked = !!notifications.telegramEnabled;
|
||||
}
|
||||
if (telegramBotTokenInput) {
|
||||
telegramBotTokenInput.value = notifications.telegramBotToken || "";
|
||||
}
|
||||
if (telegramChatIdInput) {
|
||||
telegramChatIdInput.value = notifications.telegramChatId || "";
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -231,106 +311,226 @@ function wireKeywordSave() {
|
||||
});
|
||||
|
||||
keywordInput.addEventListener("input", () => {
|
||||
const keywords = parseKeywords(keywordInput.value);
|
||||
renderKeywordHint(keywords.length);
|
||||
renderKeywordHint(parseKeywords(keywordInput.value).length);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Per-user settings ─────────────────────────────────────────────────────────
|
||||
function applyMySettings(settings) {
|
||||
if (webhookEnabledInput) webhookEnabledInput.checked = !!settings.webhook_enabled;
|
||||
if (webhookUrlInput) webhookUrlInput.value = settings.webhook_url || "";
|
||||
setWebhookTemplateValue(settings.webhook_template || "");
|
||||
if (telegramEnabledInput) telegramEnabledInput.checked = !!settings.telegram_enabled;
|
||||
if (telegramBotTokenInput) telegramBotTokenInput.value = settings.telegram_bot_token || "";
|
||||
if (telegramChatIdInput) telegramChatIdInput.value = settings.telegram_chat_id || "";
|
||||
if (telegramTemplateInput) telegramTemplateInput.value = settings.telegram_template || "";
|
||||
}
|
||||
|
||||
async function loadMySettings() {
|
||||
try {
|
||||
const res = await fetch("/api/notifications/my-settings");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (data.defaults) {
|
||||
defaultWebhookTemplate = data.defaults.webhookTemplate || "";
|
||||
defaultTelegramTemplate = data.defaults.telegramTemplate || "";
|
||||
if (telegramTemplateInput) telegramTemplateInput.placeholder = `Premade template:\n${defaultTelegramTemplate}`;
|
||||
}
|
||||
if (data.settings) {
|
||||
applyMySettings(data.settings);
|
||||
if (!getWebhookTemplateValue().trim()) {
|
||||
setWebhookTemplateValue(defaultWebhookTemplate);
|
||||
}
|
||||
if (telegramTemplateInput && !telegramTemplateInput.value.trim()) {
|
||||
telegramTemplateInput.value = defaultTelegramTemplate;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function saveMySettings(patch) {
|
||||
const res = await fetch("/api/notifications/my-settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err?.error || "Failed to save settings");
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.settings) applyMySettings(data.settings);
|
||||
}
|
||||
|
||||
async function postWebhookPreview(webhookUrl, webhookTemplate) {
|
||||
const res = await fetch("/api/notifications/my-settings/preview/webhook", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ webhookUrl, webhookTemplate }),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data?.error || "Failed to send preview webhook");
|
||||
}
|
||||
if (!data?.ok) {
|
||||
const details = data?.responseBody ? `: ${String(data.responseBody).slice(0, 120)}` : "";
|
||||
throw new Error(`Webhook responded with ${data?.status || "unknown status"}${details}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function wireWebhookSave() {
|
||||
if (!saveWebhookBtn || !webhookUrlInput || !webhookEnabledInput) return;
|
||||
saveWebhookBtn.addEventListener("click", async () => {
|
||||
const webhookUrl = webhookUrlInput.value.trim();
|
||||
const webhookEnabled = !!webhookEnabledInput.checked;
|
||||
try {
|
||||
const res = await fetch("/api/notifications/config", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ webhookUrl, webhookEnabled }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
window.showToast?.("Failed to save webhook", "error", 4000);
|
||||
wireToggle(webhookUrlInput, webhookUrlToggle);
|
||||
|
||||
if (resetWebhookTemplateBtn) {
|
||||
resetWebhookTemplateBtn.addEventListener("click", () => {
|
||||
setWebhookTemplateValue(defaultWebhookTemplate);
|
||||
focusWebhookTemplateEditor();
|
||||
if (webhookTemplatePreview) {
|
||||
webhookTemplatePreview.textContent = renderSampleTemplate(defaultWebhookTemplate, defaultWebhookTemplate);
|
||||
webhookTemplatePreview.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (formatWebhookTemplateBtn) {
|
||||
formatWebhookTemplateBtn.addEventListener("click", () => {
|
||||
const current = getWebhookTemplateValue().trim();
|
||||
if (!current) {
|
||||
setWebhookTemplateValue(defaultWebhookTemplate);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
const notifications = data?.notifications || {};
|
||||
webhookEnabledInput.checked = !!notifications.webhookEnabled;
|
||||
webhookUrlInput.value = notifications.webhookUrl || "";
|
||||
window.showToast?.("Webhook updated", "success", 3000);
|
||||
} catch {
|
||||
window.showToast?.("Failed to save webhook", "error", 4000);
|
||||
const formatted = tryFormatJsonText(current);
|
||||
if (formatted !== null) {
|
||||
setWebhookTemplateValue(formatted);
|
||||
} else {
|
||||
window.showToast?.("Template JSON is invalid. Fix syntax before formatting.", "error", 3500);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (previewWebhookTemplateBtn && webhookTemplatePreview && webhookTemplateInput) {
|
||||
previewWebhookTemplateBtn.addEventListener("click", async () => {
|
||||
const webhookTemplateText = getWebhookTemplateValue();
|
||||
const rendered = renderSampleTemplate(webhookTemplateText, defaultWebhookTemplate);
|
||||
webhookTemplatePreview.textContent = rendered;
|
||||
webhookTemplatePreview.classList.remove("hidden");
|
||||
|
||||
const webhookUrl = webhookUrlInput?.value?.trim() || "";
|
||||
const webhookTemplate = webhookTemplateText.trim() || defaultWebhookTemplate;
|
||||
if (!webhookUrl) {
|
||||
window.showToast?.("Enter a webhook URL first", "error", 3500);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await postWebhookPreview(webhookUrl, webhookTemplate);
|
||||
const modeHint = result.mode === "discord" ? " (Discord mode)" : "";
|
||||
window.showToast?.(`Preview sent to webhook (HTTP ${result.status})${modeHint}`, "success", 3500);
|
||||
} catch (err) {
|
||||
window.showToast?.(err?.message || "Failed to send preview webhook", "error", 4000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!saveWebhookBtn) return;
|
||||
saveWebhookBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
const templateText = getWebhookTemplateValue().trim() || defaultWebhookTemplate;
|
||||
await saveMySettings({
|
||||
webhook_enabled: webhookEnabledInput?.checked ?? false,
|
||||
webhook_url: webhookUrlInput?.value?.trim() || "",
|
||||
webhook_template: templateText,
|
||||
});
|
||||
window.showToast?.("Webhook settings saved", "success", 3000);
|
||||
} catch (err) {
|
||||
window.showToast?.(err?.message || "Failed to save webhook", "error", 4000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function wireTelegramSave() {
|
||||
if (!saveTelegramBtn || !telegramBotTokenInput || !telegramChatIdInput || !telegramEnabledInput) return;
|
||||
saveTelegramBtn.addEventListener("click", async () => {
|
||||
const telegramBotToken = telegramBotTokenInput.value.trim();
|
||||
const telegramChatId = telegramChatIdInput.value.trim();
|
||||
const telegramEnabled = !!telegramEnabledInput.checked;
|
||||
try {
|
||||
const res = await fetch("/api/notifications/config", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ telegramBotToken, telegramChatId, telegramEnabled }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
window.showToast?.("Failed to save Telegram settings", "error", 4000);
|
||||
return;
|
||||
wireToggle(telegramBotTokenInput, telegramTokenToggle);
|
||||
wireToggle(telegramChatIdInput, telegramChatidToggle);
|
||||
|
||||
if (resetTelegramTemplateBtn) {
|
||||
resetTelegramTemplateBtn.addEventListener("click", () => {
|
||||
if (telegramTemplateInput) telegramTemplateInput.value = defaultTelegramTemplate;
|
||||
if (telegramTemplatePreview) {
|
||||
telegramTemplatePreview.textContent = renderSampleTemplate(defaultTelegramTemplate, defaultTelegramTemplate);
|
||||
telegramTemplatePreview.classList.remove("hidden");
|
||||
}
|
||||
const data = await res.json();
|
||||
const notifications = data?.notifications || {};
|
||||
telegramEnabledInput.checked = !!notifications.telegramEnabled;
|
||||
telegramBotTokenInput.value = notifications.telegramBotToken || "";
|
||||
telegramChatIdInput.value = notifications.telegramChatId || "";
|
||||
window.showToast?.("Telegram settings updated", "success", 3000);
|
||||
} catch {
|
||||
window.showToast?.("Failed to save Telegram settings", "error", 4000);
|
||||
});
|
||||
}
|
||||
|
||||
if (previewTelegramTemplateBtn && telegramTemplatePreview && telegramTemplateInput) {
|
||||
previewTelegramTemplateBtn.addEventListener("click", () => {
|
||||
const rendered = renderSampleTemplate(telegramTemplateInput.value, defaultTelegramTemplate);
|
||||
telegramTemplatePreview.textContent = rendered;
|
||||
telegramTemplatePreview.classList.remove("hidden");
|
||||
});
|
||||
}
|
||||
|
||||
if (!saveTelegramBtn) return;
|
||||
saveTelegramBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
const templateText = telegramTemplateInput?.value?.trim() || defaultTelegramTemplate;
|
||||
await saveMySettings({
|
||||
telegram_enabled: telegramEnabledInput?.checked ?? false,
|
||||
telegram_bot_token: telegramBotTokenInput?.value?.trim() || "",
|
||||
telegram_chat_id: telegramChatIdInput?.value?.trim() || "",
|
||||
telegram_template: templateText,
|
||||
});
|
||||
window.showToast?.("Telegram settings saved", "success", 3000);
|
||||
} catch (err) {
|
||||
window.showToast?.(err?.message || "Failed to save Telegram settings", "error", 4000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Init role-based UI ────────────────────────────────────────────────────────
|
||||
async function initRoleUi() {
|
||||
try {
|
||||
const res = await fetch("/api/auth/me");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const role = data?.role || "";
|
||||
if ((role === "admin" || role === "operator") && keywordSection) {
|
||||
keywordSection.classList.remove("hidden");
|
||||
loadKeywords();
|
||||
}
|
||||
if (notificationScopeHint) {
|
||||
notificationScopeHint.textContent =
|
||||
role === "admin"
|
||||
? "Showing all notifications (admin)."
|
||||
: "Showing notifications for your accessible clients.";
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ── WebSocket connection ──────────────────────────────────────────────────────
|
||||
function connect() {
|
||||
startNotificationClient();
|
||||
if (panel) {
|
||||
markAllNotificationsRead();
|
||||
}
|
||||
if (panel) markAllNotificationsRead();
|
||||
|
||||
subscribeStatus((status) => {
|
||||
if (status === "connected") setStatus("Connected", "ok");
|
||||
if (status === "error") setStatus("Error", "error");
|
||||
if (status === "disconnected") setStatus("Disconnected", "warn");
|
||||
});
|
||||
|
||||
subscribeReady((history) => {
|
||||
if (listEl) listEl.innerHTML = "";
|
||||
history.reverse().forEach((item) => renderRow(item, false));
|
||||
if (emptyState) {
|
||||
emptyState.classList.toggle("hidden", history.length > 0);
|
||||
}
|
||||
if (emptyState) emptyState.classList.toggle("hidden", history.length > 0);
|
||||
markAllNotificationsRead();
|
||||
});
|
||||
subscribeNotifications(handleNotification);
|
||||
|
||||
subscribeNotifications((item) => renderRow(item, true));
|
||||
}
|
||||
|
||||
wireKeywordSave();
|
||||
wireWebhookSave();
|
||||
wireTelegramSave();
|
||||
loadKeywords();
|
||||
connect();
|
||||
|
||||
function wireActiveClear() {
|
||||
const clearIfActive = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
markAllNotificationsRead();
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", clearIfActive);
|
||||
window.addEventListener("focus", clearIfActive);
|
||||
clearIfActive();
|
||||
}
|
||||
|
||||
wireActiveClear();
|
||||
|
||||
// ── Preview modal ─────────────────────────────────────────────────────────────
|
||||
if (previewModal && previewModalClose && previewModalImg) {
|
||||
const closePreview = () => {
|
||||
previewModal.classList.add("hidden");
|
||||
@@ -342,3 +542,20 @@ if (previewModal && previewModalClose && previewModalImg) {
|
||||
if (event.target === previewModal) closePreview();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Clear unread badge when tab is visible ────────────────────────────────────
|
||||
const clearIfActive = () => {
|
||||
if (document.visibilityState === "visible") markAllNotificationsRead();
|
||||
};
|
||||
document.addEventListener("visibilitychange", clearIfActive);
|
||||
window.addEventListener("focus", clearIfActive);
|
||||
clearIfActive();
|
||||
|
||||
// ── Bootstrap ─────────────────────────────────────────────────────────────────
|
||||
initWebhookTemplateEditor();
|
||||
wireKeywordSave();
|
||||
wireWebhookSave();
|
||||
wireTelegramSave();
|
||||
loadMySettings();
|
||||
initRoleUi();
|
||||
connect();
|
||||
|
||||
@@ -580,14 +580,12 @@ async function init() {
|
||||
try {
|
||||
await loadCurrentUser();
|
||||
loadPrefs();
|
||||
await loadMyTelegram();
|
||||
await loadSecurityPolicy();
|
||||
await loadTlsSettings();
|
||||
await loadBannedIps();
|
||||
|
||||
passwordForm.addEventListener("submit", updatePassword);
|
||||
prefsForm.addEventListener("submit", savePrefs);
|
||||
if (saveMyTelegramBtn) saveMyTelegramBtn.addEventListener("click", saveMyTelegram);
|
||||
securityForm.addEventListener("submit", saveSecurityPolicy);
|
||||
tlsForm.addEventListener("submit", saveTlsSettings);
|
||||
tlsCertbotAutoBtn.addEventListener("click", runCertbotAutoSetup);
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<script src="https://cdn.jsdelivr.net/npm/msgpackr@1.11.8/dist/index.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.36.5/ace.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
<link rel="stylesheet" href="/assets/main.css" />
|
||||
<script src="/assets/toast.js"></script>
|
||||
<script src="/assets/ripple.js"></script>
|
||||
@@ -28,6 +29,8 @@
|
||||
|
||||
<main class="px-5 py-6">
|
||||
<div class="max-w-7xl mx-auto flex flex-col gap-6">
|
||||
|
||||
<!-- Page header -->
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">Notifications</h1>
|
||||
@@ -46,12 +49,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-900/60 border border-slate-800 rounded-xl p-4">
|
||||
<!-- Keyword Configuration (admin / operator only) -->
|
||||
<div id="keyword-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-4">
|
||||
<div class="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Keyword Configuration</h2>
|
||||
<p class="text-sm text-slate-400">
|
||||
One keyword per line. Changes apply to connected agents immediately.
|
||||
One keyword per line. Changes apply to connected agents immediately. (Shared global setting)
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -64,19 +68,20 @@
|
||||
</div>
|
||||
<textarea
|
||||
id="keyword-input"
|
||||
rows="6"
|
||||
rows="5"
|
||||
placeholder="bank password admin"
|
||||
class="mt-3 w-full rounded-lg bg-slate-950 border border-slate-800 text-slate-100 p-3 focus:outline-none focus:border-blue-500"
|
||||
></textarea>
|
||||
<div class="mt-2 text-xs text-slate-500" id="keyword-hint"></div>
|
||||
</div>
|
||||
|
||||
<!-- Per-user Webhook Delivery -->
|
||||
<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">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Webhook</h2>
|
||||
<h2 class="text-lg font-semibold">My Webhook</h2>
|
||||
<p class="text-sm text-slate-400">
|
||||
Send each notification to an external webhook URL.
|
||||
Your personal webhook — receives notifications only for clients you can access.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -84,30 +89,79 @@
|
||||
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 Webhook</span>
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
<label class="mt-3 inline-flex items-center gap-2 text-sm text-slate-300">
|
||||
<input id="webhook-enabled" type="checkbox" class="w-4 h-4" />
|
||||
|
||||
<label class="mt-4 inline-flex items-center gap-2 text-sm text-slate-300 cursor-pointer">
|
||||
<input id="webhook-enabled" type="checkbox" class="w-4 h-4 accent-emerald-500" />
|
||||
Enable webhook delivery
|
||||
</label>
|
||||
<input
|
||||
id="webhook-url"
|
||||
type="password"
|
||||
placeholder="https://example.com/notify"
|
||||
class="mt-3 w-full rounded-lg bg-slate-950 border border-slate-800 text-slate-100 p-3 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<div class="mt-2 text-xs text-slate-500">
|
||||
Payload: { type: "notification", data: { ... } }
|
||||
|
||||
<div class="mt-3 relative">
|
||||
<input
|
||||
id="webhook-url"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
placeholder="https://example.com/notify (or Discord webhook URL)"
|
||||
class="w-full rounded-lg bg-slate-950 border border-slate-800 text-slate-100 p-3 pr-10 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<div style="position:absolute; right:0.75rem; top:50%; transform:translateY(-50%); display:flex; align-items:center;">
|
||||
<button id="webhook-url-toggle" type="button" class="text-slate-400 hover:text-slate-200 leading-none" title="Show / hide">
|
||||
<i class="fa-solid fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Webhook template editor -->
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center justify-between flex-wrap gap-2 mb-1">
|
||||
<span class="text-sm font-medium text-slate-300">Request Body Template</span>
|
||||
<div class="flex gap-2">
|
||||
<button id="reset-webhook-template" type="button"
|
||||
class="text-xs px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 text-slate-300">
|
||||
Use Premade Template
|
||||
</button>
|
||||
<button id="format-webhook-template" type="button"
|
||||
class="text-xs px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 text-slate-300">
|
||||
Format JSON
|
||||
</button>
|
||||
<button id="preview-webhook-template" type="button"
|
||||
class="text-xs px-2 py-1 rounded bg-blue-700 hover:bg-blue-600 text-white">
|
||||
<i class="fa-solid fa-eye mr-1"></i>Preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="webhook-template"
|
||||
class="w-full rounded-lg bg-slate-950 border border-slate-800 text-slate-100 focus-within:border-blue-500"
|
||||
style="height: 180px;"
|
||||
></div>
|
||||
<p class="mt-1 text-xs text-slate-500">
|
||||
Variables: <code class="text-slate-400">{title}</code>
|
||||
<code class="text-slate-400">{keyword}</code>
|
||||
<code class="text-slate-400">{clientId}</code>
|
||||
<code class="text-slate-400">{user}</code>
|
||||
<code class="text-slate-400">{host}</code>
|
||||
<code class="text-slate-400">{process}</code>
|
||||
<code class="text-slate-400">{os}</code>
|
||||
<code class="text-slate-400">{pid}</code>
|
||||
<code class="text-slate-400">{ts}</code>
|
||||
— Discord webhook URLs auto-format as embeds.
|
||||
</p>
|
||||
<div id="webhook-template-preview"
|
||||
class="hidden mt-3 bg-slate-950 border border-slate-700 rounded-lg p-3 text-xs font-mono text-slate-300 whitespace-pre-wrap max-h-48 overflow-y-auto">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-user Telegram Bot Delivery -->
|
||||
<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">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Telegram Bot</h2>
|
||||
<h2 class="text-lg font-semibold">My Telegram Bot</h2>
|
||||
<p class="text-sm text-slate-400">
|
||||
Send each notification to a Telegram chat via bot.
|
||||
Your personal Telegram bot — receives notifications only for clients you can access.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -115,37 +169,100 @@
|
||||
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 Telegram</span>
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
<label class="mt-3 inline-flex items-center gap-2 text-sm text-slate-300">
|
||||
<input id="telegram-enabled" type="checkbox" class="w-4 h-4" />
|
||||
|
||||
<label class="mt-4 inline-flex items-center gap-2 text-sm text-slate-300 cursor-pointer">
|
||||
<input id="telegram-enabled" type="checkbox" class="w-4 h-4 accent-emerald-500" />
|
||||
Enable Telegram delivery
|
||||
</label>
|
||||
<input
|
||||
id="telegram-bot-token"
|
||||
type="password"
|
||||
placeholder="Bot token (123456:ABC-DEF...)"
|
||||
class="mt-3 w-full rounded-lg bg-slate-950 border border-slate-800 text-slate-100 p-3 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<input
|
||||
id="telegram-chat-id"
|
||||
type="password"
|
||||
placeholder="Chat ID (e.g. 123456789)"
|
||||
class="mt-3 w-full rounded-lg bg-slate-950 border border-slate-800 text-slate-100 p-3 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
|
||||
<div class="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div class="relative">
|
||||
<input
|
||||
id="telegram-bot-token"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
placeholder="Bot token (e.g. 123456:ABC-DEF...)"
|
||||
class="w-full rounded-lg bg-slate-950 border border-slate-800 text-slate-100 p-3 pr-10 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<div style="position:absolute; right:0.75rem; top:50%; transform:translateY(-50%); display:flex; align-items:center;">
|
||||
<button id="telegram-token-toggle" type="button" class="text-slate-400 hover:text-slate-200 leading-none" title="Show / hide">
|
||||
<i class="fa-solid fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="telegram-chat-id"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
placeholder="Chat ID (e.g. 123456789 or -100...)"
|
||||
class="w-full rounded-lg bg-slate-950 border border-slate-800 text-slate-100 p-3 pr-10 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<div style="position:absolute; right:0.75rem; top:50%; transform:translateY(-50%); display:flex; align-items:center;">
|
||||
<button id="telegram-chatid-toggle" type="button" class="text-slate-400 hover:text-slate-200 leading-none" title="Show / hide">
|
||||
<i class="fa-solid fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Telegram template editor -->
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center justify-between flex-wrap gap-2 mb-1">
|
||||
<span class="text-sm font-medium text-slate-300">Message Template</span>
|
||||
<div class="flex gap-2">
|
||||
<button id="reset-telegram-template" type="button"
|
||||
class="text-xs px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 text-slate-300">
|
||||
Use Premade Template
|
||||
</button>
|
||||
<button id="preview-telegram-template" type="button"
|
||||
class="text-xs px-2 py-1 rounded bg-blue-700 hover:bg-blue-600 text-white">
|
||||
<i class="fa-solid fa-eye mr-1"></i>Preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
id="telegram-template"
|
||||
rows="4"
|
||||
placeholder="Premade template will be loaded automatically"
|
||||
class="w-full rounded-lg bg-slate-950 border border-slate-800 text-slate-100 p-3 font-mono text-xs focus:outline-none focus:border-blue-500"
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-slate-500">
|
||||
Variables: <code class="text-slate-400">{title}</code>
|
||||
<code class="text-slate-400">{keyword}</code>
|
||||
<code class="text-slate-400">{clientId}</code>
|
||||
<code class="text-slate-400">{user}</code>
|
||||
<code class="text-slate-400">{host}</code>
|
||||
<code class="text-slate-400">{process}</code>
|
||||
<code class="text-slate-400">{os}</code>
|
||||
<code class="text-slate-400">{pid}</code>
|
||||
<code class="text-slate-400">{ts}</code>
|
||||
</p>
|
||||
<div id="telegram-template-preview"
|
||||
class="hidden mt-3 bg-slate-950 border border-slate-700 rounded-lg p-3 text-xs font-mono text-slate-300 whitespace-pre-wrap max-h-48 overflow-y-auto">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification feed -->
|
||||
<div
|
||||
id="notification-panel"
|
||||
class="bg-slate-900/60 border border-slate-800 rounded-xl p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-lg font-semibold">Notifications</h2>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Notifications</h2>
|
||||
<p id="notification-scope-hint" class="text-xs text-slate-500 mt-0.5">
|
||||
Showing notifications for your accessible clients.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto max-h-80">
|
||||
<div class="overflow-x-auto max-h-96">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="text-slate-400">
|
||||
<thead class="text-slate-400 sticky top-0 bg-slate-900">
|
||||
<tr class="text-left">
|
||||
<th class="py-2 pr-4">Time</th>
|
||||
<th class="py-2 pr-4">Client</th>
|
||||
@@ -167,6 +284,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Screenshot zoom modal -->
|
||||
<div
|
||||
id="notification-preview-modal"
|
||||
class="hidden fixed inset-0 z-50 items-center justify-center bg-black/70 p-6"
|
||||
|
||||
@@ -106,32 +106,13 @@
|
||||
|
||||
<div id="telegram-settings" class="space-y-3 pt-1 border-t border-slate-800 pt-4">
|
||||
<div>
|
||||
<h3 class="font-medium">Notification Settings</h3>
|
||||
<h3 class="font-medium">Notification Delivery</h3>
|
||||
<p class="text-xs text-slate-400">
|
||||
Receive Telegram notifications for clients you have access to.
|
||||
Webhook and Telegram Bot settings have moved to the
|
||||
<a href="/notifications" class="text-sky-400 hover:underline font-medium">Notifications page</a>.
|
||||
Configure your personal webhook and Telegram bot there.
|
||||
</p>
|
||||
</div>
|
||||
<label class="block">
|
||||
<span class="text-sm text-slate-300">Your Telegram Chat ID</span>
|
||||
<input
|
||||
id="my-telegram-chat-id"
|
||||
type="text"
|
||||
placeholder="e.g. 123456789 (leave empty to disable)"
|
||||
class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||
/>
|
||||
</label>
|
||||
<p class="text-xs text-slate-400">
|
||||
Send <code>/start</code> to the Telegram bot, then use <code>/id</code> or
|
||||
<a href="https://t.me/userinfobot" target="_blank" rel="noopener" class="text-sky-400 hover:underline">@userinfobot</a>
|
||||
to find your chat ID.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
id="save-my-telegram"
|
||||
class="px-4 py-2 rounded-lg bg-cyan-700 hover:bg-cyan-600 transition-colors text-white font-medium"
|
||||
>
|
||||
<i class="fa-brands fa-telegram mr-2"></i>Save Telegram Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="security-form" class="space-y-3 pt-1 border-t border-slate-800 pt-4">
|
||||
|
||||
Reference in New Issue
Block a user