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:
@@ -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) {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Settings - Overlord</title>
|
||||
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
|
||||
<link rel="stylesheet" href="/assets/tailwind.css" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<link rel="stylesheet" href="/assets/main.css" />
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-950 text-slate-100">
|
||||
<header id="top-nav"></header>
|
||||
|
||||
<main class="max-w-6xl w-full mx-auto px-4 py-8 space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-100">Settings</h1>
|
||||
<p class="text-slate-400 text-sm">
|
||||
Manage your account preferences and review banned IP addresses.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section class="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
<div class="bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Basic Settings</h2>
|
||||
<p class="text-sm text-slate-400">Update account and local dashboard preferences.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||||
<div class="rounded-lg bg-slate-900 border border-slate-800 px-3 py-2">
|
||||
<div class="text-slate-400">Username</div>
|
||||
<div id="settings-username" class="font-medium">-</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-slate-900 border border-slate-800 px-3 py-2">
|
||||
<div class="text-slate-400">Role</div>
|
||||
<div id="settings-role" class="font-medium">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="password-form" class="space-y-3 pt-1">
|
||||
<h3 class="font-medium">Change Password</h3>
|
||||
<input
|
||||
id="current-password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="Current password"
|
||||
class="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
id="new-password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
minlength="6"
|
||||
placeholder="New password (min 6 chars)"
|
||||
class="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
minlength="6"
|
||||
placeholder="Confirm new password"
|
||||
class="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||
required
|
||||
/>
|
||||
<p id="password-policy-hint" class="text-xs text-slate-400"></p>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 rounded-lg bg-sky-600 hover:bg-sky-700 transition-colors text-white font-medium"
|
||||
>
|
||||
<i class="fa-solid fa-key mr-2"></i>Update Password
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form id="prefs-form" class="space-y-3 pt-1">
|
||||
<h3 class="font-medium">Dashboard Preferences</h3>
|
||||
<label class="flex items-center gap-2">
|
||||
<input id="pref-notifications" type="checkbox" class="h-4 w-4" />
|
||||
<span>Enable notification badge updates</span>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm text-slate-300">Client refresh interval (seconds)</span>
|
||||
<input
|
||||
id="pref-refresh-seconds"
|
||||
type="number"
|
||||
min="3"
|
||||
max="120"
|
||||
class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 transition-colors text-white font-medium"
|
||||
>
|
||||
<i class="fa-solid fa-floppy-disk mr-2"></i>Save Preferences
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form id="security-form" class="space-y-3 pt-1 border-t border-slate-800 pt-4">
|
||||
<div>
|
||||
<h3 class="font-medium">Security Policy</h3>
|
||||
<p class="text-xs text-slate-400">Admin-only global security controls.</p>
|
||||
</div>
|
||||
|
||||
<div id="security-permission-note" class="hidden rounded-lg border border-amber-800 bg-amber-900/20 text-amber-200 px-3 py-2 text-sm">
|
||||
Only admins can update security policy settings.
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<label class="block">
|
||||
<span class="text-sm text-slate-300">Session TTL (hours)</span>
|
||||
<input
|
||||
id="security-session-ttl"
|
||||
type="number"
|
||||
min="1"
|
||||
max="720"
|
||||
class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm text-slate-300">Max login attempts</span>
|
||||
<input
|
||||
id="security-login-max-attempts"
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm text-slate-300">Login window (minutes)</span>
|
||||
<input
|
||||
id="security-login-window"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1440"
|
||||
class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm text-slate-300">Lockout duration (minutes)</span>
|
||||
<input
|
||||
id="security-lockout"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1440"
|
||||
class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||
/>
|
||||
</label>
|
||||
<label class="block sm:col-span-2">
|
||||
<span class="text-sm text-slate-300">Minimum password length</span>
|
||||
<input
|
||||
id="security-password-min"
|
||||
type="number"
|
||||
min="6"
|
||||
max="128"
|
||||
class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
|
||||
<label class="flex items-center gap-2">
|
||||
<input id="security-require-uppercase" type="checkbox" class="h-4 w-4" />
|
||||
<span>Require uppercase letter</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input id="security-require-lowercase" type="checkbox" class="h-4 w-4" />
|
||||
<span>Require lowercase letter</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input id="security-require-number" type="checkbox" class="h-4 w-4" />
|
||||
<span>Require number</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input id="security-require-symbol" type="checkbox" class="h-4 w-4" />
|
||||
<span>Require symbol</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
id="security-save-btn"
|
||||
class="px-4 py-2 rounded-lg bg-indigo-700 hover:bg-indigo-600 transition-colors text-white font-medium"
|
||||
>
|
||||
<i class="fa-solid fa-shield-halved mr-2"></i>Save Security Policy
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p id="settings-message" class="hidden text-sm rounded-lg px-3 py-2"></p>
|
||||
</div>
|
||||
|
||||
<div 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>
|
||||
<h2 class="text-lg font-semibold">Banned IPs</h2>
|
||||
<p class="text-sm text-slate-400">Unban blocked agent source IPs.</p>
|
||||
</div>
|
||||
<button
|
||||
id="refresh-bans-btn"
|
||||
class="px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700 text-sm"
|
||||
type="button"
|
||||
>
|
||||
<i class="fa-solid fa-rotate mr-1"></i>Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="bans-permission-note" class="hidden rounded-lg border border-amber-800 bg-amber-900/20 text-amber-200 px-3 py-2 text-sm">
|
||||
Your role does not allow IP unban actions.
|
||||
</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">Reason</th>
|
||||
<th class="text-left px-3 py-2">Banned At</th>
|
||||
<th class="text-right px-3 py-2">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="bans-table-body" class="divide-y divide-slate-800">
|
||||
<tr>
|
||||
<td colspan="4" class="px-3 py-6 text-center text-slate-400">Loading...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/assets/nav.js"></script>
|
||||
<script type="module" src="/assets/settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,139 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>User Client Access - Overlord</title>
|
||||
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
|
||||
<link rel="stylesheet" href="/assets/tailwind.css" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<link rel="stylesheet" href="/assets/main.css" />
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-950 text-slate-100">
|
||||
<header id="top-nav"></header>
|
||||
|
||||
<main class="w-full px-4 py-7 space-y-5 min-h-[calc(100vh-72px)]">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Client Visibility Access</h1>
|
||||
<p class="text-slate-400 text-sm" id="target-user-subtitle">Loading user...</p>
|
||||
</div>
|
||||
<a
|
||||
href="/users"
|
||||
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-800 border border-slate-700 hover:bg-slate-700"
|
||||
>
|
||||
<i class="fa-solid fa-arrow-left"></i>
|
||||
<span>Back to Users</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<section class="grid grid-cols-1 xl:grid-cols-3 gap-4 min-h-[calc(100vh-170px)]">
|
||||
<div class="xl:col-span-2 bg-slate-900/60 border border-slate-800 rounded-xl p-4 space-y-4 flex flex-col min-h-[420px]">
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<label class="flex-1 min-w-[240px]">
|
||||
<span class="text-sm text-slate-300">Search clients</span>
|
||||
<div class="mt-1 flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-900 border border-slate-700">
|
||||
<i class="fa-solid fa-magnifying-glass text-slate-500"></i>
|
||||
<input
|
||||
id="clients-search"
|
||||
type="search"
|
||||
class="w-full bg-transparent outline-none placeholder:text-slate-500"
|
||||
placeholder="Search by host, user, id"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<button
|
||||
id="clients-refresh-btn"
|
||||
type="button"
|
||||
class="px-3 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700"
|
||||
>
|
||||
<i class="fa-solid fa-rotate mr-1"></i>Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto overflow-y-auto border border-slate-800 rounded-xl flex-1 min-h-0">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-800/70 text-slate-300">
|
||||
<tr>
|
||||
<th class="text-left px-3 py-2">Client</th>
|
||||
<th class="text-left px-3 py-2">Host/User</th>
|
||||
<th class="text-left px-3 py-2">Status</th>
|
||||
<th class="text-left px-3 py-2">Rule</th>
|
||||
<th class="text-left px-3 py-2">Effective</th>
|
||||
<th class="text-right px-3 py-2">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="clients-table-body" class="divide-y divide-slate-800">
|
||||
<tr>
|
||||
<td colspan="6" class="px-3 py-6 text-center text-slate-400">Loading clients...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<button
|
||||
id="clients-prev-btn"
|
||||
type="button"
|
||||
class="px-3 py-2 rounded-lg bg-slate-800 border border-slate-700 hover:bg-slate-700 disabled:opacity-50"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<div id="clients-page-label" class="text-sm text-slate-400"></div>
|
||||
<button
|
||||
id="clients-next-btn"
|
||||
type="button"
|
||||
class="px-3 py-2 rounded-lg bg-slate-800 border border-slate-700 hover:bg-slate-700 disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-900/60 border border-slate-800 rounded-xl p-4 space-y-4 min-h-[420px]">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Visibility Policy</h2>
|
||||
<p class="text-sm text-slate-400">Control what this user is allowed to see.</p>
|
||||
</div>
|
||||
|
||||
<form id="scope-form" class="space-y-3">
|
||||
<label class="block">
|
||||
<span class="text-sm text-slate-300">Access Scope</span>
|
||||
<select
|
||||
id="scope-select"
|
||||
class="mt-1 w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="none">none - No clients</option>
|
||||
<option value="allowlist">allowlist - Approved only</option>
|
||||
<option value="denylist">denylist - All except denied</option>
|
||||
<option value="all">all - All clients</option>
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-3 py-2 rounded-lg bg-indigo-700 hover:bg-indigo-600 text-white"
|
||||
>
|
||||
<i class="fa-solid fa-floppy-disk mr-1"></i>Save Scope
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="border-t border-slate-800 pt-3">
|
||||
<h3 class="font-medium text-slate-200">Current Rules</h3>
|
||||
<p class="text-xs text-slate-400 mb-2">Explicit per-client allow/deny overrides.</p>
|
||||
<div id="rules-list" class="space-y-1 max-h-[320px] overflow-auto pr-1"></div>
|
||||
</div>
|
||||
|
||||
<p id="access-message" class="hidden text-sm rounded-lg px-3 py-2"></p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/assets/nav.js"></script>
|
||||
<script type="module" src="/assets/user-client-access.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user