Active boxes
+128
++12 today42 passworded
+diff --git a/lib/routing/routes.go b/lib/routing/routes.go index b910f0e..656b1f4 100644 --- a/lib/routing/routes.go +++ b/lib/routing/routes.go @@ -16,6 +16,12 @@ type Handlers struct { FileStatusUpdate gin.HandlerFunc DirectBoxUpload gin.HandlerFunc LegacyUpload gin.HandlerFunc + + AdminLogin gin.HandlerFunc + AdminLoginPost gin.HandlerFunc + AdminLogout gin.HandlerFunc + AdminDashboard gin.HandlerFunc + AdminAuth gin.HandlerFunc } func Register(router *gin.Engine, handlers Handlers) { @@ -36,4 +42,12 @@ func Register(router *gin.Engine, handlers Handlers) { // Legacy upload routes are kept for compatibility with older clients. router.POST("/box/:id/upload", handlers.DirectBoxUpload) router.POST("/upload", handlers.LegacyUpload) + + admin := router.Group("/admin") + admin.GET("/login", handlers.AdminLogin) + admin.POST("/login", handlers.AdminLoginPost) + admin.GET("/logout", handlers.AdminLogout) + + protected := router.Group("/admin", handlers.AdminAuth) + protected.GET("/dashboard", handlers.AdminDashboard) } diff --git a/lib/server/admin.go b/lib/server/admin.go new file mode 100644 index 0000000..1303c37 --- /dev/null +++ b/lib/server/admin.go @@ -0,0 +1,102 @@ +package server + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "warpbox/lib/config" +) + +const adminSessionCookie = "warpbox_admin_session" +const adminSessionMarker = "1" + +func (app *App) adminLoginEnabled() bool { + return app.config.AdminLoginEnabled(app.config.AdminPassword != "") +} + +func (app *App) adminAuthMiddleware(ctx *gin.Context) { + if !app.adminLoginEnabled() { + ctx.Redirect(http.StatusSeeOther, "/") + ctx.Abort() + return + } + + token, err := ctx.Cookie(adminSessionCookie) + if err != nil || token != app.adminSessionToken() { + ctx.Redirect(http.StatusSeeOther, "/admin/login") + ctx.Abort() + return + } + + ctx.Next() +} + +func (app *App) adminSessionToken() string { + // A simple deterministic token derived from the admin credentials. + // This will improve when proper user/session storage is added. + return app.config.AdminUsername + ":" + app.config.AdminPassword +} + +func (app *App) handleAdminLogin(ctx *gin.Context) { + if !app.adminLoginEnabled() { + ctx.Redirect(http.StatusSeeOther, "/") + return + } + + // Already logged in. + if token, err := ctx.Cookie(adminSessionCookie); err == nil && token == app.adminSessionToken() { + ctx.Redirect(http.StatusSeeOther, "/admin/dashboard") + return + } + + ctx.HTML(http.StatusOK, "admin/login.html", gin.H{}) +} + +func (app *App) handleAdminLoginPost(ctx *gin.Context) { + if !app.adminLoginEnabled() { + ctx.Redirect(http.StatusSeeOther, "/") + return + } + + username := strings.TrimSpace(ctx.PostForm("username")) + password := ctx.PostForm("password") + + if username != app.config.AdminUsername || password != app.config.AdminPassword { + ctx.HTML(http.StatusUnauthorized, "admin/login.html", gin.H{ + "ErrorMessage": "Invalid username or password.", + }) + return + } + + secure := app.config.AdminCookieSecure + maxAge := int(app.config.SessionTTLSeconds) + + ctx.SetCookie(adminSessionCookie, app.adminSessionToken(), maxAge, "/admin", "", secure, true) + ctx.Redirect(http.StatusSeeOther, "/admin/dashboard") +} + +func (app *App) handleAdminLogout(ctx *gin.Context) { + secure := app.config.AdminCookieSecure + ctx.SetCookie(adminSessionCookie, "", -1, "/admin", "", secure, true) + ctx.Redirect(http.StatusSeeOther, "/admin/login") +} + +func (app *App) handleAdminDashboard(ctx *gin.Context) { + if !app.adminLoginEnabled() { + ctx.Redirect(http.StatusSeeOther, "/") + return + } + + dashboardEnabled := config.AdminEnabledTrue + if cfgVal := app.config.AdminEnabled; cfgVal == config.AdminEnabledAuto || cfgVal == config.AdminEnabledTrue { + dashboardEnabled = cfgVal + } + + ctx.HTML(http.StatusOK, "admin/dashboard.html", gin.H{ + "AdminUsername": app.config.AdminUsername, + "AdminEmail": app.config.AdminEmail, + "DashboardEnabled": string(dashboardEnabled), + }) +} diff --git a/lib/server/server.go b/lib/server/server.go index 867046c..d308aba 100644 --- a/lib/server/server.go +++ b/lib/server/server.go @@ -1,6 +1,7 @@ package server import ( + "html/template" "time" "github.com/gin-contrib/gzip" @@ -29,7 +30,11 @@ func Run(addr string) error { app := &App{config: cfg} router := gin.Default() - router.LoadHTMLGlob("templates/*.html") + htmlTemplates, err := loadHTMLTemplates() + if err != nil { + return err + } + router.SetHTMLTemplate(htmlTemplates) routing.Register(router, routing.Handlers{ Index: app.handleIndex, @@ -45,6 +50,12 @@ func Run(addr string) error { FileStatusUpdate: app.handleFileStatusUpdate, DirectBoxUpload: app.handleDirectBoxUpload, LegacyUpload: app.handleLegacyUpload, + + AdminLogin: app.handleAdminLogin, + AdminLoginPost: app.handleAdminLoginPost, + AdminLogout: app.handleAdminLogout, + AdminDashboard: app.handleAdminDashboard, + AdminAuth: app.adminAuthMiddleware, }) compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression)) @@ -55,6 +66,22 @@ func Run(addr string) error { return router.Run(addr) } +func loadHTMLTemplates() (*template.Template, error) { + tmpl := template.New("") + for _, pattern := range []string{ + "templates/*.html", + "templates/admin/*.html", + "templates/admin/partials/*.html", + } { + var err error + tmpl, err = tmpl.ParseGlob(pattern) + if err != nil { + return nil, err + } + } + return tmpl, nil +} + func applyBoxstoreRuntimeConfig(cfg *config.Config) { boxstore.SetUploadRoot(cfg.UploadsDir) boxstore.SetOneTimeDownloadExpiry(cfg.OneTimeDownloadExpirySeconds) diff --git a/static/css/admin.css b/static/css/admin.css new file mode 100644 index 0000000..23715e7 --- /dev/null +++ b/static/css/admin.css @@ -0,0 +1,732 @@ +/* =========================== + Admin Shell / Frame + =========================== */ +.admin-shell { + width: 100%; + min-height: 100vh; + display: flex; + justify-content: flex-start; + align-items: center; + flex-direction: column; + padding: 10px 16px 34px; + gap: 10px; +} + +.admin-frame { + width: min(var(--admin-frame-width, 1320px), 100%); + display: grid; + grid-template-rows: auto auto; + gap: 10px; + align-items: start; +} + +/* =========================== + Admin Taskbar (top nav) + =========================== */ +.admin-taskbar { + width: 100%; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + color: #000000; + background-color: var(--w98-gray); + background-image: linear-gradient(180deg, rgba(255,255,255,.36), rgba(0,0,0,.08)), repeating-linear-gradient(45deg, rgba(255,255,255,.12) 0 1px, transparent 1px 5px); + 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, 4px 4px 0 rgba(0,0,0,.45); + padding: 3px; + position: sticky; + top: 0; + z-index: 50; + transition: box-shadow 120ms steps(2, end), filter 120ms steps(2, end); +} + +.admin-taskbar.is-scrolled { + box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf, 0 5px 0 rgba(0,0,0,.55), 0 11px 0 rgba(0,0,0,.18); + filter: brightness(1.02); +} + +.admin-taskbar.is-scrolled::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: -10px; + height: 10px; + pointer-events: none; + background: linear-gradient(to bottom, rgba(0,0,0,.46), rgba(0,0,0,0)); +} + +/* =========================== + Start Button + =========================== */ +.admin-start-button { + min-width: 108px; + height: 24px; + display: inline-grid; + grid-template-columns: 18px 1fr; + align-items: center; + gap: 5px; + padding: 0 8px; + 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-weight: bold; + text-decoration: none; + white-space: nowrap; +} + +.admin-start-button:active { + border-top-color: #000000; + border-left-color: #000000; + border-right-color: #ffffff; + border-bottom-color: #ffffff; + box-shadow: inset -1px -1px 0 #dfdfdf, inset 1px 1px 0 #808080; + padding-top: 1px; +} + +.admin-start-logo { + width: 16px; + height: 16px; + display: grid; + place-items: center; + color: #ffffff; + background: #000078; + border: 1px solid #ffffff; + box-shadow: inset -5px 0 0 #0f80cd, inset 0 -5px 0 #4c1ca0; + font-size: 10px; + line-height: 10px; +} + +/* =========================== + Taskbar Nav Buttons + =========================== */ +.admin-taskbar-nav { + min-width: 0; + display: flex; + align-items: center; + gap: 4px; + overflow-x: auto; + scrollbar-width: thin; + padding-bottom: 1px; +} + +.admin-taskbar-button { + height: 24px; + min-width: 76px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + padding: 0 8px; + color: #000000; + background: var(--w98-gray); + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #808080; + border-bottom: 1px solid #808080; + text-decoration: none; + white-space: nowrap; +} + +.admin-taskbar-button:active { + border-top-color: #000000; + border-left-color: #000000; + border-right-color: #ffffff; + border-bottom-color: #ffffff; + box-shadow: inset -1px -1px 0 #dfdfdf, inset 1px 1px 0 #808080; + padding-top: 1px; +} + +.admin-taskbar-button.is-active { + color: #ffffff; + background: #000078; + border-top-color: #000000; + border-left-color: #000000; + border-right-color: #ffffff; + border-bottom-color: #ffffff; +} + +.admin-taskbar-button:hover { + color: #ffffff; + background: #000078; +} + +/* =========================== + Taskbar Session Chips + =========================== */ +.admin-taskbar-session { + min-width: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 5px; + white-space: nowrap; +} + +.admin-session-chip, +.admin-alert-chip { + height: 24px; + display: inline-flex; + align-items: center; + gap: 5px; + padding: 0 8px; + background: #dfdfdf; + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; + color: #000000; + text-decoration: none; + white-space: nowrap; +} + +.admin-alert-chip.is-ok { background: #e8ffe8; border-color: #008000 #ffffff #ffffff #008000; } +.admin-alert-chip.is-info { background: #d8e5f8; } +.admin-alert-chip.is-warning { + background: #ffffcc; + border: 3px solid transparent; + border-image: repeating-linear-gradient(45deg, #111111 0 8px, #ffcc00 8px 16px) 3; +} +.admin-alert-chip.is-danger { + color: #ffffff; + background: #800000; + border: 3px solid transparent; + border-image: repeating-linear-gradient(45deg, #ffcccc 0 8px, #300000 8px 16px) 3; +} + +/* =========================== + Dashboard Window + =========================== */ +.admin-dashboard-window { + width: 100%; + min-height: 0; + padding: 0; + overflow: visible; + color: #000000; + background-color: var(--w98-gray); + background-image: linear-gradient(180deg, rgba(255,255,255,.24), rgba(0,0,0,.06)); +} + +.admin-dashboard-window > .win98-titlebar { + margin: 2px 2px 0; +} + +.admin-dashboard-window > .menu-bar { + flex: 0 0 auto; + height: auto; + min-height: 24px; + margin: 0 2px; + padding: 1px 6px; + color: #000000; + background: var(--w98-gray); + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #808080; + border-bottom: 1px solid #808080; + z-index: 30; +} + +.admin-dashboard-window > .menu-bar .menu-button { + color: #000000; +} + +.admin-dashboard-window > .dashboard-body { + flex: 1 1 auto; + margin-top: 0; + padding: 0 10px 10px; + background-color: var(--w98-gray); + background-image: linear-gradient(180deg, rgba(255,255,255,.18), rgba(0,0,0,.05)); +} + +.admin-dashboard-statusbar { + grid-template-columns: minmax(0, 1fr) 160px 210px; + height: 28px; + padding: 3px 4px 4px; + background: var(--w98-gray); + font-size: 12px; + line-height: 14px; +} + +.admin-dashboard-statusbar span { + min-height: 19px; + align-items: center; + padding: 1px 6px; +} + +/* =========================== + Menu Bar (toolbar) + =========================== */ +.admin-menu-bar { + position: relative; + display: flex; + align-items: center; + gap: 2px; + min-height: 24px; + padding: 1px 6px; + font-size: 13px; + line-height: 13px; + z-index: 20; +} + +.admin-menu-item { + position: relative; +} + +.admin-menu-button { + height: 20px; + min-width: 54px; + padding: 0 8px; + color: #000000; + background: transparent; + border: 1px solid transparent; + font-family: inherit; + font-size: 13px; + text-align: left; +} + +.admin-menu-button:hover, +.admin-menu-button:focus-visible { + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #808080; + border-bottom: 1px solid #808080; + outline: none; +} + +.admin-menu-popup { + position: absolute; + top: 22px; + left: 0; + min-width: 220px; + padding: 2px; + 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: 3px 3px 0 rgba(0,0,0,.35); + display: none; + z-index: 60; +} + +.admin-menu-item.is-open .admin-menu-popup { + display: block; +} + +.admin-menu-action { + width: 100%; + min-height: 22px; + display: grid; + grid-template-columns: 20px minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + padding: 2px 6px; + color: #000000; + background: transparent; + border: 0; + font-family: inherit; + font-size: 12px; + text-align: left; +} + +.admin-menu-action:hover, +.admin-menu-action:focus-visible { + color: #ffffff; + background: #000078; + outline: none; +} + +.admin-menu-separator { + height: 1px; + margin: 3px 2px; + background: #808080; + border-bottom: 1px solid #ffffff; +} + +.admin-menu-action .shortcut { + color: #555555; +} + +.admin-menu-action:hover .shortcut { + color: #ffffff; +} + +/* =========================== + Hero Section + =========================== */ +.admin-hero { + display: grid; + grid-template-columns: minmax(0, 1fr) 330px; + gap: 10px; + padding: 9px; + align-items: stretch; +} + +.admin-hero-copy h2 { + margin: 0 0 5px; + font-size: 22px; + line-height: 24px; +} + +.admin-hero-copy p { + margin: 0; + color: #333333; + font-size: 13px; + line-height: 15px; +} + +.admin-hero-status { + display: grid; + gap: 4px; + align-content: center; + padding: 7px; + background: #ffffff; + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; + font-size: 12px; + line-height: 13px; +} + +.admin-hero-status-row { + display: flex; + justify-content: space-between; + gap: 8px; +} + +.admin-status-ok { color: #008000; } +.admin-status-warn { color: #8a6200; } +.admin-status-danger { color: #800000; } + +/* =========================== + Stats Grid + =========================== */ +.admin-stats-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.admin-stat-card { + position: relative; + min-height: 122px; + padding: 10px 11px 10px 14px; + overflow: hidden; +} + +/* Left accent bar */ +.admin-stat-card::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 7px; + border-left: 7px solid #000078; + pointer-events: none; +} + +/* Severity color states */ +.admin-stat-card.is-ok { background: linear-gradient(180deg, #eeffee, #ffffff); } +.admin-stat-card.is-ok::before { border-left-color: #008000; } +.admin-stat-card.is-info { background: linear-gradient(180deg, #edf4ff, #ffffff); } +.admin-stat-card.is-info::before { border-left-color: #000078; } +.admin-stat-card.is-warning { background: linear-gradient(180deg, #ffffcc, #ffffff); } +.admin-stat-card.is-warning::before { border-left-color: #ffcc00; } +.admin-stat-card.is-danger { + color: #000000; + background: repeating-linear-gradient(45deg, #fff2f2 0 6px, #ffe1e1 6px 12px); +} +.admin-stat-card.is-danger::before { border-left-color: #800000; } + +.admin-stat-label { + margin: 0 0 6px; + color: #333333; + font-size: 13px; + line-height: 13px; + font-weight: bold; +} + +.admin-stat-value { + margin: 0 0 7px; + font-size: 32px; + line-height: 32px; + font-weight: bold; +} + +.admin-stat-note { + display: flex; + gap: 4px; + flex-wrap: wrap; + margin: 0; + color: #222222; + font-size: 12px; + line-height: 14px; +} + +.admin-stat-note-pill { + display: inline-flex; + align-items: center; + min-height: 18px; + padding: 1px 6px; + background: #dfdfdf; + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #808080; + border-bottom: 1px solid #808080; + white-space: nowrap; +} + +/* =========================== + Main Grid / Section Windows + =========================== */ +.admin-main-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 12px; + align-items: start; +} + +.admin-span-2 { + grid-column: 1 / -1; +} + +.admin-section-window { + min-height: 0; + box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf, 3px 4px 0 rgba(0,0,0,.38); +} + +.admin-section-body { + margin: 0 6px 6px; + padding: 8px; + min-height: 0; +} + +/* =========================== + Quick Actions + =========================== */ +.admin-link-list { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: 6px; +} + +.admin-link-list li { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 8px; + align-items: center; + color: #000000; + font-size: 13px; + line-height: 13px; +} + +.admin-link-button { + min-width: 112px; + height: 24px; + display: inline-grid; + place-items: center; + padding: 0 10px; + color: #000000; + background: var(--w98-gray); + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #000000; + border-bottom: 1px solid #000000; + box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf; + font-size: 12px; + line-height: 12px; + text-decoration: none; +} + +.admin-link-button:hover { + filter: brightness(1.06); +} + +/* Titlebar action links (Show all) */ +.titlebar-actions { + display: flex; + align-items: center; + gap: 2px; + margin-left: 8px; +} + +.titlebar-link-button { + height: 18px; + min-width: 64px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 7px; + color: #000000; + background: var(--w98-gray); + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #000000; + border-bottom: 1px solid #000000; + box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf; + text-decoration: none; + font-size: 12px; + line-height: 12px; + white-space: nowrap; +} + +.titlebar-link-button:hover { + filter: brightness(1.08); +} + +/* =========================== + Compact Mode + =========================== */ +body.is-compact .admin-dashboard-body { + gap: 8px; +} + +body.is-compact .admin-section-body { + padding: 5px; +} + +/* =========================== + Responsive: Medium (tablets) + =========================== */ +@media (max-width: 1180px) { + .admin-taskbar { + grid-template-columns: auto minmax(0, 1fr); + } + + .admin-taskbar-session { + grid-column: 1 / -1; + justify-content: flex-start; + overflow-x: auto; + } + + .admin-stats-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .admin-hero { + grid-template-columns: 1fr; + } + + .admin-main-grid { + grid-template-columns: 1fr; + } + + .admin-span-2 { + grid-column: auto; + } +} + +/* =========================== + Responsive: Small (mobile) + =========================== */ +@media (max-width: 760px) { + .admin-shell { + padding: 0 0 18px; + align-items: stretch; + } + + .admin-frame { + width: 100%; + gap: 8px; + } + + .admin-taskbar { + grid-template-columns: 1fr; + border-left: 0; + border-right: 0; + box-shadow: none; + } + + .admin-start-button { + width: 100%; + justify-content: center; + } + + .admin-taskbar-nav { + width: 100%; + overflow-x: auto; + padding-bottom: 3px; + } + + .admin-taskbar-button { + min-width: 92px; + } + + .admin-taskbar-session { + width: 100%; + overflow-x: auto; + padding-bottom: 3px; + } + + .admin-session-chip, + .admin-alert-chip { + flex: 0 0 auto; + } + + .admin-dashboard-window { + min-height: 100dvh; + border-left: 0; + border-right: 0; + box-shadow: none; + } + + .admin-dashboard-body { + padding: 6px; + gap: 8px; + } + + .admin-stats-grid { + grid-template-columns: 1fr; + } + + .admin-stat-card { + min-height: 112px; + } + + .admin-menu-popup { + position: fixed; + left: 6px; + right: 6px; + top: 74px; + min-width: 0; + } + + .titlebar-actions { + margin-left: 4px; + } + + .titlebar-link-button { + min-width: 58px; + padding: 0 5px; + } + + .admin-dashboard-statusbar { + grid-template-columns: 1fr; + height: auto; + min-height: 70px; + } + + .win98-titlebar h1, + .win98-titlebar h2 { + font-size: 13px; + } + + .win98-window-controls { + display: none; + } +} + +/* Override global main layout on admin pages since admin uses its own shell */ +body:has(.admin-shell) main { + display: contents; +} diff --git a/static/css/dashboard.css b/static/css/dashboard.css new file mode 100644 index 0000000..b7981eb --- /dev/null +++ b/static/css/dashboard.css @@ -0,0 +1,289 @@ +/* ============================================== + Dashboard-specific styles (shared with admin) + Reusable across account dashboard pages + ============================================== */ + +/* Hero section */ +.dashboard-hero { + display: grid; + grid-template-columns: minmax(0, 1fr) 330px; + gap: 10px; + padding: 9px; + align-items: stretch; +} + +.hero-copy h2 { margin: 0 0 5px; font-size: 22px; line-height: 24px; } +.hero-copy p { margin: 0; color: #333; font-size: 13px; line-height: 15px; } + +.hero-status { + display: grid; + gap: 4px; + align-content: center; + padding: 7px; + background: #ffffff; + border-top: 1px solid #808080; + border-left: 1px solid #808080; + border-right: 1px solid #ffffff; + border-bottom: 1px solid #ffffff; + font-size: 12px; + line-height: 13px; +} + +.hero-status-row { display: flex; justify-content: space-between; gap: 8px; } +.status-ok { color: #008000; } +.status-warn { color: #8a6200; } +.status-danger { color: #800000; } + +/* Stats grid */ +.stats-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.stat-card { + position: relative; + min-height: 122px; + padding: 10px 11px 10px 14px; + overflow: hidden; +} + +.stat-card::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 7px; + border-left: 7px solid #000078; + pointer-events: none; +} + +.stat-card.is-ok { background: linear-gradient(180deg, #eeffee, #ffffff); } +.stat-card.is-ok::before { border-left-color: #008000; } +.stat-card.is-info { background: linear-gradient(180deg, #edf4ff, #ffffff); } +.stat-card.is-info::before { border-left-color: #000078; } +.stat-card.is-warning { background: linear-gradient(180deg, #ffffcc, #ffffff); } +.stat-card.is-warning::before { border-left-color: #ffcc00; } +.stat-card.is-danger { + color: #000; + background: repeating-linear-gradient(45deg, #fff2f2 0 6px, #ffe1e1 6px 12px); +} +.stat-card.is-danger::before { border-left-color: #800000; } + +.stat-label { margin: 0 0 6px; color: #333; font-size: 13px; line-height: 13px; font-weight: bold; } +.stat-value { margin: 0 0 7px; font-size: 32px; line-height: 32px; font-weight: bold; } +.stat-note { display: flex; gap: 4px; flex-wrap: wrap; margin: 0; color: #222; font-size: 12px; line-height: 14px; } +.stat-note-pill { + display: inline-flex; + align-items: center; + min-height: 18px; + padding: 1px 6px; + background: #dfdfdf; + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #808080; + border-bottom: 1px solid #808080; + white-space: nowrap; +} + +/* Main two-column grid */ +.dashboard-main-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 12px; + align-items: start; +} + +.dashboard-span-2 { grid-column: 1 / -1; } + +/* Dashboard body */ +.dashboard-body { + display: grid; + gap: 12px; + padding: 10px; +} + +/* Section windows */ +.section-window { min-height: 0; box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf, 3px 4px 0 rgba(0,0,0,.38); } +.section-body { margin: 0 6px 6px; padding: 8px; min-height: 0; } + +/* Scroll panels */ +.scroll-panel { overflow: auto; background: #ffffff; border-top: 2px solid #606060; border-left: 2px solid #606060; border-right: 2px solid #ffffff; border-bottom: 2px solid #ffffff; } +.alerts-scroll { height: 326px; } +.boxes-scroll { height: 352px; } +.activity-scroll { height: 326px; } + +/* Alerts */ +.alert-list { display: grid; min-width: 0; } +.alert-row { + display: grid; + grid-template-columns: 72px minmax(0, 1fr) auto; + gap: 8px; + align-items: start; + min-height: 74px; + padding: 7px; + color: #000; + border-bottom: 1px solid #dfdfdf; + background: #ffffff; +} +.alert-row:nth-child(even) { background: #f5f8ff; } +.alert-row.is-dismissed { display: none; } +.alert-severity { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 60px; + min-height: 20px; + padding: 2px 5px; + text-transform: uppercase; + font-weight: bold; + background: #dfdfdf; + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #808080; + border-bottom: 1px solid #808080; +} +.alert-row[data-severity="low"] .alert-severity { color: #000078; } +.alert-row[data-severity="medium"] .alert-severity { color: #8a6200; background: #ffffcc; } +.alert-row[data-severity="high"] .alert-severity { color: #ffffff; background: #800000; } +.alert-title { margin: 0 0 3px; font-weight: bold; font-size: 14px; line-height: 15px; } +.alert-desc { margin: 0 0 3px; color: #333; font-size: 12px; line-height: 14px; } +.alert-trace { margin: 0; color: #555; font-family: 'MonoCraft', 'Courier New', monospace; font-size: 10px; line-height: 13px; overflow-wrap: anywhere; } +.alert-actions { display: flex; gap: 5px; flex-wrap: wrap; justify-content: flex-end; } + +/* Boxes table */ +.box-table { + width: 100%; + min-width: 900px; + border-collapse: collapse; + color: #000; + font-size: 12px; + line-height: 14px; +} +.box-table th, .box-table td { padding: 6px 7px; border-bottom: 1px solid #dfdfdf; text-align: left; vertical-align: middle; } +.box-table th { position: sticky; top: 0; z-index: 5; background: #dfdfdf; border-bottom: 1px solid #808080; } +.box-table tr:nth-child(even) td { background: #f5f8ff; } +.box-actions { display: flex; gap: 5px; flex-wrap: nowrap; } +.box-action-button { min-width: 62px; height: 22px; padding: 0 6px; font-size: 12px; line-height: 12px; } + +/* Activity */ +.activity-list { display: grid; } +.activity-row { + display: grid; + grid-template-columns: 56px minmax(0, 1fr) auto; + gap: 9px; + align-items: center; + min-height: 48px; + padding: 6px 8px; + border-bottom: 1px solid #dfdfdf; + background: #ffffff; + color: #000; +} +.activity-row:nth-child(even) { background: #f5f8ff; } +.activity-time { font-weight: bold; color: #000078; } +.activity-title { margin: 0 0 2px; font-weight: bold; } +.activity-meta { margin: 0; color: #555; font-size: 12px; line-height: 13px; } + +/* Modal / Popup */ +.modal-backdrop { + position: fixed; + inset: 0; + display: none; + background: rgba(128, 128, 128, .42); + z-index: 70; +} +.modal-backdrop.is-visible { display: block; } + +.popup-window { + position: fixed; + left: 50%; + top: 50%; + transform: translate(calc(-50% - 1px), -50%); + width: min(760px, calc(100vw - 24px)); + max-height: min(760px, calc(100vh - 24px)); + display: none; + z-index: 80; +} +.popup-window.is-visible { display: flex; animation: popup-open 160ms steps(5, end); } +@keyframes popup-open { + from { transform: translate(calc(-50% - 1px), calc(-50% + 10px)) scale(.97); opacity: .45; } + to { transform: translate(calc(-50% - 1px), -50%) scale(1); opacity: 1; } +} +.popup-body { margin: 0 6px 6px; padding: 10px; max-height: calc(100vh - 90px); overflow: auto; color: #000; } +.metadata-pre { + min-height: 240px; + margin: 0; + padding: 10px; + overflow: auto; + color: #b7ffc8; + background: #030403; + background-image: repeating-linear-gradient(transparent 0 4px, rgba(0,255,102,.018) 4px 6px); + font-family: 'MonoCraft', 'Courier New', monospace; + font-size: 12px; + line-height: 16px; + white-space: pre-wrap; +} + +/* Tiny button (for alerts / boxes) */ +.tiny-button { + min-width: 56px; + height: 22px; + display: inline-grid; + place-items: center; + padding: 0 7px; + color: #000; + background: var(--w98-gray); + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #000000; + border-bottom: 1px solid #000000; + box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf; + font-size: 12px; + line-height: 12px; + text-decoration: none; +} +.tiny-button:hover { filter: brightness(1.06); } + +/* Compact mode */ +body.is-compact .dashboard-body { gap: 8px; } +body.is-compact .section-body { padding: 5px; } +body.is-compact .alerts-scroll, +body.is-compact .boxes-scroll { height: 280px; } +body.is-compact .activity-scroll { height: 280px; } +body.is-compact .alert-row { min-height: 62px; } +body.is-compact .activity-row { min-height: 42px; } + +/* Responsive: medium */ +@media (max-width: 1180px) { + .stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .dashboard-hero { grid-template-columns: 1fr; } + .dashboard-main-grid { grid-template-columns: 1fr; } + .dashboard-span-2 { grid-column: auto; } + .alerts-scroll, .boxes-scroll { height: 310px; } + .activity-scroll { height: 310px; } +} + +/* Responsive: small (mobile) */ +@media (max-width: 760px) { + .dashboard-body { padding: 6px; gap: 8px; } + .stats-grid { grid-template-columns: 1fr; } + .stat-card { min-height: 112px; } + .alert-row { grid-template-columns: 1fr; min-height: 0; } + .alert-actions { justify-content: flex-start; } + .alerts-scroll, .boxes-scroll, .activity-scroll { height: 320px; } + .boxes-scroll { overflow-x: auto; } + .activity-row { grid-template-columns: 48px minmax(0, 1fr); } + .activity-row .tag { grid-column: 2; justify-self: start; } + .popup-window { + left: 0; + top: 0; + transform: none; + width: 100vw; + height: 100dvh; + max-height: none; + border: 0; + box-shadow: none; + } + .popup-window.is-visible { animation: popup-open-mobile 150ms steps(5, end); } + @keyframes popup-open-mobile { from { transform: translateY(10px); opacity: .35; } to { transform: translateY(0); opacity: 1; } } + .popup-body { max-height: calc(100dvh - 40px); } +} diff --git a/static/css/window.css b/static/css/window.css index a261087..ac9af21 100644 --- a/static/css/window.css +++ b/static/css/window.css @@ -128,3 +128,81 @@ font-size: 13px; line-height: 13px; } + +/* Raised panel - appears to sit above the surface */ +.raised-panel { + background: #dfdfdf; + border-top: 1px solid #ffffff; + border-left: 1px solid #ffffff; + border-right: 1px solid #808080; + border-bottom: 1px solid #808080; + box-shadow: inset 1px 1px 0 #f7f7f7, inset -1px -1px 0 #b0b0b0; +} + +/* Sunken panel - appears to be inset into the surface */ +.sunken-panel { + background-color: #ffffff; + background-image: + linear-gradient(180deg, rgba(255,255,255,.9), rgba(238,238,238,.58)), + repeating-linear-gradient(0deg, rgba(0,0,0,.025) 0 1px, transparent 1px 6px); + border-top: 2px solid #606060; + border-left: 2px solid #606060; + border-right: 2px solid #ffffff; + border-bottom: 2px solid #ffffff; +} + +/* Scroll panel - used for scrollable content areas within windows */ +.scroll-panel { + overflow: auto; + background: #ffffff; + border-top: 2px solid #606060; + border-left: 2px solid #606060; + border-right: 2px solid #ffffff; + border-bottom: 2px solid #ffffff; +} + +/* Meter track for progress bars */ +.meter-track { + display: block; + height: 14px; + margin-top: 9px; + background-color: #ffffff; + background-image: repeating-linear-gradient(to right, rgba(0,0,0,.06) 0 1px, transparent 1px 18px); + border-top: 2px solid #808080; + border-left: 2px solid #808080; + border-right: 2px solid #ffffff; + border-bottom: 2px solid #ffffff; +} + +.meter-bar { + display: block; + height: 100%; + width: var(--meter, 0%); + background-color: #000078; + background-image: repeating-linear-gradient(to right, rgba(255,255,255,.13) 0 1px, transparent 1px 18px); +} + +/* Tag styles for status indicators */ +.tag { + display: inline-flex; + align-items: center; + min-height: 17px; + margin: 1px 2px 1px 0; + padding: 1px 5px; + color: #000000; + background: #dfdfdf; + border: 1px solid #808080; + box-shadow: inset 1px 1px 0 #ffffff; + white-space: nowrap; +} + +.tag.ok { color: #008000; background: #eeffee; } +.tag.info { color: #000078; background: #edf4ff; } +.tag.warn { color: #8a6200; background: #ffffcc; } +.tag.danger { color: #ffffff; background: #800000; } + +/* Titlebar animation - gradient drift */ +@keyframes titlebar-drift { + from { background-position: 0% 50%; } + to { background-position: 100% 50%; } +} diff --git a/static/js/admin/dashboard.js b/static/js/admin/dashboard.js new file mode 100644 index 0000000..690dfc7 --- /dev/null +++ b/static/js/admin/dashboard.js @@ -0,0 +1,201 @@ +(() => { + const menuController = window.WarpBoxUI?.bindMenuBar?.() || { + close() { + document.querySelectorAll(".menu-item.is-open").forEach((item) => { + item.classList.remove("is-open"); + item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false"); + }); + } + }; + const toast = document.getElementById("toast"); + const statusText = document.getElementById("statusText"); + const modal = document.querySelector("[data-alert-modal]"); + const backdrop = document.querySelector("[data-modal-backdrop]"); + const modalTitle = document.getElementById("modalTitle"); + const modalMeta = document.getElementById("modalMeta"); + const alertCountValue = document.getElementById("alertCountValue"); + const alertStatNote = document.getElementById("alertStatNote"); + const alertsCard = document.getElementById("alertsCard"); + const topAlertChip = document.getElementById("topAlertChip"); + const topTaskbar = document.querySelector(".admin-taskbar"); + + if (!statusText || !alertsCard || !topAlertChip) return; + + function showToast(message, type = "info") { + if (window.WarpBoxUI) { + window.WarpBoxUI.toast(message, type, { target: toast }); + return; + } + if (!toast) return; + toast.textContent = message; + toast.classList.add("is-visible"); + window.clearTimeout(showToast.timer); + showToast.timer = window.setTimeout(() => toast.classList.remove("is-visible"), 2600); + } + + function setStatus(message) { + statusText.textContent = message; + } + + function openModal(title, meta) { + if (!modal || !backdrop || !modalTitle || !modalMeta) return; + modalTitle.textContent = title; + modalMeta.textContent = meta; + modal.classList.add("is-visible"); + modal.setAttribute("aria-hidden", "false"); + backdrop.classList.add("is-visible"); + } + + function closeModal() { + modal?.classList.remove("is-visible"); + modal?.setAttribute("aria-hidden", "true"); + backdrop?.classList.remove("is-visible"); + } + + function visibleAlertRows() { + return Array.from(document.querySelectorAll(".alert-row")).filter((row) => !row.classList.contains("is-dismissed")); + } + + function updateStickyHeader() { + topTaskbar?.classList.toggle("is-scrolled", window.scrollY > 4); + } + + function updateAlertSummary() { + const rows = visibleAlertRows(); + const counts = rows.reduce((acc, row) => { + const severity = row.dataset.severity || "low"; + acc[severity] = (acc[severity] || 0) + 1; + return acc; + }, { high: 0, medium: 0, low: 0 }); + const score = counts.high * 5 + counts.medium * 2 + counts.low; + const total = rows.length; + const stateClass = counts.high > 0 || score >= 12 ? "is-danger" : counts.medium >= 2 || score >= 5 ? "is-warning" : total > 0 ? "is-info" : "is-ok"; + + alertsCard.classList.remove("is-ok", "is-info", "is-warning", "is-danger"); + alertsCard.classList.add(stateClass); + topAlertChip.classList.remove("is-ok", "is-info", "is-warning", "is-danger"); + topAlertChip.classList.add(stateClass); + if (alertCountValue) alertCountValue.textContent = String(total); + topAlertChip.textContent = total === 0 ? "OK no alerts" : `! ${total} alerts`; + if (alertStatNote) { + alertStatNote.innerHTML = total === 0 + ? 'all clear' + : `${counts.high} high${counts.medium} medium${counts.low} low`; + } + } + + function scrollToSection(id) { + const target = document.getElementById(id); + if (!target) return; + target.scrollIntoView({ behavior: "smooth", block: "start" }); + setStatus(`Focused ${id.replace("-", " ")}`); + } + + const commandMessages = { + refresh: "CURRENTLY_MOCKED_LEAVE_AS_IS: dashboard refresh would re-fetch dashboard data.", + "dashboard-snapshot": "CURRENTLY_MOCKED_LEAVE_AS_IS: dashboard snapshot export would start here.", + logout: "CURRENTLY_MOCKED_LEAVE_AS_IS: logout would submit to the account logout route.", + "compact-mode": "Toggled compact density.", + "show-all-boxes": "TO-DO: navigate to /account/boxes.", + "show-all-alerts": "TO-DO: navigate to /account/alerts.", + "export-boxes": "CURRENTLY_MOCKED_LEAVE_AS_IS: boxes CSV export would be requested.", + "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 /account/users for admins.", + "open-settings": "TO-DO: navigate to /account/settings for admins.", + "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}`; + showToast(message); + setStatus(message); + } + + document.querySelectorAll("[data-command]").forEach((button) => { + button.addEventListener("click", () => { + menuController.close(); + runCommand(button.dataset.command); + }); + }); + + document.querySelectorAll("[data-scroll-to]").forEach((button) => { + button.addEventListener("click", () => { + menuController.close(); + scrollToSection(button.dataset.scrollTo); + }); + }); + + document.querySelectorAll("[data-view-meta]").forEach((button) => { + button.addEventListener("click", () => { + const row = button.closest(".alert-row"); + const title = row?.dataset.alertTitle || "Alert Metadata"; + let meta = row?.dataset.alertMeta || "{}"; + try { + meta = JSON.stringify(JSON.parse(meta), null, 2); + } catch (_) { + meta = row?.dataset.alertMeta || "{}"; + } + openModal(`${title} (${row?.dataset.alertCode || "mock"})`, meta); + }); + }); + + document.querySelectorAll("[data-dismiss-alert]").forEach((button) => { + button.addEventListener("click", () => { + const row = button.closest(".alert-row"); + row?.classList.add("is-dismissed"); + updateAlertSummary(); + showToast(`Closed alert ${row?.dataset.alertCode || "mock"}.`); + setStatus(`Closed alert ${row?.dataset.alertCode || "mock"}`); + }); + }); + + document.querySelector("[data-close-modal]")?.addEventListener("click", closeModal); + backdrop?.addEventListener("click", closeModal); + topAlertChip.addEventListener("click", (event) => { + event.preventDefault(); + scrollToSection("alerts"); + }); + + window.addEventListener("scroll", updateStickyHeader, { passive: true }); + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + menuController.close(); + closeModal(); + } + if (event.key === "F5") { + event.preventDefault(); + runCommand("refresh"); + } + if (event.altKey && event.key.toLowerCase() === "a") { + event.preventDefault(); + scrollToSection("alerts"); + } + if (event.altKey && event.key.toLowerCase() === "b") { + event.preventDefault(); + scrollToSection("recent-boxes"); + } + if (event.altKey && event.key.toLowerCase() === "r") { + event.preventDefault(); + scrollToSection("recent-activity"); + } + }); + + updateAlertSummary(); + updateStickyHeader(); +})(); diff --git a/static/js/warpbox-ui.js b/static/js/warpbox-ui.js index d43ae25..6010815 100644 --- a/static/js/warpbox-ui.js +++ b/static/js/warpbox-ui.js @@ -53,5 +53,46 @@ function renderTemplate(template, data = {}) { }); } -return { toast, openPopup, closePopup, htmlEscape, renderTemplate }; +function bindMenuBar(options = {}) { + const root = options.root || document; + const itemSelector = options.itemSelector || ".menu-item"; + const buttonSelector = options.buttonSelector || ".menu-button"; + const items = Array.from(root.querySelectorAll(itemSelector)); + + function close() { + items.forEach((item) => { + item.classList.remove("is-open"); + item.querySelector(buttonSelector)?.setAttribute("aria-expanded", "false"); + }); + } + + function open(item) { + close(); + item.classList.add("is-open"); + item.querySelector(buttonSelector)?.setAttribute("aria-expanded", "true"); + } + + items.forEach((item) => { + const button = item.querySelector(buttonSelector); + button?.addEventListener("click", (event) => { + event.stopPropagation(); + const wasOpen = item.classList.contains("is-open"); + close(); + if (!wasOpen) open(item); + }); + + item.addEventListener("mouseenter", () => { + if (!root.querySelector(`${itemSelector}.is-open`)) return; + open(item); + }); + }); + + document.addEventListener("click", (event) => { + if (!event.target.closest(itemSelector)) close(); + }); + + return { close, open }; +} + +return { toast, openPopup, closePopup, htmlEscape, renderTemplate, bindMenuBar }; })(); diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html new file mode 100644 index 0000000..33c3ab8 --- /dev/null +++ b/templates/admin/dashboard.html @@ -0,0 +1,337 @@ +{{ define "admin/dashboard.html" }} + + +
+ + +At-a-glance account and admin overview for boxes, alerts, storage, users, and recent activity.
+Active boxes
+128
++12 today42 passworded
+Storage available
+812 GiB
+188 GiB used1 TiB app caplocal backend
+ +Alerts
+15
+2 high5 medium8 low
+Users
+19
+15 active4 disabledadmin-only
+Storage backend is almost full
The active local storage backend has less than 5% free capacity under the configured app cap.
code 421, trace storage.local.capacity.high
Disabled user has active sessions
A disabled account still has active sessions that should be revoked.
code 181, trace auth.sessions.disabled_user_active
Expired boxes waiting cleanup
Expired boxes are still present on disk and are eligible for cleanup.
code 301, trace boxes.expiry.cleanup_pending
API key UI enabled but key backend missing
The frontend advertises API key usage while server-side API key validation is not connected yet.
code 711, trace api_keys.ui.backend_missing
Thumbnail queue is behind
The thumbnail worker has accumulated more pending previews than expected.
code 602, trace thumbnails.worker.queue_lag
Large ZIP download failed
A ZIP stream failed before the response finished.
code 502, trace downloads.zip.stream_failed
Guest quota close to daily cap
A guest IP is close to its configured daily upload cap.
code 231, trace quotas.guest.daily.near_cap
Thumbnail generation skipped
A preview could not be generated for one image file.
code 601, trace thumbnails.generate.skipped
One-time box downloaded
A one-time ZIP handoff completed and the box was queued for deletion.
code 511, trace downloads.one_time.completed
Settings override changed
A runtime setting was changed through the settings UI.
code 801, trace settings.override.changed
Password protected box created
A user created a password protected upload box.
code 121, trace boxes.create.passworded
Upload completed slowly
An upload completed but exceeded the expected duration threshold.
code 222, trace uploads.performance.slow_complete
Session refreshed
The current local session was refreshed after account activity.
code 182, trace auth.session.refreshed
Box visited from share URL
A public box was opened through its normal shared page.
code 401, trace boxes.share.opened
Support summary generated
A local support summary was generated from the toolbar.
code 901, trace support.summary.generated
Box BX-9F31 completed upload
Alert 421 created
Guest created box BX-A71D
Thumbnail worker skipped one image
Cleanup dry run opened
Large ZIP download completed
Settings snapshot requested
Temporary cleanup skipped
User maya uploaded 6 files
Box BX-55E0 expired
One-time box created
User ana uploaded archive set
Guest accessed public box
User mihai created box BX-F02A
Failed login attempt recorded
| Box | Owner | Files | Size | Created | Expires | Flags | Actions |
|---|---|---|---|---|---|---|---|
| BX-9F31 | maya | 4 | 91.9 MiB | 10:12 | 5h 41m | complete password | |
| BX-A71D | guest | 12 | 1.8 GiB | 10:04 | 6h 00m | large | |
| BX-20BD | operator | 2 | 8.4 MiB | 09:58 | 1d 12h | complete | |
| BX-7D20 | admin | 12 | 856.3 MiB | 09:44 | 23h 11m | zip failed | |
| BX-1AA2 | guest | 1 | 4.7 GiB | 09:21 | expired | locked | |
| BX-C2A8 | maya | 6 | 24.8 MiB | 09:09 | 2d 03h | complete password | |
| BX-55E0 | guest | 1 | 4.2 MiB | 08:55 | expired | expired | |
| BX-440C | admin | 3 | 63.0 MiB | 08:42 | 2d 00h | complete one-time | |
| BX-88B4 | ana | 7 | 520.8 MiB | 08:31 | 5d 00h | complete | |
| BX-39C1 | guest | 2 | 23.1 MiB | 08:20 | 16h 00m | public | |
| BX-F02A | mihai | 5 | 108.6 MiB | 08:07 | 4d 00h | complete | |
| BX-ABC4 | guest | 1 | 755 KiB | 07:54 | 3h 00m | complete | |
| BX-74E9 | operator | 10 | 987.3 MiB | 07:41 | 7d 00h | bulk | |
| BX-218B | daniel | 3 | 44.0 MiB | 07:28 | 1d 00h | complete | |
| BX-00FE | guest | 2 | 13.7 MiB | 07:12 | 2h 00m | soon |