finish up turbo and add stimulus.
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
"": {
|
||||
"name": "overlord-server",
|
||||
"dependencies": {
|
||||
"@hotwired/stimulus": "^3.2.2",
|
||||
"@hotwired/turbo": "^8.0.23",
|
||||
"@msgpack/msgpack": "^3.0.0",
|
||||
"@solana/web3.js": "^1.98.4",
|
||||
@@ -63,6 +64,8 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hotwired/stimulus": "^3.2.2",
|
||||
"@hotwired/turbo": "^8.0.23",
|
||||
"@msgpack/msgpack": "^3.0.0",
|
||||
"@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 searchInput = document.getElementById("graph-search");
|
||||
const statusSelect = document.getElementById("graph-status");
|
||||
@@ -12,6 +14,7 @@ const detailActions = document.getElementById("graph-detail-actions");
|
||||
let cy = null;
|
||||
let currentGraph = null;
|
||||
let searchTimer = null;
|
||||
let graphPageActive = true;
|
||||
const LARGE_GRAPH_ANIMATION_CLIENTS = 30000;
|
||||
|
||||
const typeColors = {
|
||||
@@ -215,16 +218,12 @@ function selectNode(node) {
|
||||
}
|
||||
|
||||
function renderGraph(graph) {
|
||||
if (!graphPageActive || !container?.isConnected) return;
|
||||
currentGraph = graph;
|
||||
setSummary(graph.summary);
|
||||
|
||||
if (!window.cytoscape) {
|
||||
if (summaryEl) summaryEl.textContent = "Cytoscape failed to load";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cy) {
|
||||
cy = window.cytoscape({
|
||||
cy = cytoscape({
|
||||
container,
|
||||
elements: graphElements(graph),
|
||||
minZoom: 0.18,
|
||||
@@ -305,7 +304,7 @@ function renderGraph(graph) {
|
||||
}
|
||||
|
||||
async function loadGraph() {
|
||||
if (!container) return;
|
||||
if (!graphPageActive || !container) return;
|
||||
if (summaryEl) summaryEl.textContent = "Loading...";
|
||||
const params = new URLSearchParams({
|
||||
limit: "350",
|
||||
@@ -314,10 +313,12 @@ async function loadGraph() {
|
||||
});
|
||||
const res = await fetch(`/api/client-graph?${params}`, { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
if (!graphPageActive) return;
|
||||
if (summaryEl) summaryEl.textContent = `Graph unavailable (${res.status})`;
|
||||
return;
|
||||
}
|
||||
renderGraph(await res.json());
|
||||
const graph = await res.json();
|
||||
if (graphPageActive) renderGraph(graph);
|
||||
}
|
||||
|
||||
function debounceLoad() {
|
||||
@@ -332,10 +333,19 @@ searchInput?.addEventListener("input", debounceLoad);
|
||||
statusSelect?.addEventListener("change", loadGraph);
|
||||
refreshBtn?.addEventListener("click", loadGraph);
|
||||
fitBtn?.addEventListener("click", () => cy?.fit(undefined, 32));
|
||||
window.addEventListener("resize", () => {
|
||||
const handleResize = () => {
|
||||
cy?.resize();
|
||||
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();
|
||||
loadGraph();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import Chart from "/vendor/chart.js/chart.esm.js";
|
||||
|
||||
let clientsChart = null;
|
||||
let commandsChart = null;
|
||||
let bandwidthChart = null;
|
||||
@@ -29,6 +31,8 @@ let countryGlobeHoverEntry = null;
|
||||
let countryGlobeSortedFeatures = [];
|
||||
let countryGlobeLastSpinFrameAt = 0;
|
||||
let countryGlobeLastHoverFrameAt = 0;
|
||||
let metricsPollTimer = null;
|
||||
let metricsPageActive = true;
|
||||
|
||||
const GEOJSON_URL = "/vendor/geo-countries/countries.geojson";
|
||||
const MAX_CHART_POINTS = 240;
|
||||
@@ -1806,6 +1810,7 @@ function updateCharts(history, snapshot) {
|
||||
}
|
||||
|
||||
async function fetchMetrics() {
|
||||
if (!metricsPageActive) return;
|
||||
try {
|
||||
const response = await fetch(`/api/metrics?historyLimit=${MAX_CHART_POINTS}`, {
|
||||
credentials: "include",
|
||||
@@ -1821,11 +1826,13 @@ async function fetchMetrics() {
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!metricsPageActive) return;
|
||||
updateMetrics(data.snapshot, data.debug);
|
||||
updateCharts(data.history, data.snapshot);
|
||||
|
||||
document.getElementById("status-text").textContent = "Live";
|
||||
} catch (err) {
|
||||
if (!metricsPageActive) return;
|
||||
console.error("Error fetching metrics:", err);
|
||||
document.getElementById("status-text").textContent = "Error";
|
||||
}
|
||||
@@ -1834,12 +1841,14 @@ async function fetchMetrics() {
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const res = await fetch("/api/auth/me", { credentials: "include" });
|
||||
if (!metricsPageActive) return;
|
||||
if (!res.ok) {
|
||||
window.location.href = "/";
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (!metricsPageActive) return;
|
||||
document.getElementById("username-display").textContent = data.username;
|
||||
|
||||
const roleBadge = document.getElementById("role-badge");
|
||||
@@ -1895,16 +1904,18 @@ async function checkAuth() {
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
async function initMetricsPage() {
|
||||
await checkAuth();
|
||||
if (!metricsPageActive) return;
|
||||
|
||||
initCharts();
|
||||
|
||||
await initCountryMap();
|
||||
if (!metricsPageActive) return;
|
||||
|
||||
await fetchMetrics();
|
||||
|
||||
setInterval(fetchMetrics, METRICS_POLL_INTERVAL_MS);
|
||||
metricsPollTimer = setInterval(fetchMetrics, METRICS_POLL_INTERVAL_MS);
|
||||
|
||||
const logoutBtn = document.getElementById("logout-btn");
|
||||
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 { loadPluginNavItems } from "./nav/plugins-loader.js";
|
||||
import { init as initCommandPalette } from "./command-palette.js";
|
||||
import "./stimulus/application.js";
|
||||
import {
|
||||
runWithoutPageTracking,
|
||||
setupTurboNavigation,
|
||||
|
||||
@@ -64,7 +64,7 @@ export const NAV_GROUPS = [
|
||||
children: [
|
||||
{ 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: "/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) {
|
||||
const hiddenCls = child.hidden ? " hidden" : "";
|
||||
const prefetchAttr = child.turboPrefetch === false ? ' data-turbo-prefetch="false"' : "";
|
||||
const badgeHtml = child.hasBadge
|
||||
? `<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}">
|
||||
<i class="fa-solid ${child.icon} ${child.iconColor} nav-dd-item-icon"></i>
|
||||
<span>${child.label}</span>
|
||||
|
||||
@@ -85,6 +85,8 @@ let defaultWebhookTemplate = "";
|
||||
let defaultTelegramTemplate = "";
|
||||
let webhookTemplateEditor = null;
|
||||
let notificationTable = null;
|
||||
let pageActive = true;
|
||||
const subscriptionCleanups = [];
|
||||
|
||||
function tryFormatJsonText(text) {
|
||||
const source = String(text || "").trim();
|
||||
@@ -336,12 +338,17 @@ function entryMatchesSearch(entry, query) {
|
||||
}
|
||||
|
||||
function applyTableView() {
|
||||
if (!pageActive) return;
|
||||
const q = tableState.search.trim().toLowerCase();
|
||||
const all = Array.from(tableState.entries.values());
|
||||
const filtered = q ? all.filter((entry) => entryMatchesSearch(entry, q)) : all;
|
||||
filtered.sort(compareEntries);
|
||||
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 (all.length === 0) {
|
||||
@@ -894,24 +901,25 @@ function connect() {
|
||||
startNotificationClient();
|
||||
if (panel) markAllNotificationsRead();
|
||||
|
||||
subscribeStatus((status) => {
|
||||
subscriptionCleanups.push(subscribeStatus((status) => {
|
||||
if (status === "connected") setStatus("Connected", "ok");
|
||||
if (status === "error") setStatus("Error", "error");
|
||||
if (status === "disconnected") setStatus("Disconnected", "warn");
|
||||
});
|
||||
}));
|
||||
|
||||
subscribeReady((history) => {
|
||||
subscriptionCleanups.push(subscribeReady((history) => {
|
||||
if (!pageActive) return;
|
||||
clearTable();
|
||||
for (const item of history) {
|
||||
upsertEntry(item, "notification", false);
|
||||
}
|
||||
applyTableView();
|
||||
markAllNotificationsRead();
|
||||
});
|
||||
}));
|
||||
|
||||
subscribeNotifications((item) => addNotification(item, true));
|
||||
subscribeClientEvents((item) => addClientEvent(item));
|
||||
subscribeNotificationsCleared((clientId) => removeEntriesForClient(clientId));
|
||||
subscriptionCleanups.push(subscribeNotifications((item) => addNotification(item, true)));
|
||||
subscriptionCleanups.push(subscribeClientEvents((item) => addClientEvent(item)));
|
||||
subscriptionCleanups.push(subscribeNotificationsCleared((clientId) => removeEntriesForClient(clientId)));
|
||||
}
|
||||
|
||||
const CLIENT_EVENT_BADGE = {
|
||||
@@ -1166,3 +1174,14 @@ wireTableControls();
|
||||
wireClientIdToggle();
|
||||
initRoleUi();
|
||||
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 ──────────────────────────────────────────────────
|
||||
|
||||
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) {
|
||||
console.error("settings init failed", 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;
|
||||
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) {
|
||||
original.removeEventListener.call(entry.target, entry.type, entry.listener, entry.options);
|
||||
}
|
||||
@@ -225,8 +225,8 @@ export function setupTurboNavigation({ onPathChange } = {}) {
|
||||
|
||||
installPageResourceTracker();
|
||||
config.drive.progressBarDelay = 150;
|
||||
// Existing forms already have explicit fetch handlers. Turbo owns navigation only.
|
||||
config.forms.mode = "off";
|
||||
// Existing forms keep their explicit fetch handlers. New Turbo Frames opt in per form.
|
||||
config.forms.mode = "optin";
|
||||
cache.exemptPageFromCache();
|
||||
|
||||
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="/assets/main.css" />
|
||||
<link rel="stylesheet" href="/assets/custom.css" />
|
||||
<script defer src="/vendor/cytoscape/cytoscape.min.js"></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-950 text-slate-100">
|
||||
<header id="top-nav"></header>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="turbo-visit-control" content="reload" />
|
||||
<title>Overlord Login</title>
|
||||
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
|
||||
<link rel="stylesheet" href="/vendor/inter/400.css" />
|
||||
|
||||
@@ -76,7 +76,6 @@
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
<script defer src="/vendor/chart.js/chart.umd.js"></script>
|
||||
<script defer src="/assets/toast.js"></script>
|
||||
<script defer src="/assets/ripple.js"></script>
|
||||
</head>
|
||||
|
||||
@@ -273,56 +273,23 @@
|
||||
<p id="settings-message" class="hidden text-sm rounded-lg px-3 py-2 border"></p>
|
||||
</section>
|
||||
|
||||
<!-- Active Sessions -->
|
||||
<section id="section-sessions" class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<!-- Active Sessions: server-rendered and managed independently by Turbo. -->
|
||||
<turbo-frame
|
||||
id="section-sessions"
|
||||
src="/ui/settings/sessions"
|
||||
loading="lazy"
|
||||
class="block bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4 settings-section"
|
||||
>
|
||||
<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>
|
||||
<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 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>
|
||||
</turbo-frame>
|
||||
|
||||
<!-- Dashboard Preferences -->
|
||||
<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(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 ───────────────────────────────────────────────── */
|
||||
|
||||
@@ -193,8 +211,8 @@ copyFile(
|
||||
|
||||
console.log("Copying Cytoscape.js ...");
|
||||
copyFile(
|
||||
path.join(NM, "cytoscape", "dist", "cytoscape.min.js"),
|
||||
path.join(VENDOR, "cytoscape", "cytoscape.min.js"),
|
||||
path.join(NM, "cytoscape", "dist", "cytoscape.esm.min.mjs"),
|
||||
path.join(VENDOR, "cytoscape", "cytoscape.esm.min.mjs"),
|
||||
);
|
||||
|
||||
/* ── Hotwire Turbo ───────────────────────────────────────────────── */
|
||||
@@ -205,6 +223,14 @@ copyFile(
|
||||
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) ──────────────────────── */
|
||||
|
||||
console.log("Bundling highlight.js ...");
|
||||
|
||||
@@ -19,6 +19,7 @@ import { metrics } from "./metrics";
|
||||
import { getClientDbSyncStats } from "./client-db-sync";
|
||||
import { ensureDataDir } from "./paths";
|
||||
import { handleAuthRoutes } from "./server/routes/auth-routes";
|
||||
import { handleUiRoutes } from "./server/routes/ui-routes";
|
||||
import { handleAutoScriptsRoutes } from "./server/routes/auto-scripts-routes";
|
||||
import { handleSavedScriptsRoutes } from "./server/routes/saved-scripts-routes";
|
||||
import { handleEnrollmentRoutes, setPostApproveHook } from "./server/routes/enrollment-routes";
|
||||
@@ -715,6 +716,7 @@ async function startServer() {
|
||||
...routeDeps.registration,
|
||||
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) => handleOidcRoutes(req, url, srv as any),
|
||||
(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