3 Commits

Author SHA1 Message Date
a2c80ac105 feat(admin): make dashboard live and disk-aware
Wire dashboard panels to real alerts, activity, boxes, and users data instead of static mock rows.

Enable working dashboard actions (close alerts, close low alerts, cleanup expired boxes, exports, and navigation).

Update storage overview to use real filesystem free/total space from the uploads volume.

Make top alert chip data-driven across admin pages.
2026-05-04 10:54:44 +03:00
d7cbba1bf2 feat(users): add account limits and API keys
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m43s
2026-05-04 02:27:36 +03:00
dc379ea6a6 fix(versioning): Removed v pre-pending 2026-05-04 00:52:34 +03:00
20 changed files with 2289 additions and 477 deletions

View File

@@ -26,6 +26,11 @@ type Handlers struct {
AdminBoxes gin.HandlerFunc AdminBoxes gin.HandlerFunc
AdminBoxesAction gin.HandlerFunc AdminBoxesAction gin.HandlerFunc
AdminUsers gin.HandlerFunc AdminUsers gin.HandlerFunc
AdminUsersList gin.HandlerFunc
AdminUsersSave gin.HandlerFunc
AdminUsersDelete gin.HandlerFunc
AdminUserKeyCreate gin.HandlerFunc
AdminUserKeyRevoke gin.HandlerFunc
AdminActivity gin.HandlerFunc AdminActivity gin.HandlerFunc
AdminSecurity gin.HandlerFunc AdminSecurity gin.HandlerFunc
AdminAlertsAction gin.HandlerFunc AdminAlertsAction gin.HandlerFunc
@@ -36,6 +41,10 @@ type Handlers struct {
AdminSettingsImport gin.HandlerFunc AdminSettingsImport gin.HandlerFunc
AdminSettingsReset gin.HandlerFunc AdminSettingsReset gin.HandlerFunc
AdminAuth gin.HandlerFunc AdminAuth gin.HandlerFunc
UserLogin gin.HandlerFunc
UserLogout gin.HandlerFunc
UserMe gin.HandlerFunc
UserCreateAPIKey gin.HandlerFunc
} }
func Register(router *gin.Engine, handlers Handlers) { func Register(router *gin.Engine, handlers Handlers) {
@@ -57,6 +66,10 @@ func Register(router *gin.Engine, handlers Handlers) {
// Legacy upload routes are kept for compatibility with older clients. // Legacy upload routes are kept for compatibility with older clients.
router.POST("/box/:id/upload", handlers.DirectBoxUpload) router.POST("/box/:id/upload", handlers.DirectBoxUpload)
router.POST("/upload", handlers.LegacyUpload) router.POST("/upload", handlers.LegacyUpload)
router.POST("/auth/login", handlers.UserLogin)
router.POST("/auth/logout", handlers.UserLogout)
router.GET("/auth/me", handlers.UserMe)
router.POST("/auth/api-keys/create", handlers.UserCreateAPIKey)
admin := router.Group("/admin") admin := router.Group("/admin")
admin.GET("/login", handlers.AdminLogin) admin.GET("/login", handlers.AdminLogin)
@@ -70,6 +83,11 @@ func Register(router *gin.Engine, handlers Handlers) {
protected.GET("/boxes", handlers.AdminBoxes) protected.GET("/boxes", handlers.AdminBoxes)
protected.POST("/boxes/actions", handlers.AdminBoxesAction) protected.POST("/boxes/actions", handlers.AdminBoxesAction)
protected.GET("/users", handlers.AdminUsers) protected.GET("/users", handlers.AdminUsers)
protected.GET("/users/list", handlers.AdminUsersList)
protected.POST("/users/save", handlers.AdminUsersSave)
protected.POST("/users/delete", handlers.AdminUsersDelete)
protected.POST("/users/api-keys/create", handlers.AdminUserKeyCreate)
protected.POST("/users/api-keys/revoke", handlers.AdminUserKeyRevoke)
protected.GET("/activity", handlers.AdminActivity) protected.GET("/activity", handlers.AdminActivity)
protected.GET("/security", handlers.AdminSecurity) protected.GET("/security", handlers.AdminSecurity)
protected.POST("/security/actions", handlers.AdminSecurityAction) protected.POST("/security/actions", handlers.AdminSecurityAction)

View File

@@ -4,17 +4,93 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"syscall"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"warpbox/lib/activity"
"warpbox/lib/alerts" "warpbox/lib/alerts"
"warpbox/lib/config" "warpbox/lib/config"
"warpbox/lib/helpers"
"warpbox/lib/security" "warpbox/lib/security"
"warpbox/lib/userstore"
) )
const adminSessionCookie = "warpbox_admin_session" const adminSessionCookie = "warpbox_admin_session"
const adminSessionMarker = "1" const adminSessionMarker = "1"
type adminDashboardView struct {
ActiveBoxes int `json:"active_boxes"`
BoxesCreatedToday int `json:"boxes_created_today"`
PasswordedBoxes int `json:"passworded_boxes"`
StorageUsedLabel string `json:"storage_used_label"`
StorageFreeLabel string `json:"storage_free_label"`
StorageCapLabel string `json:"storage_cap_label"`
StorageMeter string `json:"storage_meter"`
StorageBackend string `json:"storage_backend"`
OpenAlerts int `json:"open_alerts"`
HighAlerts int `json:"high_alerts"`
MediumAlerts int `json:"medium_alerts"`
LowAlerts int `json:"low_alerts"`
TotalUsers int `json:"total_users"`
ActiveUsers int `json:"active_users"`
DisabledUsers int `json:"disabled_users"`
APIKeyCount int `json:"api_key_count"`
GuestUploadsLabel string `json:"guest_uploads_label"`
APIUploadsLabel string `json:"api_uploads_label"`
ZipDownloadsLabel string `json:"zip_downloads_label"`
Alerts []adminDashboardAlert `json:"alerts"`
Events []adminDashboardActivity `json:"events"`
Boxes []adminDashboardBox `json:"boxes"`
}
type adminDashboardAlert struct {
ID string `json:"id"`
Title string `json:"title"`
Severity string `json:"severity"`
Status string `json:"status"`
Group string `json:"group"`
Code string `json:"code"`
Trace string `json:"trace"`
Message string `json:"message"`
CreatedAt string `json:"created_at"`
CreatedAtLabel string `json:"created_at_label"`
Meta map[string]string `json:"meta,omitempty"`
}
type adminDashboardActivity struct {
ID string `json:"id"`
Kind string `json:"kind"`
Severity string `json:"severity"`
Message string `json:"message"`
IP string `json:"ip"`
Path string `json:"path"`
Method string `json:"method"`
CreatedAt string `json:"created_at"`
CreatedAtLabel string `json:"created_at_label"`
Meta map[string]string `json:"meta,omitempty"`
TagClass string `json:"tag_class"`
TagLabel string `json:"tag_label"`
}
type adminDashboardBox struct {
ID string `json:"id"`
Status string `json:"status"`
StatusLabel string `json:"status_label"`
StatusClass string `json:"status_class"`
FileCount int `json:"file_count"`
CompleteFiles int `json:"complete_files"`
TotalSizeLabel string `json:"total_size_label"`
CreatedAtLabel string `json:"created_at_label"`
ExpiresAtLabel string `json:"expires_at_label"`
PasswordProtected bool `json:"password_protected"`
OneTimeDownload bool `json:"one_time_download"`
OpenURL string `json:"open_url"`
ZipURL string `json:"zip_url"`
Flags []string `json:"flags"`
}
func (app *App) adminLoginEnabled() bool { func (app *App) adminLoginEnabled() bool {
return app.config.AdminLoginEnabled(app.config.AdminPassword != "") return app.config.AdminLoginEnabled(app.config.AdminPassword != "")
} }
@@ -119,14 +195,256 @@ func (app *App) handleAdminDashboard(ctx *gin.Context) {
dashboardEnabled = cfgVal dashboardEnabled = cfgVal
} }
dashboard := app.buildAdminDashboardView()
ctx.HTML(http.StatusOK, "admin/dashboard.html", gin.H{ ctx.HTML(http.StatusOK, "admin/dashboard.html", gin.H{
"AdminUsername": app.config.AdminUsername, "AdminUsername": app.config.AdminUsername,
"AdminEmail": app.config.AdminEmail, "AdminEmail": app.config.AdminEmail,
"ActivePage": "dashboard", "ActivePage": "dashboard",
"DashboardEnabled": string(dashboardEnabled), "DashboardEnabled": string(dashboardEnabled),
"Dashboard": dashboard,
"AlertChipClass": adminAlertChipClass(dashboard.OpenAlerts, dashboard.HighAlerts, dashboard.MediumAlerts),
"AlertChipLabel": adminAlertChipLabel(dashboard.OpenAlerts),
}) })
} }
func (app *App) buildAdminDashboardView() adminDashboardView {
boxes, _ := app.listAdminBoxes()
alertsList := []alerts.Alert{}
if app.alertStore != nil {
alertsList, _ = app.alertStore.List(500)
}
events := []activity.Event{}
if app.activityStore != nil {
events, _ = app.activityStore.List(80, app.config.ActivityRetentionSeconds)
}
users := []userstore.User{}
if app.userStore != nil {
users = app.userStore.List()
}
view := adminDashboardView{
StorageBackend: "local",
GuestUploadsLabel: adminBoolLabel(app.config.GuestUploadsEnabled && app.config.APIEnabled),
APIUploadsLabel: adminBoolLabel(app.config.APIEnabled),
ZipDownloadsLabel: adminBoolLabel(app.config.ZipDownloadsEnabled),
Alerts: make([]adminDashboardAlert, 0, 12),
Events: make([]adminDashboardActivity, 0, 15),
Boxes: make([]adminDashboardBox, 0, 12),
}
now := time.Now().UTC()
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
usedBytes := int64(0)
for _, box := range boxes {
usedBytes += box.TotalSizeBytes
createdAt, _ := time.Parse(time.RFC3339, box.CreatedAtISO)
if !createdAt.IsZero() && createdAt.After(dayStart) {
view.BoxesCreatedToday++
}
if box.PasswordProtected {
view.PasswordedBoxes++
}
if box.Status != "expired" && box.Status != "consumed" {
view.ActiveBoxes++
}
if len(view.Boxes) < 12 {
view.Boxes = append(view.Boxes, adminDashboardBox{
ID: box.ID,
Status: box.Status,
StatusLabel: box.StatusLabel,
StatusClass: adminDashboardStatusClass(box.Status),
FileCount: box.FileCount,
CompleteFiles: box.CompleteFiles,
TotalSizeLabel: box.TotalSizeLabel,
CreatedAtLabel: box.CreatedAtLabel,
ExpiresAtLabel: box.ExpiresAtLabel,
PasswordProtected: box.PasswordProtected,
OneTimeDownload: box.OneTimeDownload,
OpenURL: box.OpenURL,
ZipURL: box.ZipURL,
Flags: box.Flags,
})
}
}
view.StorageUsedLabel = helpers.FormatBytes(usedBytes)
view.StorageCapLabel = "unknown"
view.StorageFreeLabel = "unknown"
view.StorageMeter = "0%"
if diskTotal, diskFree, ok := adminDiskCapacity(app.config.UploadsDir); ok && diskTotal > 0 {
diskUsed := diskTotal - diskFree
if diskUsed < 0 {
diskUsed = 0
}
view.StorageUsedLabel = helpers.FormatBytes(diskUsed)
view.StorageFreeLabel = helpers.FormatBytes(diskFree)
view.StorageCapLabel = helpers.FormatBytes(diskTotal)
percent := float64(diskUsed) / float64(diskTotal) * 100
if percent > 100 {
percent = 100
}
view.StorageMeter = strconv.FormatFloat(percent, 'f', 1, 64) + "%"
}
for _, alert := range alertsList {
if alert.Status != alerts.StatusClosed {
view.OpenAlerts++
switch alert.Severity {
case "high":
view.HighAlerts++
case "medium":
view.MediumAlerts++
default:
view.LowAlerts++
}
if len(view.Alerts) < 12 {
view.Alerts = append(view.Alerts, adminDashboardAlert{
ID: alert.ID,
Title: alert.Title,
Severity: adminFallback(alert.Severity, "low"),
Status: adminFallback(string(alert.Status), "open"),
Group: alert.Group,
Code: alert.Code,
Trace: alert.Trace,
Message: alert.Message,
CreatedAt: formatBrowserTime(alert.CreatedAt),
CreatedAtLabel: adminShortTimeLabel(alert.CreatedAt),
Meta: alert.Meta,
})
}
}
}
for _, event := range events {
if len(view.Events) >= 15 {
break
}
view.Events = append(view.Events, adminDashboardActivity{
ID: event.ID,
Kind: event.Kind,
Severity: adminFallback(event.Severity, "low"),
Message: event.Message,
IP: event.IP,
Path: event.Path,
Method: event.Method,
CreatedAt: formatBrowserTime(event.CreatedAt),
CreatedAtLabel: adminShortTimeLabel(event.CreatedAt),
Meta: event.Meta,
TagClass: adminSeverityTagClass(event.Severity),
TagLabel: adminActivityTagLabel(event.Kind),
})
}
for _, user := range users {
view.TotalUsers++
if user.Status == userstore.StatusDisabled {
view.DisabledUsers++
} else {
view.ActiveUsers++
}
for _, key := range user.APIKeys {
if key.RevokedAt == nil {
view.APIKeyCount++
}
}
}
return view
}
func adminBoolLabel(enabled bool) string {
if enabled {
return "enabled"
}
return "disabled"
}
func adminFallback(value string, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}
func adminShortTimeLabel(value time.Time) string {
if value.IsZero() {
return "-"
}
return value.UTC().Format("15:04")
}
func adminDashboardStatusClass(status string) string {
switch status {
case "ready":
return "ok"
case "uploading", "legacy":
return "warn"
case "attention", "expired", "consumed":
return "danger"
default:
return "info"
}
}
func adminSeverityTagClass(severity string) string {
switch severity {
case "high":
return "danger"
case "medium":
return "warn"
case "low":
return "ok"
default:
return "info"
}
}
func adminActivityTagLabel(kind string) string {
parts := strings.Split(kind, ".")
if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" {
return "event"
}
return parts[0]
}
func adminDiskCapacity(path string) (int64, int64, bool) {
if strings.TrimSpace(path) == "" {
return 0, 0, false
}
var stats syscall.Statfs_t
if err := syscall.Statfs(path, &stats); err != nil {
return 0, 0, false
}
blockSize := int64(stats.Bsize)
if blockSize <= 0 {
return 0, 0, false
}
total := int64(stats.Blocks) * blockSize
free := int64(stats.Bavail) * blockSize
return total, free, true
}
func adminAlertChipClass(total int, high int, medium int) string {
score := high*5 + medium*2 + (total - high - medium)
switch {
case high > 0 || score >= 12:
return "is-danger"
case medium >= 2 || score >= 5:
return "is-warning"
case total > 0:
return "is-info"
default:
return "is-ok"
}
}
func adminAlertChipLabel(total int) string {
if total == 0 {
return "OK no alerts"
}
return "! " + strconv.Itoa(total) + " alerts"
}
func (app *App) handleAdminAlerts(ctx *gin.Context) { func (app *App) handleAdminAlerts(ctx *gin.Context) {
if !app.adminLoginEnabled() { if !app.adminLoginEnabled() {
ctx.Redirect(http.StatusSeeOther, "/") ctx.Redirect(http.StatusSeeOther, "/")
@@ -144,6 +462,7 @@ func (app *App) handleAdminAlerts(ctx *gin.Context) {
} }
openCount := 0 openCount := 0
highCount := 0 highCount := 0
mediumCount := 0
ackedCount := 0 ackedCount := 0
closedCount := 0 closedCount := 0
for _, alert := range alertsList { for _, alert := range alertsList {
@@ -158,6 +477,9 @@ func (app *App) handleAdminAlerts(ctx *gin.Context) {
if alert.Severity == "high" && string(alert.Status) != "closed" { if alert.Severity == "high" && string(alert.Status) != "closed" {
highCount++ highCount++
} }
if alert.Severity == "medium" && string(alert.Status) != "closed" {
mediumCount++
}
} }
ctx.HTML(http.StatusOK, "admin/alerts.html", gin.H{ ctx.HTML(http.StatusOK, "admin/alerts.html", gin.H{
@@ -169,5 +491,7 @@ func (app *App) handleAdminAlerts(ctx *gin.Context) {
"HighCount": strconv.Itoa(highCount), "HighCount": strconv.Itoa(highCount),
"AckCount": strconv.Itoa(ackedCount), "AckCount": strconv.Itoa(ackedCount),
"ClosedCount": strconv.Itoa(closedCount), "ClosedCount": strconv.Itoa(closedCount),
"AlertChipClass": adminAlertChipClass(openCount, highCount, mediumCount),
"AlertChipLabel": adminAlertChipLabel(openCount),
}) })
} }

View File

@@ -37,6 +37,7 @@ type adminBoxView struct {
CompleteFiles int `json:"complete_files"` CompleteFiles int `json:"complete_files"`
PendingFiles int `json:"pending_files"` PendingFiles int `json:"pending_files"`
FailedFiles int `json:"failed_files"` FailedFiles int `json:"failed_files"`
TotalSizeBytes int64 `json:"total_size_bytes"`
TotalSizeLabel string `json:"total_size_label"` TotalSizeLabel string `json:"total_size_label"`
CreatedAtLabel string `json:"created_at_label"` CreatedAtLabel string `json:"created_at_label"`
CreatedAtISO string `json:"created_at_iso"` CreatedAtISO string `json:"created_at_iso"`
@@ -203,6 +204,7 @@ func (app *App) buildAdminBoxView(boxID string) (adminBoxView, error) {
boxView := adminBoxView{ boxView := adminBoxView{
ID: summary.ID, ID: summary.ID,
FileCount: summary.FileCount, FileCount: summary.FileCount,
TotalSizeBytes: summary.TotalSize,
TotalSizeLabel: summary.TotalSizeLabel, TotalSizeLabel: summary.TotalSizeLabel,
CreatedAtLabel: adminTimeLabel(summary.CreatedAt), CreatedAtLabel: adminTimeLabel(summary.CreatedAt),
CreatedAtISO: formatBrowserTime(summary.CreatedAt), CreatedAtISO: formatBrowserTime(summary.CreatedAt),

View File

@@ -2,10 +2,82 @@ package server
import ( import (
"net/http" "net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"warpbox/lib/userstore"
) )
const bytesPerMegabyte = 1024 * 1024
type adminUserView struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Status string `json:"status"`
Permissions userstore.Permissions `json:"permissions"`
Limits userstore.Limits `json:"limits"`
APIKeys []adminAPIKeyView `json:"api_keys"`
APIKeyCount int `json:"api_key_count"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
LastSeenAt string `json:"last_seen_at"`
}
type adminAPIKeyView struct {
ID string `json:"id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
CreatedAt string `json:"created_at"`
LastUsedAt string `json:"last_used_at"`
RevokedAt string `json:"revoked_at"`
}
func formatMaybeTime(value *time.Time) string {
if value == nil || value.IsZero() {
return ""
}
return value.UTC().Format(time.RFC3339)
}
func toAdminAPIKeyView(key userstore.APIKey) adminAPIKeyView {
return adminAPIKeyView{
ID: key.ID,
Name: key.Name,
Prefix: key.Prefix,
CreatedAt: key.CreatedAt.UTC().Format(time.RFC3339),
LastUsedAt: formatMaybeTime(key.LastUsedAt),
RevokedAt: formatMaybeTime(key.RevokedAt),
}
}
func toAdminAPIKeyViews(keys []userstore.APIKey) []adminAPIKeyView {
views := make([]adminAPIKeyView, 0, len(keys))
for _, key := range keys {
views = append(views, toAdminAPIKeyView(key))
}
return views
}
func toAdminUserView(user userstore.User) adminUserView {
return adminUserView{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Status: user.Status,
Permissions: user.Permissions,
Limits: user.Limits,
APIKeys: toAdminAPIKeyViews(user.APIKeys),
APIKeyCount: len(user.APIKeys),
CreatedAt: user.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: user.UpdatedAt.UTC().Format(time.RFC3339),
LastSeenAt: formatMaybeTime(user.LastSeenAt),
}
}
func (app *App) handleAdminUsers(ctx *gin.Context) { func (app *App) handleAdminUsers(ctx *gin.Context) {
if !app.adminLoginEnabled() { if !app.adminLoginEnabled() {
ctx.Redirect(http.StatusSeeOther, "/") ctx.Redirect(http.StatusSeeOther, "/")
@@ -18,3 +90,154 @@ func (app *App) handleAdminUsers(ctx *gin.Context) {
"ActivePage": "users", "ActivePage": "users",
}) })
} }
func (app *App) handleAdminUsersList(ctx *gin.Context) {
if app.userStore == nil {
ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "User store unavailable"})
return
}
users := app.userStore.List()
items := make([]adminUserView, 0, len(users))
for _, user := range users {
items = append(items, toAdminUserView(user))
}
ctx.JSON(http.StatusOK, gin.H{"users": items})
}
func parseInt64OrZero(value string) int64 {
value = strings.TrimSpace(value)
if value == "" {
return 0
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed < 0 {
return 0
}
return parsed
}
func parseMegabytesToBytesOrZero(value string) int64 {
megabytes := parseInt64OrZero(value)
if megabytes <= 0 {
return 0
}
return megabytes * bytesPerMegabyte
}
func (app *App) handleAdminUsersSave(ctx *gin.Context) {
if app.userStore == nil {
ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "User store unavailable"})
return
}
var payload struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Status string `json:"status"`
MaxFileMB string `json:"max_file_size_mb"`
MaxBoxMB string `json:"max_box_size_mb"`
MaxFileSize string `json:"max_file_size_bytes"`
MaxBoxSize string `json:"max_box_size_bytes"`
Permissions struct {
CanUseWeb bool `json:"can_use_web"`
CanUseAPI bool `json:"can_use_api"`
CanCreateBox bool `json:"can_create_box"`
CanUploadFile bool `json:"can_upload_file"`
} `json:"permissions"`
}
if err := ctx.ShouldBindJSON(&payload); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user payload"})
return
}
permissions := userstore.Permissions{
CanUseWeb: payload.Permissions.CanUseWeb,
CanUseAPI: payload.Permissions.CanUseAPI,
CanCreateBox: payload.Permissions.CanCreateBox,
CanUploadFile: payload.Permissions.CanUploadFile,
}
limits := userstore.Limits{
MaxFileSizeBytes: parseMegabytesToBytesOrZero(payload.MaxFileMB),
MaxBoxSizeBytes: parseMegabytesToBytesOrZero(payload.MaxBoxMB),
}
if limits.MaxFileSizeBytes == 0 && strings.TrimSpace(payload.MaxFileSize) != "" {
limits.MaxFileSizeBytes = parseInt64OrZero(payload.MaxFileSize)
}
if limits.MaxBoxSizeBytes == 0 && strings.TrimSpace(payload.MaxBoxSize) != "" {
limits.MaxBoxSizeBytes = parseInt64OrZero(payload.MaxBoxSize)
}
var (
user userstore.User
err error
)
if strings.TrimSpace(payload.ID) == "" {
user, err = app.userStore.Create(payload.Username, payload.Email, permissions, limits, payload.Status)
} else {
user, err = app.userStore.Update(payload.ID, payload.Username, payload.Email, permissions, limits, payload.Status)
}
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true, "user": toAdminUserView(user)})
}
func (app *App) handleAdminUsersDelete(ctx *gin.Context) {
if app.userStore == nil {
ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "User store unavailable"})
return
}
var payload struct {
ID string `json:"id"`
}
if err := ctx.ShouldBindJSON(&payload); err != nil || strings.TrimSpace(payload.ID) == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "User id is required"})
return
}
if err := app.userStore.Delete(payload.ID); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
func (app *App) handleAdminUserAPIKeyCreate(ctx *gin.Context) {
if app.userStore == nil {
ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "User store unavailable"})
return
}
var payload struct {
UserID string `json:"user_id"`
Name string `json:"name"`
}
if err := ctx.ShouldBindJSON(&payload); err != nil || strings.TrimSpace(payload.UserID) == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "User id is required"})
return
}
key, raw, err := app.userStore.CreateAPIKey(payload.UserID, payload.Name)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true, "api_key": raw, "key": toAdminAPIKeyView(key)})
}
func (app *App) handleAdminUserAPIKeyRevoke(ctx *gin.Context) {
if app.userStore == nil {
ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "User store unavailable"})
return
}
var payload struct {
UserID string `json:"user_id"`
KeyID string `json:"key_id"`
}
if err := ctx.ShouldBindJSON(&payload); err != nil || strings.TrimSpace(payload.UserID) == "" || strings.TrimSpace(payload.KeyID) == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "User id and key id are required"})
return
}
if err := app.userStore.RevokeAPIKey(payload.UserID, payload.KeyID); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}

View File

@@ -17,6 +17,7 @@ import (
"warpbox/lib/config" "warpbox/lib/config"
"warpbox/lib/routing" "warpbox/lib/routing"
"warpbox/lib/security" "warpbox/lib/security"
"warpbox/lib/userstore"
) )
type App struct { type App struct {
@@ -26,6 +27,7 @@ type App struct {
alertStore *alerts.Store alertStore *alerts.Store
securityGuard *security.Guard securityGuard *security.Guard
appVersion string appVersion string
userStore *userstore.Store
} }
func Run(addr string) error { func Run(addr string) error {
@@ -61,6 +63,11 @@ func Run(addr string) error {
securityGuard: security.NewGuard(), securityGuard: security.NewGuard(),
appVersion: currentAppVersion(), appVersion: currentAppVersion(),
} }
userStore, err := userstore.NewStore(filepath.Join(cfg.DBDir, "users.json"))
if err != nil {
return err
}
app.userStore = userStore
if err := app.reloadSecurityConfig(); err != nil { if err := app.reloadSecurityConfig(); err != nil {
return err return err
} }
@@ -99,6 +106,11 @@ func Run(addr string) error {
AdminBoxes: app.handleAdminBoxes, AdminBoxes: app.handleAdminBoxes,
AdminBoxesAction: app.handleAdminBoxesAction, AdminBoxesAction: app.handleAdminBoxesAction,
AdminUsers: app.handleAdminUsers, AdminUsers: app.handleAdminUsers,
AdminUsersList: app.handleAdminUsersList,
AdminUsersSave: app.handleAdminUsersSave,
AdminUsersDelete: app.handleAdminUsersDelete,
AdminUserKeyCreate: app.handleAdminUserAPIKeyCreate,
AdminUserKeyRevoke: app.handleAdminUserAPIKeyRevoke,
AdminActivity: app.handleAdminActivity, AdminActivity: app.handleAdminActivity,
AdminSecurity: app.handleAdminSecurity, AdminSecurity: app.handleAdminSecurity,
AdminAlertsAction: app.handleAdminAlertsAction, AdminAlertsAction: app.handleAdminAlertsAction,
@@ -109,6 +121,10 @@ func Run(addr string) error {
AdminSettingsImport: app.handleAdminSettingsImport, AdminSettingsImport: app.handleAdminSettingsImport,
AdminSettingsReset: app.handleAdminSettingsReset, AdminSettingsReset: app.handleAdminSettingsReset,
AdminAuth: app.adminAuthMiddleware, AdminAuth: app.adminAuthMiddleware,
UserLogin: app.handleUserLogin,
UserLogout: app.handleUserLogout,
UserMe: app.handleUserMe,
UserCreateAPIKey: app.handleSelfCreateAPIKey,
}) })
compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression)) compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression))

View File

@@ -17,7 +17,11 @@ func (app *App) handleCreateBox(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) { if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return return
} }
app.limitRequestBody(ctx) actor, ok := app.authorizeUpload(ctx)
if !ok {
return
}
app.limitRequestBodyForActor(ctx, actor)
boxID, err := boxstore.NewBoxID() boxID, err := boxstore.NewBoxID()
if err != nil { if err != nil {
@@ -35,7 +39,7 @@ func (app *App) handleCreateBox(ctx *gin.Context) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box payload"}) ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box payload"})
return return
} }
if err := app.validateCreateBoxRequest(&request); err != nil { if err := app.validateCreateBoxRequestForActor(&request, actor); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
@@ -60,7 +64,11 @@ func (app *App) handleManifestFileUpload(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) { if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return return
} }
app.limitRequestBody(ctx) actor, ok := app.authorizeUpload(ctx)
if !ok {
return
}
app.limitRequestBodyForActor(ctx, actor)
boxID := ctx.Param("id") boxID := ctx.Param("id")
fileID := ctx.Param("file_id") fileID := ctx.Param("file_id")
@@ -75,7 +83,7 @@ func (app *App) handleManifestFileUpload(ctx *gin.Context) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"}) ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"})
return return
} }
if err := app.validateManifestFileUpload(boxID, fileID, file.Size); err != nil { if err := app.validateManifestFileUploadForActor(boxID, fileID, file.Size, actor); err != nil {
boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed) boxstore.MarkFileStatus(boxID, fileID, models.FileStatusFailed)
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
@@ -135,7 +143,11 @@ func (app *App) handleDirectBoxUpload(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) { if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return return
} }
app.limitRequestBody(ctx) actor, ok := app.authorizeUpload(ctx)
if !ok {
return
}
app.limitRequestBodyForActor(ctx, actor)
boxID := ctx.Param("id") boxID := ctx.Param("id")
if !boxstore.ValidBoxID(boxID) { if !boxstore.ValidBoxID(boxID) {
@@ -148,7 +160,7 @@ func (app *App) handleDirectBoxUpload(ctx *gin.Context) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"}) ctx.JSON(http.StatusBadRequest, gin.H{"error": "No file received"})
return return
} }
if err := app.validateIncomingFile(boxID, file.Size); err != nil { if err := app.validateIncomingFileForActor(boxID, file.Size, actor); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
@@ -169,7 +181,11 @@ func (app *App) handleLegacyUpload(ctx *gin.Context) {
if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) { if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) {
return return
} }
app.limitRequestBody(ctx) actor, ok := app.authorizeUpload(ctx)
if !ok {
return
}
app.limitRequestBodyForActor(ctx, actor)
form, err := ctx.MultipartForm() form, err := ctx.MultipartForm()
if err != nil { if err != nil {
@@ -184,13 +200,13 @@ func (app *App) handleLegacyUpload(ctx *gin.Context) {
} }
totalSize := int64(0) totalSize := int64(0)
for _, file := range files { for _, file := range files {
if err := app.validateFileSize(file.Size); err != nil { if err := app.validateFileSizeForActor(file.Size, actor); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
totalSize += file.Size totalSize += file.Size
} }
if err := app.validateBoxSize(totalSize); err != nil { if err := app.validateBoxSizeForActor(totalSize, actor); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
@@ -226,7 +242,7 @@ func (app *App) handleLegacyUpload(ctx *gin.Context) {
for _, file := range files { for _, file := range files {
request.Files = append(request.Files, models.CreateBoxFileRequest{Name: file.Filename, Size: file.Size}) request.Files = append(request.Files, models.CreateBoxFileRequest{Name: file.Filename, Size: file.Size})
} }
if err := app.validateCreateBoxRequest(&request); err != nil { if err := app.validateCreateBoxRequestForActor(&request, actor); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }

188
lib/server/user_auth.go Normal file
View File

@@ -0,0 +1,188 @@
package server
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"warpbox/lib/userstore"
)
const userSessionCookie = "warpbox_user_session"
type requestActor struct {
User userstore.User
FromAPIKey bool
KeyID string
}
func requestBearerToken(ctx *gin.Context) string {
auth := strings.TrimSpace(ctx.GetHeader("Authorization"))
if !strings.HasPrefix(strings.ToLower(auth), "bearer ") {
return ""
}
return strings.TrimSpace(auth[7:])
}
func (app *App) sessionSecret() string {
return app.config.AdminUsername + "|" + app.config.AdminPassword + "|warpbox"
}
func (app *App) signSessionToken(userID string, expiresAt time.Time) string {
payload := userID + "|" + expiresAt.UTC().Format(time.RFC3339)
mac := hmac.New(sha256.New, []byte(app.sessionSecret()))
mac.Write([]byte(payload))
sig := hex.EncodeToString(mac.Sum(nil))
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + sig
}
func (app *App) parseSessionToken(token string) (string, bool) {
parts := strings.Split(token, ".")
if len(parts) != 2 {
return "", false
}
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return "", false
}
payload := string(payloadBytes)
mac := hmac.New(sha256.New, []byte(app.sessionSecret()))
mac.Write([]byte(payload))
expectedSig := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expectedSig), []byte(parts[1])) {
return "", false
}
items := strings.Split(payload, "|")
if len(items) != 2 {
return "", false
}
expiresAt, err := time.Parse(time.RFC3339, items[1])
if err != nil || time.Now().UTC().After(expiresAt) {
return "", false
}
return items[0], true
}
func (app *App) resolveActor(ctx *gin.Context) (*requestActor, bool) {
if app.userStore == nil {
return nil, false
}
if rawKey := requestBearerToken(ctx); rawKey != "" {
user, key, ok := app.userStore.FindByAPIKey(rawKey)
if ok {
app.userStore.TouchAPIKey(user.ID, key.ID)
return &requestActor{User: user, FromAPIKey: true, KeyID: key.ID}, true
}
return nil, false
}
if token, err := ctx.Cookie(userSessionCookie); err == nil {
if userID, ok := app.parseSessionToken(token); ok {
if user, found := app.userStore.FindByID(userID); found {
app.userStore.TouchUser(user.ID)
return &requestActor{User: user}, true
}
}
}
return nil, false
}
func (app *App) denyActor(ctx *gin.Context, status int, message string) bool {
ctx.JSON(status, gin.H{"error": message})
return false
}
func (app *App) authorizeUpload(ctx *gin.Context) (*requestActor, bool) {
actor, ok := app.resolveActor(ctx)
if !ok {
if requestBearerToken(ctx) != "" {
return nil, app.denyActor(ctx, http.StatusUnauthorized, "Invalid API key")
}
return nil, true
}
if actor.User.Status != userstore.StatusActive {
return nil, app.denyActor(ctx, http.StatusForbidden, "User account is disabled")
}
if !actor.User.Permissions.CanUseAPI {
return nil, app.denyActor(ctx, http.StatusForbidden, "API access is not allowed for this user")
}
if !actor.User.Permissions.CanCreateBox {
return nil, app.denyActor(ctx, http.StatusForbidden, "Creating boxes is not allowed for this user")
}
if !actor.User.Permissions.CanUploadFile {
return nil, app.denyActor(ctx, http.StatusForbidden, "Uploading files is not allowed for this user")
}
return actor, true
}
func (app *App) handleUserLogin(ctx *gin.Context) {
var payload struct {
APIKey string `json:"api_key"`
}
if err := ctx.ShouldBindJSON(&payload); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid login payload"})
return
}
if app.userStore == nil {
ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "User store unavailable"})
return
}
user, key, ok := app.userStore.FindByAPIKey(payload.APIKey)
if !ok {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
return
}
if user.Status != userstore.StatusActive {
ctx.JSON(http.StatusForbidden, gin.H{"error": "User account is disabled"})
return
}
if !user.Permissions.CanUseWeb {
ctx.JSON(http.StatusForbidden, gin.H{"error": "Web access is not allowed for this user"})
return
}
app.userStore.TouchAPIKey(user.ID, key.ID)
expiresAt := time.Now().UTC().Add(time.Duration(app.config.SessionTTLSeconds) * time.Second)
ctx.SetCookie(userSessionCookie, app.signSessionToken(user.ID, expiresAt), int(app.config.SessionTTLSeconds), "/", "", false, true)
ctx.JSON(http.StatusOK, gin.H{"ok": true, "user": gin.H{"id": user.ID, "email": user.Email, "username": user.Username}})
}
func (app *App) handleUserLogout(ctx *gin.Context) {
ctx.SetCookie(userSessionCookie, "", -1, "/", "", false, true)
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
func (app *App) handleUserMe(ctx *gin.Context) {
actor, ok := app.resolveActor(ctx)
if !ok || actor == nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
ctx.JSON(http.StatusOK, gin.H{"user": toAdminUserView(actor.User)})
}
func (app *App) handleSelfCreateAPIKey(ctx *gin.Context) {
actor, ok := app.resolveActor(ctx)
if !ok || actor == nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
if actor.User.Status != userstore.StatusActive {
ctx.JSON(http.StatusForbidden, gin.H{"error": "User account is disabled"})
return
}
var payload struct {
Name string `json:"name"`
}
_ = ctx.ShouldBindJSON(&payload)
key, raw, err := app.userStore.CreateAPIKey(actor.User.ID, payload.Name)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true, "api_key": raw, "key": toAdminAPIKeyView(key)})
}

View File

@@ -29,6 +29,10 @@ func (app *App) requireGuestUploads(ctx *gin.Context) bool {
} }
func (app *App) validateCreateBoxRequest(request *models.CreateBoxRequest) error { func (app *App) validateCreateBoxRequest(request *models.CreateBoxRequest) error {
return app.validateCreateBoxRequestForActor(request, nil)
}
func (app *App) validateCreateBoxRequestForActor(request *models.CreateBoxRequest, actor *requestActor) error {
if request == nil { if request == nil {
return nil return nil
} }
@@ -45,19 +49,23 @@ func (app *App) validateCreateBoxRequest(request *models.CreateBoxRequest) error
totalSize := int64(0) totalSize := int64(0)
for _, file := range request.Files { for _, file := range request.Files {
if err := app.validateFileSize(file.Size); err != nil { if err := app.validateFileSizeForActor(file.Size, actor); err != nil {
return err return err
} }
totalSize += file.Size totalSize += file.Size
} }
return app.validateBoxSize(totalSize) return app.validateBoxSizeForActor(totalSize, actor)
} }
func (app *App) validateIncomingFile(boxID string, size int64) error { func (app *App) validateIncomingFile(boxID string, size int64) error {
if err := app.validateFileSize(size); err != nil { return app.validateIncomingFileForActor(boxID, size, nil)
}
func (app *App) validateIncomingFileForActor(boxID string, size int64, actor *requestActor) error {
if err := app.validateFileSizeForActor(size, actor); err != nil {
return err return err
} }
if app.config.GlobalMaxBoxSizeBytes <= 0 { if app.effectiveMaxBoxBytes(actor) <= 0 {
return nil return nil
} }
@@ -69,23 +77,27 @@ func (app *App) validateIncomingFile(boxID string, size int64) error {
for _, file := range files { for _, file := range files {
totalSize += file.Size totalSize += file.Size
} }
return app.validateBoxSize(totalSize) return app.validateBoxSizeForActor(totalSize, actor)
} }
func (app *App) validateManifestFileUpload(boxID string, fileID string, size int64) error { func (app *App) validateManifestFileUpload(boxID string, fileID string, size int64) error {
if err := app.validateFileSize(size); err != nil { return app.validateManifestFileUploadForActor(boxID, fileID, size, nil)
}
func (app *App) validateManifestFileUploadForActor(boxID string, fileID string, size int64, actor *requestActor) error {
if err := app.validateFileSizeForActor(size, actor); err != nil {
return err return err
} }
manifest, err := boxstore.ReadManifest(boxID) manifest, err := boxstore.ReadManifest(boxID)
if err != nil { if err != nil {
return app.validateIncomingFile(boxID, size) return app.validateIncomingFileForActor(boxID, size, actor)
} }
if boxstore.IsExpired(manifest) { if boxstore.IsExpired(manifest) {
_ = boxstore.DeleteBox(boxID) _ = boxstore.DeleteBox(boxID)
return fmt.Errorf("Box expired") return fmt.Errorf("Box expired")
} }
if app.config.GlobalMaxBoxSizeBytes <= 0 { if app.effectiveMaxBoxBytes(actor) <= 0 {
return nil return nil
} }
totalSize := int64(0) totalSize := int64(0)
@@ -101,24 +113,54 @@ func (app *App) validateManifestFileUpload(boxID string, fileID string, size int
if !found { if !found {
totalSize += size totalSize += size
} }
return app.validateBoxSize(totalSize) return app.validateBoxSizeForActor(totalSize, actor)
} }
func (app *App) validateFileSize(size int64) error { func (app *App) validateFileSize(size int64) error {
return app.validateFileSizeForActor(size, nil)
}
func (app *App) effectiveMaxFileBytes(actor *requestActor) int64 {
if actor == nil {
return app.config.GlobalMaxFileSizeBytes
}
return actor.User.Limits.MaxFileSizeBytes
}
func (app *App) effectiveMaxBoxBytes(actor *requestActor) int64 {
if actor == nil {
return app.config.GlobalMaxBoxSizeBytes
}
return actor.User.Limits.MaxBoxSizeBytes
}
func (app *App) validateFileSizeForActor(size int64, actor *requestActor) error {
if size < 0 { if size < 0 {
return fmt.Errorf("File size cannot be negative") return fmt.Errorf("File size cannot be negative")
} }
if app.config.GlobalMaxFileSizeBytes > 0 && size > app.config.GlobalMaxFileSizeBytes { limit := app.effectiveMaxFileBytes(actor)
if limit > 0 && size > limit {
if actor != nil {
return fmt.Errorf("File exceeds this account's max file size")
}
return fmt.Errorf("File exceeds the global max file size") return fmt.Errorf("File exceeds the global max file size")
} }
return nil return nil
} }
func (app *App) validateBoxSize(size int64) error { func (app *App) validateBoxSize(size int64) error {
return app.validateBoxSizeForActor(size, nil)
}
func (app *App) validateBoxSizeForActor(size int64, actor *requestActor) error {
if size < 0 { if size < 0 {
return fmt.Errorf("Box size cannot be negative") return fmt.Errorf("Box size cannot be negative")
} }
if app.config.GlobalMaxBoxSizeBytes > 0 && size > app.config.GlobalMaxBoxSizeBytes { limit := app.effectiveMaxBoxBytes(actor)
if limit > 0 && size > limit {
if actor != nil {
return fmt.Errorf("Box exceeds this account's max box size")
}
return fmt.Errorf("Box exceeds the global max box size") return fmt.Errorf("Box exceeds the global max box size")
} }
return nil return nil
@@ -137,7 +179,11 @@ func (app *App) rejectExpiredManifestBox(boxID string) error {
} }
func (app *App) limitRequestBody(ctx *gin.Context) { func (app *App) limitRequestBody(ctx *gin.Context) {
limit := app.maxRequestBodyBytes() app.limitRequestBodyForActor(ctx, nil)
}
func (app *App) limitRequestBodyForActor(ctx *gin.Context, actor *requestActor) {
limit := app.maxRequestBodyBytesForActor(actor)
if limit <= 0 { if limit <= 0 {
return return
} }
@@ -145,9 +191,14 @@ func (app *App) limitRequestBody(ctx *gin.Context) {
} }
func (app *App) maxRequestBodyBytes() int64 { func (app *App) maxRequestBodyBytes() int64 {
limit := app.config.GlobalMaxBoxSizeBytes return app.maxRequestBodyBytesForActor(nil)
if limit <= 0 || app.config.GlobalMaxFileSizeBytes > limit { }
limit = app.config.GlobalMaxFileSizeBytes
func (app *App) maxRequestBodyBytesForActor(actor *requestActor) int64 {
limit := app.effectiveMaxBoxBytes(actor)
fileLimit := app.effectiveMaxFileBytes(actor)
if limit <= 0 || fileLimit > limit {
limit = fileLimit
} }
if limit <= 0 { if limit <= 0 {
return 0 return 0

369
lib/userstore/store.go Normal file
View File

@@ -0,0 +1,369 @@
package userstore
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"warpbox/lib/helpers"
)
const (
StatusActive = "active"
StatusDisabled = "disabled"
)
type Permissions struct {
CanUseWeb bool `json:"can_use_web"`
CanUseAPI bool `json:"can_use_api"`
CanCreateBox bool `json:"can_create_box"`
CanUploadFile bool `json:"can_upload_file"`
}
type Limits struct {
MaxFileSizeBytes int64 `json:"max_file_size_bytes"`
MaxBoxSizeBytes int64 `json:"max_box_size_bytes"`
}
type APIKey struct {
ID string `json:"id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
KeyHash string `json:"key_hash"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
}
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Status string `json:"status"`
Permissions Permissions `json:"permissions"`
Limits Limits `json:"limits"`
APIKeys []APIKey `json:"api_keys"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
}
type diskState struct {
Users []User `json:"users"`
}
type Store struct {
path string
mu sync.RWMutex
users map[string]User
}
func NewStore(path string) (*Store, error) {
s := &Store{path: path, users: map[string]User{}}
if err := s.load(); err != nil {
return nil, err
}
return s, nil
}
func (s *Store) load() error {
s.mu.Lock()
defer s.mu.Unlock()
bytes, err := os.ReadFile(s.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
if len(bytes) == 0 {
return nil
}
var state diskState
if err := json.Unmarshal(bytes, &state); err != nil {
return err
}
for _, user := range state.Users {
s.users[user.ID] = user
}
return nil
}
func (s *Store) saveLocked() error {
state := diskState{Users: make([]User, 0, len(s.users))}
for _, user := range s.users {
state.Users = append(state.Users, user)
}
sort.Slice(state.Users, func(i, j int) bool {
return state.Users[i].CreatedAt.After(state.Users[j].CreatedAt)
})
bytes, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
return err
}
tmpPath := s.path + ".tmp"
if err := os.WriteFile(tmpPath, bytes, 0644); err != nil {
return err
}
return os.Rename(tmpPath, s.path)
}
func (s *Store) List() []User {
s.mu.RLock()
defer s.mu.RUnlock()
users := make([]User, 0, len(s.users))
for _, user := range s.users {
users = append(users, user)
}
sort.Slice(users, func(i, j int) bool {
return users[i].CreatedAt.After(users[j].CreatedAt)
})
return users
}
func normalizeStatus(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case StatusDisabled:
return StatusDisabled
default:
return StatusActive
}
}
func normalizePermissions(p Permissions) Permissions {
return Permissions{
CanUseWeb: p.CanUseWeb,
CanUseAPI: p.CanUseAPI,
CanCreateBox: p.CanCreateBox,
CanUploadFile: p.CanUploadFile,
}
}
func normalizeEmail(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func normalizeUsername(value string) string {
return strings.TrimSpace(value)
}
func validateUserInput(username string, email string) error {
if normalizeUsername(username) == "" {
return fmt.Errorf("username is required")
}
if normalizeEmail(email) == "" || !strings.Contains(email, "@") {
return fmt.Errorf("valid email is required")
}
return nil
}
func (s *Store) Create(username string, email string, permissions Permissions, limits Limits, status string) (User, error) {
s.mu.Lock()
defer s.mu.Unlock()
if err := validateUserInput(username, email); err != nil {
return User{}, err
}
normEmail := normalizeEmail(email)
for _, existing := range s.users {
if strings.EqualFold(existing.Email, normEmail) {
return User{}, fmt.Errorf("email already exists")
}
}
id, err := helpers.RandomHexID(8)
if err != nil {
return User{}, err
}
now := time.Now().UTC()
user := User{
ID: "u_" + id,
Username: normalizeUsername(username),
Email: normEmail,
Status: normalizeStatus(status),
Permissions: normalizePermissions(permissions),
Limits: limits,
APIKeys: []APIKey{},
CreatedAt: now,
UpdatedAt: now,
}
s.users[user.ID] = user
if err := s.saveLocked(); err != nil {
return User{}, err
}
return user, nil
}
func (s *Store) Update(id string, username string, email string, permissions Permissions, limits Limits, status string) (User, error) {
s.mu.Lock()
defer s.mu.Unlock()
if err := validateUserInput(username, email); err != nil {
return User{}, err
}
user, ok := s.users[id]
if !ok {
return User{}, fmt.Errorf("user not found")
}
normEmail := normalizeEmail(email)
for _, existing := range s.users {
if existing.ID == id {
continue
}
if strings.EqualFold(existing.Email, normEmail) {
return User{}, fmt.Errorf("email already exists")
}
}
user.Username = normalizeUsername(username)
user.Email = normEmail
user.Status = normalizeStatus(status)
user.Permissions = normalizePermissions(permissions)
user.Limits = limits
user.UpdatedAt = time.Now().UTC()
s.users[id] = user
if err := s.saveLocked(); err != nil {
return User{}, err
}
return user, nil
}
func (s *Store) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.users[id]; !ok {
return fmt.Errorf("user not found")
}
delete(s.users, id)
return s.saveLocked()
}
func (s *Store) FindByID(id string) (User, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
user, ok := s.users[id]
return user, ok
}
func hashKey(value string) string {
digest := sha256.Sum256([]byte(value))
return hex.EncodeToString(digest[:])
}
func (s *Store) CreateAPIKey(userID string, name string) (APIKey, string, error) {
s.mu.Lock()
defer s.mu.Unlock()
user, ok := s.users[userID]
if !ok {
return APIKey{}, "", fmt.Errorf("user not found")
}
if strings.TrimSpace(name) == "" {
name = "default"
}
rawSuffix, err := helpers.RandomHexID(20)
if err != nil {
return APIKey{}, "", err
}
keyValue := "wbk_" + rawSuffix
id, err := helpers.RandomHexID(8)
if err != nil {
return APIKey{}, "", err
}
prefix := keyValue
if len(prefix) > 12 {
prefix = prefix[:12]
}
now := time.Now().UTC()
key := APIKey{
ID: "k_" + id,
Name: strings.TrimSpace(name),
Prefix: prefix,
KeyHash: hashKey(keyValue),
CreatedAt: now,
}
user.APIKeys = append(user.APIKeys, key)
user.UpdatedAt = now
s.users[userID] = user
if err := s.saveLocked(); err != nil {
return APIKey{}, "", err
}
return key, keyValue, nil
}
func (s *Store) RevokeAPIKey(userID string, keyID string) error {
s.mu.Lock()
defer s.mu.Unlock()
user, ok := s.users[userID]
if !ok {
return fmt.Errorf("user not found")
}
now := time.Now().UTC()
for i := range user.APIKeys {
if user.APIKeys[i].ID == keyID {
user.APIKeys[i].RevokedAt = &now
user.UpdatedAt = now
s.users[userID] = user
return s.saveLocked()
}
}
return fmt.Errorf("api key not found")
}
func (s *Store) FindByAPIKey(raw string) (User, APIKey, bool) {
h := hashKey(strings.TrimSpace(raw))
s.mu.RLock()
defer s.mu.RUnlock()
for _, user := range s.users {
for _, key := range user.APIKeys {
if key.RevokedAt != nil {
continue
}
if key.KeyHash == h {
return user, key, true
}
}
}
return User{}, APIKey{}, false
}
func (s *Store) TouchAPIKey(userID string, keyID string) {
s.mu.Lock()
defer s.mu.Unlock()
user, ok := s.users[userID]
if !ok {
return
}
now := time.Now().UTC()
for i := range user.APIKeys {
if user.APIKeys[i].ID == keyID {
user.APIKeys[i].LastUsedAt = &now
break
}
}
user.LastSeenAt = &now
user.UpdatedAt = now
s.users[userID] = user
_ = s.saveLocked()
}
func (s *Store) TouchUser(userID string) {
s.mu.Lock()
defer s.mu.Unlock()
user, ok := s.users[userID]
if !ok {
return
}
now := time.Now().UTC()
user.LastSeenAt = &now
user.UpdatedAt = now
s.users[userID] = user
_ = s.saveLocked()
}

View File

@@ -111,6 +111,18 @@
.alerts-scroll { height: 326px; } .alerts-scroll { height: 326px; }
.boxes-scroll { height: 352px; } .boxes-scroll { height: 352px; }
.activity-scroll { height: 326px; } .activity-scroll { height: 326px; }
.dashboard-empty-state {
margin: 8px;
padding: 10px;
color: #333333;
background: #ffffcc;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #a08000;
border-bottom: 1px solid #a08000;
font-size: 12px;
line-height: 15px;
}
/* Alerts */ /* Alerts */
.alert-list { display: grid; min-width: 0; } .alert-list { display: grid; min-width: 0; }

View File

@@ -1,6 +1,7 @@
.users-page-body { .users-page-body {
display: grid; display: grid;
gap: 10px; gap: 10px;
align-items: start;
} }
.users-hero { .users-hero {
@@ -69,11 +70,92 @@
.users-main-grid { .users-main-grid {
display: grid; display: grid;
grid-template-columns: minmax(320px, .65fr) minmax(0, 1.35fr); grid-template-columns: 320px minmax(0, 1fr);
gap: 10px; gap: 10px;
min-height: 0; 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 { .users-panel {
min-height: 0; min-height: 0;
display: flex; display: flex;
@@ -97,6 +179,11 @@
border-bottom: 1px solid #b0b0b0; border-bottom: 1px solid #b0b0b0;
} }
.users-panel-header.compact {
margin: -8px -8px 0;
min-height: 30px;
}
.users-panel-title { .users-panel-title {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -194,13 +281,13 @@
.users-toolbar-grid { .users-toolbar-grid {
display: 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; gap: 8px;
} }
.users-table-wrap { .users-table-wrap {
min-height: 420px; min-height: 360px;
height: 420px; height: min(54vh, 520px);
overflow: auto; overflow: auto;
background: #ffffff; background: #ffffff;
border-top: 2px solid #606060; 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(odd) { background: rgba(255,255,255,.96); }
.users-table tbody tr:nth-child(even) { background: rgba(240,244,255,.9); } .users-table tbody tr:nth-child(even) { background: rgba(240,244,255,.9); }
.users-table tbody tr:hover { background: #d8e5f8; } .users-table tbody tr:hover { background: #d8e5f8; }
.users-table tbody tr.is-selected { background: #c8d8ff; }
.users-col-check { width: 30px; } .users-col-check { width: 30px; }
.users-col-actions { width: 136px; } .users-col-actions { width: 136px; }
@@ -301,6 +389,70 @@
line-height: 12px; 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) { @media (max-width: 1024px) {
.users-main-grid, .users-main-grid,
.users-hero { .users-hero {

View File

@@ -7,6 +7,7 @@
}); });
} }
}; };
const dataNode = document.getElementById("dashboard-data");
const toast = document.getElementById("toast"); const toast = document.getElementById("toast");
const statusText = document.getElementById("statusText"); const statusText = document.getElementById("statusText");
const modal = document.querySelector("[data-alert-modal]"); const modal = document.querySelector("[data-alert-modal]");
@@ -19,18 +20,28 @@
const topAlertChip = document.getElementById("topAlertChip"); const topAlertChip = document.getElementById("topAlertChip");
const topTaskbar = document.querySelector(".admin-taskbar"); const topTaskbar = document.querySelector(".admin-taskbar");
const dashboardData = parseDashboardData();
if (!statusText || !alertsCard || !topAlertChip) return; if (!statusText || !alertsCard || !topAlertChip) return;
function showToast(message, type = "info") { function parseDashboardData() {
try {
return JSON.parse(dataNode?.textContent || "{}");
} catch (_) {
return {};
}
}
function showToast(message, type = "info", duration = 2200) {
if (window.WarpBoxUI) { if (window.WarpBoxUI) {
window.WarpBoxUI.toast(message, type, { target: toast }); window.WarpBoxUI.toast(message, type, { target: toast, duration });
return; return;
} }
if (!toast) return; if (!toast) return;
toast.textContent = message; toast.textContent = message;
toast.classList.add("is-visible"); toast.classList.add("is-visible");
window.clearTimeout(showToast.timer); window.clearTimeout(showToast.timer);
showToast.timer = window.setTimeout(() => toast.classList.remove("is-visible"), 2600); showToast.timer = window.setTimeout(() => toast.classList.remove("is-visible"), duration);
} }
function setStatus(message) { function setStatus(message) {
@@ -91,45 +102,159 @@
setStatus(`Focused ${id.replace("-", " ")}`); setStatus(`Focused ${id.replace("-", " ")}`);
} }
const commandMessages = { function downloadFile(filename, content, type) {
refresh: "CURRENTLY_MOCKED_LEAVE_AS_IS: dashboard refresh would re-fetch dashboard data.", const blob = new Blob([content], { type });
"dashboard-snapshot": "CURRENTLY_MOCKED_LEAVE_AS_IS: dashboard snapshot export would start here.", const url = URL.createObjectURL(blob);
logout: "CURRENTLY_MOCKED_LEAVE_AS_IS: logout would submit to the account logout route.", const anchor = document.createElement("a");
"compact-mode": "Toggled compact density.", anchor.href = url;
"show-all-boxes": "TO-DO: navigate to the admin boxes view when that page exists.", anchor.download = filename;
"show-all-alerts": "TO-DO: navigate to /admin/alerts.", anchor.click();
"export-boxes": "CURRENTLY_MOCKED_LEAVE_AS_IS: boxes CSV export would be requested.", URL.revokeObjectURL(url);
"export-alerts": "CURRENTLY_MOCKED_LEAVE_AS_IS: alerts JSON export would be requested.",
"cleanup-dry-run": "CURRENTLY_MOCKED_LEAVE_AS_IS: cleanup dry run would calculate affected boxes without deleting.",
"dismiss-low-alerts": "Closed visible low-severity alerts in this mock.",
"config-snapshot": "CURRENTLY_MOCKED_LEAVE_AS_IS: config snapshot would summarize runtime settings and sources.",
"support-summary": "CURRENTLY_MOCKED_LEAVE_AS_IS: support summary would collect safe diagnostic information.",
"thumbnail-rebuild": "CURRENTLY_MOCKED_LEAVE_AS_IS: thumbnail rebuild would enqueue preview regeneration.",
"open-users": "TO-DO: navigate to the admin users view when that page exists.",
"open-settings": "TO-DO: navigate to the admin settings view when that page exists.",
"alerts-help": "Alerts use title, description, severity, metadata JSON, trace identifier, and unique numeric code.",
shortcuts: "Shortcuts: F5 refresh, Alt+A alerts, Alt+B boxes, Alt+R activity, Esc close menus/modal.",
about: "WarpBox dashboard mock v5, single-window Win98 account dashboard."
};
function runCommand(command) {
if (command === "compact-mode") document.body.classList.toggle("is-compact");
if (command === "dismiss-low-alerts") {
document.querySelectorAll('.alert-row[data-severity="low"]').forEach((row) => row.classList.add("is-dismissed"));
updateAlertSummary();
} }
if (command === "show-all-boxes") window.location.hash = "recent-boxes";
if (command === "show-all-alerts") window.location.hash = "alerts";
const message = commandMessages[command] || `Command: ${command}`; function csvEscape(value) {
showToast(message); const text = String(value ?? "");
setStatus(message); if (!/[",\n]/.test(text)) return text;
return `"${text.replaceAll('"', '""')}"`;
}
function exportBoxesCSV() {
const rows = dashboardData.boxes || [];
const header = ["id", "status", "files", "size", "created", "expires", "flags"];
const lines = rows.map((box) => [
box.id,
box.status_label,
`${box.complete_files}/${box.file_count}`,
box.total_size_label,
box.created_at_label,
box.expires_at_label,
(box.flags || []).join("|")
].map(csvEscape).join(","));
downloadFile(`warpbox-dashboard-boxes-${new Date().toISOString().replaceAll(":", "-")}.csv`, [header.join(","), ...lines].join("\n"), "text/csv;charset=utf-8");
showToast("Dashboard boxes exported", "success");
}
function exportAlertsJSON() {
downloadFile(`warpbox-dashboard-alerts-${new Date().toISOString().replaceAll(":", "-")}.json`, JSON.stringify(dashboardData.alerts || [], null, 2), "application/json;charset=utf-8");
showToast("Dashboard alerts exported", "success");
}
function exportSnapshot() {
downloadFile(`warpbox-dashboard-${new Date().toISOString().replaceAll(":", "-")}.json`, JSON.stringify(dashboardData, null, 2), "application/json;charset=utf-8");
showToast("Dashboard snapshot exported", "success");
}
async function postAlertAction(action, ids) {
const response = await fetch("/admin/alerts/actions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, ids })
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || "Alert action failed");
return payload;
}
async function postBoxAction(action, extra = {}) {
const response = await fetch("/admin/boxes/actions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, ...extra })
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || "Box action failed");
return payload;
}
async function closeAlert(row) {
const id = row?.dataset.alertId;
if (!id) return;
await postAlertAction("close", [id]);
row.classList.add("is-dismissed");
updateAlertSummary();
showToast(`Closed alert ${row.dataset.alertCode || id}`, "success");
setStatus(`Closed alert ${row.dataset.alertCode || id}`);
}
async function closeLowAlerts() {
const rows = Array.from(document.querySelectorAll('.alert-row[data-severity="low"]'));
const ids = rows.map((row) => row.dataset.alertId).filter(Boolean);
if (!ids.length) {
showToast("No low alerts to close");
return;
}
await postAlertAction("close", ids);
rows.forEach((row) => row.classList.add("is-dismissed"));
updateAlertSummary();
showToast(`Closed ${ids.length} low alert(s)`, "success");
setStatus(`Closed ${ids.length} low alert(s)`);
}
async function cleanupExpiredBoxes() {
if (!window.confirm("Clean up expired boxes now? This can delete expired box data.")) return;
const payload = await postBoxAction("cleanup_expired");
showToast(payload.message || "Expired cleanup complete", payload.ok ? "success" : "warning", 3200);
setStatus(payload.message || "Expired cleanup complete");
window.setTimeout(() => window.location.reload(), 900);
}
async function runCommand(command) {
if (command === "refresh") {
window.location.reload();
return;
}
if (command === "dashboard-snapshot") return exportSnapshot();
if (command === "logout") {
window.location.href = "/admin/logout";
return;
}
if (command === "compact-mode") {
document.body.classList.toggle("is-compact");
showToast("Toggled compact density");
return;
}
if (command === "show-all-boxes") {
window.location.href = "/admin/boxes";
return;
}
if (command === "show-all-alerts") {
window.location.href = "/admin/alerts";
return;
}
if (command === "open-users") {
window.location.href = "/admin/users";
return;
}
if (command === "open-activity") {
window.location.href = "/admin/activity";
return;
}
if (command === "open-settings") {
window.location.href = "/admin/settings";
return;
}
if (command === "export-boxes") return exportBoxesCSV();
if (command === "export-alerts") return exportAlertsJSON();
if (command === "close-low-alerts") return closeLowAlerts();
if (command === "cleanup-expired") return cleanupExpiredBoxes();
if (command === "shortcuts") {
showToast("Shortcuts: F5 refresh, Alt+A alerts, Alt+B boxes, Alt+R activity, Esc close menus/modal.", "info", 3600);
return;
}
if (command === "about") {
showToast("Live WarpBox admin dashboard backed by alerts, activity, boxes, users, and settings.", "info", 3600);
}
} }
document.querySelectorAll("[data-command]").forEach((button) => { document.querySelectorAll("[data-command]").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", async () => {
menuController.close(); menuController.close();
runCommand(button.dataset.command); try {
await runCommand(button.dataset.command);
} catch (error) {
showToast(error.message || "Command failed", "error", 3600);
setStatus(error.message || "Command failed");
}
}); });
}); });
@@ -150,37 +275,39 @@
} catch (_) { } catch (_) {
meta = row?.dataset.alertMeta || "{}"; meta = row?.dataset.alertMeta || "{}";
} }
openModal(`${title} (${row?.dataset.alertCode || "mock"})`, meta); openModal(`${title} (${row?.dataset.alertCode || "alert"})`, meta);
}); });
}); });
document.querySelectorAll("[data-dismiss-alert]").forEach((button) => { document.querySelectorAll("[data-close-alert]").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", async () => {
const row = button.closest(".alert-row"); try {
row?.classList.add("is-dismissed"); await closeAlert(button.closest(".alert-row"));
updateAlertSummary(); } catch (error) {
showToast(`Closed alert ${row?.dataset.alertCode || "mock"}.`); showToast(error.message || "Could not close alert", "error", 3600);
setStatus(`Closed alert ${row?.dataset.alertCode || "mock"}`); }
}); });
}); });
document.querySelector("[data-close-modal]")?.addEventListener("click", closeModal); document.querySelector("[data-close-modal]")?.addEventListener("click", closeModal);
backdrop?.addEventListener("click", closeModal); backdrop?.addEventListener("click", closeModal);
topAlertChip.addEventListener("click", (event) => { topAlertChip.addEventListener("click", (event) => {
if (document.getElementById("alerts")) {
event.preventDefault(); event.preventDefault();
scrollToSection("alerts"); scrollToSection("alerts");
}
}); });
window.addEventListener("scroll", updateStickyHeader, { passive: true }); window.addEventListener("scroll", updateStickyHeader, { passive: true });
document.addEventListener("keydown", (event) => { document.addEventListener("keydown", async (event) => {
if (event.key === "Escape") { if (event.key === "Escape") {
menuController.close(); menuController.close();
closeModal(); closeModal();
} }
if (event.key === "F5") { if (event.key === "F5") {
event.preventDefault(); event.preventDefault();
runCommand("refresh"); await runCommand("refresh");
} }
if (event.altKey && event.key.toLowerCase() === "a") { if (event.altKey && event.key.toLowerCase() === "a") {
event.preventDefault(); event.preventDefault();

View File

@@ -3,8 +3,7 @@
const toastTarget = document.getElementById("toast"); const toastTarget = document.getElementById("toast");
const body = document.getElementById("users-body"); const body = document.getElementById("users-body");
const search = document.getElementById("users-search"); const search = document.getElementById("users-search");
const status = document.getElementById("users-status"); const statusFilter = document.getElementById("users-status");
const role = document.getElementById("users-role-filter");
const sort = document.getElementById("users-sort"); const sort = document.getElementById("users-sort");
const size = document.getElementById("users-size"); const size = document.getElementById("users-size");
const masterCheck = document.getElementById("users-master-check"); const masterCheck = document.getElementById("users-master-check");
@@ -14,61 +13,234 @@
const prevBtn = document.getElementById("users-prev"); const prevBtn = document.getElementById("users-prev");
const nextBtn = document.getElementById("users-next"); const nextBtn = document.getElementById("users-next");
const selectVisible = document.getElementById("select-visible"); 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 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 = [ const state = {
{ id: "u_admin", username: "admin", email: "admin@warpbox.local", status: "active", role: "admin", plan: "unlimited", boxes: 18, created: "2026-04-12", lastSeen: "active now" }, page: 1,
{ 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" }, users: [],
{ 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" }, selected: new Set(),
{ id: "u_teo", username: "teo", email: "teo@example.test", status: "active", role: "uploader", plan: "trusted", boxes: 5, created: "2026-04-19", lastSeen: "yesterday" }, currentUserID: "",
{ 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() }; 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") { function toast(message, type = "info") {
if (window.WarpBoxUI) { if (window.WarpBoxUI) {
window.WarpBoxUI.toast(message, type, { target: toastTarget, duration: 2200 }); window.WarpBoxUI.toast(message, type, { target: toastTarget, duration: 3200 });
return; return;
} }
if (!toastTarget) return; if (toastTarget) toastTarget.textContent = message;
toastTarget.textContent = message;
toastTarget.classList.add("is-visible");
} }
function filtered() { function escapeHTML(value) {
const query = search.value.trim().toLowerCase(); return String(value || "")
const statusFilter = status.value; .replaceAll("&", "&amp;")
const roleFilter = role.value; .replaceAll("<", "&lt;")
const sortBy = sort.value; .replaceAll(">", "&gt;")
const rows = users.filter((user) => { .replaceAll('"', "&quot;")
const matchesQuery = !query || user.username.toLowerCase().includes(query) || user.email.toLowerCase().includes(query); .replaceAll("'", "&#39;");
const matchesStatus = statusFilter === "all" || user.status === statusFilter; }
const matchesRole = roleFilter === "all" || user.role === roleFilter;
return matchesQuery && matchesStatus && matchesRole;
});
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) => { rows.sort((a, b) => {
if (sortBy === "createdDesc") return b.created.localeCompare(a.created); if (sort.value === "createdDesc") return String(b.created_at).localeCompare(String(a.created_at));
if (sortBy === "lastSeenDesc") return b.lastSeen.localeCompare(a.lastSeen); if (sort.value === "lastSeenDesc") return String(b.last_seen_at || "").localeCompare(String(a.last_seen_at || ""));
if (sortBy === "boxesDesc") return b.boxes - a.boxes; if (sort.value === "keysDesc") return (b.api_key_count || 0) - (a.api_key_count || 0);
return a.username.localeCompare(b.username); return a.username.localeCompare(b.username);
}); });
return rows; return rows;
@@ -77,40 +249,56 @@
function paged(rows) { function paged(rows) {
const perPage = Number(size.value || 12); const perPage = Number(size.value || 12);
const pages = Math.max(1, Math.ceil(rows.length / perPage)); const pages = Math.max(1, Math.ceil(rows.length / perPage));
if (state.page > pages) state.page = pages; state.page = Math.max(1, Math.min(state.page, pages));
if (state.page < 1) state.page = 1;
const start = (state.page - 1) * perPage; 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) { function permissionsSummary(permissions = {}) {
return `<span class="users-pill ${value}">${value}</span>`; 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) { function renderRow(user) {
const checked = state.selected.has(user.id) ? " checked" : ""; const checked = state.selected.has(user.id) ? " checked" : "";
const active = user.id === state.currentUserID ? " is-selected" : "";
const row = document.createElement("tr"); const row = document.createElement("tr");
row.className = active;
row.dataset.userId = user.id;
row.innerHTML = ` row.innerHTML = `
<td><input type="checkbox" class="row-check"${checked}></td> <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><div class="users-username"><strong>${escapeHTML(user.username)}</strong><span class="users-muted">${escapeHTML(user.id)}</span></div></td>
<td title="${user.email}">${user.email}</td> <td title="${escapeHTML(user.email)}">${escapeHTML(user.email)}</td>
<td>${statusPill(user.status)}</td> <td><span class="users-pill ${escapeHTML(user.status)}">${escapeHTML(user.status)}</span></td>
<td>${user.role}</td> <td>${escapeHTML(permissionsSummary(user.permissions))}</td>
<td>${user.plan}</td> <td>${escapeHTML(limitsSummary(user.limits))}</td>
<td>${user.boxes}</td> <td>${user.api_key_count || 0}</td>
<td>${user.lastSeen}</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="open">Open</button></div></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) => { row.querySelector(".row-check")?.addEventListener("change", (event) => {
if (event.target.checked) state.selected.add(user.id); if (event.target.checked) state.selected.add(user.id);
else state.selected.delete(user.id); else state.selected.delete(user.id);
if (event.target.checked) state.currentUserID = user.id;
populateSelectedPanels();
syncSelected(); syncSelected();
syncMasterCheck(); syncMasterCheck();
render();
}); });
row.querySelector('[data-action="open"]')?.addEventListener("click", () => { row.querySelector('[data-action="edit"]')?.addEventListener("click", () => setSelectedUser(user.id, "edit"));
toast(`Mock user preview: ${user.username}`); row.querySelector('[data-action="keys"]')?.addEventListener("click", () => setSelectedUser(user.id, "keys"));
});
return row; return row;
} }
@@ -123,19 +311,11 @@
masterCheck.checked = checks.length > 0 && checks.every((item) => item.checked); 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() { function render() {
const rows = filtered(); const rows = filteredUsers();
const page = paged(rows); const page = paged(rows);
body.innerHTML = ""; body.innerHTML = "";
page.rows.forEach((user) => body.appendChild(renderRow(user))); page.rows.forEach((user) => body.appendChild(renderRow(user)));
visiblePill.textContent = `${rows.length} visible`; visiblePill.textContent = `${rows.length} visible`;
pageInfo.textContent = `Page ${state.page} / ${page.pages}`; pageInfo.textContent = `Page ${state.page} / ${page.pages}`;
prevBtn.disabled = state.page <= 1; prevBtn.disabled = state.page <= 1;
@@ -145,75 +325,109 @@
syncMasterCheck(); syncMasterCheck();
} }
function clearFilters() { async function fetchUsers() {
search.value = ""; const result = await api("/admin/users/list");
status.value = "all"; state.users = result.users || [];
role.value = "all"; if (state.currentUserID && !state.users.some((user) => user.id === state.currentUserID)) {
sort.value = "username"; state.currentUserID = "";
state.page = 1; }
state.selected = new Set([...state.selected].filter((id) => state.users.some((user) => user.id === id)));
renderStats();
populateSelectedPanels();
render(); render();
} }
function applyBulk(nextStatus) { async function saveUser(payload, successMessage) {
const selected = users.filter((user) => state.selected.has(user.id)); const result = await api("/admin/users/save", "POST", payload);
if (!selected.length) { state.currentUserID = result.user?.id || state.currentUserID;
state.selected.clear();
if (state.currentUserID) state.selected.add(state.currentUserID);
await fetchUsers();
toast(successMessage);
return result.user;
}
async function deleteUser(userID) {
const user = state.users.find((item) => item.id === userID);
if (!user) return;
if (!confirm(`Delete ${user.username}?`)) return;
await api("/admin/users/delete", "POST", { id: userID });
state.currentUserID = "";
state.selected.delete(userID);
await fetchUsers();
setTab("add");
toast("User deleted");
}
async function revokeAPIKey(keyID) {
const user = selectedUser();
if (!user || !keyID) return;
await api("/admin/users/api-keys/revoke", "POST", { user_id: user.id, key_id: keyID });
await fetchUsers();
setTab("keys");
toast("API key revoked");
}
async function runCommand(command) {
switch (command) {
case "tab-add":
case "create":
setTab("add");
break;
case "refresh":
await fetchUsers();
toast("Users list refreshed");
break;
case "bulk-disable":
case "bulk-enable": {
const nextStatus = command === "bulk-disable" ? "disabled" : "active";
const ids = Array.from(state.selected);
if (!ids.length) {
toast("Select one or more users first", "warning"); toast("Select one or more users first", "warning");
return; return;
} }
selected.forEach((user) => { user.status = nextStatus; }); for (const id of ids) {
toast(`Updated ${selected.length} user(s) to ${nextStatus}`); const user = state.users.find((item) => item.id === id);
renderStats(); if (!user) continue;
render(); await api("/admin/users/save", "POST", payloadFromUser(user, { status: nextStatus }));
} }
await fetchUsers();
function runCommand(command) { toast(`Updated ${ids.length} user(s)`);
switch (command) {
case "invite":
modeInput.value = "invite";
toast("Invite mode selected");
break; break;
case "create": }
modeInput.value = "create"; case "bulk-delete": {
toast("Create mode selected"); 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; break;
case "export": }
toast("Mock CSV export complete"); case "clear-filters":
break; search.value = "";
case "bulk-disable": statusFilter.value = "all";
applyBulk("disabled"); sort.value = "username";
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; state.page = 1;
render(); render();
break; 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: default:
toast(`Mock action: ${command}`);
break; break;
} }
} }
[search, status, role, sort, size].forEach((el) => { document.querySelectorAll(".users-tab").forEach((tab) => {
el.addEventListener(el.tagName === "INPUT" ? "input" : "change", () => { tab.addEventListener("click", () => setTab(tab.dataset.tab));
});
[search, statusFilter, sort, size].forEach((element) => {
element.addEventListener(element.tagName === "INPUT" ? "input" : "change", () => {
state.page = 1; state.page = 1;
render(); render();
}); });
@@ -231,74 +445,131 @@
masterCheck.addEventListener("change", () => { masterCheck.addEventListener("change", () => {
Array.from(body.querySelectorAll("tr")).forEach((row) => { Array.from(body.querySelectorAll("tr")).forEach((row) => {
const userID = row.dataset.userId || "";
const checkbox = row.querySelector(".row-check"); const checkbox = row.querySelector(".row-check");
if (!checkbox) return; if (!checkbox || !userID) return;
checkbox.checked = masterCheck.checked; checkbox.checked = masterCheck.checked;
const userID = row.querySelector(".users-muted")?.textContent || "";
if (masterCheck.checked) state.selected.add(userID); if (masterCheck.checked) state.selected.add(userID);
else state.selected.delete(userID); else state.selected.delete(userID);
}); });
syncSelected(); if (state.selected.size === 1) state.currentUserID = Array.from(state.selected)[0];
populateSelectedPanels();
render();
}); });
selectVisible.addEventListener("click", () => { selectVisible.addEventListener("click", () => {
Array.from(body.querySelectorAll("tr")).forEach((row) => { Array.from(body.querySelectorAll("tr")).forEach((row) => {
const userID = row.dataset.userId || "";
const checkbox = row.querySelector(".row-check"); const checkbox = row.querySelector(".row-check");
const userID = row.querySelector(".users-muted")?.textContent || ""; if (!checkbox || !userID) return;
if (!checkbox) return;
checkbox.checked = true; checkbox.checked = true;
state.selected.add(userID); state.selected.add(userID);
}); });
syncSelected(); if (state.selected.size === 1) state.currentUserID = Array.from(state.selected)[0];
syncMasterCheck(); populateSelectedPanels();
render();
}); });
form.addEventListener("submit", (event) => { addForm.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
const username = usernameInput.value.trim(); const username = fields.add.username.value.trim();
const email = emailInput.value.trim(); const email = fields.add.email.value.trim();
const mode = modeInput.value;
if (!username || !email) { if (!username || !email) {
toast("Username and email are required", "warning"); toast("Username and email are required", "warning");
return; return;
} }
users.unshift({ try {
id: `u_${username.toLowerCase().replaceAll(/[^a-z0-9]+/g, "_")}`, await saveUser({
username, username,
email, email,
status: mode === "invite" ? "pending" : "active", status: fields.add.status.value,
role: roleInput.value, max_file_size_mb: numericMB(fields.add.maxFile),
plan: planInput.value, max_box_size_mb: numericMB(fields.add.maxBox),
boxes: 0, permissions: permissionPayload(fields.add),
created: new Date().toISOString().slice(0, 10), }, "User created");
lastSeen: "never" 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");
}
}); });
form.reset();
modeInput.value = "invite"; editForm.addEventListener("submit", async (event) => {
renderStats(); event.preventDefault();
render(); const user = selectedUser();
toast(mode === "invite" ? "Mock invite created" : "Mock user created"); 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) => { document.querySelectorAll("[data-command]").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", async () => {
menuController.close(); 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 === "Escape") menuController.close();
if (event.key === "F5") { if (event.key === "F5") {
event.preventDefault(); event.preventDefault();
runCommand("refresh"); await runCommand("refresh");
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "i") {
event.preventDefault();
runCommand("invite");
} }
}); });
renderStats(); fetchUsers().catch((error) => toast(error.message || "Failed to load users", "warning"));
render();
})(); })();

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() { async function createBox() {
const response = await fetch("/box", { const response = await fetch("/box", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify({ body: JSON.stringify({
retention_key: el.expiry?.value || defaultRetention, retention_key: el.expiry?.value || defaultRetention,
password: el.password?.value || "", password: el.password?.value || "",
@@ -28,7 +36,7 @@ async function markFileStatus(item, status) {
try { try {
await fetch(`/box/${item.boxID}/files/${item.boxFile.id}/status`, { await fetch(`/box/${item.boxID}/files/${item.boxFile.id}/status`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify({ status }), body: JSON.stringify({ status }),
}); });
} catch (_) { } catch (_) {
@@ -62,6 +70,8 @@ function uploadFile(item, onComplete) {
formData.append("file", item.file, item.displayName); formData.append("file", item.file, item.displayName);
xhr.open("POST", item.boxFile.upload_path); xhr.open("POST", item.boxFile.upload_path);
const headers = authHeaders();
if (headers.Authorization) xhr.setRequestHeader("Authorization", headers.Authorization);
xhr.upload.addEventListener("loadstart", () => { xhr.upload.addEventListener("loadstart", () => {
item.loaded = 0; item.loaded = 0;

View File

@@ -34,8 +34,10 @@ function setBoxOptionsLocked(locked) {
function updateDisabledReasons() { function updateDisabledReasons() {
if (el.startButton) { if (el.startButton) {
let reason = ""; let reason = "";
const policyMessage = apiKeyPolicyMessage();
if (!uploadsEnabled) reason = "Guest uploads are disabled."; if (!uploadsEnabled) reason = "Guest uploads are disabled.";
else if (uploadLocked) reason = "This upload already started. Press Clear to create another box."; 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 (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."; else if (!files.length) reason = "There are no files selected. Please select files to upload.";
el.startButton.disabled = false; el.startButton.disabled = false;
@@ -101,6 +103,13 @@ function syncMenuChecks() {
function syncApiKeyField() { function syncApiKeyField() {
const enabled = Boolean(el.apiKeyMode?.checked) && !uploadLocked; const enabled = Boolean(el.apiKeyMode?.checked) && !uploadLocked;
el.apiKeyRow?.classList.toggle("is-visible", Boolean(el.apiKeyMode?.checked)); el.apiKeyRow?.classList.toggle("is-visible", Boolean(el.apiKeyMode?.checked));
if (!el.apiKeyMode?.checked) {
clearTimeout(apiKeyTimer);
apiKeyValidationRun += 1;
resetAccountLimits();
updateLimitHint();
renderFiles();
}
if (el.apiKeyInput) { if (el.apiKeyInput) {
el.apiKeyInput.disabled = !enabled; el.apiKeyInput.disabled = !enabled;
el.apiKeyInput.dataset.disabledReason = enabled ? "" : "Enable Use API key for larger quota before typing an API key."; 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"); wrapper?.classList.remove("is-checking");
if (!el.apiKeyMode?.checked) { if (!el.apiKeyMode?.checked) {
apiKeyValidationRun += 1;
resetAccountLimits();
el.apiKeyState.textContent = ""; el.apiKeyState.textContent = "";
updateLimitHint();
renderFiles();
return; return;
} }
const value = el.apiKeyInput.value.trim(); const value = el.apiKeyInput.value.trim();
if (!value) { if (!value) {
apiKeyValidationRun += 1;
resetAccountLimits();
el.apiKeyState.textContent = "waiting"; el.apiKeyState.textContent = "waiting";
updateLimitHint();
renderFiles();
saveSettings(); saveSettings();
return; 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; el.apiKeyInput.disabled = true;
wrapper?.classList.add("is-checking"); wrapper?.classList.add("is-checking");
el.apiKeyState.textContent = "checking"; el.apiKeyState.textContent = "checking";
apiKeyTimer = setTimeout(() => { 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"); wrapper?.classList.remove("is-checking");
el.apiKeyInput.disabled = uploadLocked; el.apiKeyInput.disabled = uploadLocked;
if (validApiKey(value)) { if (!response.ok || !payload.user) {
el.apiKeyState.textContent = "saved locally"; resetAccountLimits();
saveSettings();
} else {
el.apiKeyInput.value = ""; el.apiKeyInput.value = "";
el.apiKeyState.textContent = "invalid"; el.apiKeyState.textContent = "invalid";
updateLimitHint();
renderFiles();
saveSettings(); saveSettings();
showToast("Invalid API key removed. Paste a valid API key to save it.", "warning"); 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();
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); }, 650);
} }

View File

@@ -44,16 +44,20 @@ const el = {
const uploadsEnabled = el.form?.dataset.uploadsEnabled === "true"; const uploadsEnabled = el.form?.dataset.uploadsEnabled === "true";
const defaultRetention = el.form?.dataset.defaultRetention || "10s"; const defaultRetention = el.form?.dataset.defaultRetention || "10s";
const maxFileBytes = numberFromDataset(el.form?.dataset.maxFileBytes); const baseMaxFileBytes = numberFromDataset(el.form?.dataset.maxFileBytes);
const maxBoxBytes = numberFromDataset(el.form?.dataset.maxBoxBytes); const baseMaxBoxBytes = numberFromDataset(el.form?.dataset.maxBoxBytes);
const oneTimeRetentionKey = "one-time"; const oneTimeRetentionKey = "one-time";
let maxFileBytes = baseMaxFileBytes;
let maxBoxBytes = baseMaxBoxBytes;
let files = []; let files = [];
let shareUrl = ""; let shareUrl = "";
let uploadLocked = false; let uploadLocked = false;
let statusTimer = null; let statusTimer = null;
let pendingDuplicateFiles = []; let pendingDuplicateFiles = [];
let apiKeyTimer = null; let apiKeyTimer = null;
let apiKeyValidationRun = 0;
let authenticatedUser = null;
let completedImpactKeys = new Set(); let completedImpactKeys = new Set();
let overallImpactDone = false; let overallImpactDone = false;
@@ -105,6 +109,33 @@ function hasQuotaError() {
return isOverBoxQuota() || oversizedFiles().length > 0; 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) { function normalizedFileName(name) {
return String(name || "").trim().toLowerCase(); return String(name || "").trim().toLowerCase();
} }

View File

@@ -14,13 +14,12 @@
<link rel="stylesheet" href="/static/css/admin.css"> <link rel="stylesheet" href="/static/css/admin.css">
</head> </head>
<body> <body>
{{ $d := .Dashboard }}
<div class="admin-shell"> <div class="admin-shell">
<div class="admin-frame"> <div class="admin-frame">
{{ template "admin/header.html" . }} {{ template "admin/header.html" . }}
<!-- Dashboard Window -->
<div class="win98-window admin-dashboard-window" role="main"> <div class="win98-window admin-dashboard-window" role="main">
<!-- Titlebar -->
<div class="win98-titlebar"> <div class="win98-titlebar">
<div class="win98-titlebar-label"> <div class="win98-titlebar-label">
<img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true"> <img class="win98-titlebar-icon" src="/static/WarpBoxLogo.png" alt="" aria-hidden="true">
@@ -33,7 +32,6 @@
</div> </div>
</div> </div>
<!-- Menu Bar -->
<nav class="menu-bar" aria-label="Dashboard toolbar"> <nav class="menu-bar" aria-label="Dashboard toolbar">
<div class="menu-item"> <div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">File</button> <button class="menu-button" type="button" aria-expanded="false">File</button>
@@ -57,83 +55,74 @@
<div class="menu-item"> <div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">Boxes</button> <button class="menu-button" type="button" aria-expanded="false">Boxes</button>
<div class="menu-popup"> <div class="menu-popup">
<button class="menu-action" type="button" data-command="show-all-boxes"><span>B</span><span>Show all boxes</span><span></span></button> <button class="menu-action" type="button" data-command="show-all-boxes"><span>B</span><span>Open boxes page</span><span></span></button>
<button class="menu-action" type="button" data-command="export-boxes"><span>C</span><span>Export boxes CSV</span><span></span></button> <button class="menu-action" type="button" data-command="export-boxes"><span>C</span><span>Export visible boxes CSV</span><span></span></button>
<button class="menu-action" type="button" data-command="cleanup-dry-run"><span>D</span><span>Cleanup dry run</span><span></span></button> <button class="menu-action" type="button" data-command="cleanup-expired"><span>D</span><span>Cleanup expired boxes</span><span></span></button>
</div> </div>
</div> </div>
<div class="menu-item"> <div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">Alerts</button> <button class="menu-button" type="button" aria-expanded="false">Alerts</button>
<div class="menu-popup"> <div class="menu-popup">
<button class="menu-action" type="button" data-command="show-all-alerts"><span>!</span><span>Show all alerts</span><span></span></button> <button class="menu-action" type="button" data-command="show-all-alerts"><span>!</span><span>Open alerts page</span><span></span></button>
<button class="menu-action" type="button" data-command="dismiss-low-alerts"><span>L</span><span>Close all low alerts</span><span></span></button> <button class="menu-action" type="button" data-command="close-low-alerts"><span>L</span><span>Close low alerts</span><span></span></button>
<button class="menu-action" type="button" data-command="export-alerts"><span>J</span><span>Export alerts JSON</span><span></span></button> <button class="menu-action" type="button" data-command="export-alerts"><span>J</span><span>Export visible alerts JSON</span><span></span></button>
</div> </div>
</div> </div>
<div class="menu-item"> <div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">Admin</button> <button class="menu-button" type="button" aria-expanded="false">Admin</button>
<div class="menu-popup"> <div class="menu-popup">
<button class="menu-action" type="button" data-command="config-snapshot"><span>S</span><span>Config snapshot</span><span></span></button>
<button class="menu-action" type="button" data-command="support-summary"><span>?</span><span>Support summary</span><span></span></button>
<button class="menu-action" type="button" data-command="thumbnail-rebuild"><span>I</span><span>Queue thumbnail rebuild</span><span></span></button>
<div class="menu-separator"></div>
<button class="menu-action" type="button" data-command="open-users"><span>U</span><span>Open user manager</span><span></span></button> <button class="menu-action" type="button" data-command="open-users"><span>U</span><span>Open user manager</span><span></span></button>
<button class="menu-action" type="button" data-command="open-activity"><span>A</span><span>Open activity log</span><span></span></button>
<button class="menu-action" type="button" data-command="open-settings"><span>G</span><span>Open settings</span><span></span></button> <button class="menu-action" type="button" data-command="open-settings"><span>G</span><span>Open settings</span><span></span></button>
</div> </div>
</div> </div>
<div class="menu-item"> <div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">Help</button> <button class="menu-button" type="button" aria-expanded="false">Help</button>
<div class="menu-popup"> <div class="menu-popup">
<button class="menu-action" type="button" data-command="alerts-help"><span>!</span><span>How alert tracing works</span><span></span></button>
<button class="menu-action" type="button" data-command="shortcuts"><span>K</span><span>Keyboard shortcuts</span><span></span></button> <button class="menu-action" type="button" data-command="shortcuts"><span>K</span><span>Keyboard shortcuts</span><span></span></button>
<button class="menu-action" type="button" data-command="about"><span>W</span><span>About this mockup</span><span></span></button> <button class="menu-action" type="button" data-command="about"><span>W</span><span>About this dashboard</span><span></span></button>
</div> </div>
</div> </div>
</nav> </nav>
<!-- Dashboard Body -->
<div class="dashboard-body"> <div class="dashboard-body">
<!-- Hero -->
<section class="dashboard-hero raised-panel" aria-labelledby="dashboardTitle"> <section class="dashboard-hero raised-panel" aria-labelledby="dashboardTitle">
<div class="hero-copy"> <div class="hero-copy">
<h2 id="dashboardTitle">Dashboard</h2> <h2 id="dashboardTitle">Dashboard</h2>
<p>At-a-glance account and admin overview for boxes, alerts, storage, users, and recent activity.</p> <p>Live overview for boxes, alerts, storage, users, and recent account activity.</p>
</div> </div>
<div class="hero-status" aria-label="System summary"> <div class="hero-status" aria-label="System summary">
<div class="hero-status-row"><span>Guest uploads</span><strong class="status-ok">enabled</strong></div> <div class="hero-status-row"><span>Guest uploads</span><strong class="{{ if eq $d.GuestUploadsLabel "enabled" }}status-ok{{ else }}status-danger{{ end }}">{{ $d.GuestUploadsLabel }}</strong></div>
<div class="hero-status-row"><span>ZIP downloads</span><strong class="status-ok">enabled</strong></div> <div class="hero-status-row"><span>API uploads</span><strong class="{{ if eq $d.APIUploadsLabel "enabled" }}status-ok{{ else }}status-danger{{ end }}">{{ $d.APIUploadsLabel }}</strong></div>
<div class="hero-status-row"><span>One-time boxes</span><strong class="status-warn">limited</strong></div> <div class="hero-status-row"><span>ZIP downloads</span><strong class="{{ if eq $d.ZipDownloadsLabel "enabled" }}status-ok{{ else }}status-warn{{ end }}">{{ $d.ZipDownloadsLabel }}</strong></div>
</div> </div>
</section> </section>
<!-- Stats -->
<section class="stats-grid" aria-label="Dashboard statistics"> <section class="stats-grid" aria-label="Dashboard statistics">
<article class="stat-card sunken-panel is-info" id="activeBoxesCard"> <article class="stat-card sunken-panel is-info" id="activeBoxesCard">
<p class="stat-label">Active boxes</p> <p class="stat-label">Active boxes</p>
<p class="stat-value">128</p> <p class="stat-value">{{ $d.ActiveBoxes }}</p>
<p class="stat-note"><span class="stat-note-pill">+12 today</span><span class="stat-note-pill">42 passworded</span></p> <p class="stat-note"><span class="stat-note-pill">+{{ $d.BoxesCreatedToday }} today</span><span class="stat-note-pill">{{ $d.PasswordedBoxes }} passworded</span></p>
</article> </article>
<article class="stat-card sunken-panel is-info" id="storageCard"> <article class="stat-card sunken-panel is-info" id="storageCard">
<p class="stat-label">Storage available</p> <p class="stat-label">Storage available</p>
<p class="stat-value">812 GiB</p> <p class="stat-value">{{ $d.StorageFreeLabel }}</p>
<p class="stat-note"><span class="stat-note-pill">188 GiB used</span><span class="stat-note-pill">1 TiB app cap</span><span class="stat-note-pill">local backend</span></p> <p class="stat-note"><span class="stat-note-pill">{{ $d.StorageUsedLabel }} used</span><span class="stat-note-pill">{{ $d.StorageCapLabel }} cap</span><span class="stat-note-pill">{{ $d.StorageBackend }} backend</span></p>
<span class="meter-track" aria-hidden="true"><span class="meter-bar" style="--meter: 18.8%"></span></span> <span class="meter-track" aria-hidden="true"><span class="meter-bar" style="--meter: {{ $d.StorageMeter }}"></span></span>
</article> </article>
<article class="stat-card sunken-panel is-warning" id="alertsCard"> <article class="stat-card sunken-panel is-warning" id="alertsCard">
<p class="stat-label">Alerts</p> <p class="stat-label">Alerts</p>
<p class="stat-value"><span id="alertCountValue">15</span></p> <p class="stat-value"><span id="alertCountValue">{{ $d.OpenAlerts }}</span></p>
<p class="stat-note" id="alertStatNote"><span class="stat-note-pill">2 high</span><span class="stat-note-pill">5 medium</span><span class="stat-note-pill">8 low</span></p> <p class="stat-note" id="alertStatNote"><span class="stat-note-pill">{{ $d.HighAlerts }} high</span><span class="stat-note-pill">{{ $d.MediumAlerts }} medium</span><span class="stat-note-pill">{{ $d.LowAlerts }} low</span></p>
</article> </article>
<article class="stat-card sunken-panel is-ok" id="usersCard"> <article class="stat-card sunken-panel is-ok" id="usersCard">
<p class="stat-label">Users</p> <p class="stat-label">Users</p>
<p class="stat-value">19</p> <p class="stat-value">{{ $d.TotalUsers }}</p>
<p class="stat-note"><span class="stat-note-pill">15 active</span><span class="stat-note-pill">4 disabled</span><span class="stat-note-pill">admin-only</span></p> <p class="stat-note"><span class="stat-note-pill">{{ $d.ActiveUsers }} active</span><span class="stat-note-pill">{{ $d.DisabledUsers }} disabled</span><span class="stat-note-pill">{{ $d.APIKeyCount }} API keys</span></p>
</article> </article>
</section> </section>
<!-- Main Grid: Alerts, Boxes, Activity -->
<section class="dashboard-main-grid" aria-label="Dashboard panels"> <section class="dashboard-main-grid" aria-label="Dashboard panels">
<!-- Alerts -->
<article id="alerts" class="win98-window section-window"> <article id="alerts" class="win98-window section-window">
<div class="win98-titlebar"> <div class="win98-titlebar">
<div class="win98-titlebar-label"> <div class="win98-titlebar-label">
@@ -147,87 +136,29 @@
<div class="section-body sunken-panel"> <div class="section-body sunken-panel">
<div class="scroll-panel alerts-scroll" aria-label="Scrollable alerts inbox"> <div class="scroll-panel alerts-scroll" aria-label="Scrollable alerts inbox">
<div class="alert-list"> <div class="alert-list">
<div class="alert-row" data-severity="high" data-alert-title="Storage backend is almost full" data-alert-code="421" data-alert-meta='{"backend":"local","used_bytes":1009317314560,"available_bytes":45097156608,"configured_cap_bytes":1099511627776,"recommended_action":"run cleanup dry run or raise app cap"}'> {{ if $d.Alerts }}
<span class="alert-severity">high</span> {{ range $d.Alerts }}
<div><p class="alert-title">Storage backend is almost full</p><p class="alert-desc">The active local storage backend has less than 5% free capacity under the configured app cap.</p><p class="alert-trace">code 421, trace storage.local.capacity.high</p></div> <div class="alert-row" data-alert-id="{{ .ID }}" data-severity="{{ .Severity }}" data-alert-title="{{ .Title }}" data-alert-code="{{ .Code }}" data-alert-meta='{{ toJSON .Meta }}'>
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div> <span class="alert-severity">{{ .Severity }}</span>
<div>
<p class="alert-title">{{ .Title }}</p>
<p class="alert-desc">{{ .Message }}</p>
<p class="alert-trace">{{ .Code }} {{ .Trace }} · {{ .CreatedAtLabel }} UTC · {{ .Status }}</p>
</div> </div>
<div class="alert-row" data-severity="high" data-alert-title="Disabled user has active sessions" data-alert-code="181" data-alert-meta='{"user":"old-operator","active_sessions":2,"recommended_action":"revoke sessions"}'> <div class="alert-actions">
<span class="alert-severity">high</span> <button class="tiny-button" type="button" data-view-meta>Meta</button>
<div><p class="alert-title">Disabled user has active sessions</p><p class="alert-desc">A disabled account still has active sessions that should be revoked.</p><p class="alert-trace">code 181, trace auth.sessions.disabled_user_active</p></div> <button class="tiny-button" type="button" data-close-alert>Close</button>
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
</div> </div>
<div class="alert-row" data-severity="medium" data-alert-title="Expired boxes waiting cleanup" data-alert-code="301" data-alert-meta='{"expired_boxes":17,"oldest_expired_at":"2026-04-29T22:18:00+03:00","recommended_action":"run cleanup"}'>
<span class="alert-severity">medium</span>
<div><p class="alert-title">Expired boxes waiting cleanup</p><p class="alert-desc">Expired boxes are still present on disk and are eligible for cleanup.</p><p class="alert-trace">code 301, trace boxes.expiry.cleanup_pending</p></div>
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
</div>
<div class="alert-row" data-severity="medium" data-alert-title="API key UI enabled but key backend missing" data-alert-code="711" data-alert-meta='{"ui_surface":"upload.api_key_input","backend_model":"missing","recommended_action":"hide UI or implement API keys"}'>
<span class="alert-severity">medium</span>
<div><p class="alert-title">API key UI enabled but key backend missing</p><p class="alert-desc">The frontend advertises API key usage while server-side API key validation is not connected yet.</p><p class="alert-trace">code 711, trace api_keys.ui.backend_missing</p></div>
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
</div>
<div class="alert-row" data-severity="medium" data-alert-title="Thumbnail queue is behind" data-alert-code="602" data-alert-meta='{"pending_thumbnails":44,"worker_interval_seconds":30,"recommended_action":"increase batch size or queue rebuild"}'>
<span class="alert-severity">medium</span>
<div><p class="alert-title">Thumbnail queue is behind</p><p class="alert-desc">The thumbnail worker has accumulated more pending previews than expected.</p><p class="alert-trace">code 602, trace thumbnails.worker.queue_lag</p></div>
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
</div>
<div class="alert-row" data-severity="medium" data-alert-title="Large ZIP download failed" data-alert-code="502" data-alert-meta='{"box":"BX-7D20","zip_bytes":897300992,"attempt":1,"recommended_action":"retry manually or inspect files"}'>
<span class="alert-severity">medium</span>
<div><p class="alert-title">Large ZIP download failed</p><p class="alert-desc">A ZIP stream failed before the response finished.</p><p class="alert-trace">code 502, trace downloads.zip.stream_failed</p></div>
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
</div>
<div class="alert-row" data-severity="medium" data-alert-title="Guest quota close to daily cap" data-alert-code="231" data-alert-meta='{"ip":"192.0.2.44","used_today_bytes":1795162112,"daily_cap_bytes":2147483648,"recommended_action":"none"}'>
<span class="alert-severity">medium</span>
<div><p class="alert-title">Guest quota close to daily cap</p><p class="alert-desc">A guest IP is close to its configured daily upload cap.</p><p class="alert-trace">code 231, trace quotas.guest.daily.near_cap</p></div>
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
</div>
<div class="alert-row" data-severity="low" data-alert-title="Thumbnail generation skipped" data-alert-code="601" data-alert-meta='{"box":"BX-9F31","file":"mockup.webp","reason":"unsupported decoder","recommended_action":"none"}'>
<span class="alert-severity">low</span>
<div><p class="alert-title">Thumbnail generation skipped</p><p class="alert-desc">A preview could not be generated for one image file.</p><p class="alert-trace">code 601, trace thumbnails.generate.skipped</p></div>
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
</div>
<div class="alert-row" data-severity="low" data-alert-title="One-time box downloaded" data-alert-code="511" data-alert-meta='{"box":"BX-440C","delete_after_success":true,"recommended_action":"none"}'>
<span class="alert-severity">low</span>
<div><p class="alert-title">One-time box downloaded</p><p class="alert-desc">A one-time ZIP handoff completed and the box was queued for deletion.</p><p class="alert-trace">code 511, trace downloads.one_time.completed</p></div>
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
</div>
<div class="alert-row" data-severity="low" data-alert-title="Settings override changed" data-alert-code="801" data-alert-meta='{"setting":"box_poll_interval_ms","source":"admin_override","recommended_action":"audit when audit log exists"}'>
<span class="alert-severity">low</span>
<div><p class="alert-title">Settings override changed</p><p class="alert-desc">A runtime setting was changed through the settings UI.</p><p class="alert-trace">code 801, trace settings.override.changed</p></div>
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
</div>
<div class="alert-row" data-severity="low" data-alert-title="Password protected box created" data-alert-code="121" data-alert-meta='{"box":"BX-C2A8","owner":"maya","recommended_action":"none"}'>
<span class="alert-severity">low</span>
<div><p class="alert-title">Password protected box created</p><p class="alert-desc">A user created a password protected upload box.</p><p class="alert-trace">code 121, trace boxes.create.passworded</p></div>
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
</div>
<div class="alert-row" data-severity="low" data-alert-title="Upload completed slowly" data-alert-code="222" data-alert-meta='{"box":"BX-88B4","duration_seconds":731,"recommended_action":"none"}'>
<span class="alert-severity">low</span>
<div><p class="alert-title">Upload completed slowly</p><p class="alert-desc">An upload completed but exceeded the expected duration threshold.</p><p class="alert-trace">code 222, trace uploads.performance.slow_complete</p></div>
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
</div>
<div class="alert-row" data-severity="low" data-alert-title="Session refreshed" data-alert-code="182" data-alert-meta='{"user":"admin","reason":"activity_refresh","recommended_action":"none"}'>
<span class="alert-severity">low</span>
<div><p class="alert-title">Session refreshed</p><p class="alert-desc">The current local session was refreshed after account activity.</p><p class="alert-trace">code 182, trace auth.session.refreshed</p></div>
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
</div>
<div class="alert-row" data-severity="low" data-alert-title="Box visited from share URL" data-alert-code="401" data-alert-meta='{"box":"BX-39C1","viewer":"guest","recommended_action":"none"}'>
<span class="alert-severity">low</span>
<div><p class="alert-title">Box visited from share URL</p><p class="alert-desc">A public box was opened through its normal shared page.</p><p class="alert-trace">code 401, trace boxes.share.opened</p></div>
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
</div>
<div class="alert-row" data-severity="low" data-alert-title="Support summary generated" data-alert-code="901" data-alert-meta='{"requested_by":"admin","included_sections":["config","storage","alerts"],"recommended_action":"none"}'>
<span class="alert-severity">low</span>
<div><p class="alert-title">Support summary generated</p><p class="alert-desc">A local support summary was generated from the toolbar.</p><p class="alert-trace">code 901, trace support.summary.generated</p></div>
<div class="alert-actions"><button class="tiny-button" type="button" data-view-meta>Meta</button><button class="tiny-button" type="button" data-dismiss-alert>Close</button></div>
</div> </div>
{{ end }}
{{ else }}
<div class="dashboard-empty-state">No open alerts. Nice and boring, which is the good kind of admin dashboard.</div>
{{ end }}
</div> </div>
</div> </div>
</div> </div>
</article> </article>
<!-- Recent Activity -->
<article id="recent-activity" class="win98-window section-window"> <article id="recent-activity" class="win98-window section-window">
<div class="win98-titlebar"> <div class="win98-titlebar">
<div class="win98-titlebar-label"> <div class="win98-titlebar-label">
@@ -235,33 +166,31 @@
<h2>Recent Activity</h2> <h2>Recent Activity</h2>
</div> </div>
<div class="titlebar-actions"> <div class="titlebar-actions">
<a class="titlebar-link-button" href="/admin/dashboard#recent-activity">Show all</a> <a class="titlebar-link-button" href="/admin/activity">Show all</a>
</div> </div>
</div> </div>
<div class="section-body sunken-panel"> <div class="section-body sunken-panel">
<div class="scroll-panel activity-scroll" aria-label="Scrollable recent activity list"> <div class="scroll-panel activity-scroll" aria-label="Scrollable recent activity list">
<div class="activity-list"> <div class="activity-list">
<div class="activity-row"><span class="activity-time">10:12</span><div><p class="activity-title">Box BX-9F31 completed upload</p><p class="activity-meta">4 files, password protected</p></div><span class="tag ok">box</span></div> {{ if $d.Events }}
<div class="activity-row"><span class="activity-time">10:08</span><div><p class="activity-title">Alert 421 created</p><p class="activity-meta">storage.local.capacity.high</p></div><span class="tag danger">alert</span></div> {{ range $d.Events }}
<div class="activity-row"><span class="activity-time">10:04</span><div><p class="activity-title">Guest created box BX-A71D</p><p class="activity-meta">retention 6 hours</p></div><span class="tag ok">upload</span></div> <div class="activity-row">
<div class="activity-row"><span class="activity-time">09:58</span><div><p class="activity-title">Thumbnail worker skipped one image</p><p class="activity-meta">decoder unavailable for webp preview</p></div><span class="tag warn">thumbs</span></div> <span class="activity-time">{{ .CreatedAtLabel }}</span>
<div class="activity-row"><span class="activity-time">09:51</span><div><p class="activity-title">Cleanup dry run opened</p><p class="activity-meta">17 expired boxes detected</p></div><span class="tag info">tools</span></div> <div>
<div class="activity-row"><span class="activity-time">09:44</span><div><p class="activity-title">Large ZIP download completed</p><p class="activity-meta">BX-7D20, 12 files</p></div><span class="tag info">zip</span></div> <p class="activity-title">{{ .Message }}</p>
<div class="activity-row"><span class="activity-time">09:33</span><div><p class="activity-title">Settings snapshot requested</p><p class="activity-meta">admin opened config snapshot from toolbar</p></div><span class="tag info">settings</span></div> <p class="activity-meta">{{ .Kind }} · {{ .Method }} {{ .Path }} {{ if .IP }}· {{ .IP }}{{ end }}</p>
<div class="activity-row"><span class="activity-time">09:21</span><div><p class="activity-title">Temporary cleanup skipped</p><p class="activity-meta">BX-1AA2 still had an active file handle</p></div><span class="tag warn">cleanup</span></div> </div>
<div class="activity-row"><span class="activity-time">09:09</span><div><p class="activity-title">User maya uploaded 6 files</p><p class="activity-meta">91.9 MiB total</p></div><span class="tag ok">user</span></div> <span class="tag {{ .TagClass }}">{{ .TagLabel }}</span>
<div class="activity-row"><span class="activity-time">08:55</span><div><p class="activity-title">Box BX-55E0 expired</p><p class="activity-meta">eligible for cleanup</p></div><span class="tag danger">expired</span></div> </div>
<div class="activity-row"><span class="activity-time">08:42</span><div><p class="activity-title">One-time box created</p><p class="activity-meta">BX-440C, admin owner</p></div><span class="tag info">one-time</span></div> {{ end }}
<div class="activity-row"><span class="activity-time">08:31</span><div><p class="activity-title">User ana uploaded archive set</p><p class="activity-meta">7 files, 520.8 MiB</p></div><span class="tag ok">upload</span></div> {{ else }}
<div class="activity-row"><span class="activity-time">08:20</span><div><p class="activity-title">Guest accessed public box</p><p class="activity-meta">BX-39C1 viewed from share link</p></div><span class="tag info">access</span></div> <div class="dashboard-empty-state">No activity has been recorded yet.</div>
<div class="activity-row"><span class="activity-time">08:07</span><div><p class="activity-title">User mihai created box BX-F02A</p><p class="activity-meta">standard plan quota applied</p></div><span class="tag ok">quota</span></div> {{ end }}
<div class="activity-row"><span class="activity-time">07:54</span><div><p class="activity-title">Failed login attempt recorded</p><p class="activity-meta">admin account, single attempt</p></div><span class="tag warn">auth</span></div>
</div> </div>
</div> </div>
</div> </div>
</article> </article>
<!-- Recent Boxes (full width) -->
<article id="recent-boxes" class="win98-window section-window dashboard-span-2"> <article id="recent-boxes" class="win98-window section-window dashboard-span-2">
<div class="win98-titlebar"> <div class="win98-titlebar">
<div class="win98-titlebar-label"> <div class="win98-titlebar-label">
@@ -269,29 +198,33 @@
<h2>Recent Boxes</h2> <h2>Recent Boxes</h2>
</div> </div>
<div class="titlebar-actions"> <div class="titlebar-actions">
<a class="titlebar-link-button" href="/admin/dashboard#recent-boxes">Show all</a> <a class="titlebar-link-button" href="/admin/boxes">Show all</a>
</div> </div>
</div> </div>
<div class="section-body sunken-panel"> <div class="section-body sunken-panel">
<div class="scroll-panel boxes-scroll" aria-label="Scrollable recent boxes table"> <div class="scroll-panel boxes-scroll" aria-label="Scrollable recent boxes table">
<table class="box-table"> <table class="box-table">
<thead><tr><th>Box</th><th>Owner</th><th>Files</th><th>Size</th><th>Created</th><th>Expires</th><th>Flags</th><th>Actions</th></tr></thead> <thead><tr><th>Box</th><th>Status</th><th>Files</th><th>Size</th><th>Created</th><th>Expires</th><th>Flags</th><th>Actions</th></tr></thead>
<tbody> <tbody>
<tr><td>BX-9F31</td><td>maya</td><td>4</td><td>91.9 MiB</td><td>10:12</td><td>5h 41m</td><td><span class="tag ok">complete</span> <span class="tag info">password</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-9F31">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-9F31">Manage</a></div></td></tr> {{ if $d.Boxes }}
<tr><td>BX-A71D</td><td>guest</td><td>12</td><td>1.8 GiB</td><td>10:04</td><td>6h 00m</td><td><span class="tag warn">large</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-A71D">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-A71D">Manage</a></div></td></tr> {{ range $d.Boxes }}
<tr><td>BX-20BD</td><td>operator</td><td>2</td><td>8.4 MiB</td><td>09:58</td><td>1d 12h</td><td><span class="tag ok">complete</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-20BD">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-20BD">Manage</a></div></td></tr> <tr data-box-id="{{ .ID }}">
<tr><td>BX-7D20</td><td>admin</td><td>12</td><td>856.3 MiB</td><td>09:44</td><td>23h 11m</td><td><span class="tag danger">zip failed</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-7D20">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-7D20">Manage</a></div></td></tr> <td>{{ .ID }}</td>
<tr><td>BX-1AA2</td><td>guest</td><td>1</td><td>4.7 GiB</td><td>09:21</td><td>expired</td><td><span class="tag danger">locked</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-1AA2">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-1AA2">Manage</a></div></td></tr> <td><span class="tag {{ .StatusClass }}">{{ .StatusLabel }}</span></td>
<tr><td>BX-C2A8</td><td>maya</td><td>6</td><td>24.8 MiB</td><td>09:09</td><td>2d 03h</td><td><span class="tag ok">complete</span> <span class="tag info">password</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-C2A8">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-C2A8">Manage</a></div></td></tr> <td>{{ .CompleteFiles }}/{{ .FileCount }}</td>
<tr><td>BX-55E0</td><td>guest</td><td>1</td><td>4.2 MiB</td><td>08:55</td><td>expired</td><td><span class="tag danger">expired</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-55E0">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-55E0">Manage</a></div></td></tr> <td>{{ .TotalSizeLabel }}</td>
<tr><td>BX-440C</td><td>admin</td><td>3</td><td>63.0 MiB</td><td>08:42</td><td>2d 00h</td><td><span class="tag ok">complete</span> <span class="tag info">one-time</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-440C">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-440C">Manage</a></div></td></tr> <td>{{ .CreatedAtLabel }}</td>
<tr><td>BX-88B4</td><td>ana</td><td>7</td><td>520.8 MiB</td><td>08:31</td><td>5d 00h</td><td><span class="tag ok">complete</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-88B4">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-88B4">Manage</a></div></td></tr> <td>{{ .ExpiresAtLabel }}</td>
<tr><td>BX-39C1</td><td>guest</td><td>2</td><td>23.1 MiB</td><td>08:20</td><td>16h 00m</td><td><span class="tag info">public</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-39C1">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-39C1">Manage</a></div></td></tr> <td>
<tr><td>BX-F02A</td><td>mihai</td><td>5</td><td>108.6 MiB</td><td>08:07</td><td>4d 00h</td><td><span class="tag ok">complete</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-F02A">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-F02A">Manage</a></div></td></tr> {{ range .Flags }}<span class="tag info">{{ . }}</span>{{ end }}
<tr><td>BX-ABC4</td><td>guest</td><td>1</td><td>755 KiB</td><td>07:54</td><td>3h 00m</td><td><span class="tag ok">complete</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-ABC4">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-ABC4">Manage</a></div></td></tr> {{ if not .Flags }}<span class="tag ok">plain</span>{{ end }}
<tr><td>BX-74E9</td><td>operator</td><td>10</td><td>987.3 MiB</td><td>07:41</td><td>7d 00h</td><td><span class="tag info">bulk</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-74E9">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-74E9">Manage</a></div></td></tr> </td>
<tr><td>BX-218B</td><td>daniel</td><td>3</td><td>44.0 MiB</td><td>07:28</td><td>1d 00h</td><td><span class="tag ok">complete</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-218B">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-218B">Manage</a></div></td></tr> <td><div class="box-actions"><a class="win98-button box-action-button" href="{{ .OpenURL }}">Open</a><a class="win98-button box-action-button" href="/admin/boxes?q={{ .ID }}">Manage</a></div></td>
<tr><td>BX-00FE</td><td>guest</td><td>2</td><td>13.7 MiB</td><td>07:12</td><td>2h 00m</td><td><span class="tag warn">soon</span></td><td><div class="box-actions"><a class="win98-button box-action-button" href="/box/BX-00FE">Open</a><a class="win98-button box-action-button" href="/account/boxes/BX-00FE">Manage</a></div></td></tr> </tr>
{{ end }}
{{ else }}
<tr><td colspan="8">No boxes found yet.</td></tr>
{{ end }}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -300,20 +233,17 @@
</section> </section>
</div> </div>
<!-- Statusbar -->
<div class="win98-statusbar admin-dashboard-statusbar"> <div class="win98-statusbar admin-dashboard-statusbar">
<span id="statusText">Ready</span> <span id="statusText">Ready</span>
<span>WarpBox mock v5</span> <span>{{ $d.OpenAlerts }} open alert(s)</span>
<span>Single-window dashboard</span> <span>Live dashboard</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Modal backdrop -->
<div class="modal-backdrop" data-modal-backdrop></div> <div class="modal-backdrop" data-modal-backdrop></div>
<!-- Alert metadata popup -->
<aside class="popup-window win98-window" data-alert-modal aria-label="Alert metadata" aria-hidden="true"> <aside class="popup-window win98-window" data-alert-modal aria-label="Alert metadata" aria-hidden="true">
<div class="win98-titlebar"> <div class="win98-titlebar">
<div class="win98-titlebar-label"> <div class="win98-titlebar-label">
@@ -327,9 +257,9 @@
</div> </div>
</aside> </aside>
<!-- Toast -->
<div class="toast" id="toast" role="status" aria-live="polite"></div> <div class="toast" id="toast" role="status" aria-live="polite"></div>
<script id="dashboard-data" type="application/json">{{ toJSON $d }}</script>
<script src="/static/js/warpbox-ui.js"></script> <script src="/static/js/warpbox-ui.js"></script>
<script src="/static/js/admin/dashboard.js"></script> <script src="/static/js/admin/dashboard.js"></script>
</body> </body>

View File

@@ -14,7 +14,7 @@
<a class="admin-taskbar-button{{ if eq .ActivePage "settings" }} is-active{{ end }}" href="/admin/settings">Settings</a> <a class="admin-taskbar-button{{ if eq .ActivePage "settings" }} is-active{{ end }}" href="/admin/settings">Settings</a>
</nav> </nav>
<div class="admin-taskbar-session" aria-label="Admin session summary"> <div class="admin-taskbar-session" aria-label="Admin session summary">
<a class="admin-alert-chip is-warning" href="/admin/alerts" id="topAlertChip">! 15 alerts</a> <a class="admin-alert-chip {{ with .AlertChipClass }}{{ . }}{{ else }}is-info{{ end }}" href="/admin/alerts" id="topAlertChip">{{ with .AlertChipLabel }}{{ . }}{{ else }}Alerts{{ end }}</a>
<span class="admin-session-chip">signed in: {{ .AdminUsername }}</span> <span class="admin-session-chip">signed in: {{ .AdminUsername }}</span>
</div> </div>
</header> </header>

View File

@@ -35,100 +35,111 @@
<div class="menu-item"> <div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">File</button> <button class="menu-button" type="button" aria-expanded="false">File</button>
<div class="menu-popup"> <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="tab-add"><span>N</span><span>New user</span><span></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> <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> <button class="menu-action" type="button" data-command="refresh"><span>R</span><span>Refresh users</span><span>F5</span></button>
</div> </div>
</div> </div>
<div class="menu-item"> <div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">Users</button> <button class="menu-button" type="button" aria-expanded="false">Users</button>
<div class="menu-popup"> <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-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-enable"><span>E</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> <button class="menu-action" type="button" data-command="bulk-delete"><span>X</span><span>Delete selected</span><span></span></button>
</div> </div>
</div> </div>
<div class="menu-item"> <div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">View</button> <button class="menu-button" type="button" aria-expanded="false">View</button>
<div class="menu-popup"> <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="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> <button class="menu-action" type="button" data-command="clear-filters"><span>X</span><span>Clear filters</span><span></span></button>
</div> </div>
</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> </nav>
<div class="admin-workspace-body users-page-body"> <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"> <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-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-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-warning"><p>With API keys</p><strong id="stat-keys">0</strong></article>
<article class="users-stat-card is-danger"><p>Disabled</p><strong id="stat-disabled">0</strong></article> <article class="users-stat-card is-danger"><p>Disabled</p><strong id="stat-disabled">0</strong></article>
</section> </section>
<section class="users-main-grid"> <section class="users-main-grid">
<section class="users-panel"> <aside class="users-control-panel" aria-label="User actions">
<div class="users-panel-header"> <div class="users-selected-card">
<div class="users-panel-title">Create or invite <span>mock only</span></div> <span>Selected user</span>
<strong id="selected-user-name">None</strong>
<small id="selected-user-meta">Choose a row to edit policies and keys.</small>
</div> </div>
<div class="users-panel-body"> <div class="users-side-tabs" role="tablist" aria-label="User panels">
<form id="users-form" class="users-form-grid"> <button class="users-tab is-active" type="button" data-tab="add">Add New</button>
<label class="users-field">Mode <button class="users-tab" type="button" data-tab="edit">Edit</button>
<select class="users-select" id="users-mode"> <button class="users-tab" type="button" data-tab="policies">Policies</button>
<option value="invite">Send invite</option> <button class="users-tab" type="button" data-tab="keys">API Keys</button>
<option value="create">Create local user</option> </div>
</select>
</label> <section class="users-tab-panel is-active" data-panel="add">
<label class="users-field">Username<input class="users-input" id="users-username" type="text" autocomplete="off"></label> <div class="users-panel-header compact"><div class="users-panel-title">Add New</div></div>
<label class="users-field">Email<input class="users-input" id="users-email" type="email" autocomplete="off"></label> <form id="add-user-form" class="users-form-grid">
<label class="users-field">Username<input class="users-input" id="add-username" type="text" autocomplete="off"></label>
<label class="users-field">Email<input class="users-input" id="add-email" type="email" autocomplete="off"></label>
<div class="users-row-two"> <div class="users-row-two">
<label class="users-field">Role <label class="users-field">Status<select class="users-select" id="add-status"><option value="active">active</option><option value="disabled">disabled</option></select></label>
<select class="users-select" id="users-role"> <label class="users-field">Max file MB<input class="users-input" id="add-max-file" type="number" min="0" step="1" value="0"></label>
<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> </div>
<label class="users-check"><input type="checkbox" id="users-send-setup" checked>Send setup instructions</label> <label class="users-field">Max box MB<input class="users-input" id="add-max-box" type="number" min="0" step="1" value="0"></label>
<label class="users-check"><input type="checkbox" id="add-perm-web" checked>Allow web session login</label>
<label class="users-check"><input type="checkbox" id="add-perm-api" checked>Allow API access</label>
<label class="users-check"><input type="checkbox" id="add-perm-create" checked>Allow box creation</label>
<label class="users-check"><input type="checkbox" id="add-perm-upload" checked>Allow file uploads</label>
<div class="users-form-actions"> <div class="users-form-actions">
<button class="win98-button users-action-button" type="reset">Clear</button> <button class="win98-button users-action-button" type="reset">Clear</button>
<button class="win98-button users-action-button" type="submit">Apply</button> <button class="win98-button users-action-button" type="submit">Create User</button>
</div> </div>
</form> </form>
</div>
</section> </section>
<section class="users-tab-panel" data-panel="edit">
<div class="users-panel-header compact"><div class="users-panel-title">Edit Identity</div></div>
<form id="edit-user-form" class="users-form-grid">
<label class="users-field">Username<input class="users-input" id="edit-username" type="text" autocomplete="off" disabled></label>
<label class="users-field">Email<input class="users-input" id="edit-email" type="email" autocomplete="off" disabled></label>
<label class="users-field">Status<select class="users-select" id="edit-status" disabled><option value="active">active</option><option value="disabled">disabled</option></select></label>
<div class="users-form-actions">
<button class="win98-button users-action-button" type="button" id="delete-user-button" disabled>Delete</button>
<button class="win98-button users-action-button" type="submit" disabled id="save-edit-button">Save</button>
</div>
</form>
</section>
<section class="users-tab-panel" data-panel="policies">
<div class="users-panel-header compact"><div class="users-panel-title">Policies</div></div>
<form id="policies-form" class="users-form-grid">
<label class="users-field">Max file MB<input class="users-input" id="policy-max-file" type="number" min="0" step="1" disabled></label>
<label class="users-field">Max box MB<input class="users-input" id="policy-max-box" type="number" min="0" step="1" disabled></label>
<label class="users-check"><input type="checkbox" id="policy-perm-web" disabled>Allow web session login</label>
<label class="users-check"><input type="checkbox" id="policy-perm-api" disabled>Allow API access</label>
<label class="users-check"><input type="checkbox" id="policy-perm-create" disabled>Allow box creation</label>
<label class="users-check"><input type="checkbox" id="policy-perm-upload" disabled>Allow file uploads</label>
<div class="users-form-actions"><button class="win98-button users-action-button" type="submit" disabled id="save-policies-button">Save Policies</button></div>
</form>
</section>
<section class="users-tab-panel" data-panel="keys">
<div class="users-panel-header compact"><div class="users-panel-title">API Keys</div></div>
<form id="api-key-form" class="users-form-grid">
<label class="users-field">Key name<input class="users-input" id="api-key-name" type="text" value="default" disabled></label>
<button class="win98-button users-action-button" type="submit" disabled id="create-key-button">Generate Key</button>
</form>
<div class="users-key-reveal" id="api-key-reveal" hidden>
<span>New API key</span>
<input class="users-input" id="api-key-value" type="text" readonly>
</div>
<div class="users-key-list" id="api-key-list"></div>
</section>
</aside>
<section class="users-panel"> <section class="users-panel">
<div class="users-panel-header"> <div class="users-panel-header">
<div class="users-panel-title">Users <span id="visible-pill">0 visible</span></div> <div class="users-panel-title">Users <span id="visible-pill">0 visible</span></div>
@@ -136,15 +147,14 @@
<button class="win98-button users-tool-button" type="button" id="select-visible">Select visible</button> <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-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-enable">Enable</button>
<button class="win98-button users-tool-button" type="button" data-command="bulk-revoke">Revoke</button> <button class="win98-button users-tool-button" type="button" data-command="bulk-delete">Delete</button>
</div> </div>
</div> </div>
<div class="users-panel-body users-list-body"> <div class="users-panel-body users-list-body">
<div class="users-toolbar-grid"> <div class="users-toolbar-grid">
<input class="users-input" id="users-search" type="search" placeholder="Search username or email"> <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-status"><option value="all">all statuses</option><option value="active">active</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="keysDesc">api keys</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> <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>
<div class="users-table-wrap"> <div class="users-table-wrap">
@@ -155,9 +165,9 @@
<th>User</th> <th>User</th>
<th>Email</th> <th>Email</th>
<th>Status</th> <th>Status</th>
<th>Role</th> <th>Permissions</th>
<th>Plan</th> <th>Limits</th>
<th>Boxes</th> <th>Keys</th>
<th>Last seen</th> <th>Last seen</th>
<th class="users-col-actions">Actions</th> <th class="users-col-actions">Actions</th>
</tr> </tr>
@@ -179,15 +189,15 @@
</div> </div>
<footer class="status-bar admin-dashboard-statusbar"> <footer class="status-bar admin-dashboard-statusbar">
<span id="users-status-left">Ready. Client-side mock data only.</span> <span id="users-status-left">Ready.</span>
<span>server paging planned</span> <span>real user store</span>
<span>admin only</span> <span>admin only</span>
</footer> </footer>
</div> </div>
</div> </div>
</div> </div>
<div id="toast" class="wb-toast" role="status" aria-live="polite"></div> <div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="/static/js/warpbox-ui.js"></script> <script src="/static/js/warpbox-ui.js"></script>
<script src="/static/js/admin/users.js"></script> <script src="/static/js/admin/users.js"></script>
</body> </body>

View File

@@ -126,7 +126,7 @@
<div class="win98-statusbar upload-statusbar"> <div class="win98-statusbar upload-statusbar">
<span id="status-text">{{ if .UploadsEnabled }}Ready · drag files anywhere onto the window{{ else }}Guest uploads are disabled{{ end }}</span> <span id="status-text">{{ if .UploadsEnabled }}Ready · drag files anywhere onto the window{{ else }}Guest uploads are disabled{{ end }}</span>
<span>WarpBox</span> <span>WarpBox</span>
<span>v{{ .AppVersion }}</span> <span>{{ .AppVersion }}</span>
</div> </div>
</section> </section>
@@ -187,7 +187,7 @@
</label> </label>
<label class="option-check"> <label class="option-check">
<input type="checkbox" id="api-key-mode"> <input type="checkbox" id="api-key-mode">
<span>Use API key for larger quota</span> <span>Use API key account limits</span>
</label> </label>
<label class="option-row api-key-row" id="api-key-row"> <label class="option-row api-key-row" id="api-key-row">
<span>API key:</span> <span>API key:</span>