huge update.

fix issues with failing tests (I'm just gonna fucking assume directx isn't installed on the github actions machines).

Added MUCH better RBAC support and control

Fixed an issue when you have over a million clients RBAC would be slow asf

Add settings page with some basic settings and configs you can change.
This commit is contained in:
KingKDot
2026-03-06 20:50:02 -06:00
parent 0b4e059f2b
commit d26668df78
26 changed files with 2092 additions and 50 deletions
+10 -1
View File
@@ -2,6 +2,7 @@ import { state } from "./state.js";
import { digestData } from "./utils.js";
const POLL_INTERVAL_MS = 5000;
const PREF_REFRESH_KEY = "overlord_refresh_interval_seconds";
let pollTimer = null;
let render = () => {};
@@ -91,11 +92,19 @@ function updateOsFilter(items) {
}
}
function getConfiguredPollIntervalMs(defaultMs) {
const savedSeconds = Number(localStorage.getItem(PREF_REFRESH_KEY));
if (!Number.isFinite(savedSeconds)) return defaultMs;
const boundedSeconds = Math.min(120, Math.max(3, savedSeconds));
return boundedSeconds * 1000;
}
export function startAutoRefresh(intervalMs = POLL_INTERVAL_MS) {
const effectiveInterval = getConfiguredPollIntervalMs(intervalMs);
clearInterval(pollTimer);
pollTimer = setInterval(() => {
loadWithOptions({ force: false });
}, intervalMs);
}, effectiveInterval);
}
export async function sendCommand(clientId, action) {
+12
View File
@@ -23,6 +23,7 @@ if (host) {
"/plugins": "plugins-link",
"/build": "build-link",
"/users": "users-link",
"/user-client-access": "users-link",
"/notifications": "notifications-link",
};
const activeId = activeMap[path];
@@ -58,6 +59,17 @@ if (host) {
});
}
if (refs.accountSettingsBtn && !refs.accountSettingsBtn.dataset.boundSettings) {
refs.accountSettingsBtn.dataset.boundSettings = "true";
refs.accountSettingsBtn.addEventListener("click", () => {
window.location.href = "/settings";
});
}
if (path === "/settings" && refs.accountSettingsBtn) {
refs.accountSettingsBtn.classList.add("ring-1", "ring-sky-500/60", "bg-slate-700");
}
const updateToggle = () => {
const enabled = getNotificationsEnabled();
if (refs.notifyToggle) {
@@ -85,8 +85,12 @@ export function mountNav(host) {
class="hidden min-w-[20px] h-5 px-1 rounded-full bg-rose-500 text-white text-xs flex items-center justify-center"
></span>
</button>
<div
class="inline-flex items-center gap-2 px-3 py-2 rounded-full bg-slate-800 text-slate-100 min-w-0 max-w-full md:max-w-none"
<button
id="account-settings-btn"
class="inline-flex items-center gap-2 px-3 py-2 rounded-full bg-slate-800 text-slate-100 min-w-0 max-w-full md:max-w-none border border-slate-700/70 hover:bg-slate-700 transition-colors"
title="Open settings"
aria-label="Open settings"
type="button"
>
<i class="fa-solid fa-user-shield text-sky-300"></i>
<span id="username-display" class="truncate max-w-[110px] sm:max-w-[180px] md:max-w-none">Loading...</span>
@@ -94,7 +98,7 @@ export function mountNav(host) {
id="role-badge"
class="text-sm px-2 py-0.5 rounded-full bg-slate-700 shrink-0"
></span>
</div>
</button>
<button
id="logout-btn"
class="group 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 transition-colors"
@@ -116,6 +120,7 @@ export function mountNav(host) {
logoutBtn: document.getElementById("logout-btn"),
notifyToggle: document.getElementById("notify-toggle"),
notifyBadge: document.getElementById("notify-badge"),
accountSettingsBtn: document.getElementById("account-settings-btn"),
usernameDisplay: document.getElementById("username-display"),
roleBadge: document.getElementById("role-badge"),
usersLink: document.getElementById("users-link"),
+384
View File
@@ -0,0 +1,384 @@
import {
getNotificationsEnabled,
setNotificationsEnabled,
} from "./notify-client.js";
const PREF_REFRESH_KEY = "overlord_refresh_interval_seconds";
const usernameEl = document.getElementById("settings-username");
const roleEl = document.getElementById("settings-role");
const messageEl = document.getElementById("settings-message");
const passwordForm = document.getElementById("password-form");
const currentPasswordInput = document.getElementById("current-password");
const newPasswordInput = document.getElementById("new-password");
const confirmPasswordInput = document.getElementById("confirm-password");
const passwordPolicyHint = document.getElementById("password-policy-hint");
const prefsForm = document.getElementById("prefs-form");
const prefNotificationsInput = document.getElementById("pref-notifications");
const prefRefreshSecondsInput = document.getElementById("pref-refresh-seconds");
const bansTableBody = document.getElementById("bans-table-body");
const bansPermissionNote = document.getElementById("bans-permission-note");
const refreshBansBtn = document.getElementById("refresh-bans-btn");
const securityForm = document.getElementById("security-form");
const securityPermissionNote = document.getElementById("security-permission-note");
const securitySaveBtn = document.getElementById("security-save-btn");
const securitySessionTtlInput = document.getElementById("security-session-ttl");
const securityLoginMaxAttemptsInput = document.getElementById("security-login-max-attempts");
const securityLoginWindowInput = document.getElementById("security-login-window");
const securityLockoutInput = document.getElementById("security-lockout");
const securityPasswordMinInput = document.getElementById("security-password-min");
const securityRequireUppercaseInput = document.getElementById("security-require-uppercase");
const securityRequireLowercaseInput = document.getElementById("security-require-lowercase");
const securityRequireNumberInput = document.getElementById("security-require-number");
const securityRequireSymbolInput = document.getElementById("security-require-symbol");
let currentUser = null;
let securityConfig = null;
function showMessage(text, type = "ok") {
if (!messageEl) return;
messageEl.textContent = text;
messageEl.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") {
messageEl.classList.add("text-rose-200", "border-rose-700", "bg-rose-900/30");
} else {
messageEl.classList.add("text-emerald-200", "border-emerald-700", "bg-emerald-900/30");
}
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text == null ? "" : String(text);
return div.innerHTML;
}
function formatDate(timestamp) {
const ts = Number(timestamp) || 0;
if (!ts) return "-";
return new Date(ts).toLocaleString();
}
function canManageClientBans(role) {
return role === "admin" || role === "operator";
}
function isAdmin(role) {
return role === "admin";
}
function getPasswordRequirementsText() {
const minLength = Number(securityConfig?.passwordMinLength) || 6;
const requirements = [`min ${minLength} chars`];
if (securityConfig?.passwordRequireUppercase) requirements.push("uppercase");
if (securityConfig?.passwordRequireLowercase) requirements.push("lowercase");
if (securityConfig?.passwordRequireNumber) requirements.push("number");
if (securityConfig?.passwordRequireSymbol) requirements.push("symbol");
return requirements.join(", ");
}
function updatePasswordPolicyUi() {
const minLength = Number(securityConfig?.passwordMinLength) || 6;
if (newPasswordInput) {
newPasswordInput.minLength = minLength;
newPasswordInput.placeholder = `New password (${getPasswordRequirementsText()})`;
}
if (confirmPasswordInput) {
confirmPasswordInput.minLength = minLength;
}
if (passwordPolicyHint) {
passwordPolicyHint.textContent = `Policy: ${getPasswordRequirementsText()}`;
}
}
function setSecurityFormDisabled(disabled) {
const controls = [
securitySessionTtlInput,
securityLoginMaxAttemptsInput,
securityLoginWindowInput,
securityLockoutInput,
securityPasswordMinInput,
securityRequireUppercaseInput,
securityRequireLowercaseInput,
securityRequireNumberInput,
securityRequireSymbolInput,
securitySaveBtn,
];
for (const control of controls) {
if (!control) continue;
control.disabled = disabled;
}
}
function applySecurityForm() {
if (!securityConfig) return;
securitySessionTtlInput.value = String(securityConfig.sessionTtlHours || 168);
securityLoginMaxAttemptsInput.value = String(securityConfig.loginMaxAttempts || 5);
securityLoginWindowInput.value = String(securityConfig.loginWindowMinutes || 15);
securityLockoutInput.value = String(securityConfig.loginLockoutMinutes || 30);
securityPasswordMinInput.value = String(securityConfig.passwordMinLength || 6);
securityRequireUppercaseInput.checked = Boolean(securityConfig.passwordRequireUppercase);
securityRequireLowercaseInput.checked = Boolean(securityConfig.passwordRequireLowercase);
securityRequireNumberInput.checked = Boolean(securityConfig.passwordRequireNumber);
securityRequireSymbolInput.checked = Boolean(securityConfig.passwordRequireSymbol);
updatePasswordPolicyUi();
}
async function loadSecurityPolicy() {
if (!currentUser) return;
if (!isAdmin(currentUser.role)) {
securityPermissionNote.classList.remove("hidden");
setSecurityFormDisabled(true);
securityConfig = {
passwordMinLength: 6,
passwordRequireUppercase: false,
passwordRequireLowercase: false,
passwordRequireNumber: false,
passwordRequireSymbol: false,
};
updatePasswordPolicyUi();
return;
}
securityPermissionNote.classList.add("hidden");
const res = await fetch("/api/settings/security", { credentials: "include" });
if (!res.ok) {
showMessage("Failed to load security settings.", "error");
setSecurityFormDisabled(true);
return;
}
const data = await res.json().catch(() => ({}));
securityConfig = data.security || null;
applySecurityForm();
setSecurityFormDisabled(false);
}
function loadPrefs() {
prefNotificationsInput.checked = getNotificationsEnabled();
const refreshSeconds = Number(localStorage.getItem(PREF_REFRESH_KEY) || 8);
prefRefreshSecondsInput.value = String(Math.min(120, Math.max(3, refreshSeconds)));
}
async function loadCurrentUser() {
const res = await fetch("/api/auth/me", { credentials: "include" });
if (!res.ok) {
window.location.href = "/login.html";
return;
}
currentUser = await res.json();
usernameEl.textContent = currentUser.username || "unknown";
roleEl.textContent = currentUser.role || "unknown";
}
async function updatePassword(event) {
event.preventDefault();
if (!currentUser?.userId) return;
const currentPassword = currentPasswordInput.value;
const newPassword = newPasswordInput.value;
const confirmPassword = confirmPasswordInput.value;
const minLength = Number(securityConfig?.passwordMinLength) || 6;
if (newPassword.length < minLength) {
showMessage(`New password must be at least ${minLength} characters.`, "error");
return;
}
if (newPassword !== confirmPassword) {
showMessage("Password confirmation does not match.", "error");
return;
}
const res = await fetch(`/api/users/${currentUser.userId}/password`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
currentPassword,
newPassword,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showMessage(data.error || "Failed to update password.", "error");
return;
}
passwordForm.reset();
showMessage("Password updated successfully.");
}
function savePrefs(event) {
event.preventDefault();
const refreshSeconds = Math.min(
120,
Math.max(3, Number(prefRefreshSecondsInput.value || 8)),
);
setNotificationsEnabled(prefNotificationsInput.checked);
localStorage.setItem(PREF_REFRESH_KEY, String(refreshSeconds));
prefRefreshSecondsInput.value = String(refreshSeconds);
showMessage("Preferences saved.");
}
async function saveSecurityPolicy(event) {
event.preventDefault();
if (!isAdmin(currentUser?.role)) {
showMessage("Admin role required.", "error");
return;
}
const payload = {
sessionTtlHours: Number(securitySessionTtlInput.value || 168),
loginMaxAttempts: Number(securityLoginMaxAttemptsInput.value || 5),
loginWindowMinutes: Number(securityLoginWindowInput.value || 15),
loginLockoutMinutes: Number(securityLockoutInput.value || 30),
passwordMinLength: Number(securityPasswordMinInput.value || 6),
passwordRequireUppercase: securityRequireUppercaseInput.checked,
passwordRequireLowercase: securityRequireLowercaseInput.checked,
passwordRequireNumber: securityRequireNumberInput.checked,
passwordRequireSymbol: securityRequireSymbolInput.checked,
};
const res = await fetch("/api/settings/security", {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showMessage(data.error || "Failed to save security policy.", "error");
return;
}
securityConfig = data.security || payload;
applySecurityForm();
showMessage("Security policy updated.");
}
async function loadBannedIps() {
if (!currentUser) return;
if (!canManageClientBans(currentUser.role)) {
bansPermissionNote.classList.remove("hidden");
refreshBansBtn.disabled = true;
bansTableBody.innerHTML = `
<tr>
<td colspan="4" class="px-3 py-6 text-center text-slate-500">No access</td>
</tr>
`;
return;
}
bansPermissionNote.classList.add("hidden");
const res = await fetch("/api/clients/banned-ips", { credentials: "include" });
if (!res.ok) {
bansTableBody.innerHTML = `
<tr>
<td colspan="4" class="px-3 py-6 text-center text-rose-300">Failed to load banned IPs</td>
</tr>
`;
return;
}
const data = await res.json().catch(() => ({ items: [] }));
const items = Array.isArray(data.items) ? data.items : [];
if (items.length === 0) {
bansTableBody.innerHTML = `
<tr>
<td colspan="4" class="px-3 py-6 text-center text-slate-400">No banned IPs</td>
</tr>
`;
return;
}
bansTableBody.innerHTML = items
.map(
(item) => `
<tr>
<td class="px-3 py-2 font-mono text-xs sm:text-sm text-slate-100">${escapeHtml(item.ip)}</td>
<td class="px-3 py-2 text-slate-300">${escapeHtml(item.reason || "Manual ban")}</td>
<td class="px-3 py-2 text-slate-400">${formatDate(item.createdAt)}</td>
<td class="px-3 py-2 text-right">
<button
type="button"
class="unban-btn px-2.5 py-1.5 rounded bg-emerald-700/80 hover:bg-emerald-600 text-white text-xs"
data-ip="${escapeHtml(item.ip)}"
>
<i class="fa-solid fa-unlock mr-1"></i>Unban
</button>
</td>
</tr>
`,
)
.join("");
}
async function handleUnbanClick(event) {
const button = event.target.closest(".unban-btn");
if (!button) return;
const ip = button.dataset.ip;
if (!ip) return;
if (!confirm(`Unban ${ip}?`)) return;
button.disabled = true;
const res = await fetch(`/api/clients/banned-ips?ip=${encodeURIComponent(ip)}`, {
method: "DELETE",
credentials: "include",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showMessage(data.error || `Failed to unban ${ip}.`, "error");
button.disabled = false;
return;
}
showMessage(`Unbanned ${ip}.`);
await loadBannedIps();
}
async function init() {
try {
await loadCurrentUser();
loadPrefs();
await loadSecurityPolicy();
await loadBannedIps();
passwordForm.addEventListener("submit", updatePassword);
prefsForm.addEventListener("submit", savePrefs);
securityForm.addEventListener("submit", saveSecurityPolicy);
refreshBansBtn.addEventListener("click", loadBannedIps);
bansTableBody.addEventListener("click", handleUnbanClick);
} catch (error) {
console.error("settings init failed", error);
showMessage("Failed to load settings.", "error");
}
}
init();
@@ -0,0 +1,358 @@
const params = new URLSearchParams(window.location.search);
const targetUserId = Number(params.get("userId") || 0);
const subtitleEl = document.getElementById("target-user-subtitle");
const messageEl = document.getElementById("access-message");
const searchInput = document.getElementById("clients-search");
const refreshBtn = document.getElementById("clients-refresh-btn");
const tableBody = document.getElementById("clients-table-body");
const pageLabel = document.getElementById("clients-page-label");
const prevBtn = document.getElementById("clients-prev-btn");
const nextBtn = document.getElementById("clients-next-btn");
const scopeForm = document.getElementById("scope-form");
const scopeSelect = document.getElementById("scope-select");
const rulesList = document.getElementById("rules-list");
const state = {
me: null,
targetUser: null,
scope: "none",
rules: [],
rulesMap: new Map(),
page: 1,
pageSize: 30,
total: 0,
items: [],
search: "",
};
function showMessage(text, type = "ok") {
messageEl.textContent = text;
messageEl.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") {
messageEl.classList.add("text-rose-200", "border-rose-700", "bg-rose-900/30");
} else {
messageEl.classList.add("text-emerald-200", "border-emerald-700", "bg-emerald-900/30");
}
}
function escapeHtml(value) {
const div = document.createElement("div");
div.textContent = value == null ? "" : String(value);
return div.innerHTML;
}
function getRule(clientId) {
return state.rulesMap.get(clientId) || "";
}
function getEffective(clientId) {
if (state.targetUser?.role === "admin") return true;
const rule = getRule(clientId);
if (state.scope === "none") return false;
if (state.scope === "all") return true;
if (state.scope === "allowlist") return rule === "allow";
if (state.scope === "denylist") return rule !== "deny";
return false;
}
function renderRules() {
if (!state.rules.length) {
rulesList.innerHTML = '<div class="text-sm text-slate-500">No explicit rules.</div>';
return;
}
rulesList.innerHTML = state.rules
.map(
(rule) => `
<div class="flex items-center justify-between gap-2 text-sm rounded-lg bg-slate-900 border border-slate-800 px-2 py-1.5">
<div class="min-w-0 flex-1">
<span class="block font-mono text-xs sm:text-sm text-slate-200 truncate" title="${escapeHtml(rule.clientId)}">${escapeHtml(rule.clientId)}</span>
<span class="ml-2 ${rule.access === "allow" ? "text-emerald-300" : "text-rose-300"}">${rule.access}</span>
</div>
<button
type="button"
class="rule-remove-btn px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 text-slate-300"
data-client-id="${escapeHtml(rule.clientId)}"
>
<i class="fa-solid fa-xmark"></i>
</button>
</div>
`,
)
.join("");
}
function renderClients() {
if (!state.items.length) {
tableBody.innerHTML = `
<tr>
<td colspan="6" class="px-3 py-6 text-center text-slate-400">No matching clients</td>
</tr>
`;
} else {
tableBody.innerHTML = state.items
.map((client) => {
const rule = getRule(client.id);
const effective = getEffective(client.id);
const status = client.online ? "online" : "offline";
return `
<tr>
<td class="px-3 py-2">
<div class="font-mono text-xs sm:text-sm text-slate-100">${escapeHtml(client.id)}</div>
<div class="text-xs text-slate-500">${escapeHtml(client.os || "unknown")} / ${escapeHtml(client.arch || "-")}</div>
</td>
<td class="px-3 py-2 text-slate-300">
<div>${escapeHtml(client.host || "-")}</div>
<div class="text-xs text-slate-500">${escapeHtml(client.user || "-")}</div>
</td>
<td class="px-3 py-2">
<span class="px-2 py-0.5 rounded-full text-xs ${client.online ? "bg-emerald-900/40 text-emerald-300 border border-emerald-800" : "bg-slate-800 text-slate-400 border border-slate-700"}">${status}</span>
</td>
<td class="px-3 py-2 ${rule === "allow" ? "text-emerald-300" : rule === "deny" ? "text-rose-300" : "text-slate-500"}">${rule || "none"}</td>
<td class="px-3 py-2 ${effective ? "text-emerald-300" : "text-rose-300"}">${effective ? "visible" : "hidden"}</td>
<td class="px-3 py-2 text-right">
<div class="inline-flex gap-1">
<button type="button" class="rule-btn px-2 py-1 text-xs rounded bg-emerald-800/70 hover:bg-emerald-700 text-white disabled:opacity-50" data-client-id="${escapeHtml(client.id)}" data-access="allow" ${rule === "allow" ? "disabled" : ""}>Allow</button>
<button type="button" class="rule-btn px-2 py-1 text-xs rounded bg-rose-800/70 hover:bg-rose-700 text-white disabled:opacity-50" data-client-id="${escapeHtml(client.id)}" data-access="deny" ${rule === "deny" ? "disabled" : ""}>Deny</button>
<button type="button" class="rule-clear-btn px-2 py-1 text-xs rounded bg-slate-800 hover:bg-slate-700 text-slate-200 disabled:opacity-50" data-client-id="${escapeHtml(client.id)}" ${!rule ? "disabled" : ""}>Clear</button>
</div>
</td>
</tr>
`;
})
.join("");
}
const totalPages = Math.max(1, Math.ceil(state.total / state.pageSize));
pageLabel.textContent = `Page ${state.page} / ${totalPages} (${state.total} clients)`;
prevBtn.disabled = state.page <= 1;
nextBtn.disabled = state.page >= totalPages;
}
async function loadCurrentUser() {
const res = await fetch("/api/auth/me", { credentials: "include" });
if (!res.ok) {
window.location.href = "/login.html";
return;
}
state.me = await res.json();
if (state.me.role !== "admin") {
window.location.href = "/";
}
}
async function loadTargetUser() {
const res = await fetch("/api/users", { credentials: "include" });
if (!res.ok) {
throw new Error("Failed to load users");
}
const data = await res.json().catch(() => ({ users: [] }));
state.targetUser = (data.users || []).find((user) => user.id === targetUserId) || null;
if (!state.targetUser) {
throw new Error("Target user not found");
}
subtitleEl.textContent = `Managing visibility for ${state.targetUser.username} (${state.targetUser.role})`;
}
async function loadPolicy() {
const res = await fetch(`/api/users/${targetUserId}/client-access`, { credentials: "include" });
if (!res.ok) {
throw new Error("Failed to load access policy");
}
const data = await res.json().catch(() => ({ scope: "none", rules: [] }));
state.scope = data.scope || "none";
state.rules = Array.isArray(data.rules) ? data.rules : [];
state.rulesMap = new Map(state.rules.map((rule) => [rule.clientId, rule.access]));
scopeSelect.value = state.scope;
renderRules();
}
async function loadClients() {
tableBody.innerHTML = `
<tr>
<td colspan="6" class="px-3 py-6 text-center text-slate-400">Loading clients...</td>
</tr>
`;
const query = new URLSearchParams({
page: String(state.page),
pageSize: String(state.pageSize),
q: state.search,
sort: "last_seen_desc",
status: "all",
os: "all",
});
const res = await fetch(`/api/clients?${query.toString()}`, { credentials: "include" });
if (!res.ok) {
throw new Error("Failed to load clients");
}
const data = await res.json();
state.items = Array.isArray(data.items) ? data.items : [];
state.total = Number(data.total) || 0;
renderClients();
}
async function saveScope(event) {
event.preventDefault();
const nextScope = scopeSelect.value;
const res = await fetch(`/api/users/${targetUserId}/client-access`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ scope: nextScope }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showMessage(data.error || "Failed to save scope", "error");
return;
}
state.scope = nextScope;
showMessage("Scope updated.");
renderClients();
}
async function setRule(clientId, access) {
const res = await fetch(`/api/users/${targetUserId}/client-access/rules`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ clientId, access }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showMessage(data.error || "Failed to save rule", "error");
return;
}
await loadPolicy();
renderClients();
showMessage(`Rule saved: ${access} ${clientId}`);
}
async function clearRule(clientId) {
const res = await fetch(
`/api/users/${targetUserId}/client-access/rules?clientId=${encodeURIComponent(clientId)}`,
{
method: "DELETE",
credentials: "include",
},
);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showMessage(data.error || "Failed to remove rule", "error");
return;
}
await loadPolicy();
renderClients();
showMessage(`Rule removed for ${clientId}`);
}
tableBody.addEventListener("click", async (event) => {
const ruleBtn = event.target.closest(".rule-btn");
if (ruleBtn) {
const clientId = ruleBtn.dataset.clientId;
const access = ruleBtn.dataset.access;
if (clientId && (access === "allow" || access === "deny")) {
await setRule(clientId, access);
}
return;
}
const clearBtn = event.target.closest(".rule-clear-btn");
if (clearBtn) {
const clientId = clearBtn.dataset.clientId;
if (clientId) {
await clearRule(clientId);
}
}
});
rulesList.addEventListener("click", async (event) => {
const removeBtn = event.target.closest(".rule-remove-btn");
if (!removeBtn) return;
const clientId = removeBtn.dataset.clientId;
if (clientId) {
await clearRule(clientId);
}
});
scopeForm.addEventListener("submit", saveScope);
searchInput.addEventListener("input", () => {
state.search = searchInput.value.trim();
state.page = 1;
loadClients().catch((error) => {
console.error(error);
showMessage("Failed to load clients", "error");
});
});
refreshBtn.addEventListener("click", () => {
loadClients().catch((error) => {
console.error(error);
showMessage("Failed to refresh clients", "error");
});
});
prevBtn.addEventListener("click", () => {
if (state.page <= 1) return;
state.page -= 1;
loadClients().catch((error) => {
console.error(error);
showMessage("Failed to load previous page", "error");
});
});
nextBtn.addEventListener("click", () => {
const totalPages = Math.max(1, Math.ceil(state.total / state.pageSize));
if (state.page >= totalPages) return;
state.page += 1;
loadClients().catch((error) => {
console.error(error);
showMessage("Failed to load next page", "error");
});
});
async function init() {
if (!targetUserId || Number.isNaN(targetUserId)) {
window.location.href = "/users";
return;
}
try {
await loadCurrentUser();
await loadTargetUser();
await loadPolicy();
await loadClients();
} catch (error) {
console.error(error);
showMessage("Failed to initialize access manager", "error");
}
}
init();
+18
View File
@@ -183,6 +183,16 @@ function renderUsers() {
>
<i class="fa-solid fa-user-tag"></i>
</button>
<button
class="user-action-btn px-3 py-1.5 text-sm bg-indigo-900/30 hover:bg-indigo-900/50 text-indigo-300 rounded border border-indigo-800 transition-colors"
data-action="client-access"
data-user-id="${user.id}"
data-username="${escapeHtml(user.username)}"
data-role="${escapeHtml(user.role)}"
title="Client Visibility"
>
<i class="fa-solid fa-user-shield"></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"
@@ -242,6 +252,9 @@ function attachActionListeners() {
case "delete":
deleteUser(userId, username);
break;
case "client-access":
configureClientAccess(userId, username, role);
break;
}
};
@@ -411,5 +424,10 @@ window.deleteUser = async function (userId, username) {
}
};
window.configureClientAccess = async function (userId, username, role) {
const params = new URLSearchParams({ userId: String(userId) });
window.location.href = `/user-client-access?${params.toString()}`;
};
getCurrentUser();
loadUsers();