Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2c80ac105 | |||
| d7cbba1bf2 | |||
| dc379ea6a6 |
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
188
lib/server/user_auth.go
Normal 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)})
|
||||||
|
}
|
||||||
@@ -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
369
lib/userstore/store.go
Normal 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()
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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("&", "&")
|
||||||
const roleFilter = role.value;
|
.replaceAll("<", "<")
|
||||||
const sortBy = sort.value;
|
.replaceAll(">", ">")
|
||||||
const rows = users.filter((user) => {
|
.replaceAll('"', """)
|
||||||
const matchesQuery = !query || user.username.toLowerCase().includes(query) || user.email.toLowerCase().includes(query);
|
.replaceAll("'", "'");
|
||||||
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();
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user