feat(users): add account limits and API keys
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m43s
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m43s
This commit is contained in:
@@ -3,8 +3,7 @@
|
||||
const toastTarget = document.getElementById("toast");
|
||||
const body = document.getElementById("users-body");
|
||||
const search = document.getElementById("users-search");
|
||||
const status = document.getElementById("users-status");
|
||||
const role = document.getElementById("users-role-filter");
|
||||
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");
|
||||
@@ -14,61 +13,234 @@
|
||||
const prevBtn = document.getElementById("users-prev");
|
||||
const nextBtn = document.getElementById("users-next");
|
||||
const selectVisible = document.getElementById("select-visible");
|
||||
const form = document.getElementById("users-form");
|
||||
const modeInput = document.getElementById("users-mode");
|
||||
const usernameInput = document.getElementById("users-username");
|
||||
const emailInput = document.getElementById("users-email");
|
||||
const roleInput = document.getElementById("users-role");
|
||||
const planInput = document.getElementById("users-plan");
|
||||
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 || !status || !role || !sort || !size) return;
|
||||
if (!body || !search || !statusFilter || !sort || !size) return;
|
||||
|
||||
const users = [
|
||||
{ id: "u_admin", username: "admin", email: "admin@warpbox.local", status: "active", role: "admin", plan: "unlimited", boxes: 18, created: "2026-04-12", lastSeen: "active now" },
|
||||
{ id: "u_geo", username: "geo", email: "geo@example.test", status: "active", role: "uploader", plan: "trusted", boxes: 7, created: "2026-04-21", lastSeen: "today 12:10" },
|
||||
{ id: "u_reo", username: "reo", email: "reo@example.test", status: "active", role: "uploader", plan: "standard", boxes: 3, created: "2026-04-20", lastSeen: "today 09:44" },
|
||||
{ id: "u_teo", username: "teo", email: "teo@example.test", status: "active", role: "uploader", plan: "trusted", boxes: 5, created: "2026-04-19", lastSeen: "yesterday" },
|
||||
{ id: "u_mara", username: "mara", email: "mara@example.test", status: "pending", role: "viewer", plan: "guest-like", boxes: 0, created: "2026-04-28", lastSeen: "never" },
|
||||
{ id: "u_ion", username: "ion", email: "ion@example.test", status: "disabled", role: "uploader", plan: "standard", boxes: 2, created: "2026-04-01", lastSeen: "2026-04-15" },
|
||||
{ id: "u_sara", username: "sara", email: "sara@example.test", status: "active", role: "operator", plan: "trusted", boxes: 12, created: "2026-03-30", lastSeen: "today 08:25" },
|
||||
{ id: "u_vlad", username: "vlad", email: "vlad@example.test", status: "pending", role: "uploader", plan: "standard", boxes: 0, created: "2026-04-27", lastSeen: "never" },
|
||||
{ id: "u_lina", username: "lina", email: "lina@example.test", status: "active", role: "viewer", plan: "guest-like", boxes: 1, created: "2026-03-22", lastSeen: "2026-04-29" },
|
||||
{ id: "u_adi", username: "adi", email: "adi@example.test", status: "active", role: "uploader", plan: "standard", boxes: 4, created: "2026-02-18", lastSeen: "2026-04-26" },
|
||||
{ id: "u_nora", username: "nora", email: "nora@example.test", status: "disabled", role: "viewer", plan: "guest-like", boxes: 0, created: "2026-01-14", lastSeen: "2026-03-02" },
|
||||
{ id: "u_alex", username: "alex", email: "alex@example.test", status: "active", role: "uploader", plan: "trusted", boxes: 9, created: "2026-04-10", lastSeen: "2026-04-30" },
|
||||
{ id: "u_rina", username: "rina", email: "rina@example.test", status: "pending", role: "uploader", plan: "standard", boxes: 0, created: "2026-04-29", lastSeen: "never" },
|
||||
{ id: "u_mihai", username: "mihai", email: "mihai@example.test", status: "active", role: "operator", plan: "trusted", boxes: 6, created: "2026-02-08", lastSeen: "2026-04-22" }
|
||||
];
|
||||
const state = {
|
||||
page: 1,
|
||||
users: [],
|
||||
selected: new Set(),
|
||||
currentUserID: "",
|
||||
};
|
||||
|
||||
const state = { page: 1, selected: new Set() };
|
||||
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: 2200 });
|
||||
window.WarpBoxUI.toast(message, type, { target: toastTarget, duration: 3200 });
|
||||
return;
|
||||
}
|
||||
if (!toastTarget) return;
|
||||
toastTarget.textContent = message;
|
||||
toastTarget.classList.add("is-visible");
|
||||
if (toastTarget) toastTarget.textContent = message;
|
||||
}
|
||||
|
||||
function filtered() {
|
||||
const query = search.value.trim().toLowerCase();
|
||||
const statusFilter = status.value;
|
||||
const roleFilter = role.value;
|
||||
const sortBy = sort.value;
|
||||
const rows = users.filter((user) => {
|
||||
const matchesQuery = !query || user.username.toLowerCase().includes(query) || user.email.toLowerCase().includes(query);
|
||||
const matchesStatus = statusFilter === "all" || user.status === statusFilter;
|
||||
const matchesRole = roleFilter === "all" || user.role === roleFilter;
|
||||
return matchesQuery && matchesStatus && matchesRole;
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
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 (sortBy === "createdDesc") return b.created.localeCompare(a.created);
|
||||
if (sortBy === "lastSeenDesc") return b.lastSeen.localeCompare(a.lastSeen);
|
||||
if (sortBy === "boxesDesc") return b.boxes - a.boxes;
|
||||
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;
|
||||
@@ -77,40 +249,56 @@
|
||||
function paged(rows) {
|
||||
const perPage = Number(size.value || 12);
|
||||
const pages = Math.max(1, Math.ceil(rows.length / perPage));
|
||||
if (state.page > pages) state.page = pages;
|
||||
if (state.page < 1) state.page = 1;
|
||||
state.page = Math.max(1, Math.min(state.page, pages));
|
||||
const start = (state.page - 1) * perPage;
|
||||
return { rows: rows.slice(start, start + perPage), pages, start };
|
||||
return { rows: rows.slice(start, start + perPage), pages };
|
||||
}
|
||||
|
||||
function statusPill(value) {
|
||||
return `<span class="users-pill ${value}">${value}</span>`;
|
||||
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>${user.username}</strong><span class="users-muted">${user.id}</span></div></td>
|
||||
<td title="${user.email}">${user.email}</td>
|
||||
<td>${statusPill(user.status)}</td>
|
||||
<td>${user.role}</td>
|
||||
<td>${user.plan}</td>
|
||||
<td>${user.boxes}</td>
|
||||
<td>${user.lastSeen}</td>
|
||||
<td><div class="users-row-actions"><button class="win98-button users-row-button" type="button" data-action="open">Open</button></div></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="open"]')?.addEventListener("click", () => {
|
||||
toast(`Mock user preview: ${user.username}`);
|
||||
});
|
||||
row.querySelector('[data-action="edit"]')?.addEventListener("click", () => setSelectedUser(user.id, "edit"));
|
||||
row.querySelector('[data-action="keys"]')?.addEventListener("click", () => setSelectedUser(user.id, "keys"));
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -123,19 +311,11 @@
|
||||
masterCheck.checked = checks.length > 0 && checks.every((item) => item.checked);
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
document.getElementById("stat-total").textContent = String(users.length);
|
||||
document.getElementById("stat-active").textContent = String(users.filter((u) => u.status === "active").length);
|
||||
document.getElementById("stat-pending").textContent = String(users.filter((u) => u.status === "pending").length);
|
||||
document.getElementById("stat-disabled").textContent = String(users.filter((u) => u.status === "disabled").length);
|
||||
}
|
||||
|
||||
function render() {
|
||||
const rows = filtered();
|
||||
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;
|
||||
@@ -145,75 +325,109 @@
|
||||
syncMasterCheck();
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
search.value = "";
|
||||
status.value = "all";
|
||||
role.value = "all";
|
||||
sort.value = "username";
|
||||
state.page = 1;
|
||||
render();
|
||||
}
|
||||
|
||||
function applyBulk(nextStatus) {
|
||||
const selected = users.filter((user) => state.selected.has(user.id));
|
||||
if (!selected.length) {
|
||||
toast("Select one or more users first", "warning");
|
||||
return;
|
||||
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 = "";
|
||||
}
|
||||
selected.forEach((user) => { user.status = nextStatus; });
|
||||
toast(`Updated ${selected.length} user(s) to ${nextStatus}`);
|
||||
state.selected = new Set([...state.selected].filter((id) => state.users.some((user) => user.id === id)));
|
||||
renderStats();
|
||||
populateSelectedPanels();
|
||||
render();
|
||||
}
|
||||
|
||||
function runCommand(command) {
|
||||
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 "invite":
|
||||
modeInput.value = "invite";
|
||||
toast("Invite mode selected");
|
||||
break;
|
||||
case "tab-add":
|
||||
case "create":
|
||||
modeInput.value = "create";
|
||||
toast("Create mode selected");
|
||||
break;
|
||||
case "export":
|
||||
toast("Mock CSV export complete");
|
||||
break;
|
||||
case "bulk-disable":
|
||||
applyBulk("disabled");
|
||||
break;
|
||||
case "bulk-enable":
|
||||
applyBulk("active");
|
||||
break;
|
||||
case "bulk-revoke":
|
||||
toast("Mock session revocation queued");
|
||||
setTab("add");
|
||||
break;
|
||||
case "refresh":
|
||||
await fetchUsers();
|
||||
toast("Users list refreshed");
|
||||
render();
|
||||
break;
|
||||
case "pending-only":
|
||||
status.value = "pending";
|
||||
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;
|
||||
case "clear-filters":
|
||||
clearFilters();
|
||||
break;
|
||||
case "policy-help":
|
||||
toast("Policy editor will be added in user details later.");
|
||||
break;
|
||||
case "mock-note":
|
||||
toast("Mock-only page: no backend writes yet.");
|
||||
break;
|
||||
default:
|
||||
toast(`Mock action: ${command}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
[search, status, role, sort, size].forEach((el) => {
|
||||
el.addEventListener(el.tagName === "INPUT" ? "input" : "change", () => {
|
||||
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();
|
||||
});
|
||||
@@ -231,74 +445,131 @@
|
||||
|
||||
masterCheck.addEventListener("change", () => {
|
||||
Array.from(body.querySelectorAll("tr")).forEach((row) => {
|
||||
const userID = row.dataset.userId || "";
|
||||
const checkbox = row.querySelector(".row-check");
|
||||
if (!checkbox) return;
|
||||
if (!checkbox || !userID) return;
|
||||
checkbox.checked = masterCheck.checked;
|
||||
const userID = row.querySelector(".users-muted")?.textContent || "";
|
||||
if (masterCheck.checked) state.selected.add(userID);
|
||||
else state.selected.delete(userID);
|
||||
});
|
||||
syncSelected();
|
||||
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");
|
||||
const userID = row.querySelector(".users-muted")?.textContent || "";
|
||||
if (!checkbox) return;
|
||||
if (!checkbox || !userID) return;
|
||||
checkbox.checked = true;
|
||||
state.selected.add(userID);
|
||||
});
|
||||
syncSelected();
|
||||
syncMasterCheck();
|
||||
if (state.selected.size === 1) state.currentUserID = Array.from(state.selected)[0];
|
||||
populateSelectedPanels();
|
||||
render();
|
||||
});
|
||||
|
||||
form.addEventListener("submit", (event) => {
|
||||
addForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const username = usernameInput.value.trim();
|
||||
const email = emailInput.value.trim();
|
||||
const mode = modeInput.value;
|
||||
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;
|
||||
}
|
||||
users.unshift({
|
||||
id: `u_${username.toLowerCase().replaceAll(/[^a-z0-9]+/g, "_")}`,
|
||||
username,
|
||||
email,
|
||||
status: mode === "invite" ? "pending" : "active",
|
||||
role: roleInput.value,
|
||||
plan: planInput.value,
|
||||
boxes: 0,
|
||||
created: new Date().toISOString().slice(0, 10),
|
||||
lastSeen: "never"
|
||||
});
|
||||
form.reset();
|
||||
modeInput.value = "invite";
|
||||
renderStats();
|
||||
render();
|
||||
toast(mode === "invite" ? "Mock invite created" : "Mock user created");
|
||||
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", () => {
|
||||
button.addEventListener("click", async () => {
|
||||
menuController.close();
|
||||
runCommand(button.dataset.command);
|
||||
try {
|
||||
await runCommand(button.dataset.command);
|
||||
} catch (error) {
|
||||
toast(error.message || "Action failed", "warning");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
document.addEventListener("keydown", async (event) => {
|
||||
if (event.key === "Escape") menuController.close();
|
||||
if (event.key === "F5") {
|
||||
event.preventDefault();
|
||||
runCommand("refresh");
|
||||
}
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "i") {
|
||||
event.preventDefault();
|
||||
runCommand("invite");
|
||||
await runCommand("refresh");
|
||||
}
|
||||
});
|
||||
|
||||
renderStats();
|
||||
render();
|
||||
fetchUsers().catch((error) => toast(error.message || "Failed to load users", "warning"));
|
||||
})();
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
function authHeaders() {
|
||||
const headers = {};
|
||||
const apiKeyEnabled = Boolean(el.apiKeyMode?.checked);
|
||||
const apiKey = String(el.apiKeyInput?.value || "").trim();
|
||||
if (apiKeyEnabled && apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function createBox() {
|
||||
const response = await fetch("/box", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||
body: JSON.stringify({
|
||||
retention_key: el.expiry?.value || defaultRetention,
|
||||
password: el.password?.value || "",
|
||||
@@ -28,7 +36,7 @@ async function markFileStatus(item, status) {
|
||||
try {
|
||||
await fetch(`/box/${item.boxID}/files/${item.boxFile.id}/status`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
} catch (_) {
|
||||
@@ -62,6 +70,8 @@ function uploadFile(item, onComplete) {
|
||||
formData.append("file", item.file, item.displayName);
|
||||
|
||||
xhr.open("POST", item.boxFile.upload_path);
|
||||
const headers = authHeaders();
|
||||
if (headers.Authorization) xhr.setRequestHeader("Authorization", headers.Authorization);
|
||||
|
||||
xhr.upload.addEventListener("loadstart", () => {
|
||||
item.loaded = 0;
|
||||
|
||||
@@ -34,8 +34,10 @@ function setBoxOptionsLocked(locked) {
|
||||
function updateDisabledReasons() {
|
||||
if (el.startButton) {
|
||||
let reason = "";
|
||||
const policyMessage = apiKeyPolicyMessage();
|
||||
if (!uploadsEnabled) reason = "Guest uploads are disabled.";
|
||||
else if (uploadLocked) reason = "This upload already started. Press Clear to create another box.";
|
||||
else if (policyMessage) reason = policyMessage;
|
||||
else if (hasQuotaError()) reason = "Over maximum upload size. Remove highlighted files or clear some files.";
|
||||
else if (!files.length) reason = "There are no files selected. Please select files to upload.";
|
||||
el.startButton.disabled = false;
|
||||
@@ -101,6 +103,13 @@ function syncMenuChecks() {
|
||||
function syncApiKeyField() {
|
||||
const enabled = Boolean(el.apiKeyMode?.checked) && !uploadLocked;
|
||||
el.apiKeyRow?.classList.toggle("is-visible", Boolean(el.apiKeyMode?.checked));
|
||||
if (!el.apiKeyMode?.checked) {
|
||||
clearTimeout(apiKeyTimer);
|
||||
apiKeyValidationRun += 1;
|
||||
resetAccountLimits();
|
||||
updateLimitHint();
|
||||
renderFiles();
|
||||
}
|
||||
if (el.apiKeyInput) {
|
||||
el.apiKeyInput.disabled = !enabled;
|
||||
el.apiKeyInput.dataset.disabledReason = enabled ? "" : "Enable Use API key for larger quota before typing an API key.";
|
||||
@@ -115,30 +124,83 @@ function validateApiKeyField() {
|
||||
wrapper?.classList.remove("is-checking");
|
||||
|
||||
if (!el.apiKeyMode?.checked) {
|
||||
apiKeyValidationRun += 1;
|
||||
resetAccountLimits();
|
||||
el.apiKeyState.textContent = "";
|
||||
updateLimitHint();
|
||||
renderFiles();
|
||||
return;
|
||||
}
|
||||
const value = el.apiKeyInput.value.trim();
|
||||
if (!value) {
|
||||
apiKeyValidationRun += 1;
|
||||
resetAccountLimits();
|
||||
el.apiKeyState.textContent = "waiting";
|
||||
updateLimitHint();
|
||||
renderFiles();
|
||||
saveSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validApiKey(value)) {
|
||||
apiKeyValidationRun += 1;
|
||||
resetAccountLimits();
|
||||
el.apiKeyInput.value = "";
|
||||
el.apiKeyState.textContent = "invalid";
|
||||
updateLimitHint();
|
||||
renderFiles();
|
||||
saveSettings();
|
||||
showToast("Invalid API key removed. Paste a valid API key to save it.", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
const runID = apiKeyValidationRun + 1;
|
||||
apiKeyValidationRun = runID;
|
||||
el.apiKeyInput.disabled = true;
|
||||
wrapper?.classList.add("is-checking");
|
||||
el.apiKeyState.textContent = "checking";
|
||||
apiKeyTimer = setTimeout(() => {
|
||||
wrapper?.classList.remove("is-checking");
|
||||
el.apiKeyInput.disabled = uploadLocked;
|
||||
if (validApiKey(value)) {
|
||||
el.apiKeyState.textContent = "saved locally";
|
||||
apiKeyTimer = setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch("/auth/me", {
|
||||
headers: { Authorization: `Bearer ${value}` },
|
||||
});
|
||||
let payload = {};
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch (_) {}
|
||||
if (runID !== apiKeyValidationRun) return;
|
||||
wrapper?.classList.remove("is-checking");
|
||||
el.apiKeyInput.disabled = uploadLocked;
|
||||
if (!response.ok || !payload.user) {
|
||||
resetAccountLimits();
|
||||
el.apiKeyInput.value = "";
|
||||
el.apiKeyState.textContent = "invalid";
|
||||
updateLimitHint();
|
||||
renderFiles();
|
||||
saveSettings();
|
||||
showToast(payload.error || "API key was not accepted.", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
applyAccountLimits(payload.user);
|
||||
const policyMessage = apiKeyPolicyMessage();
|
||||
const fileText = maxFileBytes ? formatBytes(maxFileBytes) : "unlimited";
|
||||
const boxText = maxBoxBytes ? formatBytes(maxBoxBytes) : "unlimited";
|
||||
el.apiKeyState.textContent = policyMessage ? "limited by policy" : "account limits applied";
|
||||
updateLimitHint();
|
||||
renderFiles();
|
||||
saveSettings();
|
||||
} else {
|
||||
el.apiKeyInput.value = "";
|
||||
el.apiKeyState.textContent = "invalid";
|
||||
saveSettings();
|
||||
showToast("Invalid API key removed. Paste a valid API key to save it.", "warning");
|
||||
setStatus(`${payload.user.username || payload.user.email} limits: file ${fileText}, box ${boxText}`);
|
||||
if (policyMessage) showToast(policyMessage, "warning");
|
||||
} catch (_) {
|
||||
if (runID !== apiKeyValidationRun) return;
|
||||
wrapper?.classList.remove("is-checking");
|
||||
el.apiKeyInput.disabled = uploadLocked;
|
||||
resetAccountLimits();
|
||||
updateLimitHint();
|
||||
renderFiles();
|
||||
el.apiKeyState.textContent = "check failed";
|
||||
showToast("Could not check API key limits.", "warning");
|
||||
}
|
||||
}, 650);
|
||||
}
|
||||
|
||||
@@ -44,16 +44,20 @@ const el = {
|
||||
|
||||
const uploadsEnabled = el.form?.dataset.uploadsEnabled === "true";
|
||||
const defaultRetention = el.form?.dataset.defaultRetention || "10s";
|
||||
const maxFileBytes = numberFromDataset(el.form?.dataset.maxFileBytes);
|
||||
const maxBoxBytes = numberFromDataset(el.form?.dataset.maxBoxBytes);
|
||||
const baseMaxFileBytes = numberFromDataset(el.form?.dataset.maxFileBytes);
|
||||
const baseMaxBoxBytes = numberFromDataset(el.form?.dataset.maxBoxBytes);
|
||||
const oneTimeRetentionKey = "one-time";
|
||||
|
||||
let maxFileBytes = baseMaxFileBytes;
|
||||
let maxBoxBytes = baseMaxBoxBytes;
|
||||
let files = [];
|
||||
let shareUrl = "";
|
||||
let uploadLocked = false;
|
||||
let statusTimer = null;
|
||||
let pendingDuplicateFiles = [];
|
||||
let apiKeyTimer = null;
|
||||
let apiKeyValidationRun = 0;
|
||||
let authenticatedUser = null;
|
||||
let completedImpactKeys = new Set();
|
||||
let overallImpactDone = false;
|
||||
|
||||
@@ -105,6 +109,33 @@ function hasQuotaError() {
|
||||
return isOverBoxQuota() || oversizedFiles().length > 0;
|
||||
}
|
||||
|
||||
function effectiveLimit(baseLimit, userLimit) {
|
||||
return numberFromDataset(userLimit);
|
||||
}
|
||||
|
||||
function resetAccountLimits() {
|
||||
authenticatedUser = null;
|
||||
maxFileBytes = baseMaxFileBytes;
|
||||
maxBoxBytes = baseMaxBoxBytes;
|
||||
}
|
||||
|
||||
function applyAccountLimits(user) {
|
||||
authenticatedUser = user || null;
|
||||
const limits = authenticatedUser?.limits || {};
|
||||
maxFileBytes = effectiveLimit(baseMaxFileBytes, limits.max_file_size_bytes);
|
||||
maxBoxBytes = effectiveLimit(baseMaxBoxBytes, limits.max_box_size_bytes);
|
||||
}
|
||||
|
||||
function apiKeyPolicyMessage() {
|
||||
if (!el.apiKeyMode?.checked || !authenticatedUser) return "";
|
||||
const permissions = authenticatedUser.permissions || {};
|
||||
if (authenticatedUser.status && authenticatedUser.status !== "active") return "The API key belongs to a disabled account.";
|
||||
if (!permissions.can_use_api) return "This account is not allowed to use the API.";
|
||||
if (!permissions.can_create_box) return "This account is not allowed to create boxes.";
|
||||
if (!permissions.can_upload_file) return "This account is not allowed to upload files.";
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizedFileName(name) {
|
||||
return String(name || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user