Total users
0Accounts, invites, and access
+Mock administrative users view for creation, invitation, filtering, and safe bulk actions.
+Active
0Pending invites
0Disabled
0| + | User | +Status | +Role | +Plan | +Boxes | +Last seen | +Actions | +
|---|
diff --git a/lib/routing/routes.go b/lib/routing/routes.go index 77346f9..8d238b3 100644 --- a/lib/routing/routes.go +++ b/lib/routing/routes.go @@ -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) diff --git a/lib/server/admin_users.go b/lib/server/admin_users.go new file mode 100644 index 0000000..30bbdd2 --- /dev/null +++ b/lib/server/admin_users.go @@ -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", + }) +} diff --git a/lib/server/server.go b/lib/server/server.go index 11ad781..fe4b6a3 100644 --- a/lib/server/server.go +++ b/lib/server/server.go @@ -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, diff --git a/static/css/users.css b/static/css/users.css new file mode 100644 index 0000000..a2272af --- /dev/null +++ b/static/css/users.css @@ -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; + } +} diff --git a/static/js/admin/users.js b/static/js/admin/users.js new file mode 100644 index 0000000..69f703e --- /dev/null +++ b/static/js/admin/users.js @@ -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 `${value}`; + } + + function renderRow(user) { + const checked = state.selected.has(user.id) ? " checked" : ""; + const row = document.createElement("tr"); + row.innerHTML = ` +