feat(routing): add user admin panel support

Adds the user administration route, associated server handlers, and frontend assets for managing user accounts.
This commit is contained in:
2026-05-01 02:34:47 +03:00
parent 1cf38d126d
commit 9b57b2a535
7 changed files with 832 additions and 0 deletions

View File

@@ -24,6 +24,7 @@ type Handlers struct {
AdminAlerts gin.HandlerFunc
AdminBoxes gin.HandlerFunc
AdminBoxesAction gin.HandlerFunc
AdminUsers gin.HandlerFunc
AdminSettings gin.HandlerFunc
AdminSettingsExport gin.HandlerFunc
AdminSettingsSave gin.HandlerFunc
@@ -61,6 +62,7 @@ func Register(router *gin.Engine, handlers Handlers) {
protected.GET("/alerts", handlers.AdminAlerts)
protected.GET("/boxes", handlers.AdminBoxes)
protected.POST("/boxes/actions", handlers.AdminBoxesAction)
protected.GET("/users", handlers.AdminUsers)
protected.GET("/settings", handlers.AdminSettings)
protected.GET("/settings/export", handlers.AdminSettingsExport)
protected.POST("/settings/save", handlers.AdminSettingsSave)

20
lib/server/admin_users.go Normal file
View File

@@ -0,0 +1,20 @@
package server
import (
"net/http"
"github.com/gin-gonic/gin"
)
func (app *App) handleAdminUsers(ctx *gin.Context) {
if !app.adminLoginEnabled() {
ctx.Redirect(http.StatusSeeOther, "/")
return
}
ctx.HTML(http.StatusOK, "admin/users.html", gin.H{
"AdminUsername": app.config.AdminUsername,
"AdminEmail": app.config.AdminEmail,
"ActivePage": "users",
})
}

View File

@@ -69,6 +69,7 @@ func Run(addr string) error {
AdminAlerts: app.handleAdminAlerts,
AdminBoxes: app.handleAdminBoxes,
AdminBoxesAction: app.handleAdminBoxesAction,
AdminUsers: app.handleAdminUsers,
AdminSettings: app.handleAdminSettings,
AdminSettingsExport: app.handleAdminSettingsExport,
AdminSettingsSave: app.handleAdminSettingsSave,

309
static/css/users.css Normal file
View File

@@ -0,0 +1,309 @@
.users-page-body {
display: grid;
gap: 10px;
}
.users-hero {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(300px, .9fr);
gap: 10px;
padding: 10px;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
}
.users-hero h2 {
margin: 0 0 6px;
font-size: 24px;
line-height: 24px;
}
.users-hero p {
margin: 0;
color: #333333;
font-size: 13px;
line-height: 16px;
}
.users-hero-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
align-content: start;
}
.users-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.users-stat-card {
padding: 8px;
background: #dfdfdf;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #808080;
border-bottom: 1px solid #808080;
}
.users-stat-card p {
margin: 0 0 6px;
font-size: 12px;
line-height: 12px;
text-transform: uppercase;
}
.users-stat-card strong {
font-size: 24px;
line-height: 24px;
}
.users-stat-card.is-info { background: linear-gradient(180deg, #d7e6fb, #bfd7f8); }
.users-stat-card.is-ok { background: linear-gradient(180deg, #dbf4dc, #c3ebc5); }
.users-stat-card.is-warning { background: linear-gradient(180deg, #fff1c9, #ffe39f); }
.users-stat-card.is-danger { background: linear-gradient(180deg, #ffd8d8, #f1b3b3); }
.users-main-grid {
display: grid;
grid-template-columns: minmax(320px, .65fr) minmax(0, 1.35fr);
gap: 10px;
min-height: 0;
}
.users-panel {
min-height: 0;
display: flex;
flex-direction: column;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
}
.users-panel-header {
flex: 0 0 auto;
min-height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 8px;
background: #dfdfdf;
border-bottom: 1px solid #b0b0b0;
}
.users-panel-title {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
font-weight: bold;
font-size: 15px;
line-height: 15px;
}
.users-panel-title span {
font-weight: normal;
color: #444444;
font-size: 12px;
line-height: 12px;
}
.users-panel-tools {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.users-panel-body {
flex: 1 1 auto;
min-height: 0;
padding: 10px;
background: linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58));
}
.users-list-body {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 8px;
overflow: hidden;
}
.users-form-grid {
display: grid;
gap: 8px;
}
.users-row-two {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.users-field {
display: grid;
gap: 4px;
font-size: 12px;
line-height: 12px;
}
.users-input,
.users-select {
width: 100%;
min-width: 0;
height: 28px;
color: #000000;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
padding: 4px 6px;
font-family: inherit;
font-size: 13px;
}
.users-check {
min-height: 20px;
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
.users-form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.users-action-button,
.users-tool-button,
.users-page-button {
min-width: 70px;
height: 24px;
padding: 0 8px;
font-size: 12px;
line-height: 12px;
}
.users-toolbar-grid {
display: grid;
grid-template-columns: minmax(220px, 1.2fr) repeat(4, minmax(100px, .6fr));
gap: 8px;
}
.users-table-wrap {
min-height: 420px;
height: 420px;
overflow: auto;
background: #ffffff;
border-top: 2px solid #606060;
border-left: 2px solid #606060;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
}
.users-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: 12px;
line-height: 14px;
}
.users-table th,
.users-table td {
padding: 6px;
border-bottom: 1px solid #e1e1e1;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.users-table th {
position: sticky;
top: 0;
z-index: 2;
text-align: left;
background: #dfdfdf;
border-bottom: 1px solid #b0b0b0;
}
.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-col-check { width: 30px; }
.users-col-actions { width: 136px; }
.users-username {
display: grid;
gap: 2px;
}
.users-username strong {
font-size: 13px;
line-height: 13px;
}
.users-muted {
color: #555555;
font-size: 11px;
line-height: 11px;
}
.users-pill {
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 0 6px;
color: #222222;
background: #f1f1f1;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #b0b0b0;
border-bottom: 1px solid #b0b0b0;
font-size: 12px;
line-height: 12px;
}
.users-pill.active { background: #def2e0; }
.users-pill.pending { background: #fff1c9; }
.users-pill.disabled { background: #ffdcdc; }
.users-row-actions {
display: flex;
justify-content: flex-end;
gap: 4px;
}
.users-row-button {
min-width: 60px;
height: 22px;
padding: 0 6px;
font-size: 12px;
line-height: 12px;
}
.users-pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 12px;
line-height: 12px;
}
@media (max-width: 1024px) {
.users-main-grid,
.users-hero {
grid-template-columns: 1fr;
}
}

304
static/js/admin/users.js Normal file
View File

@@ -0,0 +1,304 @@
(() => {
const menuController = window.WarpBoxUI?.bindMenuBar?.() || { close() {} };
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 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 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");
if (!body || !search || !status || !role || !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, selected: new Set() };
function toast(message, type = "info") {
if (window.WarpBoxUI) {
window.WarpBoxUI.toast(message, type, { target: toastTarget, duration: 2200 });
return;
}
if (!toastTarget) return;
toastTarget.textContent = message;
toastTarget.classList.add("is-visible");
}
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;
});
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;
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));
if (state.page > pages) state.page = pages;
if (state.page < 1) state.page = 1;
const start = (state.page - 1) * perPage;
return { rows: rows.slice(start, start + perPage), pages, start };
}
function statusPill(value) {
return `<span class="users-pill ${value}">${value}</span>`;
}
function renderRow(user) {
const checked = state.selected.has(user.id) ? " checked" : "";
const row = document.createElement("tr");
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>
`;
row.querySelector(".row-check")?.addEventListener("change", (event) => {
if (event.target.checked) state.selected.add(user.id);
else state.selected.delete(user.id);
syncSelected();
syncMasterCheck();
});
row.querySelector('[data-action="open"]')?.addEventListener("click", () => {
toast(`Mock user preview: ${user.username}`);
});
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 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 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();
}
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;
}
selected.forEach((user) => { user.status = nextStatus; });
toast(`Updated ${selected.length} user(s) to ${nextStatus}`);
renderStats();
render();
}
function runCommand(command) {
switch (command) {
case "invite":
modeInput.value = "invite";
toast("Invite mode selected");
break;
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");
break;
case "refresh":
toast("Users list refreshed");
render();
break;
case "pending-only":
status.value = "pending";
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", () => {
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 checkbox = row.querySelector(".row-check");
if (!checkbox) 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();
});
selectVisible.addEventListener("click", () => {
Array.from(body.querySelectorAll("tr")).forEach((row) => {
const checkbox = row.querySelector(".row-check");
const userID = row.querySelector(".users-muted")?.textContent || "";
if (!checkbox) return;
checkbox.checked = true;
state.selected.add(userID);
});
syncSelected();
syncMasterCheck();
});
form.addEventListener("submit", (event) => {
event.preventDefault();
const username = usernameInput.value.trim();
const email = emailInput.value.trim();
const mode = modeInput.value;
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");
});
document.querySelectorAll("[data-command]").forEach((button) => {
button.addEventListener("click", () => {
menuController.close();
runCommand(button.dataset.command);
});
});
document.addEventListener("keydown", (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");
}
});
renderStats();
render();
})();

View File

@@ -8,6 +8,7 @@
<a class="admin-taskbar-button{{ if eq .ActivePage "dashboard" }} is-active{{ end }}" href="/admin/dashboard">Dashboard</a>
<a class="admin-taskbar-button{{ if eq .ActivePage "alerts" }} is-active{{ end }}" href="/admin/alerts">Alerts</a>
<a class="admin-taskbar-button{{ if eq .ActivePage "boxes" }} is-active{{ end }}" href="/admin/boxes">Boxes</a>
<a class="admin-taskbar-button{{ if eq .ActivePage "users" }} is-active{{ end }}" href="/admin/users">Users</a>
<a class="admin-taskbar-button{{ if eq .ActivePage "settings" }} is-active{{ end }}" href="/admin/settings">Settings</a>
</nav>
<div class="admin-taskbar-session" aria-label="Admin session summary">

195
templates/admin/users.html Normal file
View File

@@ -0,0 +1,195 @@
{{ define "admin/users.html" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WarpBox Admin Users</title>
<link rel="icon" type="image/png" href="/static/WarpBoxLogo.png">
<link rel="stylesheet" href="/static/css/app.css">
<link rel="stylesheet" href="/static/css/window.css">
<link rel="stylesheet" href="/static/css/components/buttons.css">
<link rel="stylesheet" href="/static/css/components/toast.css">
<link rel="stylesheet" href="/static/css/admin.css">
<link rel="stylesheet" href="/static/css/users.css">
</head>
<body>
<div class="admin-shell">
<div class="admin-frame">
{{ template "admin/header.html" . }}
<div class="win98-window admin-workspace-window" role="main">
<div class="win98-titlebar">
<div class="win98-titlebar-label">
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
<h1>WarpBox Users</h1>
</div>
<div class="win98-window-controls" aria-hidden="true">
<button class="win98-control" type="button">_</button>
<button class="win98-control" type="button"></button>
<button class="win98-control" type="button">x</button>
</div>
</div>
<nav class="menu-bar" aria-label="Users toolbar">
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">File</button>
<div class="menu-popup">
<button class="menu-action" type="button" data-command="invite"><span>I</span><span>Invite user</span><span>Ctrl+I</span></button>
<button class="menu-action" type="button" data-command="create"><span>C</span><span>Create local user</span><span></span></button>
<div class="menu-separator"></div>
<button class="menu-action" type="button" data-command="export"><span>E</span><span>Export visible CSV</span><span></span></button>
</div>
</div>
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">Users</button>
<div class="menu-popup">
<button class="menu-action" type="button" data-command="bulk-disable"><span>D</span><span>Disable selected</span><span></span></button>
<button class="menu-action" type="button" data-command="bulk-enable"><span>U</span><span>Enable selected</span><span></span></button>
<button class="menu-action" type="button" data-command="bulk-revoke"><span>R</span><span>Revoke sessions</span><span></span></button>
</div>
</div>
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">View</button>
<div class="menu-popup">
<button class="menu-action" type="button" data-command="refresh"><span>F</span><span>Refresh list</span><span>F5</span></button>
<button class="menu-action" type="button" data-command="pending-only"><span>P</span><span>Show pending invites</span><span></span></button>
<button class="menu-action" type="button" data-command="clear-filters"><span>X</span><span>Clear filters</span><span></span></button>
</div>
</div>
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">Help</button>
<div class="menu-popup">
<button class="menu-action" type="button" data-command="policy-help"><span>?</span><span>User policy notes</span><span></span></button>
<button class="menu-action" type="button" data-command="mock-note"><span>M</span><span>Mock-only notes</span><span></span></button>
</div>
</div>
</nav>
<div class="admin-workspace-body users-page-body">
<section class="users-hero">
<div>
<h2>Accounts, invites, and access</h2>
<p>Mock administrative users view for creation, invitation, filtering, and safe bulk actions.</p>
</div>
<div class="users-hero-actions">
<button class="win98-button users-action-button" type="button" data-command="invite">Invite user</button>
<button class="win98-button users-action-button" type="button" data-command="create">Create local user</button>
<button class="win98-button users-action-button" type="button" data-command="export">Export CSV</button>
<button class="win98-button users-action-button" type="button" data-command="policy-help">Policy notes</button>
</div>
</section>
<section class="users-summary-grid">
<article class="users-stat-card is-info"><p>Total users</p><strong id="stat-total">0</strong></article>
<article class="users-stat-card is-ok"><p>Active</p><strong id="stat-active">0</strong></article>
<article class="users-stat-card is-warning"><p>Pending invites</p><strong id="stat-pending">0</strong></article>
<article class="users-stat-card is-danger"><p>Disabled</p><strong id="stat-disabled">0</strong></article>
</section>
<section class="users-main-grid">
<section class="users-panel">
<div class="users-panel-header">
<div class="users-panel-title">Create or invite <span>mock only</span></div>
</div>
<div class="users-panel-body">
<form id="users-form" class="users-form-grid">
<label class="users-field">Mode
<select class="users-select" id="users-mode">
<option value="invite">Send invite</option>
<option value="create">Create local user</option>
</select>
</label>
<label class="users-field">Username<input class="users-input" id="users-username" type="text" autocomplete="off"></label>
<label class="users-field">Email<input class="users-input" id="users-email" type="email" autocomplete="off"></label>
<div class="users-row-two">
<label class="users-field">Role
<select class="users-select" id="users-role">
<option value="uploader">uploader</option>
<option value="operator">operator</option>
<option value="viewer">viewer</option>
<option value="admin">admin</option>
</select>
</label>
<label class="users-field">Plan
<select class="users-select" id="users-plan">
<option value="standard">standard</option>
<option value="trusted">trusted</option>
<option value="guest-like">guest-like</option>
<option value="unlimited">unlimited</option>
</select>
</label>
</div>
<label class="users-check"><input type="checkbox" id="users-send-setup" checked>Send setup instructions</label>
<div class="users-form-actions">
<button class="win98-button users-action-button" type="reset">Clear</button>
<button class="win98-button users-action-button" type="submit">Apply</button>
</div>
</form>
</div>
</section>
<section class="users-panel">
<div class="users-panel-header">
<div class="users-panel-title">Users <span id="visible-pill">0 visible</span></div>
<div class="users-panel-tools">
<button class="win98-button users-tool-button" type="button" id="select-visible">Select visible</button>
<button class="win98-button users-tool-button" type="button" data-command="bulk-disable">Disable</button>
<button class="win98-button users-tool-button" type="button" data-command="bulk-enable">Enable</button>
<button class="win98-button users-tool-button" type="button" data-command="bulk-revoke">Revoke</button>
</div>
</div>
<div class="users-panel-body users-list-body">
<div class="users-toolbar-grid">
<input class="users-input" id="users-search" type="search" placeholder="Search username or email">
<select class="users-select" id="users-status"><option value="all">all statuses</option><option value="active">active</option><option value="pending">pending</option><option value="disabled">disabled</option></select>
<select class="users-select" id="users-role-filter"><option value="all">all roles</option><option value="admin">admin</option><option value="operator">operator</option><option value="uploader">uploader</option><option value="viewer">viewer</option></select>
<select class="users-select" id="users-sort"><option value="username">sort username</option><option value="createdDesc">newest first</option><option value="lastSeenDesc">last seen</option><option value="boxesDesc">box count</option></select>
<select class="users-select" id="users-size"><option value="8">8 rows</option><option value="12" selected>12 rows</option><option value="20">20 rows</option></select>
</div>
<div class="users-table-wrap">
<table class="users-table">
<thead>
<tr>
<th class="users-col-check"><input type="checkbox" id="users-master-check"></th>
<th>User</th>
<th>Email</th>
<th>Status</th>
<th>Role</th>
<th>Plan</th>
<th>Boxes</th>
<th>Last seen</th>
<th class="users-col-actions">Actions</th>
</tr>
</thead>
<tbody id="users-body"></tbody>
</table>
</div>
<div class="users-pagination">
<span id="users-page-info">Page 1</span>
<span id="users-selected-pill">0 selected</span>
<div>
<button class="win98-button users-page-button" type="button" id="users-prev">Prev</button>
<button class="win98-button users-page-button" type="button" id="users-next">Next</button>
</div>
</div>
</div>
</section>
</section>
</div>
<footer class="status-bar admin-dashboard-statusbar">
<span id="users-status-left">Ready. Client-side mock data only.</span>
<span>server paging planned</span>
<span>admin only</span>
</footer>
</div>
</div>
</div>
<div id="toast" class="wb-toast" role="status" aria-live="polite"></div>
<script src="/static/js/warpbox-ui.js"></script>
<script src="/static/js/admin/users.js"></script>
</body>
</html>
{{ end }}