feat(users): add account limits and API keys
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m43s

This commit is contained in:
2026-05-04 02:27:36 +03:00
parent dc379ea6a6
commit d7cbba1bf2
14 changed files with 1688 additions and 271 deletions

View File

@@ -1,6 +1,7 @@
.users-page-body {
display: grid;
gap: 10px;
align-items: start;
}
.users-hero {
@@ -69,11 +70,92 @@
.users-main-grid {
display: grid;
grid-template-columns: minmax(320px, .65fr) minmax(0, 1.35fr);
grid-template-columns: 320px minmax(0, 1fr);
gap: 10px;
min-height: 0;
}
.users-control-panel {
min-height: 0;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 8px;
align-self: start;
}
.users-selected-card {
display: grid;
gap: 4px;
padding: 8px;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
}
.users-selected-card span,
.users-selected-card small {
color: #444444;
font-size: 12px;
line-height: 14px;
}
.users-selected-card strong {
min-height: 18px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 16px;
line-height: 18px;
}
.users-side-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
}
.users-tab {
min-height: 28px;
color: #000000;
background: var(--w98-gray);
border-top: 2px solid #ffffff;
border-left: 2px solid #ffffff;
border-right: 2px solid #000000;
border-bottom: 2px solid #000000;
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf;
font-family: inherit;
font-size: 12px;
line-height: 12px;
}
.users-tab.is-active {
color: #ffffff;
background: #000078;
border-top-color: #000000;
border-left-color: #000000;
border-right-color: #ffffff;
border-bottom-color: #ffffff;
}
.users-tab-panel {
display: none;
min-height: 0;
padding: 8px;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
}
.users-tab-panel.is-active {
display: grid;
gap: 8px;
align-content: start;
}
.users-panel {
min-height: 0;
display: flex;
@@ -97,6 +179,11 @@
border-bottom: 1px solid #b0b0b0;
}
.users-panel-header.compact {
margin: -8px -8px 0;
min-height: 30px;
}
.users-panel-title {
display: flex;
align-items: center;
@@ -194,13 +281,13 @@
.users-toolbar-grid {
display: grid;
grid-template-columns: minmax(220px, 1.2fr) repeat(4, minmax(100px, .6fr));
grid-template-columns: minmax(220px, 1.2fr) repeat(3, minmax(100px, .6fr));
gap: 8px;
}
.users-table-wrap {
min-height: 420px;
height: 420px;
min-height: 360px;
height: min(54vh, 520px);
overflow: auto;
background: #ffffff;
border-top: 2px solid #606060;
@@ -239,6 +326,7 @@
.users-table tbody tr:nth-child(odd) { background: rgba(255,255,255,.96); }
.users-table tbody tr:nth-child(even) { background: rgba(240,244,255,.9); }
.users-table tbody tr:hover { background: #d8e5f8; }
.users-table tbody tr.is-selected { background: #c8d8ff; }
.users-col-check { width: 30px; }
.users-col-actions { width: 136px; }
@@ -301,6 +389,70 @@
line-height: 12px;
}
.users-empty-note {
margin: 0;
color: #555555;
font-size: 12px;
line-height: 15px;
}
.users-key-reveal {
display: grid;
gap: 4px;
padding: 6px;
background: #ffffcc;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
}
.users-key-reveal span {
font-size: 12px;
line-height: 12px;
}
.users-key-list {
display: grid;
gap: 6px;
}
.users-key-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 6px;
background: #f6f6f6;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #b0b0b0;
border-bottom: 1px solid #b0b0b0;
}
.users-key-row div {
min-width: 0;
display: grid;
gap: 2px;
}
.users-key-row strong,
.users-key-row span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.users-key-row span {
color: #555555;
font-size: 11px;
line-height: 12px;
}
.users-key-row.is-revoked {
opacity: .62;
}
@media (max-width: 1024px) {
.users-main-grid,
.users-hero {

View File

@@ -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("<", "&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 (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"));
})();

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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();
}