finish up turbo and add stimulus.
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "overlord-server",
|
"name": "overlord-server",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hotwired/stimulus": "^3.2.2",
|
||||||
"@hotwired/turbo": "^8.0.23",
|
"@hotwired/turbo": "^8.0.23",
|
||||||
"@msgpack/msgpack": "^3.0.0",
|
"@msgpack/msgpack": "^3.0.0",
|
||||||
"@solana/web3.js": "^1.98.4",
|
"@solana/web3.js": "^1.98.4",
|
||||||
@@ -63,6 +64,8 @@
|
|||||||
|
|
||||||
"@fortawesome/fontawesome-free": ["@fortawesome/fontawesome-free@6.5.2", "", {}, "sha512-hRILoInAx8GNT5IMkrtIt9blOdrqHOnPBH+k70aWUAqPZPgopb9G5EQJFpaBx/S8zp2fC+mPW349Bziuk1o28Q=="],
|
"@fortawesome/fontawesome-free": ["@fortawesome/fontawesome-free@6.5.2", "", {}, "sha512-hRILoInAx8GNT5IMkrtIt9blOdrqHOnPBH+k70aWUAqPZPgopb9G5EQJFpaBx/S8zp2fC+mPW349Bziuk1o28Q=="],
|
||||||
|
|
||||||
|
"@hotwired/stimulus": ["@hotwired/stimulus@3.2.2", "", {}, "sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A=="],
|
||||||
|
|
||||||
"@hotwired/turbo": ["@hotwired/turbo@8.0.23", "", {}, "sha512-GZ7cijxEZ6Ig71u7rD6LHaRv/wcE/hNsc+nEfiWOkLNqUgLOwo5MNGWOy5ZV9ZUDSiQx1no7YxjTNnT4O6//cQ=="],
|
"@hotwired/turbo": ["@hotwired/turbo@8.0.23", "", {}, "sha512-GZ7cijxEZ6Ig71u7rD6LHaRv/wcE/hNsc+nEfiWOkLNqUgLOwo5MNGWOy5ZV9ZUDSiQx1no7YxjTNnT4O6//cQ=="],
|
||||||
|
|
||||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hotwired/stimulus": "^3.2.2",
|
||||||
"@hotwired/turbo": "^8.0.23",
|
"@hotwired/turbo": "^8.0.23",
|
||||||
"@msgpack/msgpack": "^3.0.0",
|
"@msgpack/msgpack": "^3.0.0",
|
||||||
"@solana/web3.js": "^1.98.4",
|
"@solana/web3.js": "^1.98.4",
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import cytoscape from "/vendor/cytoscape/cytoscape.esm.min.mjs";
|
||||||
|
|
||||||
const container = document.getElementById("client-graph");
|
const container = document.getElementById("client-graph");
|
||||||
const searchInput = document.getElementById("graph-search");
|
const searchInput = document.getElementById("graph-search");
|
||||||
const statusSelect = document.getElementById("graph-status");
|
const statusSelect = document.getElementById("graph-status");
|
||||||
@@ -12,6 +14,7 @@ const detailActions = document.getElementById("graph-detail-actions");
|
|||||||
let cy = null;
|
let cy = null;
|
||||||
let currentGraph = null;
|
let currentGraph = null;
|
||||||
let searchTimer = null;
|
let searchTimer = null;
|
||||||
|
let graphPageActive = true;
|
||||||
const LARGE_GRAPH_ANIMATION_CLIENTS = 30000;
|
const LARGE_GRAPH_ANIMATION_CLIENTS = 30000;
|
||||||
|
|
||||||
const typeColors = {
|
const typeColors = {
|
||||||
@@ -215,16 +218,12 @@ function selectNode(node) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderGraph(graph) {
|
function renderGraph(graph) {
|
||||||
|
if (!graphPageActive || !container?.isConnected) return;
|
||||||
currentGraph = graph;
|
currentGraph = graph;
|
||||||
setSummary(graph.summary);
|
setSummary(graph.summary);
|
||||||
|
|
||||||
if (!window.cytoscape) {
|
|
||||||
if (summaryEl) summaryEl.textContent = "Cytoscape failed to load";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cy) {
|
if (!cy) {
|
||||||
cy = window.cytoscape({
|
cy = cytoscape({
|
||||||
container,
|
container,
|
||||||
elements: graphElements(graph),
|
elements: graphElements(graph),
|
||||||
minZoom: 0.18,
|
minZoom: 0.18,
|
||||||
@@ -305,7 +304,7 @@ function renderGraph(graph) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadGraph() {
|
async function loadGraph() {
|
||||||
if (!container) return;
|
if (!graphPageActive || !container) return;
|
||||||
if (summaryEl) summaryEl.textContent = "Loading...";
|
if (summaryEl) summaryEl.textContent = "Loading...";
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
limit: "350",
|
limit: "350",
|
||||||
@@ -314,10 +313,12 @@ async function loadGraph() {
|
|||||||
});
|
});
|
||||||
const res = await fetch(`/api/client-graph?${params}`, { credentials: "include" });
|
const res = await fetch(`/api/client-graph?${params}`, { credentials: "include" });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
if (!graphPageActive) return;
|
||||||
if (summaryEl) summaryEl.textContent = `Graph unavailable (${res.status})`;
|
if (summaryEl) summaryEl.textContent = `Graph unavailable (${res.status})`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderGraph(await res.json());
|
const graph = await res.json();
|
||||||
|
if (graphPageActive) renderGraph(graph);
|
||||||
}
|
}
|
||||||
|
|
||||||
function debounceLoad() {
|
function debounceLoad() {
|
||||||
@@ -332,10 +333,19 @@ searchInput?.addEventListener("input", debounceLoad);
|
|||||||
statusSelect?.addEventListener("change", loadGraph);
|
statusSelect?.addEventListener("change", loadGraph);
|
||||||
refreshBtn?.addEventListener("click", loadGraph);
|
refreshBtn?.addEventListener("click", loadGraph);
|
||||||
fitBtn?.addEventListener("click", () => cy?.fit(undefined, 32));
|
fitBtn?.addEventListener("click", () => cy?.fit(undefined, 32));
|
||||||
window.addEventListener("resize", () => {
|
const handleResize = () => {
|
||||||
cy?.resize();
|
cy?.resize();
|
||||||
cy?.fit(undefined, 32);
|
cy?.fit(undefined, 32);
|
||||||
});
|
};
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
|
||||||
|
window.addEventListener("pagehide", () => {
|
||||||
|
graphPageActive = false;
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
cy?.destroy();
|
||||||
|
cy = null;
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
bindInspectorActions();
|
bindInspectorActions();
|
||||||
loadGraph();
|
loadGraph();
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import Chart from "/vendor/chart.js/chart.esm.js";
|
||||||
|
|
||||||
let clientsChart = null;
|
let clientsChart = null;
|
||||||
let commandsChart = null;
|
let commandsChart = null;
|
||||||
let bandwidthChart = null;
|
let bandwidthChart = null;
|
||||||
@@ -29,6 +31,8 @@ let countryGlobeHoverEntry = null;
|
|||||||
let countryGlobeSortedFeatures = [];
|
let countryGlobeSortedFeatures = [];
|
||||||
let countryGlobeLastSpinFrameAt = 0;
|
let countryGlobeLastSpinFrameAt = 0;
|
||||||
let countryGlobeLastHoverFrameAt = 0;
|
let countryGlobeLastHoverFrameAt = 0;
|
||||||
|
let metricsPollTimer = null;
|
||||||
|
let metricsPageActive = true;
|
||||||
|
|
||||||
const GEOJSON_URL = "/vendor/geo-countries/countries.geojson";
|
const GEOJSON_URL = "/vendor/geo-countries/countries.geojson";
|
||||||
const MAX_CHART_POINTS = 240;
|
const MAX_CHART_POINTS = 240;
|
||||||
@@ -1806,6 +1810,7 @@ function updateCharts(history, snapshot) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchMetrics() {
|
async function fetchMetrics() {
|
||||||
|
if (!metricsPageActive) return;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/metrics?historyLimit=${MAX_CHART_POINTS}`, {
|
const response = await fetch(`/api/metrics?historyLimit=${MAX_CHART_POINTS}`, {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
@@ -1821,11 +1826,13 @@ async function fetchMetrics() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
if (!metricsPageActive) return;
|
||||||
updateMetrics(data.snapshot, data.debug);
|
updateMetrics(data.snapshot, data.debug);
|
||||||
updateCharts(data.history, data.snapshot);
|
updateCharts(data.history, data.snapshot);
|
||||||
|
|
||||||
document.getElementById("status-text").textContent = "Live";
|
document.getElementById("status-text").textContent = "Live";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (!metricsPageActive) return;
|
||||||
console.error("Error fetching metrics:", err);
|
console.error("Error fetching metrics:", err);
|
||||||
document.getElementById("status-text").textContent = "Error";
|
document.getElementById("status-text").textContent = "Error";
|
||||||
}
|
}
|
||||||
@@ -1834,12 +1841,14 @@ async function fetchMetrics() {
|
|||||||
async function checkAuth() {
|
async function checkAuth() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/auth/me", { credentials: "include" });
|
const res = await fetch("/api/auth/me", { credentials: "include" });
|
||||||
|
if (!metricsPageActive) return;
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
if (!metricsPageActive) return;
|
||||||
document.getElementById("username-display").textContent = data.username;
|
document.getElementById("username-display").textContent = data.username;
|
||||||
|
|
||||||
const roleBadge = document.getElementById("role-badge");
|
const roleBadge = document.getElementById("role-badge");
|
||||||
@@ -1895,16 +1904,18 @@ async function checkAuth() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", async () => {
|
async function initMetricsPage() {
|
||||||
await checkAuth();
|
await checkAuth();
|
||||||
|
if (!metricsPageActive) return;
|
||||||
|
|
||||||
initCharts();
|
initCharts();
|
||||||
|
|
||||||
await initCountryMap();
|
await initCountryMap();
|
||||||
|
if (!metricsPageActive) return;
|
||||||
|
|
||||||
await fetchMetrics();
|
await fetchMetrics();
|
||||||
|
|
||||||
setInterval(fetchMetrics, METRICS_POLL_INTERVAL_MS);
|
metricsPollTimer = setInterval(fetchMetrics, METRICS_POLL_INTERVAL_MS);
|
||||||
|
|
||||||
const logoutBtn = document.getElementById("logout-btn");
|
const logoutBtn = document.getElementById("logout-btn");
|
||||||
if (logoutBtn && !logoutBtn.dataset.boundLogout) {
|
if (logoutBtn && !logoutBtn.dataset.boundLogout) {
|
||||||
@@ -1929,4 +1940,37 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
function teardownMetricsPage() {
|
||||||
|
metricsPageActive = false;
|
||||||
|
if (metricsPollTimer !== null) clearInterval(metricsPollTimer);
|
||||||
|
metricsPollTimer = null;
|
||||||
|
if (countryGlobeFrame !== null) cancelAnimationFrame(countryGlobeFrame);
|
||||||
|
countryGlobeFrame = null;
|
||||||
|
countryGlobeResizeObserver?.disconnect();
|
||||||
|
countryGlobeResizeObserver = null;
|
||||||
|
countryGlobeCtx = null;
|
||||||
|
countryGlobeCanvas = null;
|
||||||
|
for (const chart of [
|
||||||
|
clientsChart,
|
||||||
|
commandsChart,
|
||||||
|
bandwidthChart,
|
||||||
|
httpRequestsChart,
|
||||||
|
httpChart,
|
||||||
|
memoryChart,
|
||||||
|
eventLoopChart,
|
||||||
|
sessionsChart,
|
||||||
|
osChart,
|
||||||
|
]) {
|
||||||
|
chart?.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("pagehide", teardownMetricsPage, { once: true });
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", initMetricsPage, { once: true });
|
||||||
|
} else {
|
||||||
|
void initMetricsPage();
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { createAdaptiveNavController } from "./nav/layout.js";
|
|||||||
import { applyUserRoleUI, applyThumbnailWallVisibility } from "./nav/role-ui.js";
|
import { applyUserRoleUI, applyThumbnailWallVisibility } from "./nav/role-ui.js";
|
||||||
import { loadPluginNavItems } from "./nav/plugins-loader.js";
|
import { loadPluginNavItems } from "./nav/plugins-loader.js";
|
||||||
import { init as initCommandPalette } from "./command-palette.js";
|
import { init as initCommandPalette } from "./command-palette.js";
|
||||||
|
import "./stimulus/application.js";
|
||||||
import {
|
import {
|
||||||
runWithoutPageTracking,
|
runWithoutPageTracking,
|
||||||
setupTurboNavigation,
|
setupTurboNavigation,
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export const NAV_GROUPS = [
|
|||||||
children: [
|
children: [
|
||||||
{ href: "/metrics", label: "Metrics", icon: "fa-chart-line", iconColor: "text-emerald-400", linkId: "metrics-link" },
|
{ href: "/metrics", label: "Metrics", icon: "fa-chart-line", iconColor: "text-emerald-400", linkId: "metrics-link" },
|
||||||
{ href: "/graph", label: "Graph", icon: "fa-diagram-project", iconColor: "text-cyan-400", linkId: "graph-link" },
|
{ href: "/graph", label: "Graph", icon: "fa-diagram-project", iconColor: "text-cyan-400", linkId: "graph-link" },
|
||||||
{ href: "/screenshots", label: "Screenshot Wall", icon: "fa-images", iconColor: "text-sky-400", linkId: "screenshots-link", hidden: true },
|
{ href: "/screenshots", label: "Screenshot Wall", icon: "fa-images", iconColor: "text-sky-400", linkId: "screenshots-link", hidden: true, turboPrefetch: false },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -83,10 +83,11 @@ export const NAV_GROUPS = [
|
|||||||
|
|
||||||
export function dropdownItem(child) {
|
export function dropdownItem(child) {
|
||||||
const hiddenCls = child.hidden ? " hidden" : "";
|
const hiddenCls = child.hidden ? " hidden" : "";
|
||||||
|
const prefetchAttr = child.turboPrefetch === false ? ' data-turbo-prefetch="false"' : "";
|
||||||
const badgeHtml = child.hasBadge
|
const badgeHtml = child.hasBadge
|
||||||
? `<span id="enrollment-badge" class="nav-dd-badge hidden"></span>`
|
? `<span id="enrollment-badge" class="nav-dd-badge hidden"></span>`
|
||||||
: "";
|
: "";
|
||||||
return `<a href="${child.href}" id="${child.linkId}"
|
return `<a href="${child.href}" id="${child.linkId}"${prefetchAttr}
|
||||||
class="nav-dd-item${hiddenCls}" data-link-id="${child.linkId}">
|
class="nav-dd-item${hiddenCls}" data-link-id="${child.linkId}">
|
||||||
<i class="fa-solid ${child.icon} ${child.iconColor} nav-dd-item-icon"></i>
|
<i class="fa-solid ${child.icon} ${child.iconColor} nav-dd-item-icon"></i>
|
||||||
<span>${child.label}</span>
|
<span>${child.label}</span>
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ let defaultWebhookTemplate = "";
|
|||||||
let defaultTelegramTemplate = "";
|
let defaultTelegramTemplate = "";
|
||||||
let webhookTemplateEditor = null;
|
let webhookTemplateEditor = null;
|
||||||
let notificationTable = null;
|
let notificationTable = null;
|
||||||
|
let pageActive = true;
|
||||||
|
const subscriptionCleanups = [];
|
||||||
|
|
||||||
function tryFormatJsonText(text) {
|
function tryFormatJsonText(text) {
|
||||||
const source = String(text || "").trim();
|
const source = String(text || "").trim();
|
||||||
@@ -336,12 +338,17 @@ function entryMatchesSearch(entry, query) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyTableView() {
|
function applyTableView() {
|
||||||
|
if (!pageActive) return;
|
||||||
const q = tableState.search.trim().toLowerCase();
|
const q = tableState.search.trim().toLowerCase();
|
||||||
const all = Array.from(tableState.entries.values());
|
const all = Array.from(tableState.entries.values());
|
||||||
const filtered = q ? all.filter((entry) => entryMatchesSearch(entry, q)) : all;
|
const filtered = q ? all.filter((entry) => entryMatchesSearch(entry, q)) : all;
|
||||||
filtered.sort(compareEntries);
|
filtered.sort(compareEntries);
|
||||||
const rows = filtered.map(entryToRow);
|
const rows = filtered.map(entryToRow);
|
||||||
if (notificationTable) notificationTable.replaceData(rows);
|
if (notificationTable && tableEl?.isConnected) {
|
||||||
|
void notificationTable.replaceData(rows).catch((err) => {
|
||||||
|
if (pageActive) console.error("Failed to update notification table:", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (emptyState) {
|
if (emptyState) {
|
||||||
if (all.length === 0) {
|
if (all.length === 0) {
|
||||||
@@ -894,24 +901,25 @@ function connect() {
|
|||||||
startNotificationClient();
|
startNotificationClient();
|
||||||
if (panel) markAllNotificationsRead();
|
if (panel) markAllNotificationsRead();
|
||||||
|
|
||||||
subscribeStatus((status) => {
|
subscriptionCleanups.push(subscribeStatus((status) => {
|
||||||
if (status === "connected") setStatus("Connected", "ok");
|
if (status === "connected") setStatus("Connected", "ok");
|
||||||
if (status === "error") setStatus("Error", "error");
|
if (status === "error") setStatus("Error", "error");
|
||||||
if (status === "disconnected") setStatus("Disconnected", "warn");
|
if (status === "disconnected") setStatus("Disconnected", "warn");
|
||||||
});
|
}));
|
||||||
|
|
||||||
subscribeReady((history) => {
|
subscriptionCleanups.push(subscribeReady((history) => {
|
||||||
|
if (!pageActive) return;
|
||||||
clearTable();
|
clearTable();
|
||||||
for (const item of history) {
|
for (const item of history) {
|
||||||
upsertEntry(item, "notification", false);
|
upsertEntry(item, "notification", false);
|
||||||
}
|
}
|
||||||
applyTableView();
|
applyTableView();
|
||||||
markAllNotificationsRead();
|
markAllNotificationsRead();
|
||||||
});
|
}));
|
||||||
|
|
||||||
subscribeNotifications((item) => addNotification(item, true));
|
subscriptionCleanups.push(subscribeNotifications((item) => addNotification(item, true)));
|
||||||
subscribeClientEvents((item) => addClientEvent(item));
|
subscriptionCleanups.push(subscribeClientEvents((item) => addClientEvent(item)));
|
||||||
subscribeNotificationsCleared((clientId) => removeEntriesForClient(clientId));
|
subscriptionCleanups.push(subscribeNotificationsCleared((clientId) => removeEntriesForClient(clientId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
const CLIENT_EVENT_BADGE = {
|
const CLIENT_EVENT_BADGE = {
|
||||||
@@ -1166,3 +1174,14 @@ wireTableControls();
|
|||||||
wireClientIdToggle();
|
wireClientIdToggle();
|
||||||
initRoleUi();
|
initRoleUi();
|
||||||
connect();
|
connect();
|
||||||
|
|
||||||
|
window.addEventListener("pagehide", () => {
|
||||||
|
pageActive = false;
|
||||||
|
for (const unsubscribe of subscriptionCleanups.splice(0)) unsubscribe();
|
||||||
|
webhookTemplateEditor?.dispose?.();
|
||||||
|
webhookTemplateEditor = null;
|
||||||
|
try {
|
||||||
|
notificationTable?.destroy();
|
||||||
|
} catch {}
|
||||||
|
notificationTable = null;
|
||||||
|
}, { once: true });
|
||||||
|
|||||||
@@ -1858,178 +1858,6 @@ async function wipeOfflineClients() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionsTableBody = document.getElementById("sessions-table-body");
|
|
||||||
const sessionsMessage = document.getElementById("sessions-message");
|
|
||||||
const refreshSessionsBtn = document.getElementById("refresh-sessions-btn");
|
|
||||||
const removeInactiveSessionsBtn = document.getElementById("remove-inactive-sessions-btn");
|
|
||||||
|
|
||||||
function parseUserAgent(ua) {
|
|
||||||
if (!ua) return "Unknown";
|
|
||||||
if (ua.length > 80) return ua.substring(0, 77) + "...";
|
|
||||||
return ua;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRelativeTime(epochSeconds) {
|
|
||||||
if (!epochSeconds) return "—";
|
|
||||||
const diff = Math.floor(Date.now() / 1000) - epochSeconds;
|
|
||||||
if (diff < 60) return "Just now";
|
|
||||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
|
||||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
|
||||||
return `${Math.floor(diff / 86400)}d ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showSessionsMessage(text, type = "ok") {
|
|
||||||
if (!sessionsMessage) return;
|
|
||||||
sessionsMessage.textContent = text;
|
|
||||||
sessionsMessage.classList.remove(
|
|
||||||
"hidden",
|
|
||||||
"text-emerald-200", "border-emerald-700", "bg-emerald-900/30",
|
|
||||||
"text-rose-200", "border-rose-700", "bg-rose-900/30",
|
|
||||||
);
|
|
||||||
if (type === "error") {
|
|
||||||
sessionsMessage.classList.add("text-rose-200", "border-rose-700", "bg-rose-900/30");
|
|
||||||
} else {
|
|
||||||
sessionsMessage.classList.add("text-emerald-200", "border-emerald-700", "bg-emerald-900/30");
|
|
||||||
}
|
|
||||||
setTimeout(() => sessionsMessage.classList.add("hidden"), 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSessions() {
|
|
||||||
if (!sessionsTableBody) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/sessions", { credentials: "include" });
|
|
||||||
if (!res.ok) {
|
|
||||||
sessionsTableBody.innerHTML = `
|
|
||||||
<tr>
|
|
||||||
<td colspan="6" class="px-3 py-6 text-center text-rose-300">Failed to load sessions</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
const sessions = Array.isArray(data.sessions) ? data.sessions : [];
|
|
||||||
|
|
||||||
if (sessions.length === 0) {
|
|
||||||
sessionsTableBody.innerHTML = `
|
|
||||||
<tr>
|
|
||||||
<td colspan="6" class="px-3 py-6 text-center text-slate-400">No sessions found</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionsTableBody.innerHTML = sessions
|
|
||||||
.map((s) => {
|
|
||||||
const isExpired = s.expiresAt && s.expiresAt < Math.floor(Date.now() / 1000);
|
|
||||||
const statusLabel = s.revoked
|
|
||||||
? '<span class="text-rose-400">Revoked</span>'
|
|
||||||
: isExpired
|
|
||||||
? '<span class="text-slate-500">Expired</span>'
|
|
||||||
: '<span class="text-emerald-400">Active</span>';
|
|
||||||
const currentBadge = s.current
|
|
||||||
? ' <span class="ml-1 px-1.5 py-0.5 text-[10px] rounded bg-sky-600/30 text-sky-300 border border-sky-500/30">Current</span>'
|
|
||||||
: "";
|
|
||||||
const canRevoke = !s.revoked && !isExpired;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<tr>
|
|
||||||
<td class="px-3 py-2 font-mono text-xs text-slate-100">${escapeHtml(s.ip || "—")}</td>
|
|
||||||
<td class="px-3 py-2 text-slate-300 text-xs max-w-[200px] truncate" title="${escapeHtml(s.userAgent || "")}">${escapeHtml(parseUserAgent(s.userAgent))}</td>
|
|
||||||
<td class="px-3 py-2 text-slate-400 text-xs">${formatDate(s.createdAt * 1000)}</td>
|
|
||||||
<td class="px-3 py-2 text-slate-400 text-xs">${formatRelativeTime(s.lastActivity)}</td>
|
|
||||||
<td class="px-3 py-2 text-xs">${statusLabel}${currentBadge}</td>
|
|
||||||
<td class="px-3 py-2 text-right">
|
|
||||||
${canRevoke ? `
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="revoke-session-btn px-2.5 py-1.5 rounded bg-red-700/80 hover:bg-red-600 text-white text-xs"
|
|
||||||
data-session-id="${escapeHtml(s.id)}"
|
|
||||||
${s.current ? 'data-is-current="true"' : ""}
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-right-from-bracket mr-1"></i>Revoke
|
|
||||||
</button>
|
|
||||||
` : ""}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
} catch (err) {
|
|
||||||
sessionsTableBody.innerHTML = `
|
|
||||||
<tr>
|
|
||||||
<td colspan="6" class="px-3 py-6 text-center text-rose-300">Error loading sessions</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRevokeSessionClick(event) {
|
|
||||||
const button = event.target.closest(".revoke-session-btn");
|
|
||||||
if (!button) return;
|
|
||||||
|
|
||||||
const sessionId = button.dataset.sessionId;
|
|
||||||
const isCurrent = button.dataset.isCurrent === "true";
|
|
||||||
|
|
||||||
if (isCurrent) {
|
|
||||||
if (!confirm("This will revoke your current session and log you out. Continue?")) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.disabled = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json().catch(() => ({}));
|
|
||||||
showSessionsMessage(data.error || "Failed to revoke session", "error");
|
|
||||||
button.disabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCurrent) {
|
|
||||||
window.location.href = "/";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
showSessionsMessage("Session revoked successfully");
|
|
||||||
await loadSessions();
|
|
||||||
} catch {
|
|
||||||
showSessionsMessage("Request failed", "error");
|
|
||||||
button.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRemoveInactiveSessions() {
|
|
||||||
if (!confirm("Remove all expired and revoked sessions?")) return;
|
|
||||||
|
|
||||||
if (removeInactiveSessionsBtn) removeInactiveSessionsBtn.disabled = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/sessions/inactive", {
|
|
||||||
method: "DELETE",
|
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json().catch(() => ({}));
|
|
||||||
if (!res.ok) {
|
|
||||||
showSessionsMessage(data.error || "Failed to remove inactive sessions", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
showSessionsMessage(`Removed ${data.removed || 0} inactive session(s)`);
|
|
||||||
await loadSessions();
|
|
||||||
} catch {
|
|
||||||
showSessionsMessage("Request failed", "error");
|
|
||||||
} finally {
|
|
||||||
if (removeInactiveSessionsBtn) removeInactiveSessionsBtn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Registration Settings ──────────────────────────────────────────────────
|
// ── Registration Settings ──────────────────────────────────────────────────
|
||||||
|
|
||||||
async function loadRegistrationSettings() {
|
async function loadRegistrationSettings() {
|
||||||
@@ -2844,10 +2672,6 @@ async function init() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadSessions();
|
|
||||||
if (refreshSessionsBtn) refreshSessionsBtn.addEventListener("click", loadSessions);
|
|
||||||
if (removeInactiveSessionsBtn) removeInactiveSessionsBtn.addEventListener("click", handleRemoveInactiveSessions);
|
|
||||||
if (sessionsTableBody) sessionsTableBody.addEventListener("click", handleRevokeSessionClick);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("settings init failed", error);
|
console.error("settings init failed", error);
|
||||||
showMessage("Failed to load settings.", "error");
|
showMessage("Failed to load settings.", "error");
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Application } from "/vendor/hotwired/stimulus.js";
|
||||||
|
import SessionsController from "./controllers/sessions-controller.js";
|
||||||
|
|
||||||
|
const application = Application.start();
|
||||||
|
application.debug = false;
|
||||||
|
application.register("sessions", SessionsController);
|
||||||
|
|
||||||
|
window.OverlordStimulus = application;
|
||||||
|
|
||||||
|
export { application };
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Controller } from "/vendor/hotwired/stimulus.js";
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["time"];
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.updateTimes();
|
||||||
|
this.clock = window.setInterval(() => this.updateTimes(), 30_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
window.clearInterval(this.clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm(event) {
|
||||||
|
const message = event.currentTarget.dataset.confirmMessage;
|
||||||
|
if (message && !window.confirm(message)) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTimes() {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
for (const element of this.timeTargets) {
|
||||||
|
const timestamp = Number(element.dataset.timestamp);
|
||||||
|
if (!Number.isFinite(timestamp) || timestamp <= 0) {
|
||||||
|
element.textContent = "—";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.dataset.format === "relative") {
|
||||||
|
const difference = Math.max(0, now - timestamp);
|
||||||
|
element.textContent = difference < 60
|
||||||
|
? "Just now"
|
||||||
|
: difference < 3_600
|
||||||
|
? `${Math.floor(difference / 60)}m ago`
|
||||||
|
: difference < 86_400
|
||||||
|
? `${Math.floor(difference / 3_600)}h ago`
|
||||||
|
: `${Math.floor(difference / 86_400)}d ago`;
|
||||||
|
} else {
|
||||||
|
element.textContent = new Date(timestamp * 1_000).toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ function cleanupPageResources() {
|
|||||||
pageTrackingEnabled = false;
|
pageTrackingEnabled = false;
|
||||||
window.dispatchEvent(new Event("pagehide"));
|
window.dispatchEvent(new Event("pagehide"));
|
||||||
|
|
||||||
for (const id of tracked.intervals) original.clearInterval(id);
|
for (const id of tracked.intervals) original.clearInterval.call(window, id);
|
||||||
for (const entry of tracked.events) {
|
for (const entry of tracked.events) {
|
||||||
original.removeEventListener.call(entry.target, entry.type, entry.listener, entry.options);
|
original.removeEventListener.call(entry.target, entry.type, entry.listener, entry.options);
|
||||||
}
|
}
|
||||||
@@ -225,8 +225,8 @@ export function setupTurboNavigation({ onPathChange } = {}) {
|
|||||||
|
|
||||||
installPageResourceTracker();
|
installPageResourceTracker();
|
||||||
config.drive.progressBarDelay = 150;
|
config.drive.progressBarDelay = 150;
|
||||||
// Existing forms already have explicit fetch handlers. Turbo owns navigation only.
|
// Existing forms keep their explicit fetch handlers. New Turbo Frames opt in per form.
|
||||||
config.forms.mode = "off";
|
config.forms.mode = "optin";
|
||||||
cache.exemptPageFromCache();
|
cache.exemptPageFromCache();
|
||||||
|
|
||||||
document.getElementById("top-nav")?.setAttribute("data-turbo-permanent", "");
|
document.getElementById("top-nav")?.setAttribute("data-turbo-permanent", "");
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
<link rel="stylesheet" href="/vendor/fontawesome/css/all.min.css" />
|
<link rel="stylesheet" href="/vendor/fontawesome/css/all.min.css" />
|
||||||
<link rel="stylesheet" href="/assets/main.css" />
|
<link rel="stylesheet" href="/assets/main.css" />
|
||||||
<link rel="stylesheet" href="/assets/custom.css" />
|
<link rel="stylesheet" href="/assets/custom.css" />
|
||||||
<script defer src="/vendor/cytoscape/cytoscape.min.js"></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen bg-slate-950 text-slate-100">
|
<body class="min-h-screen bg-slate-950 text-slate-100">
|
||||||
<header id="top-nav"></header>
|
<header id="top-nav"></header>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="turbo-visit-control" content="reload" />
|
||||||
<title>Overlord Login</title>
|
<title>Overlord Login</title>
|
||||||
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
|
||||||
<link rel="stylesheet" href="/vendor/inter/400.css" />
|
<link rel="stylesheet" href="/vendor/inter/400.css" />
|
||||||
|
|||||||
@@ -76,7 +76,6 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script defer src="/vendor/chart.js/chart.umd.js"></script>
|
|
||||||
<script defer src="/assets/toast.js"></script>
|
<script defer src="/assets/toast.js"></script>
|
||||||
<script defer src="/assets/ripple.js"></script>
|
<script defer src="/assets/ripple.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -273,56 +273,23 @@
|
|||||||
<p id="settings-message" class="hidden text-sm rounded-lg px-3 py-2 border"></p>
|
<p id="settings-message" class="hidden text-sm rounded-lg px-3 py-2 border"></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Active Sessions -->
|
<!-- Active Sessions: server-rendered and managed independently by Turbo. -->
|
||||||
<section id="section-sessions" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
<turbo-frame
|
||||||
<div class="flex items-center justify-between gap-3">
|
id="section-sessions"
|
||||||
<div class="flex items-center gap-3">
|
src="/ui/settings/sessions"
|
||||||
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-emerald-500/10 border border-emerald-500/30">
|
loading="lazy"
|
||||||
<i class="fa-solid fa-desktop text-emerald-400"></i>
|
class="block bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section"
|
||||||
</div>
|
>
|
||||||
<div>
|
<div class="flex items-center gap-3">
|
||||||
<h2 class="text-lg font-semibold">Active Sessions</h2>
|
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-emerald-500/10 border border-emerald-500/30">
|
||||||
<p class="text-sm text-slate-400">View and manage your active login sessions.</p>
|
<i class="fa-solid fa-desktop text-emerald-400"></i>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold">Active Sessions</h2>
|
||||||
|
<p class="text-sm text-slate-400">Loading sessions…</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
id="refresh-sessions-btn"
|
|
||||||
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700 text-sm transition-colors"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-rotate"></i>Refresh
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
id="remove-inactive-sessions-btn"
|
|
||||||
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-red-900/40 hover:bg-red-800/60 text-red-100 border border-red-700/60 text-sm transition-colors"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-broom"></i>Remove Inactive
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</turbo-frame>
|
||||||
<div class="overflow-x-auto border border-slate-800 rounded-lg">
|
|
||||||
<table class="w-full text-sm">
|
|
||||||
<thead class="bg-slate-800/60 text-slate-300">
|
|
||||||
<tr>
|
|
||||||
<th class="text-left px-3 py-2">IP</th>
|
|
||||||
<th class="text-left px-3 py-2">Browser / Client</th>
|
|
||||||
<th class="text-left px-3 py-2">Created</th>
|
|
||||||
<th class="text-left px-3 py-2">Last Active</th>
|
|
||||||
<th class="text-left px-3 py-2">Status</th>
|
|
||||||
<th class="text-right px-3 py-2">Action</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="sessions-table-body" class="divide-y divide-slate-800">
|
|
||||||
<tr>
|
|
||||||
<td colspan="6" class="px-3 py-6 text-center text-slate-400">Loading...</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p id="sessions-message" class="hidden text-sm rounded-lg px-3 py-2 border"></p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Dashboard Preferences -->
|
<!-- Dashboard Preferences -->
|
||||||
<section id="section-prefs" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
<section id="section-prefs" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||||
|
|||||||
@@ -155,6 +155,24 @@ copyFile(
|
|||||||
path.join(NM, "chart.js", "dist", "chart.umd.js"),
|
path.join(NM, "chart.js", "dist", "chart.umd.js"),
|
||||||
path.join(VENDOR, "chart.js", "chart.umd.js"),
|
path.join(VENDOR, "chart.js", "chart.umd.js"),
|
||||||
);
|
);
|
||||||
|
const chartEntry = `
|
||||||
|
export { default } from '${path.join(NM, "chart.js", "auto", "auto.js").replace(/\\/g, "/")}';
|
||||||
|
`;
|
||||||
|
const chartTmp = path.join(ROOT, "scripts", "_chart-entry.ts");
|
||||||
|
await Bun.write(chartTmp, chartEntry);
|
||||||
|
const chartBuild = await Bun.build({
|
||||||
|
entrypoints: [chartTmp],
|
||||||
|
minify: true,
|
||||||
|
target: "browser",
|
||||||
|
format: "esm",
|
||||||
|
});
|
||||||
|
if (chartBuild.success) {
|
||||||
|
await Bun.write(path.join(VENDOR, "chart.js", "chart.esm.js"), chartBuild.outputs[0]);
|
||||||
|
} else {
|
||||||
|
console.error("Chart.js bundle failed:", chartBuild.logs);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
rmSync(chartTmp, { force: true });
|
||||||
|
|
||||||
/* ── Monaco Editor ───────────────────────────────────────────────── */
|
/* ── Monaco Editor ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -193,8 +211,8 @@ copyFile(
|
|||||||
|
|
||||||
console.log("Copying Cytoscape.js ...");
|
console.log("Copying Cytoscape.js ...");
|
||||||
copyFile(
|
copyFile(
|
||||||
path.join(NM, "cytoscape", "dist", "cytoscape.min.js"),
|
path.join(NM, "cytoscape", "dist", "cytoscape.esm.min.mjs"),
|
||||||
path.join(VENDOR, "cytoscape", "cytoscape.min.js"),
|
path.join(VENDOR, "cytoscape", "cytoscape.esm.min.mjs"),
|
||||||
);
|
);
|
||||||
|
|
||||||
/* ── Hotwire Turbo ───────────────────────────────────────────────── */
|
/* ── Hotwire Turbo ───────────────────────────────────────────────── */
|
||||||
@@ -205,6 +223,14 @@ copyFile(
|
|||||||
path.join(VENDOR, "hotwired", "turbo.es2017-esm.js"),
|
path.join(VENDOR, "hotwired", "turbo.es2017-esm.js"),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* ── Hotwire Stimulus ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
console.log("Copying Hotwire Stimulus ...");
|
||||||
|
copyFile(
|
||||||
|
path.join(NM, "@hotwired", "stimulus", "dist", "stimulus.js"),
|
||||||
|
path.join(VENDOR, "hotwired", "stimulus.js"),
|
||||||
|
);
|
||||||
|
|
||||||
/* ── highlight.js (bundle core + languages) ──────────────────────── */
|
/* ── highlight.js (bundle core + languages) ──────────────────────── */
|
||||||
|
|
||||||
console.log("Bundling highlight.js ...");
|
console.log("Bundling highlight.js ...");
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { metrics } from "./metrics";
|
|||||||
import { getClientDbSyncStats } from "./client-db-sync";
|
import { getClientDbSyncStats } from "./client-db-sync";
|
||||||
import { ensureDataDir } from "./paths";
|
import { ensureDataDir } from "./paths";
|
||||||
import { handleAuthRoutes } from "./server/routes/auth-routes";
|
import { handleAuthRoutes } from "./server/routes/auth-routes";
|
||||||
|
import { handleUiRoutes } from "./server/routes/ui-routes";
|
||||||
import { handleAutoScriptsRoutes } from "./server/routes/auto-scripts-routes";
|
import { handleAutoScriptsRoutes } from "./server/routes/auto-scripts-routes";
|
||||||
import { handleSavedScriptsRoutes } from "./server/routes/saved-scripts-routes";
|
import { handleSavedScriptsRoutes } from "./server/routes/saved-scripts-routes";
|
||||||
import { handleEnrollmentRoutes, setPostApproveHook } from "./server/routes/enrollment-routes";
|
import { handleEnrollmentRoutes, setPostApproveHook } from "./server/routes/enrollment-routes";
|
||||||
@@ -715,6 +716,7 @@ async function startServer() {
|
|||||||
...routeDeps.registration,
|
...routeDeps.registration,
|
||||||
requestIP: (srv as any).requestIP,
|
requestIP: (srv as any).requestIP,
|
||||||
}),
|
}),
|
||||||
|
(req, url, srv) => handleUiRoutes(req, url, srv as any),
|
||||||
(req, url, srv) => handleAuthRoutes(req, url, srv as any),
|
(req, url, srv) => handleAuthRoutes(req, url, srv as any),
|
||||||
(req, url, srv) => handleOidcRoutes(req, url, srv as any),
|
(req, url, srv) => handleOidcRoutes(req, url, srv as any),
|
||||||
(req, url, srv) => handleNotificationsConfigRoutes(req, url, srv as any, routeDeps.notificationsConfig),
|
(req, url, srv) => handleNotificationsConfigRoutes(req, url, srv as any, routeDeps.notificationsConfig),
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "bun:test";
|
||||||
|
import { generateToken } from "../../auth";
|
||||||
|
import {
|
||||||
|
createSession,
|
||||||
|
getSessionById,
|
||||||
|
hashTokenForSession,
|
||||||
|
listUserSessions,
|
||||||
|
revokeSessionById,
|
||||||
|
} from "../../db";
|
||||||
|
import { createUser, deleteUser, getUserById } from "../../users";
|
||||||
|
import { handleUiRoutes } from "./ui-routes";
|
||||||
|
|
||||||
|
const PASSWORD = "Aa1!TurboFrameSessionsTest";
|
||||||
|
const createdUserIds: number[] = [];
|
||||||
|
const mockServer = { requestIP: () => ({ address: "127.0.0.1" }) };
|
||||||
|
|
||||||
|
async function tempUser(userAgent = "Turbo Frame Test") {
|
||||||
|
const username = `ui_sessions_${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`;
|
||||||
|
const created = await createUser(username, PASSWORD, "admin", "test");
|
||||||
|
expect(created.success).toBe(true);
|
||||||
|
const user = getUserById(created.userId!);
|
||||||
|
expect(user).not.toBeNull();
|
||||||
|
createdUserIds.push(user!.id);
|
||||||
|
const token = await generateToken(user!, { ip: "127.0.0.1", userAgent });
|
||||||
|
return { user: user!, token };
|
||||||
|
}
|
||||||
|
|
||||||
|
function call(method: string, path: string, token: string | null) {
|
||||||
|
const url = new URL(`https://localhost${path}`);
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
return handleUiRoutes(new Request(url, { method, headers }), url, mockServer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSession(userId: number, userAgent: string) {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const session = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
userId,
|
||||||
|
tokenHash: crypto.randomUUID().replaceAll("-", ""),
|
||||||
|
ip: "127.0.0.2",
|
||||||
|
userAgent,
|
||||||
|
createdAt: now,
|
||||||
|
expiresAt: now + 3600,
|
||||||
|
};
|
||||||
|
createSession(session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (createdUserIds.length > 0) {
|
||||||
|
const id = createdUserIds.pop();
|
||||||
|
if (typeof id === "number") deleteUser(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("settings sessions Turbo Frame", () => {
|
||||||
|
test("redirects unauthenticated frame requests to login", async () => {
|
||||||
|
const response = await call("GET", "/ui/settings/sessions", null);
|
||||||
|
expect(response!.status).toBe(303);
|
||||||
|
expect(response!.headers.get("location")).toBe("https://localhost/login.html");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders an escaped frame and marks the current session", async () => {
|
||||||
|
const account = await tempUser('<script>alert("xss")</script>');
|
||||||
|
const response = await call("GET", "/ui/settings/sessions", account.token);
|
||||||
|
expect(response!.status).toBe(200);
|
||||||
|
expect(response!.headers.get("content-type")).toContain("text/html");
|
||||||
|
expect(response!.headers.get("cache-control")).toBe("no-store");
|
||||||
|
|
||||||
|
const body = await response!.text();
|
||||||
|
expect(body).toContain('<turbo-frame id="section-sessions"');
|
||||||
|
expect(body).toContain("Current");
|
||||||
|
expect(body).toContain("<script>alert("xss")</script>");
|
||||||
|
expect(body).not.toContain('<script>alert("xss")</script>');
|
||||||
|
expect(body).toContain('data-controller="sessions"');
|
||||||
|
expect(body).toContain('data-turbo="true"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("revokes another session and returns the refreshed frame", async () => {
|
||||||
|
const account = await tempUser();
|
||||||
|
const added = addSession(account.user.id, "Second browser");
|
||||||
|
const secondSession = getSessionById(added.id);
|
||||||
|
expect(secondSession).toBeDefined();
|
||||||
|
|
||||||
|
const response = await call(
|
||||||
|
"POST",
|
||||||
|
`/ui/settings/sessions/${encodeURIComponent(secondSession!.id)}/revoke`,
|
||||||
|
account.token,
|
||||||
|
);
|
||||||
|
expect(response!.status).toBe(200);
|
||||||
|
expect(await response!.text()).toContain("Session revoked successfully.");
|
||||||
|
expect(getSessionById(secondSession!.id)?.revoked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("revoking the current session clears the cookie and redirects", async () => {
|
||||||
|
const account = await tempUser();
|
||||||
|
const currentHash = hashTokenForSession(account.token);
|
||||||
|
const currentSession = listUserSessions(account.user.id).find((session) => session.tokenHash === currentHash);
|
||||||
|
expect(currentSession).toBeDefined();
|
||||||
|
|
||||||
|
const response = await call(
|
||||||
|
"POST",
|
||||||
|
`/ui/settings/sessions/${encodeURIComponent(currentSession!.id)}/revoke`,
|
||||||
|
account.token,
|
||||||
|
);
|
||||||
|
expect(response!.status).toBe(303);
|
||||||
|
expect(response!.headers.get("location")).toBe("/");
|
||||||
|
expect(response!.headers.get("set-cookie")).toContain("Max-Age=0");
|
||||||
|
expect(getSessionById(currentSession!.id)?.revoked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("removes inactive sessions and reports the count", async () => {
|
||||||
|
const account = await tempUser();
|
||||||
|
const added = addSession(account.user.id, "Inactive browser");
|
||||||
|
const secondSession = getSessionById(added.id);
|
||||||
|
expect(secondSession).toBeDefined();
|
||||||
|
revokeSessionById(secondSession!.id);
|
||||||
|
|
||||||
|
const response = await call("POST", "/ui/settings/sessions/inactive", account.token);
|
||||||
|
expect(response!.status).toBe(200);
|
||||||
|
expect(await response!.text()).toContain("Removed 1 inactive session(s).");
|
||||||
|
expect(getSessionById(secondSession!.id)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { authenticateRequest, extractTokenFromRequest } from "../../auth";
|
||||||
|
import { AuditAction, logAudit } from "../../auditLog";
|
||||||
|
import {
|
||||||
|
deleteInactiveSessions,
|
||||||
|
getSessionById,
|
||||||
|
hashTokenForSession,
|
||||||
|
listUserSessions,
|
||||||
|
persistRevokedTokenHash,
|
||||||
|
revokeSessionById,
|
||||||
|
} from "../../db";
|
||||||
|
import { htmlResponse } from "../ui/html";
|
||||||
|
import { renderSessionsFrame } from "../ui/sessions-view";
|
||||||
|
import { makeAuthCookieClear } from "./auth-cookie";
|
||||||
|
|
||||||
|
type UiRouteServer = {
|
||||||
|
requestIP: (req: Request) => { address?: string } | null | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
function redirectToLogin(req: Request): Response {
|
||||||
|
return Response.redirect(new URL("/login.html", req.url), 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentTokenHash(req: Request): string | null {
|
||||||
|
const token = extractTokenFromRequest(req);
|
||||||
|
return token ? hashTokenForSession(token) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleUiRoutes(
|
||||||
|
req: Request,
|
||||||
|
url: URL,
|
||||||
|
server: UiRouteServer,
|
||||||
|
): Promise<Response | null> {
|
||||||
|
if (!url.pathname.startsWith("/ui/")) return null;
|
||||||
|
|
||||||
|
const user = await authenticateRequest(req);
|
||||||
|
if (!user) return redirectToLogin(req);
|
||||||
|
|
||||||
|
if (req.method === "GET" && url.pathname === "/ui/settings/sessions") {
|
||||||
|
return htmlResponse(renderSessionsFrame(
|
||||||
|
listUserSessions(user.userId),
|
||||||
|
currentTokenHash(req),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && url.pathname === "/ui/settings/sessions/inactive") {
|
||||||
|
const removed = deleteInactiveSessions(user.userId);
|
||||||
|
logAudit({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
username: user.username,
|
||||||
|
ip: server.requestIP(req)?.address || "unknown",
|
||||||
|
action: AuditAction.LOGOUT,
|
||||||
|
details: `Removed ${removed} inactive session(s)`,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
return htmlResponse(renderSessionsFrame(
|
||||||
|
listUserSessions(user.userId),
|
||||||
|
currentTokenHash(req),
|
||||||
|
{ text: `Removed ${removed} inactive session(s).` },
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
const revokeMatch = req.method === "POST"
|
||||||
|
? url.pathname.match(/^\/ui\/settings\/sessions\/([^/]+)\/revoke$/)
|
||||||
|
: null;
|
||||||
|
if (revokeMatch) {
|
||||||
|
let sessionId = "";
|
||||||
|
try {
|
||||||
|
sessionId = decodeURIComponent(revokeMatch[1]);
|
||||||
|
} catch {
|
||||||
|
return htmlResponse(renderSessionsFrame(
|
||||||
|
listUserSessions(user.userId),
|
||||||
|
currentTokenHash(req),
|
||||||
|
{ text: "Invalid session identifier.", type: "error" },
|
||||||
|
), { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = getSessionById(sessionId);
|
||||||
|
if (!session || session.userId !== user.userId) {
|
||||||
|
return htmlResponse(renderSessionsFrame(
|
||||||
|
listUserSessions(user.userId),
|
||||||
|
currentTokenHash(req),
|
||||||
|
{ text: "Session not found.", type: "error" },
|
||||||
|
), { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenHash = currentTokenHash(req);
|
||||||
|
const revokingCurrentSession = session.tokenHash === tokenHash;
|
||||||
|
const result = revokeSessionById(sessionId);
|
||||||
|
if (result.tokenHash) persistRevokedTokenHash(result.tokenHash, session.expiresAt);
|
||||||
|
|
||||||
|
logAudit({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
username: user.username,
|
||||||
|
ip: server.requestIP(req)?.address || "unknown",
|
||||||
|
action: AuditAction.LOGOUT,
|
||||||
|
details: `Revoked session ${sessionId}`,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (revokingCurrentSession) {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 303,
|
||||||
|
headers: {
|
||||||
|
"Location": "/",
|
||||||
|
"Set-Cookie": makeAuthCookieClear(req),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return htmlResponse(renderSessionsFrame(
|
||||||
|
listUserSessions(user.userId),
|
||||||
|
tokenHash,
|
||||||
|
{ text: "Session revoked successfully." },
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return htmlResponse("<h1>Not found</h1>", { status: 404 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export function escapeHtml(value: unknown): string {
|
||||||
|
return String(value ?? "")
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function htmlResponse(
|
||||||
|
html: string,
|
||||||
|
init: ResponseInit = {},
|
||||||
|
): Response {
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
headers.set("Content-Type", "text/html; charset=utf-8");
|
||||||
|
headers.set("Cache-Control", "no-store");
|
||||||
|
headers.append("Vary", "Turbo-Frame");
|
||||||
|
return new Response(html, { ...init, headers });
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type { SessionRecord } from "../../db";
|
||||||
|
import { escapeHtml } from "./html";
|
||||||
|
|
||||||
|
type SessionsMessage = {
|
||||||
|
text: string;
|
||||||
|
type?: "success" | "error";
|
||||||
|
};
|
||||||
|
|
||||||
|
function summarizeUserAgent(userAgent: string | null): string {
|
||||||
|
if (!userAgent) return "Unknown";
|
||||||
|
return userAgent.length > 80 ? `${userAgent.slice(0, 77)}...` : userAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMessage(message?: SessionsMessage): string {
|
||||||
|
if (!message) return "";
|
||||||
|
const classes = message.type === "error"
|
||||||
|
? "text-rose-200 border-rose-700 bg-rose-900/30"
|
||||||
|
: "text-emerald-200 border-emerald-700 bg-emerald-900/30";
|
||||||
|
return `<p class="text-sm rounded-lg px-3 py-2 border ${classes}" role="status">${escapeHtml(message.text)}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSessionRow(
|
||||||
|
session: SessionRecord,
|
||||||
|
currentTokenHash: string | null,
|
||||||
|
now: number,
|
||||||
|
): string {
|
||||||
|
const current = session.tokenHash === currentTokenHash;
|
||||||
|
const expired = session.expiresAt <= now;
|
||||||
|
const canRevoke = !session.revoked && !expired;
|
||||||
|
const status = session.revoked
|
||||||
|
? '<span class="text-rose-400">Revoked</span>'
|
||||||
|
: expired
|
||||||
|
? '<span class="text-slate-500">Expired</span>'
|
||||||
|
: '<span class="text-emerald-400">Active</span>';
|
||||||
|
const currentBadge = current
|
||||||
|
? ' <span class="ml-1 px-1.5 py-0.5 text-[10px] rounded bg-sky-600/30 text-sky-300 border border-sky-500/30">Current</span>'
|
||||||
|
: "";
|
||||||
|
const confirmation = current
|
||||||
|
? "This will revoke your current session and log you out. Continue?"
|
||||||
|
: "Revoke this session?";
|
||||||
|
|
||||||
|
return `<tr id="session-${escapeHtml(session.id)}">
|
||||||
|
<td class="px-3 py-2 font-mono text-xs text-slate-100">${escapeHtml(session.ip || "—")}</td>
|
||||||
|
<td class="px-3 py-2 text-slate-300 text-xs max-w-[200px] truncate" title="${escapeHtml(session.userAgent || "")}">${escapeHtml(summarizeUserAgent(session.userAgent))}</td>
|
||||||
|
<td class="px-3 py-2 text-slate-400 text-xs"><time data-sessions-target="time" data-timestamp="${session.createdAt}" datetime="${new Date(session.createdAt * 1000).toISOString()}">—</time></td>
|
||||||
|
<td class="px-3 py-2 text-slate-400 text-xs"><time data-sessions-target="time" data-format="relative" data-timestamp="${session.lastActivity}" datetime="${new Date(session.lastActivity * 1000).toISOString()}">—</time></td>
|
||||||
|
<td class="px-3 py-2 text-xs">${status}${currentBadge}</td>
|
||||||
|
<td class="px-3 py-2 text-right">
|
||||||
|
${canRevoke ? `<form method="post" action="/ui/settings/sessions/${encodeURIComponent(session.id)}/revoke" data-turbo="true" data-action="submit->sessions#confirm" data-confirm-message="${escapeHtml(confirmation)}" class="inline">
|
||||||
|
<button type="submit" class="px-2.5 py-1.5 rounded bg-red-700/80 hover:bg-red-600 text-white text-xs disabled:opacity-50">
|
||||||
|
<i class="fa-solid fa-right-from-bracket mr-1"></i>Revoke
|
||||||
|
</button>
|
||||||
|
</form>` : ""}
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderSessionsFrame(
|
||||||
|
sessions: SessionRecord[],
|
||||||
|
currentTokenHash: string | null,
|
||||||
|
message?: SessionsMessage,
|
||||||
|
): string {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const rows = sessions.length > 0
|
||||||
|
? sessions.map((session) => renderSessionRow(session, currentTokenHash, now)).join("")
|
||||||
|
: '<tr><td colspan="6" class="px-3 py-6 text-center text-slate-400">No sessions found</td></tr>';
|
||||||
|
|
||||||
|
return `<turbo-frame id="section-sessions" class="block bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section" data-controller="sessions">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-emerald-500/10 border border-emerald-500/30">
|
||||||
|
<i class="fa-solid fa-desktop text-emerald-400"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold">Active Sessions</h2>
|
||||||
|
<p class="text-sm text-slate-400">View and manage your active login sessions.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<a href="/ui/settings/sessions" class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700 text-sm transition-colors">
|
||||||
|
<i class="fa-solid fa-rotate"></i>Refresh
|
||||||
|
</a>
|
||||||
|
<form method="post" action="/ui/settings/sessions/inactive" data-turbo="true" data-action="submit->sessions#confirm" data-confirm-message="Remove all expired and revoked sessions?">
|
||||||
|
<button type="submit" class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-red-900/40 hover:bg-red-800/60 text-red-100 border border-red-700/60 text-sm transition-colors disabled:opacity-50">
|
||||||
|
<i class="fa-solid fa-broom"></i>Remove Inactive
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto border border-slate-800 rounded-lg">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-slate-800/60 text-slate-300">
|
||||||
|
<tr>
|
||||||
|
<th class="text-left px-3 py-2">IP</th>
|
||||||
|
<th class="text-left px-3 py-2">Browser / Client</th>
|
||||||
|
<th class="text-left px-3 py-2">Created</th>
|
||||||
|
<th class="text-left px-3 py-2">Last Active</th>
|
||||||
|
<th class="text-left px-3 py-2">Status</th>
|
||||||
|
<th class="text-right px-3 py-2">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-800">${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
${renderMessage(message)}
|
||||||
|
</turbo-frame>`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user