2026-05-01 02:34:47 +03:00
|
|
|
(() => {
|
|
|
|
|
const menuController = window.WarpBoxUI?.bindMenuBar?.() || { close() {} };
|
|
|
|
|
const toastTarget = document.getElementById("toast");
|
|
|
|
|
const body = document.getElementById("users-body");
|
|
|
|
|
const search = document.getElementById("users-search");
|
2026-05-04 02:27:36 +03:00
|
|
|
const statusFilter = document.getElementById("users-status");
|
2026-05-01 02:34:47 +03:00
|
|
|
const sort = document.getElementById("users-sort");
|
|
|
|
|
const size = document.getElementById("users-size");
|
|
|
|
|
const masterCheck = document.getElementById("users-master-check");
|
|
|
|
|
const pageInfo = document.getElementById("users-page-info");
|
|
|
|
|
const visiblePill = document.getElementById("visible-pill");
|
|
|
|
|
const selectedPill = document.getElementById("users-selected-pill");
|
|
|
|
|
const prevBtn = document.getElementById("users-prev");
|
|
|
|
|
const nextBtn = document.getElementById("users-next");
|
|
|
|
|
const selectVisible = document.getElementById("select-visible");
|
|
|
|
|
const statusLeft = document.getElementById("users-status-left");
|
2026-05-04 02:27:36 +03:00
|
|
|
const selectedName = document.getElementById("selected-user-name");
|
|
|
|
|
const selectedMeta = document.getElementById("selected-user-meta");
|
|
|
|
|
const addForm = document.getElementById("add-user-form");
|
|
|
|
|
const editForm = document.getElementById("edit-user-form");
|
|
|
|
|
const policiesForm = document.getElementById("policies-form");
|
|
|
|
|
const apiKeyForm = document.getElementById("api-key-form");
|
|
|
|
|
const apiKeyList = document.getElementById("api-key-list");
|
|
|
|
|
const apiKeyReveal = document.getElementById("api-key-reveal");
|
|
|
|
|
const apiKeyValue = document.getElementById("api-key-value");
|
2026-05-01 02:34:47 +03:00
|
|
|
|
2026-05-04 02:27:36 +03:00
|
|
|
if (!body || !search || !statusFilter || !sort || !size) return;
|
|
|
|
|
|
|
|
|
|
const state = {
|
|
|
|
|
page: 1,
|
|
|
|
|
users: [],
|
|
|
|
|
selected: new Set(),
|
|
|
|
|
currentUserID: "",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const fields = {
|
|
|
|
|
add: {
|
|
|
|
|
username: document.getElementById("add-username"),
|
|
|
|
|
email: document.getElementById("add-email"),
|
|
|
|
|
status: document.getElementById("add-status"),
|
|
|
|
|
maxFile: document.getElementById("add-max-file"),
|
|
|
|
|
maxBox: document.getElementById("add-max-box"),
|
|
|
|
|
web: document.getElementById("add-perm-web"),
|
|
|
|
|
api: document.getElementById("add-perm-api"),
|
|
|
|
|
create: document.getElementById("add-perm-create"),
|
|
|
|
|
upload: document.getElementById("add-perm-upload"),
|
|
|
|
|
},
|
|
|
|
|
edit: {
|
|
|
|
|
username: document.getElementById("edit-username"),
|
|
|
|
|
email: document.getElementById("edit-email"),
|
|
|
|
|
status: document.getElementById("edit-status"),
|
|
|
|
|
save: document.getElementById("save-edit-button"),
|
|
|
|
|
delete: document.getElementById("delete-user-button"),
|
|
|
|
|
},
|
|
|
|
|
policies: {
|
|
|
|
|
maxFile: document.getElementById("policy-max-file"),
|
|
|
|
|
maxBox: document.getElementById("policy-max-box"),
|
|
|
|
|
web: document.getElementById("policy-perm-web"),
|
|
|
|
|
api: document.getElementById("policy-perm-api"),
|
|
|
|
|
create: document.getElementById("policy-perm-create"),
|
|
|
|
|
upload: document.getElementById("policy-perm-upload"),
|
|
|
|
|
save: document.getElementById("save-policies-button"),
|
|
|
|
|
},
|
|
|
|
|
keys: {
|
|
|
|
|
name: document.getElementById("api-key-name"),
|
|
|
|
|
create: document.getElementById("create-key-button"),
|
|
|
|
|
},
|
|
|
|
|
};
|
2026-05-01 02:34:47 +03:00
|
|
|
|
|
|
|
|
function toast(message, type = "info") {
|
|
|
|
|
if (window.WarpBoxUI) {
|
2026-05-04 02:27:36 +03:00
|
|
|
window.WarpBoxUI.toast(message, type, { target: toastTarget, duration: 3200 });
|
2026-05-01 02:34:47 +03:00
|
|
|
return;
|
|
|
|
|
}
|
2026-05-04 02:27:36 +03:00
|
|
|
if (toastTarget) toastTarget.textContent = message;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function escapeHTML(value) {
|
|
|
|
|
return String(value || "")
|
|
|
|
|
.replaceAll("&", "&")
|
|
|
|
|
.replaceAll("<", "<")
|
|
|
|
|
.replaceAll(">", ">")
|
|
|
|
|
.replaceAll('"', """)
|
|
|
|
|
.replaceAll("'", "'");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function api(path, method = "GET", payload = null) {
|
|
|
|
|
const response = await fetch(path, {
|
|
|
|
|
method,
|
|
|
|
|
headers: payload ? { "Content-Type": "application/json" } : undefined,
|
|
|
|
|
body: payload ? JSON.stringify(payload) : undefined,
|
|
|
|
|
});
|
|
|
|
|
let data = {};
|
|
|
|
|
try {
|
|
|
|
|
data = await response.json();
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
if (!response.ok) throw new Error(data.error || "Request failed");
|
|
|
|
|
return data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function selectedUser() {
|
|
|
|
|
return state.users.find((user) => user.id === state.currentUserID) || null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const BYTES_PER_MB = 1024 * 1024;
|
|
|
|
|
|
|
|
|
|
function numericMB(input) {
|
|
|
|
|
const value = Number(input?.value || 0);
|
|
|
|
|
return Number.isFinite(value) && value > 0 ? String(Math.floor(value)) : "0";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function bytesToMB(value) {
|
|
|
|
|
const bytes = Number(value || 0);
|
|
|
|
|
return Number.isFinite(bytes) && bytes > 0 ? String(Math.ceil(bytes / BYTES_PER_MB)) : "0";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function limitLabelMB(value) {
|
|
|
|
|
const mb = Number(bytesToMB(value));
|
|
|
|
|
return mb > 0 ? `${mb} MB` : "unlimited";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function permissionPayload(source) {
|
|
|
|
|
return {
|
|
|
|
|
can_use_web: Boolean(source.web?.checked),
|
|
|
|
|
can_use_api: Boolean(source.api?.checked),
|
|
|
|
|
can_create_box: Boolean(source.create?.checked),
|
|
|
|
|
can_upload_file: Boolean(source.upload?.checked),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function payloadFromUser(user, overrides = {}) {
|
|
|
|
|
return {
|
|
|
|
|
id: user.id,
|
|
|
|
|
username: user.username,
|
|
|
|
|
email: user.email,
|
|
|
|
|
status: user.status,
|
|
|
|
|
max_file_size_mb: bytesToMB(user.limits?.max_file_size_bytes),
|
|
|
|
|
max_box_size_mb: bytesToMB(user.limits?.max_box_size_bytes),
|
|
|
|
|
permissions: user.permissions || {},
|
|
|
|
|
...overrides,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setTab(tabName) {
|
|
|
|
|
document.querySelectorAll(".users-tab").forEach((tab) => {
|
|
|
|
|
tab.classList.toggle("is-active", tab.dataset.tab === tabName);
|
|
|
|
|
});
|
|
|
|
|
document.querySelectorAll(".users-tab-panel").forEach((panel) => {
|
|
|
|
|
panel.classList.toggle("is-active", panel.dataset.panel === tabName);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setSelectedUser(userID, preferredTab = "edit") {
|
|
|
|
|
state.currentUserID = userID || "";
|
|
|
|
|
state.selected.clear();
|
|
|
|
|
if (userID) state.selected.add(userID);
|
|
|
|
|
populateSelectedPanels();
|
|
|
|
|
render();
|
|
|
|
|
if (preferredTab) setTab(preferredTab);
|
2026-05-01 02:34:47 +03:00
|
|
|
}
|
|
|
|
|
|
2026-05-04 02:27:36 +03:00
|
|
|
function setControlsEnabled(group, enabled) {
|
|
|
|
|
Object.values(group).forEach((element) => {
|
|
|
|
|
if (!element) return;
|
|
|
|
|
element.disabled = !enabled;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function populateSelectedPanels() {
|
|
|
|
|
const user = selectedUser();
|
|
|
|
|
const hasUser = Boolean(user);
|
|
|
|
|
selectedName.textContent = hasUser ? user.username : "None";
|
|
|
|
|
selectedMeta.textContent = hasUser ? `${user.email} · ${user.status}` : "Choose a row to edit policies and keys.";
|
|
|
|
|
|
|
|
|
|
setControlsEnabled(fields.edit, hasUser);
|
|
|
|
|
setControlsEnabled(fields.policies, hasUser);
|
|
|
|
|
setControlsEnabled(fields.keys, hasUser);
|
|
|
|
|
|
|
|
|
|
if (!hasUser) {
|
|
|
|
|
fields.edit.username.value = "";
|
|
|
|
|
fields.edit.email.value = "";
|
|
|
|
|
fields.edit.status.value = "active";
|
|
|
|
|
fields.policies.maxFile.value = "";
|
|
|
|
|
fields.policies.maxBox.value = "";
|
|
|
|
|
[fields.policies.web, fields.policies.api, fields.policies.create, fields.policies.upload].forEach((item) => { item.checked = false; });
|
|
|
|
|
fields.keys.name.value = "default";
|
|
|
|
|
apiKeyList.innerHTML = `<p class="users-empty-note">Select a user to manage API keys.</p>`;
|
|
|
|
|
apiKeyReveal.hidden = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fields.edit.username.value = user.username || "";
|
|
|
|
|
fields.edit.email.value = user.email || "";
|
|
|
|
|
fields.edit.status.value = user.status || "active";
|
|
|
|
|
fields.policies.maxFile.value = bytesToMB(user.limits?.max_file_size_bytes);
|
|
|
|
|
fields.policies.maxBox.value = bytesToMB(user.limits?.max_box_size_bytes);
|
|
|
|
|
fields.policies.web.checked = Boolean(user.permissions?.can_use_web);
|
|
|
|
|
fields.policies.api.checked = Boolean(user.permissions?.can_use_api);
|
|
|
|
|
fields.policies.create.checked = Boolean(user.permissions?.can_create_box);
|
|
|
|
|
fields.policies.upload.checked = Boolean(user.permissions?.can_upload_file);
|
|
|
|
|
fields.keys.name.value = "default";
|
|
|
|
|
renderAPIKeys(user);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderAPIKeys(user) {
|
|
|
|
|
const keys = user.api_keys || [];
|
|
|
|
|
if (!keys.length) {
|
|
|
|
|
apiKeyList.innerHTML = `<p class="users-empty-note">No API keys yet.</p>`;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
apiKeyList.innerHTML = keys.map((key) => {
|
|
|
|
|
const revoked = Boolean(key.revoked_at);
|
|
|
|
|
return `
|
|
|
|
|
<div class="users-key-row ${revoked ? "is-revoked" : ""}">
|
|
|
|
|
<div><strong>${escapeHTML(key.name || "default")}</strong><span>${escapeHTML(key.prefix || key.id)}${revoked ? " · revoked" : ""}</span></div>
|
|
|
|
|
<button class="win98-button users-row-button" type="button" data-revoke-key="${escapeHTML(key.id)}" ${revoked ? "disabled" : ""}>Revoke</button>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
}).join("");
|
|
|
|
|
apiKeyList.querySelectorAll("[data-revoke-key]").forEach((button) => {
|
|
|
|
|
button.addEventListener("click", () => revokeAPIKey(button.dataset.revokeKey));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderStats() {
|
|
|
|
|
document.getElementById("stat-total").textContent = String(state.users.length);
|
|
|
|
|
document.getElementById("stat-active").textContent = String(state.users.filter((u) => u.status === "active").length);
|
|
|
|
|
document.getElementById("stat-keys").textContent = String(state.users.filter((u) => (u.api_key_count || 0) > 0).length);
|
|
|
|
|
document.getElementById("stat-disabled").textContent = String(state.users.filter((u) => u.status === "disabled").length);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function filteredUsers() {
|
2026-05-01 02:34:47 +03:00
|
|
|
const query = search.value.trim().toLowerCase();
|
2026-05-04 02:27:36 +03:00
|
|
|
const currentStatus = statusFilter.value;
|
|
|
|
|
const rows = state.users.filter((user) => {
|
2026-05-01 02:34:47 +03:00
|
|
|
const matchesQuery = !query || user.username.toLowerCase().includes(query) || user.email.toLowerCase().includes(query);
|
2026-05-04 02:27:36 +03:00
|
|
|
const matchesStatus = currentStatus === "all" || user.status === currentStatus;
|
|
|
|
|
return matchesQuery && matchesStatus;
|
2026-05-01 02:34:47 +03:00
|
|
|
});
|
|
|
|
|
rows.sort((a, b) => {
|
2026-05-04 02:27:36 +03:00
|
|
|
if (sort.value === "createdDesc") return String(b.created_at).localeCompare(String(a.created_at));
|
|
|
|
|
if (sort.value === "lastSeenDesc") return String(b.last_seen_at || "").localeCompare(String(a.last_seen_at || ""));
|
|
|
|
|
if (sort.value === "keysDesc") return (b.api_key_count || 0) - (a.api_key_count || 0);
|
2026-05-01 02:34:47 +03:00
|
|
|
return a.username.localeCompare(b.username);
|
|
|
|
|
});
|
|
|
|
|
return rows;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function paged(rows) {
|
|
|
|
|
const perPage = Number(size.value || 12);
|
|
|
|
|
const pages = Math.max(1, Math.ceil(rows.length / perPage));
|
2026-05-04 02:27:36 +03:00
|
|
|
state.page = Math.max(1, Math.min(state.page, pages));
|
2026-05-01 02:34:47 +03:00
|
|
|
const start = (state.page - 1) * perPage;
|
2026-05-04 02:27:36 +03:00
|
|
|
return { rows: rows.slice(start, start + perPage), pages };
|
2026-05-01 02:34:47 +03:00
|
|
|
}
|
|
|
|
|
|
2026-05-04 02:27:36 +03:00
|
|
|
function permissionsSummary(permissions = {}) {
|
|
|
|
|
const items = [];
|
|
|
|
|
if (permissions.can_use_web) items.push("web");
|
|
|
|
|
if (permissions.can_use_api) items.push("api");
|
|
|
|
|
if (permissions.can_create_box) items.push("create");
|
|
|
|
|
if (permissions.can_upload_file) items.push("upload");
|
|
|
|
|
return items.join(", ") || "none";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function limitsSummary(limits = {}) {
|
|
|
|
|
return `file ${limitLabelMB(limits.max_file_size_bytes)} / box ${limitLabelMB(limits.max_box_size_bytes)}`;
|
2026-05-01 02:34:47 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderRow(user) {
|
|
|
|
|
const checked = state.selected.has(user.id) ? " checked" : "";
|
2026-05-04 02:27:36 +03:00
|
|
|
const active = user.id === state.currentUserID ? " is-selected" : "";
|
2026-05-01 02:34:47 +03:00
|
|
|
const row = document.createElement("tr");
|
2026-05-04 02:27:36 +03:00
|
|
|
row.className = active;
|
|
|
|
|
row.dataset.userId = user.id;
|
2026-05-01 02:34:47 +03:00
|
|
|
row.innerHTML = `
|
|
|
|
|
<td><input type="checkbox" class="row-check"${checked}></td>
|
2026-05-04 02:27:36 +03:00
|
|
|
<td><div class="users-username"><strong>${escapeHTML(user.username)}</strong><span class="users-muted">${escapeHTML(user.id)}</span></div></td>
|
|
|
|
|
<td title="${escapeHTML(user.email)}">${escapeHTML(user.email)}</td>
|
|
|
|
|
<td><span class="users-pill ${escapeHTML(user.status)}">${escapeHTML(user.status)}</span></td>
|
|
|
|
|
<td>${escapeHTML(permissionsSummary(user.permissions))}</td>
|
|
|
|
|
<td>${escapeHTML(limitsSummary(user.limits))}</td>
|
|
|
|
|
<td>${user.api_key_count || 0}</td>
|
|
|
|
|
<td>${escapeHTML(user.last_seen_at || "never")}</td>
|
|
|
|
|
<td><div class="users-row-actions"><button class="win98-button users-row-button" type="button" data-action="edit">Edit</button><button class="win98-button users-row-button" type="button" data-action="keys">Keys</button></div></td>
|
2026-05-01 02:34:47 +03:00
|
|
|
`;
|
2026-05-04 02:27:36 +03:00
|
|
|
row.addEventListener("click", (event) => {
|
|
|
|
|
if (event.target.closest(".row-check") || event.target.closest("button")) return;
|
|
|
|
|
setSelectedUser(user.id, "edit");
|
|
|
|
|
});
|
2026-05-01 02:34:47 +03:00
|
|
|
row.querySelector(".row-check")?.addEventListener("change", (event) => {
|
|
|
|
|
if (event.target.checked) state.selected.add(user.id);
|
|
|
|
|
else state.selected.delete(user.id);
|
2026-05-04 02:27:36 +03:00
|
|
|
if (event.target.checked) state.currentUserID = user.id;
|
|
|
|
|
populateSelectedPanels();
|
2026-05-01 02:34:47 +03:00
|
|
|
syncSelected();
|
|
|
|
|
syncMasterCheck();
|
2026-05-04 02:27:36 +03:00
|
|
|
render();
|
2026-05-01 02:34:47 +03:00
|
|
|
});
|
2026-05-04 02:27:36 +03:00
|
|
|
row.querySelector('[data-action="edit"]')?.addEventListener("click", () => setSelectedUser(user.id, "edit"));
|
|
|
|
|
row.querySelector('[data-action="keys"]')?.addEventListener("click", () => setSelectedUser(user.id, "keys"));
|
2026-05-01 02:34:47 +03:00
|
|
|
return row;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function syncSelected() {
|
|
|
|
|
selectedPill.textContent = `${state.selected.size} selected`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function syncMasterCheck() {
|
|
|
|
|
const checks = Array.from(body.querySelectorAll(".row-check"));
|
|
|
|
|
masterCheck.checked = checks.length > 0 && checks.every((item) => item.checked);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function render() {
|
2026-05-04 02:27:36 +03:00
|
|
|
const rows = filteredUsers();
|
2026-05-01 02:34:47 +03:00
|
|
|
const page = paged(rows);
|
|
|
|
|
body.innerHTML = "";
|
|
|
|
|
page.rows.forEach((user) => body.appendChild(renderRow(user)));
|
|
|
|
|
visiblePill.textContent = `${rows.length} visible`;
|
|
|
|
|
pageInfo.textContent = `Page ${state.page} / ${page.pages}`;
|
|
|
|
|
prevBtn.disabled = state.page <= 1;
|
|
|
|
|
nextBtn.disabled = state.page >= page.pages;
|
|
|
|
|
statusLeft.textContent = `Ready. ${rows.length} user rows in current filter.`;
|
|
|
|
|
syncSelected();
|
|
|
|
|
syncMasterCheck();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 02:27:36 +03:00
|
|
|
async function fetchUsers() {
|
|
|
|
|
const result = await api("/admin/users/list");
|
|
|
|
|
state.users = result.users || [];
|
|
|
|
|
if (state.currentUserID && !state.users.some((user) => user.id === state.currentUserID)) {
|
|
|
|
|
state.currentUserID = "";
|
2026-05-01 02:34:47 +03:00
|
|
|
}
|
2026-05-04 02:27:36 +03:00
|
|
|
state.selected = new Set([...state.selected].filter((id) => state.users.some((user) => user.id === id)));
|
2026-05-01 02:34:47 +03:00
|
|
|
renderStats();
|
2026-05-04 02:27:36 +03:00
|
|
|
populateSelectedPanels();
|
2026-05-01 02:34:47 +03:00
|
|
|
render();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 02:27:36 +03:00
|
|
|
async function saveUser(payload, successMessage) {
|
|
|
|
|
const result = await api("/admin/users/save", "POST", payload);
|
|
|
|
|
state.currentUserID = result.user?.id || state.currentUserID;
|
|
|
|
|
state.selected.clear();
|
|
|
|
|
if (state.currentUserID) state.selected.add(state.currentUserID);
|
|
|
|
|
await fetchUsers();
|
|
|
|
|
toast(successMessage);
|
|
|
|
|
return result.user;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function deleteUser(userID) {
|
|
|
|
|
const user = state.users.find((item) => item.id === userID);
|
|
|
|
|
if (!user) return;
|
|
|
|
|
if (!confirm(`Delete ${user.username}?`)) return;
|
|
|
|
|
await api("/admin/users/delete", "POST", { id: userID });
|
|
|
|
|
state.currentUserID = "";
|
|
|
|
|
state.selected.delete(userID);
|
|
|
|
|
await fetchUsers();
|
|
|
|
|
setTab("add");
|
|
|
|
|
toast("User deleted");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function revokeAPIKey(keyID) {
|
|
|
|
|
const user = selectedUser();
|
|
|
|
|
if (!user || !keyID) return;
|
|
|
|
|
await api("/admin/users/api-keys/revoke", "POST", { user_id: user.id, key_id: keyID });
|
|
|
|
|
await fetchUsers();
|
|
|
|
|
setTab("keys");
|
|
|
|
|
toast("API key revoked");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function runCommand(command) {
|
2026-05-01 02:34:47 +03:00
|
|
|
switch (command) {
|
2026-05-04 02:27:36 +03:00
|
|
|
case "tab-add":
|
2026-05-01 02:34:47 +03:00
|
|
|
case "create":
|
2026-05-04 02:27:36 +03:00
|
|
|
setTab("add");
|
2026-05-01 02:34:47 +03:00
|
|
|
break;
|
2026-05-04 02:27:36 +03:00
|
|
|
case "refresh":
|
|
|
|
|
await fetchUsers();
|
|
|
|
|
toast("Users list refreshed");
|
2026-05-01 02:34:47 +03:00
|
|
|
break;
|
|
|
|
|
case "bulk-disable":
|
2026-05-04 02:27:36 +03:00
|
|
|
case "bulk-enable": {
|
|
|
|
|
const nextStatus = command === "bulk-disable" ? "disabled" : "active";
|
|
|
|
|
const ids = Array.from(state.selected);
|
|
|
|
|
if (!ids.length) {
|
|
|
|
|
toast("Select one or more users first", "warning");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
for (const id of ids) {
|
|
|
|
|
const user = state.users.find((item) => item.id === id);
|
|
|
|
|
if (!user) continue;
|
|
|
|
|
await api("/admin/users/save", "POST", payloadFromUser(user, { status: nextStatus }));
|
|
|
|
|
}
|
|
|
|
|
await fetchUsers();
|
|
|
|
|
toast(`Updated ${ids.length} user(s)`);
|
2026-05-01 02:34:47 +03:00
|
|
|
break;
|
2026-05-04 02:27:36 +03:00
|
|
|
}
|
|
|
|
|
case "bulk-delete": {
|
|
|
|
|
const ids = Array.from(state.selected);
|
|
|
|
|
if (!ids.length) {
|
|
|
|
|
toast("Select one or more users first", "warning");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!confirm(`Delete ${ids.length} selected user(s)?`)) return;
|
|
|
|
|
for (const id of ids) await api("/admin/users/delete", "POST", { id });
|
|
|
|
|
state.selected.clear();
|
|
|
|
|
state.currentUserID = "";
|
|
|
|
|
await fetchUsers();
|
|
|
|
|
setTab("add");
|
|
|
|
|
toast(`Deleted ${ids.length} user(s)`);
|
2026-05-01 02:34:47 +03:00
|
|
|
break;
|
2026-05-04 02:27:36 +03:00
|
|
|
}
|
|
|
|
|
case "clear-filters":
|
|
|
|
|
search.value = "";
|
|
|
|
|
statusFilter.value = "all";
|
|
|
|
|
sort.value = "username";
|
2026-05-01 02:34:47 +03:00
|
|
|
state.page = 1;
|
|
|
|
|
render();
|
|
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 02:27:36 +03:00
|
|
|
document.querySelectorAll(".users-tab").forEach((tab) => {
|
|
|
|
|
tab.addEventListener("click", () => setTab(tab.dataset.tab));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
[search, statusFilter, sort, size].forEach((element) => {
|
|
|
|
|
element.addEventListener(element.tagName === "INPUT" ? "input" : "change", () => {
|
2026-05-01 02:34:47 +03:00
|
|
|
state.page = 1;
|
|
|
|
|
render();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
prevBtn.addEventListener("click", () => {
|
|
|
|
|
state.page -= 1;
|
|
|
|
|
render();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
nextBtn.addEventListener("click", () => {
|
|
|
|
|
state.page += 1;
|
|
|
|
|
render();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
masterCheck.addEventListener("change", () => {
|
|
|
|
|
Array.from(body.querySelectorAll("tr")).forEach((row) => {
|
2026-05-04 02:27:36 +03:00
|
|
|
const userID = row.dataset.userId || "";
|
2026-05-01 02:34:47 +03:00
|
|
|
const checkbox = row.querySelector(".row-check");
|
2026-05-04 02:27:36 +03:00
|
|
|
if (!checkbox || !userID) return;
|
2026-05-01 02:34:47 +03:00
|
|
|
checkbox.checked = masterCheck.checked;
|
|
|
|
|
if (masterCheck.checked) state.selected.add(userID);
|
|
|
|
|
else state.selected.delete(userID);
|
|
|
|
|
});
|
2026-05-04 02:27:36 +03:00
|
|
|
if (state.selected.size === 1) state.currentUserID = Array.from(state.selected)[0];
|
|
|
|
|
populateSelectedPanels();
|
|
|
|
|
render();
|
2026-05-01 02:34:47 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
selectVisible.addEventListener("click", () => {
|
|
|
|
|
Array.from(body.querySelectorAll("tr")).forEach((row) => {
|
2026-05-04 02:27:36 +03:00
|
|
|
const userID = row.dataset.userId || "";
|
2026-05-01 02:34:47 +03:00
|
|
|
const checkbox = row.querySelector(".row-check");
|
2026-05-04 02:27:36 +03:00
|
|
|
if (!checkbox || !userID) return;
|
2026-05-01 02:34:47 +03:00
|
|
|
checkbox.checked = true;
|
|
|
|
|
state.selected.add(userID);
|
|
|
|
|
});
|
2026-05-04 02:27:36 +03:00
|
|
|
if (state.selected.size === 1) state.currentUserID = Array.from(state.selected)[0];
|
|
|
|
|
populateSelectedPanels();
|
|
|
|
|
render();
|
2026-05-01 02:34:47 +03:00
|
|
|
});
|
|
|
|
|
|
2026-05-04 02:27:36 +03:00
|
|
|
addForm.addEventListener("submit", async (event) => {
|
2026-05-01 02:34:47 +03:00
|
|
|
event.preventDefault();
|
2026-05-04 02:27:36 +03:00
|
|
|
const username = fields.add.username.value.trim();
|
|
|
|
|
const email = fields.add.email.value.trim();
|
2026-05-01 02:34:47 +03:00
|
|
|
if (!username || !email) {
|
|
|
|
|
toast("Username and email are required", "warning");
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-04 02:27:36 +03:00
|
|
|
try {
|
|
|
|
|
await saveUser({
|
|
|
|
|
username,
|
|
|
|
|
email,
|
|
|
|
|
status: fields.add.status.value,
|
|
|
|
|
max_file_size_mb: numericMB(fields.add.maxFile),
|
|
|
|
|
max_box_size_mb: numericMB(fields.add.maxBox),
|
|
|
|
|
permissions: permissionPayload(fields.add),
|
|
|
|
|
}, "User created");
|
|
|
|
|
addForm.reset();
|
|
|
|
|
fields.add.status.value = "active";
|
|
|
|
|
fields.add.maxFile.value = "0";
|
|
|
|
|
fields.add.maxBox.value = "0";
|
|
|
|
|
[fields.add.web, fields.add.api, fields.add.create, fields.add.upload].forEach((item) => { item.checked = true; });
|
|
|
|
|
setTab("edit");
|
|
|
|
|
} catch (error) {
|
|
|
|
|
toast(error.message || "Could not create user", "warning");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
editForm.addEventListener("submit", async (event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
const user = selectedUser();
|
|
|
|
|
if (!user) return;
|
|
|
|
|
try {
|
|
|
|
|
await saveUser(payloadFromUser(user, {
|
|
|
|
|
username: fields.edit.username.value.trim(),
|
|
|
|
|
email: fields.edit.email.value.trim(),
|
|
|
|
|
status: fields.edit.status.value,
|
|
|
|
|
}), "User updated");
|
|
|
|
|
} catch (error) {
|
|
|
|
|
toast(error.message || "Could not update user", "warning");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
fields.edit.delete.addEventListener("click", () => {
|
|
|
|
|
const user = selectedUser();
|
|
|
|
|
if (user) deleteUser(user.id);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
policiesForm.addEventListener("submit", async (event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
const user = selectedUser();
|
|
|
|
|
if (!user) return;
|
|
|
|
|
try {
|
|
|
|
|
await saveUser(payloadFromUser(user, {
|
|
|
|
|
max_file_size_mb: numericMB(fields.policies.maxFile),
|
|
|
|
|
max_box_size_mb: numericMB(fields.policies.maxBox),
|
|
|
|
|
permissions: permissionPayload(fields.policies),
|
|
|
|
|
}), "Policies updated");
|
|
|
|
|
} catch (error) {
|
|
|
|
|
toast(error.message || "Could not update policies", "warning");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
apiKeyForm.addEventListener("submit", async (event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
const user = selectedUser();
|
|
|
|
|
if (!user) return;
|
|
|
|
|
try {
|
|
|
|
|
const result = await api("/admin/users/api-keys/create", "POST", {
|
|
|
|
|
user_id: user.id,
|
|
|
|
|
name: fields.keys.name.value.trim() || "default",
|
|
|
|
|
});
|
|
|
|
|
apiKeyReveal.hidden = false;
|
|
|
|
|
apiKeyValue.value = result.api_key || "";
|
|
|
|
|
await fetchUsers();
|
|
|
|
|
setTab("keys");
|
|
|
|
|
toast("API key generated");
|
|
|
|
|
} catch (error) {
|
|
|
|
|
toast(error.message || "Could not generate API key", "warning");
|
|
|
|
|
}
|
2026-05-01 02:34:47 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
document.querySelectorAll("[data-command]").forEach((button) => {
|
2026-05-04 02:27:36 +03:00
|
|
|
button.addEventListener("click", async () => {
|
2026-05-01 02:34:47 +03:00
|
|
|
menuController.close();
|
2026-05-04 02:27:36 +03:00
|
|
|
try {
|
|
|
|
|
await runCommand(button.dataset.command);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
toast(error.message || "Action failed", "warning");
|
|
|
|
|
}
|
2026-05-01 02:34:47 +03:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-04 02:27:36 +03:00
|
|
|
document.addEventListener("keydown", async (event) => {
|
2026-05-01 02:34:47 +03:00
|
|
|
if (event.key === "Escape") menuController.close();
|
|
|
|
|
if (event.key === "F5") {
|
|
|
|
|
event.preventDefault();
|
2026-05-04 02:27:36 +03:00
|
|
|
await runCommand("refresh");
|
2026-05-01 02:34:47 +03:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-04 02:27:36 +03:00
|
|
|
fetchUsers().catch((error) => toast(error.message || "Failed to load users", "warning"));
|
2026-05-01 02:34:47 +03:00
|
|
|
})();
|