feat(admin): add security and activity management features
This commit is contained in:
272
static/js/admin/security.js
Normal file
272
static/js/admin/security.js
Normal file
@@ -0,0 +1,272 @@
|
||||
(() => {
|
||||
const menuController = window.WarpBoxUI?.bindMenuBar?.() || { close() {} };
|
||||
const eventsNode = document.getElementById("security-events-data");
|
||||
const alertsNode = document.getElementById("security-alerts-data");
|
||||
const bansNode = document.getElementById("security-bans-data");
|
||||
const ipInput = document.getElementById("security-ip-input");
|
||||
const banUntilInput = document.getElementById("security-ban-until");
|
||||
const alertList = document.getElementById("security-alert-list");
|
||||
const activityBody = document.getElementById("security-activity-body");
|
||||
const bansBody = document.getElementById("security-bans-body");
|
||||
const bansCount = document.getElementById("security-bans-count");
|
||||
const toast = document.getElementById("toast");
|
||||
|
||||
const detail = {
|
||||
ip: document.getElementById("security-detail-ip"),
|
||||
risk: document.getElementById("security-detail-risk"),
|
||||
threat: document.getElementById("security-detail-threat"),
|
||||
geo: document.getElementById("security-detail-geo"),
|
||||
asn: document.getElementById("security-detail-asn"),
|
||||
until: document.getElementById("security-detail-until")
|
||||
};
|
||||
|
||||
if (!eventsNode || !alertsNode || !bansNode) return;
|
||||
const state = {
|
||||
events: parse(eventsNode),
|
||||
alerts: parse(alertsNode),
|
||||
bans: parse(bansNode),
|
||||
selectedIP: ""
|
||||
};
|
||||
|
||||
setDefaultBanUntil();
|
||||
|
||||
function parse(node) {
|
||||
try {
|
||||
return JSON.parse(node.textContent || "[]");
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message, type = "info", duration = 1800) {
|
||||
window.WarpBoxUI?.toast?.(message, type, { target: toast, duration });
|
||||
}
|
||||
|
||||
function createdLabel(value) {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return "-";
|
||||
return parsed.toISOString().replace("T", " ").slice(0, 16) + " UTC";
|
||||
}
|
||||
|
||||
function setDefaultBanUntil() {
|
||||
const base = new Date(Date.now() + 30 * 60 * 1000);
|
||||
const yyyy = String(base.getUTCFullYear());
|
||||
const mm = String(base.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(base.getUTCDate()).padStart(2, "0");
|
||||
const hh = String(base.getUTCHours()).padStart(2, "0");
|
||||
const mi = String(base.getUTCMinutes()).padStart(2, "0");
|
||||
banUntilInput.value = `${yyyy}-${mm}-${dd}T${hh}:${mi}`;
|
||||
}
|
||||
|
||||
function toRFC3339FromLocalUTC(datetimeLocalValue) {
|
||||
if (!datetimeLocalValue) return "";
|
||||
const date = new Date(datetimeLocalValue + ":00Z");
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function setSelectedIP(ip) {
|
||||
state.selectedIP = ip || "";
|
||||
if (state.selectedIP) ipInput.value = state.selectedIP;
|
||||
renderBans();
|
||||
renderIPDetails();
|
||||
}
|
||||
|
||||
function render() {
|
||||
renderAlerts();
|
||||
renderActivity();
|
||||
renderBans();
|
||||
renderIPDetails();
|
||||
}
|
||||
|
||||
function renderAlerts() {
|
||||
alertList.innerHTML = "";
|
||||
state.alerts.slice(0, 12).forEach((alert) => {
|
||||
const entry = document.createElement("li");
|
||||
entry.textContent = `${createdLabel(alert.created_at)} | ${alert.severity || "low"} | ${alert.title || "-"}`;
|
||||
alertList.appendChild(entry);
|
||||
});
|
||||
}
|
||||
|
||||
function renderActivity() {
|
||||
activityBody.innerHTML = "";
|
||||
state.events
|
||||
.filter((event) => String(event.kind || "").startsWith("security") || String(event.kind || "").startsWith("auth.admin"))
|
||||
.slice(0, 60)
|
||||
.forEach((event) => {
|
||||
const row = document.createElement("tr");
|
||||
row.innerHTML = `
|
||||
<td>${createdLabel(event.created_at)}</td>
|
||||
<td>${escapeHtml(event.kind || "-")}</td>
|
||||
<td>${escapeHtml(event.severity || "-")}</td>
|
||||
<td>${escapeHtml(event.ip || "-")}</td>
|
||||
<td>${escapeHtml(event.path || "-")}</td>
|
||||
<td title="${escapeHtml(event.message || "-")}">${escapeHtml(event.message || "-")}</td>
|
||||
`;
|
||||
activityBody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function renderBans() {
|
||||
bansBody.innerHTML = "";
|
||||
const banMap = new Map(state.bans.map((entry) => [entry.ip, entry]));
|
||||
const ips = new Set();
|
||||
state.events.forEach((event) => {
|
||||
const ip = String(event.ip || "").trim();
|
||||
if (ip) ips.add(ip);
|
||||
});
|
||||
state.bans.forEach((entry) => {
|
||||
const ip = String(entry.ip || "").trim();
|
||||
if (ip) ips.add(ip);
|
||||
});
|
||||
const rows = Array.from(ips).sort();
|
||||
rows.forEach((ip) => {
|
||||
const ban = banMap.get(ip) || null;
|
||||
const status = ban ? "banned" : "observed";
|
||||
const row = document.createElement("tr");
|
||||
row.className = "security-bans-body-row";
|
||||
if (ip === state.selectedIP) row.classList.add("is-selected");
|
||||
row.innerHTML = `
|
||||
<td>${escapeHtml(ip || "-")}</td>
|
||||
<td>${status}</td>
|
||||
<td>${ban ? createdLabel(ban.until) : "-"}</td>
|
||||
`;
|
||||
row.addEventListener("click", () => setSelectedIP(ip));
|
||||
bansBody.appendChild(row);
|
||||
});
|
||||
bansCount.textContent = `${state.bans.length} active bans`;
|
||||
}
|
||||
|
||||
function renderIPDetails() {
|
||||
const ip = state.selectedIP || String(ipInput.value || "").trim();
|
||||
if (!ip) {
|
||||
detail.ip.textContent = "No IP selected";
|
||||
detail.risk.textContent = "-";
|
||||
detail.threat.textContent = "-";
|
||||
detail.geo.textContent = "Placeholder (geoipfast later)";
|
||||
detail.asn.textContent = "Placeholder";
|
||||
detail.until.textContent = "-";
|
||||
return;
|
||||
}
|
||||
const ban = state.bans.find((entry) => entry.ip === ip);
|
||||
detail.ip.textContent = ip;
|
||||
detail.risk.textContent = ban ? "high" : "medium";
|
||||
detail.threat.textContent = ban ? "Temporary banned source" : "Observed source";
|
||||
detail.geo.textContent = "Placeholder country/region lookup";
|
||||
detail.asn.textContent = "Placeholder ASN/provider lookup";
|
||||
detail.until.textContent = ban ? createdLabel(ban.until) : "Not banned";
|
||||
if (ban && ban.until) {
|
||||
const parsed = new Date(ban.until);
|
||||
if (!Number.isNaN(parsed.getTime())) {
|
||||
const yyyy = String(parsed.getUTCFullYear());
|
||||
const mm = String(parsed.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(parsed.getUTCDate()).padStart(2, "0");
|
||||
const hh = String(parsed.getUTCHours()).padStart(2, "0");
|
||||
const mi = String(parsed.getUTCMinutes()).padStart(2, "0");
|
||||
banUntilInput.value = `${yyyy}-${mm}-${dd}T${hh}:${mi}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return window.WarpBoxUI?.htmlEscape?.(value) || String(value ?? "");
|
||||
}
|
||||
|
||||
async function postAction(action, payload = {}) {
|
||||
const response = await fetch("/admin/security/actions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action, ...payload })
|
||||
});
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(result.error || "Request failed");
|
||||
if (Array.isArray(result.bans)) state.bans = result.bans;
|
||||
return result;
|
||||
}
|
||||
|
||||
async function banIP() {
|
||||
const ip = String(ipInput.value || "").trim();
|
||||
if (!ip) {
|
||||
showToast("Enter IP first", "warning");
|
||||
return;
|
||||
}
|
||||
const payload = await postAction("ban", { ip });
|
||||
setSelectedIP(ip);
|
||||
showToast(payload.message || "IP banned", "success");
|
||||
}
|
||||
|
||||
async function banUntil() {
|
||||
const ip = String(ipInput.value || "").trim();
|
||||
if (!ip) {
|
||||
showToast("Enter IP first", "warning");
|
||||
return;
|
||||
}
|
||||
const banUntil = toRFC3339FromLocalUTC(banUntilInput.value);
|
||||
if (!banUntil) {
|
||||
showToast("Set valid expiration date", "warning");
|
||||
return;
|
||||
}
|
||||
const payload = await postAction("ban_until", { ip, ban_until: banUntil });
|
||||
setSelectedIP(ip);
|
||||
showToast(payload.message || "IP ban expiration updated", "success");
|
||||
}
|
||||
|
||||
async function unbanIP() {
|
||||
const ip = state.selectedIP || String(ipInput.value || "").trim();
|
||||
if (!ip) {
|
||||
showToast("Select or enter IP first", "warning");
|
||||
return;
|
||||
}
|
||||
const payload = await postAction("unban", { ip });
|
||||
setSelectedIP("");
|
||||
showToast(payload.message || "IP unbanned", "success");
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-command]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
menuController.close();
|
||||
try {
|
||||
const command = button.dataset.command;
|
||||
if (command === "refresh") {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
if (command === "ban-ip") {
|
||||
await banIP();
|
||||
return;
|
||||
}
|
||||
if (command === "ban-until") {
|
||||
await banUntil();
|
||||
return;
|
||||
}
|
||||
if (command === "unban-ip") {
|
||||
await unbanIP();
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error.message, "error", 3200);
|
||||
} finally {
|
||||
renderBans();
|
||||
renderIPDetails();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipInput.addEventListener("input", () => renderIPDetails());
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") menuController.close();
|
||||
if (event.key === "F5") {
|
||||
event.preventDefault();
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
|
||||
if (state.bans.length > 0) {
|
||||
setSelectedIP(state.bans[0].ip);
|
||||
} else {
|
||||
const firstObserved = state.events.find((event) => String(event.ip || "").trim() !== "");
|
||||
if (firstObserved) setSelectedIP(String(firstObserved.ip || "").trim());
|
||||
}
|
||||
render();
|
||||
})();
|
||||
Reference in New Issue
Block a user