Files
warpbox/static/js/admin/users.js

576 lines
23 KiB
JavaScript
Raw Normal View History

(() => {
const menuController = window.WarpBoxUI?.bindMenuBar?.() || { close() {} };
const toastTarget = document.getElementById("toast");
const body = document.getElementById("users-body");
const search = document.getElementById("users-search");
const statusFilter = document.getElementById("users-status");
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");
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");
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"),
},
};
function toast(message, type = "info") {
if (window.WarpBoxUI) {
window.WarpBoxUI.toast(message, type, { target: toastTarget, duration: 3200 });
return;
}
if (toastTarget) toastTarget.textContent = message;
}
function escapeHTML(value) {
return String(value || "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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);
}
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() {
const query = search.value.trim().toLowerCase();
const currentStatus = statusFilter.value;
const rows = state.users.filter((user) => {
const matchesQuery = !query || user.username.toLowerCase().includes(query) || user.email.toLowerCase().includes(query);
const matchesStatus = currentStatus === "all" || user.status === currentStatus;
return matchesQuery && matchesStatus;
});
rows.sort((a, b) => {
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);
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));
state.page = Math.max(1, Math.min(state.page, pages));
const start = (state.page - 1) * perPage;
return { rows: rows.slice(start, start + perPage), pages };
}
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)}`;
}
function renderRow(user) {
const checked = state.selected.has(user.id) ? " checked" : "";
const active = user.id === state.currentUserID ? " is-selected" : "";
const row = document.createElement("tr");
row.className = active;
row.dataset.userId = user.id;
row.innerHTML = `
<td><input type="checkbox" class="row-check"${checked}></td>
<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>
`;
row.addEventListener("click", (event) => {
if (event.target.closest(".row-check") || event.target.closest("button")) return;
setSelectedUser(user.id, "edit");
});
row.querySelector(".row-check")?.addEventListener("change", (event) => {
if (event.target.checked) state.selected.add(user.id);
else state.selected.delete(user.id);
if (event.target.checked) state.currentUserID = user.id;
populateSelectedPanels();
syncSelected();
syncMasterCheck();
render();
});
row.querySelector('[data-action="edit"]')?.addEventListener("click", () => setSelectedUser(user.id, "edit"));
row.querySelector('[data-action="keys"]')?.addEventListener("click", () => setSelectedUser(user.id, "keys"));
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() {
const rows = filteredUsers();
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();
}
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 = "";
}
state.selected = new Set([...state.selected].filter((id) => state.users.some((user) => user.id === id)));
renderStats();
populateSelectedPanels();
render();
}
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) {
switch (command) {
case "tab-add":
case "create":
setTab("add");
break;
case "refresh":
await fetchUsers();
toast("Users list refreshed");
break;
case "bulk-disable":
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)`);
break;
}
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)`);
break;
}
case "clear-filters":
search.value = "";
statusFilter.value = "all";
sort.value = "username";
state.page = 1;
render();
break;
default:
break;
}
}
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", () => {
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) => {
const userID = row.dataset.userId || "";
const checkbox = row.querySelector(".row-check");
if (!checkbox || !userID) return;
checkbox.checked = masterCheck.checked;
if (masterCheck.checked) state.selected.add(userID);
else state.selected.delete(userID);
});
if (state.selected.size === 1) state.currentUserID = Array.from(state.selected)[0];
populateSelectedPanels();
render();
});
selectVisible.addEventListener("click", () => {
Array.from(body.querySelectorAll("tr")).forEach((row) => {
const userID = row.dataset.userId || "";
const checkbox = row.querySelector(".row-check");
if (!checkbox || !userID) return;
checkbox.checked = true;
state.selected.add(userID);
});
if (state.selected.size === 1) state.currentUserID = Array.from(state.selected)[0];
populateSelectedPanels();
render();
});
addForm.addEventListener("submit", async (event) => {
event.preventDefault();
const username = fields.add.username.value.trim();
const email = fields.add.email.value.trim();
if (!username || !email) {
toast("Username and email are required", "warning");
return;
}
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");
}
});
document.querySelectorAll("[data-command]").forEach((button) => {
button.addEventListener("click", async () => {
menuController.close();
try {
await runCommand(button.dataset.command);
} catch (error) {
toast(error.message || "Action failed", "warning");
}
});
});
document.addEventListener("keydown", async (event) => {
if (event.key === "Escape") menuController.close();
if (event.key === "F5") {
event.preventDefault();
await runCommand("refresh");
}
});
fetchUsers().catch((error) => toast(error.message || "Failed to load users", "warning"));
})();