feat(users): add account limits and API keys
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m43s

This commit is contained in:
2026-05-04 02:27:36 +03:00
parent dc379ea6a6
commit d7cbba1bf2
14 changed files with 1688 additions and 271 deletions

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

@@ -1,6 +1,7 @@
.users-page-body { .users-page-body {
display: grid; display: grid;
gap: 10px; gap: 10px;
align-items: start;
} }
.users-hero { .users-hero {
@@ -69,11 +70,92 @@
.users-main-grid { .users-main-grid {
display: grid; display: grid;
grid-template-columns: minmax(320px, .65fr) minmax(0, 1.35fr); grid-template-columns: 320px minmax(0, 1fr);
gap: 10px; gap: 10px;
min-height: 0; min-height: 0;
} }
.users-control-panel {
min-height: 0;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 8px;
align-self: start;
}
.users-selected-card {
display: grid;
gap: 4px;
padding: 8px;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
}
.users-selected-card span,
.users-selected-card small {
color: #444444;
font-size: 12px;
line-height: 14px;
}
.users-selected-card strong {
min-height: 18px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 16px;
line-height: 18px;
}
.users-side-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
}
.users-tab {
min-height: 28px;
color: #000000;
background: var(--w98-gray);
border-top: 2px solid #ffffff;
border-left: 2px solid #ffffff;
border-right: 2px solid #000000;
border-bottom: 2px solid #000000;
box-shadow: inset -1px -1px 0 #808080, inset 1px 1px 0 #dfdfdf;
font-family: inherit;
font-size: 12px;
line-height: 12px;
}
.users-tab.is-active {
color: #ffffff;
background: #000078;
border-top-color: #000000;
border-left-color: #000000;
border-right-color: #ffffff;
border-bottom-color: #ffffff;
}
.users-tab-panel {
display: none;
min-height: 0;
padding: 8px;
background: #ffffff;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
}
.users-tab-panel.is-active {
display: grid;
gap: 8px;
align-content: start;
}
.users-panel { .users-panel {
min-height: 0; min-height: 0;
display: flex; display: flex;
@@ -97,6 +179,11 @@
border-bottom: 1px solid #b0b0b0; border-bottom: 1px solid #b0b0b0;
} }
.users-panel-header.compact {
margin: -8px -8px 0;
min-height: 30px;
}
.users-panel-title { .users-panel-title {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -194,13 +281,13 @@
.users-toolbar-grid { .users-toolbar-grid {
display: grid; display: grid;
grid-template-columns: minmax(220px, 1.2fr) repeat(4, minmax(100px, .6fr)); grid-template-columns: minmax(220px, 1.2fr) repeat(3, minmax(100px, .6fr));
gap: 8px; gap: 8px;
} }
.users-table-wrap { .users-table-wrap {
min-height: 420px; min-height: 360px;
height: 420px; height: min(54vh, 520px);
overflow: auto; overflow: auto;
background: #ffffff; background: #ffffff;
border-top: 2px solid #606060; border-top: 2px solid #606060;
@@ -239,6 +326,7 @@
.users-table tbody tr:nth-child(odd) { background: rgba(255,255,255,.96); } .users-table tbody tr:nth-child(odd) { background: rgba(255,255,255,.96); }
.users-table tbody tr:nth-child(even) { background: rgba(240,244,255,.9); } .users-table tbody tr:nth-child(even) { background: rgba(240,244,255,.9); }
.users-table tbody tr:hover { background: #d8e5f8; } .users-table tbody tr:hover { background: #d8e5f8; }
.users-table tbody tr.is-selected { background: #c8d8ff; }
.users-col-check { width: 30px; } .users-col-check { width: 30px; }
.users-col-actions { width: 136px; } .users-col-actions { width: 136px; }
@@ -301,6 +389,70 @@
line-height: 12px; line-height: 12px;
} }
.users-empty-note {
margin: 0;
color: #555555;
font-size: 12px;
line-height: 15px;
}
.users-key-reveal {
display: grid;
gap: 4px;
padding: 6px;
background: #ffffcc;
border-top: 1px solid #808080;
border-left: 1px solid #808080;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
}
.users-key-reveal span {
font-size: 12px;
line-height: 12px;
}
.users-key-list {
display: grid;
gap: 6px;
}
.users-key-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 6px;
background: #f6f6f6;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
border-right: 1px solid #b0b0b0;
border-bottom: 1px solid #b0b0b0;
}
.users-key-row div {
min-width: 0;
display: grid;
gap: 2px;
}
.users-key-row strong,
.users-key-row span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.users-key-row span {
color: #555555;
font-size: 11px;
line-height: 12px;
}
.users-key-row.is-revoked {
opacity: .62;
}
@media (max-width: 1024px) { @media (max-width: 1024px) {
.users-main-grid, .users-main-grid,
.users-hero { .users-hero {

View File

@@ -3,8 +3,7 @@
const toastTarget = document.getElementById("toast"); const toastTarget = document.getElementById("toast");
const body = document.getElementById("users-body"); const body = document.getElementById("users-body");
const search = document.getElementById("users-search"); const search = document.getElementById("users-search");
const status = document.getElementById("users-status"); const statusFilter = document.getElementById("users-status");
const role = document.getElementById("users-role-filter");
const sort = document.getElementById("users-sort"); const sort = document.getElementById("users-sort");
const size = document.getElementById("users-size"); const size = document.getElementById("users-size");
const masterCheck = document.getElementById("users-master-check"); const masterCheck = document.getElementById("users-master-check");
@@ -14,61 +13,234 @@
const prevBtn = document.getElementById("users-prev"); const prevBtn = document.getElementById("users-prev");
const nextBtn = document.getElementById("users-next"); const nextBtn = document.getElementById("users-next");
const selectVisible = document.getElementById("select-visible"); const selectVisible = document.getElementById("select-visible");
const form = document.getElementById("users-form");
const modeInput = document.getElementById("users-mode");
const usernameInput = document.getElementById("users-username");
const emailInput = document.getElementById("users-email");
const roleInput = document.getElementById("users-role");
const planInput = document.getElementById("users-plan");
const statusLeft = document.getElementById("users-status-left"); const statusLeft = document.getElementById("users-status-left");
const selectedName = document.getElementById("selected-user-name");
const selectedMeta = document.getElementById("selected-user-meta");
const addForm = document.getElementById("add-user-form");
const editForm = document.getElementById("edit-user-form");
const policiesForm = document.getElementById("policies-form");
const apiKeyForm = document.getElementById("api-key-form");
const apiKeyList = document.getElementById("api-key-list");
const apiKeyReveal = document.getElementById("api-key-reveal");
const apiKeyValue = document.getElementById("api-key-value");
if (!body || !search || !status || !role || !sort || !size) return; if (!body || !search || !statusFilter || !sort || !size) return;
const users = [ const state = {
{ id: "u_admin", username: "admin", email: "admin@warpbox.local", status: "active", role: "admin", plan: "unlimited", boxes: 18, created: "2026-04-12", lastSeen: "active now" }, page: 1,
{ id: "u_geo", username: "geo", email: "geo@example.test", status: "active", role: "uploader", plan: "trusted", boxes: 7, created: "2026-04-21", lastSeen: "today 12:10" }, users: [],
{ id: "u_reo", username: "reo", email: "reo@example.test", status: "active", role: "uploader", plan: "standard", boxes: 3, created: "2026-04-20", lastSeen: "today 09:44" }, selected: new Set(),
{ id: "u_teo", username: "teo", email: "teo@example.test", status: "active", role: "uploader", plan: "trusted", boxes: 5, created: "2026-04-19", lastSeen: "yesterday" }, currentUserID: "",
{ id: "u_mara", username: "mara", email: "mara@example.test", status: "pending", role: "viewer", plan: "guest-like", boxes: 0, created: "2026-04-28", lastSeen: "never" }, };
{ id: "u_ion", username: "ion", email: "ion@example.test", status: "disabled", role: "uploader", plan: "standard", boxes: 2, created: "2026-04-01", lastSeen: "2026-04-15" },
{ id: "u_sara", username: "sara", email: "sara@example.test", status: "active", role: "operator", plan: "trusted", boxes: 12, created: "2026-03-30", lastSeen: "today 08:25" },
{ id: "u_vlad", username: "vlad", email: "vlad@example.test", status: "pending", role: "uploader", plan: "standard", boxes: 0, created: "2026-04-27", lastSeen: "never" },
{ id: "u_lina", username: "lina", email: "lina@example.test", status: "active", role: "viewer", plan: "guest-like", boxes: 1, created: "2026-03-22", lastSeen: "2026-04-29" },
{ id: "u_adi", username: "adi", email: "adi@example.test", status: "active", role: "uploader", plan: "standard", boxes: 4, created: "2026-02-18", lastSeen: "2026-04-26" },
{ id: "u_nora", username: "nora", email: "nora@example.test", status: "disabled", role: "viewer", plan: "guest-like", boxes: 0, created: "2026-01-14", lastSeen: "2026-03-02" },
{ id: "u_alex", username: "alex", email: "alex@example.test", status: "active", role: "uploader", plan: "trusted", boxes: 9, created: "2026-04-10", lastSeen: "2026-04-30" },
{ id: "u_rina", username: "rina", email: "rina@example.test", status: "pending", role: "uploader", plan: "standard", boxes: 0, created: "2026-04-29", lastSeen: "never" },
{ id: "u_mihai", username: "mihai", email: "mihai@example.test", status: "active", role: "operator", plan: "trusted", boxes: 6, created: "2026-02-08", lastSeen: "2026-04-22" }
];
const state = { page: 1, selected: new Set() }; const fields = {
add: {
username: document.getElementById("add-username"),
email: document.getElementById("add-email"),
status: document.getElementById("add-status"),
maxFile: document.getElementById("add-max-file"),
maxBox: document.getElementById("add-max-box"),
web: document.getElementById("add-perm-web"),
api: document.getElementById("add-perm-api"),
create: document.getElementById("add-perm-create"),
upload: document.getElementById("add-perm-upload"),
},
edit: {
username: document.getElementById("edit-username"),
email: document.getElementById("edit-email"),
status: document.getElementById("edit-status"),
save: document.getElementById("save-edit-button"),
delete: document.getElementById("delete-user-button"),
},
policies: {
maxFile: document.getElementById("policy-max-file"),
maxBox: document.getElementById("policy-max-box"),
web: document.getElementById("policy-perm-web"),
api: document.getElementById("policy-perm-api"),
create: document.getElementById("policy-perm-create"),
upload: document.getElementById("policy-perm-upload"),
save: document.getElementById("save-policies-button"),
},
keys: {
name: document.getElementById("api-key-name"),
create: document.getElementById("create-key-button"),
},
};
function toast(message, type = "info") { function toast(message, type = "info") {
if (window.WarpBoxUI) { if (window.WarpBoxUI) {
window.WarpBoxUI.toast(message, type, { target: toastTarget, duration: 2200 }); window.WarpBoxUI.toast(message, type, { target: toastTarget, duration: 3200 });
return; return;
} }
if (!toastTarget) return; if (toastTarget) toastTarget.textContent = message;
toastTarget.textContent = message;
toastTarget.classList.add("is-visible");
} }
function filtered() { function escapeHTML(value) {
const query = search.value.trim().toLowerCase(); return String(value || "")
const statusFilter = status.value; .replaceAll("&", "&amp;")
const roleFilter = role.value; .replaceAll("<", "&lt;")
const sortBy = sort.value; .replaceAll(">", "&gt;")
const rows = users.filter((user) => { .replaceAll('"', "&quot;")
const matchesQuery = !query || user.username.toLowerCase().includes(query) || user.email.toLowerCase().includes(query); .replaceAll("'", "&#39;");
const matchesStatus = statusFilter === "all" || user.status === statusFilter; }
const matchesRole = roleFilter === "all" || user.role === roleFilter;
return matchesQuery && matchesStatus && matchesRole;
});
async function api(path, method = "GET", payload = null) {
const response = await fetch(path, {
method,
headers: payload ? { "Content-Type": "application/json" } : undefined,
body: payload ? JSON.stringify(payload) : undefined,
});
let data = {};
try {
data = await response.json();
} catch (_) {}
if (!response.ok) throw new Error(data.error || "Request failed");
return data;
}
function selectedUser() {
return state.users.find((user) => user.id === state.currentUserID) || null;
}
const BYTES_PER_MB = 1024 * 1024;
function numericMB(input) {
const value = Number(input?.value || 0);
return Number.isFinite(value) && value > 0 ? String(Math.floor(value)) : "0";
}
function bytesToMB(value) {
const bytes = Number(value || 0);
return Number.isFinite(bytes) && bytes > 0 ? String(Math.ceil(bytes / BYTES_PER_MB)) : "0";
}
function limitLabelMB(value) {
const mb = Number(bytesToMB(value));
return mb > 0 ? `${mb} MB` : "unlimited";
}
function permissionPayload(source) {
return {
can_use_web: Boolean(source.web?.checked),
can_use_api: Boolean(source.api?.checked),
can_create_box: Boolean(source.create?.checked),
can_upload_file: Boolean(source.upload?.checked),
};
}
function payloadFromUser(user, overrides = {}) {
return {
id: user.id,
username: user.username,
email: user.email,
status: user.status,
max_file_size_mb: bytesToMB(user.limits?.max_file_size_bytes),
max_box_size_mb: bytesToMB(user.limits?.max_box_size_bytes),
permissions: user.permissions || {},
...overrides,
};
}
function setTab(tabName) {
document.querySelectorAll(".users-tab").forEach((tab) => {
tab.classList.toggle("is-active", tab.dataset.tab === tabName);
});
document.querySelectorAll(".users-tab-panel").forEach((panel) => {
panel.classList.toggle("is-active", panel.dataset.panel === tabName);
});
}
function setSelectedUser(userID, preferredTab = "edit") {
state.currentUserID = userID || "";
state.selected.clear();
if (userID) state.selected.add(userID);
populateSelectedPanels();
render();
if (preferredTab) setTab(preferredTab);
}
function setControlsEnabled(group, enabled) {
Object.values(group).forEach((element) => {
if (!element) return;
element.disabled = !enabled;
});
}
function populateSelectedPanels() {
const user = selectedUser();
const hasUser = Boolean(user);
selectedName.textContent = hasUser ? user.username : "None";
selectedMeta.textContent = hasUser ? `${user.email} · ${user.status}` : "Choose a row to edit policies and keys.";
setControlsEnabled(fields.edit, hasUser);
setControlsEnabled(fields.policies, hasUser);
setControlsEnabled(fields.keys, hasUser);
if (!hasUser) {
fields.edit.username.value = "";
fields.edit.email.value = "";
fields.edit.status.value = "active";
fields.policies.maxFile.value = "";
fields.policies.maxBox.value = "";
[fields.policies.web, fields.policies.api, fields.policies.create, fields.policies.upload].forEach((item) => { item.checked = false; });
fields.keys.name.value = "default";
apiKeyList.innerHTML = `<p class="users-empty-note">Select a user to manage API keys.</p>`;
apiKeyReveal.hidden = true;
return;
}
fields.edit.username.value = user.username || "";
fields.edit.email.value = user.email || "";
fields.edit.status.value = user.status || "active";
fields.policies.maxFile.value = bytesToMB(user.limits?.max_file_size_bytes);
fields.policies.maxBox.value = bytesToMB(user.limits?.max_box_size_bytes);
fields.policies.web.checked = Boolean(user.permissions?.can_use_web);
fields.policies.api.checked = Boolean(user.permissions?.can_use_api);
fields.policies.create.checked = Boolean(user.permissions?.can_create_box);
fields.policies.upload.checked = Boolean(user.permissions?.can_upload_file);
fields.keys.name.value = "default";
renderAPIKeys(user);
}
function renderAPIKeys(user) {
const keys = user.api_keys || [];
if (!keys.length) {
apiKeyList.innerHTML = `<p class="users-empty-note">No API keys yet.</p>`;
return;
}
apiKeyList.innerHTML = keys.map((key) => {
const revoked = Boolean(key.revoked_at);
return `
<div class="users-key-row ${revoked ? "is-revoked" : ""}">
<div><strong>${escapeHTML(key.name || "default")}</strong><span>${escapeHTML(key.prefix || key.id)}${revoked ? " · revoked" : ""}</span></div>
<button class="win98-button users-row-button" type="button" data-revoke-key="${escapeHTML(key.id)}" ${revoked ? "disabled" : ""}>Revoke</button>
</div>
`;
}).join("");
apiKeyList.querySelectorAll("[data-revoke-key]").forEach((button) => {
button.addEventListener("click", () => revokeAPIKey(button.dataset.revokeKey));
});
}
function renderStats() {
document.getElementById("stat-total").textContent = String(state.users.length);
document.getElementById("stat-active").textContent = String(state.users.filter((u) => u.status === "active").length);
document.getElementById("stat-keys").textContent = String(state.users.filter((u) => (u.api_key_count || 0) > 0).length);
document.getElementById("stat-disabled").textContent = String(state.users.filter((u) => u.status === "disabled").length);
}
function filteredUsers() {
const query = search.value.trim().toLowerCase();
const currentStatus = statusFilter.value;
const rows = state.users.filter((user) => {
const matchesQuery = !query || user.username.toLowerCase().includes(query) || user.email.toLowerCase().includes(query);
const matchesStatus = currentStatus === "all" || user.status === currentStatus;
return matchesQuery && matchesStatus;
});
rows.sort((a, b) => { rows.sort((a, b) => {
if (sortBy === "createdDesc") return b.created.localeCompare(a.created); if (sort.value === "createdDesc") return String(b.created_at).localeCompare(String(a.created_at));
if (sortBy === "lastSeenDesc") return b.lastSeen.localeCompare(a.lastSeen); if (sort.value === "lastSeenDesc") return String(b.last_seen_at || "").localeCompare(String(a.last_seen_at || ""));
if (sortBy === "boxesDesc") return b.boxes - a.boxes; if (sort.value === "keysDesc") return (b.api_key_count || 0) - (a.api_key_count || 0);
return a.username.localeCompare(b.username); return a.username.localeCompare(b.username);
}); });
return rows; return rows;
@@ -77,40 +249,56 @@
function paged(rows) { function paged(rows) {
const perPage = Number(size.value || 12); const perPage = Number(size.value || 12);
const pages = Math.max(1, Math.ceil(rows.length / perPage)); const pages = Math.max(1, Math.ceil(rows.length / perPage));
if (state.page > pages) state.page = pages; state.page = Math.max(1, Math.min(state.page, pages));
if (state.page < 1) state.page = 1;
const start = (state.page - 1) * perPage; const start = (state.page - 1) * perPage;
return { rows: rows.slice(start, start + perPage), pages, start }; return { rows: rows.slice(start, start + perPage), pages };
} }
function statusPill(value) { function permissionsSummary(permissions = {}) {
return `<span class="users-pill ${value}">${value}</span>`; const items = [];
if (permissions.can_use_web) items.push("web");
if (permissions.can_use_api) items.push("api");
if (permissions.can_create_box) items.push("create");
if (permissions.can_upload_file) items.push("upload");
return items.join(", ") || "none";
}
function limitsSummary(limits = {}) {
return `file ${limitLabelMB(limits.max_file_size_bytes)} / box ${limitLabelMB(limits.max_box_size_bytes)}`;
} }
function renderRow(user) { function renderRow(user) {
const checked = state.selected.has(user.id) ? " checked" : ""; const checked = state.selected.has(user.id) ? " checked" : "";
const active = user.id === state.currentUserID ? " is-selected" : "";
const row = document.createElement("tr"); const row = document.createElement("tr");
row.className = active;
row.dataset.userId = user.id;
row.innerHTML = ` row.innerHTML = `
<td><input type="checkbox" class="row-check"${checked}></td> <td><input type="checkbox" class="row-check"${checked}></td>
<td><div class="users-username"><strong>${user.username}</strong><span class="users-muted">${user.id}</span></div></td> <td><div class="users-username"><strong>${escapeHTML(user.username)}</strong><span class="users-muted">${escapeHTML(user.id)}</span></div></td>
<td title="${user.email}">${user.email}</td> <td title="${escapeHTML(user.email)}">${escapeHTML(user.email)}</td>
<td>${statusPill(user.status)}</td> <td><span class="users-pill ${escapeHTML(user.status)}">${escapeHTML(user.status)}</span></td>
<td>${user.role}</td> <td>${escapeHTML(permissionsSummary(user.permissions))}</td>
<td>${user.plan}</td> <td>${escapeHTML(limitsSummary(user.limits))}</td>
<td>${user.boxes}</td> <td>${user.api_key_count || 0}</td>
<td>${user.lastSeen}</td> <td>${escapeHTML(user.last_seen_at || "never")}</td>
<td><div class="users-row-actions"><button class="win98-button users-row-button" type="button" data-action="open">Open</button></div></td> <td><div class="users-row-actions"><button class="win98-button users-row-button" type="button" data-action="edit">Edit</button><button class="win98-button users-row-button" type="button" data-action="keys">Keys</button></div></td>
`; `;
row.addEventListener("click", (event) => {
if (event.target.closest(".row-check") || event.target.closest("button")) return;
setSelectedUser(user.id, "edit");
});
row.querySelector(".row-check")?.addEventListener("change", (event) => { row.querySelector(".row-check")?.addEventListener("change", (event) => {
if (event.target.checked) state.selected.add(user.id); if (event.target.checked) state.selected.add(user.id);
else state.selected.delete(user.id); else state.selected.delete(user.id);
if (event.target.checked) state.currentUserID = user.id;
populateSelectedPanels();
syncSelected(); syncSelected();
syncMasterCheck(); syncMasterCheck();
render();
}); });
row.querySelector('[data-action="open"]')?.addEventListener("click", () => { row.querySelector('[data-action="edit"]')?.addEventListener("click", () => setSelectedUser(user.id, "edit"));
toast(`Mock user preview: ${user.username}`); row.querySelector('[data-action="keys"]')?.addEventListener("click", () => setSelectedUser(user.id, "keys"));
});
return row; return row;
} }
@@ -123,19 +311,11 @@
masterCheck.checked = checks.length > 0 && checks.every((item) => item.checked); masterCheck.checked = checks.length > 0 && checks.every((item) => item.checked);
} }
function renderStats() {
document.getElementById("stat-total").textContent = String(users.length);
document.getElementById("stat-active").textContent = String(users.filter((u) => u.status === "active").length);
document.getElementById("stat-pending").textContent = String(users.filter((u) => u.status === "pending").length);
document.getElementById("stat-disabled").textContent = String(users.filter((u) => u.status === "disabled").length);
}
function render() { function render() {
const rows = filtered(); const rows = filteredUsers();
const page = paged(rows); const page = paged(rows);
body.innerHTML = ""; body.innerHTML = "";
page.rows.forEach((user) => body.appendChild(renderRow(user))); page.rows.forEach((user) => body.appendChild(renderRow(user)));
visiblePill.textContent = `${rows.length} visible`; visiblePill.textContent = `${rows.length} visible`;
pageInfo.textContent = `Page ${state.page} / ${page.pages}`; pageInfo.textContent = `Page ${state.page} / ${page.pages}`;
prevBtn.disabled = state.page <= 1; prevBtn.disabled = state.page <= 1;
@@ -145,75 +325,109 @@
syncMasterCheck(); syncMasterCheck();
} }
function clearFilters() { async function fetchUsers() {
search.value = ""; const result = await api("/admin/users/list");
status.value = "all"; state.users = result.users || [];
role.value = "all"; if (state.currentUserID && !state.users.some((user) => user.id === state.currentUserID)) {
sort.value = "username"; state.currentUserID = "";
state.page = 1; }
state.selected = new Set([...state.selected].filter((id) => state.users.some((user) => user.id === id)));
renderStats();
populateSelectedPanels();
render(); render();
} }
function applyBulk(nextStatus) { async function saveUser(payload, successMessage) {
const selected = users.filter((user) => state.selected.has(user.id)); const result = await api("/admin/users/save", "POST", payload);
if (!selected.length) { state.currentUserID = result.user?.id || state.currentUserID;
state.selected.clear();
if (state.currentUserID) state.selected.add(state.currentUserID);
await fetchUsers();
toast(successMessage);
return result.user;
}
async function deleteUser(userID) {
const user = state.users.find((item) => item.id === userID);
if (!user) return;
if (!confirm(`Delete ${user.username}?`)) return;
await api("/admin/users/delete", "POST", { id: userID });
state.currentUserID = "";
state.selected.delete(userID);
await fetchUsers();
setTab("add");
toast("User deleted");
}
async function revokeAPIKey(keyID) {
const user = selectedUser();
if (!user || !keyID) return;
await api("/admin/users/api-keys/revoke", "POST", { user_id: user.id, key_id: keyID });
await fetchUsers();
setTab("keys");
toast("API key revoked");
}
async function runCommand(command) {
switch (command) {
case "tab-add":
case "create":
setTab("add");
break;
case "refresh":
await fetchUsers();
toast("Users list refreshed");
break;
case "bulk-disable":
case "bulk-enable": {
const nextStatus = command === "bulk-disable" ? "disabled" : "active";
const ids = Array.from(state.selected);
if (!ids.length) {
toast("Select one or more users first", "warning"); toast("Select one or more users first", "warning");
return; return;
} }
selected.forEach((user) => { user.status = nextStatus; }); for (const id of ids) {
toast(`Updated ${selected.length} user(s) to ${nextStatus}`); const user = state.users.find((item) => item.id === id);
renderStats(); if (!user) continue;
render(); await api("/admin/users/save", "POST", payloadFromUser(user, { status: nextStatus }));
} }
await fetchUsers();
function runCommand(command) { toast(`Updated ${ids.length} user(s)`);
switch (command) {
case "invite":
modeInput.value = "invite";
toast("Invite mode selected");
break; break;
case "create": }
modeInput.value = "create"; case "bulk-delete": {
toast("Create mode selected"); const ids = Array.from(state.selected);
if (!ids.length) {
toast("Select one or more users first", "warning");
return;
}
if (!confirm(`Delete ${ids.length} selected user(s)?`)) return;
for (const id of ids) await api("/admin/users/delete", "POST", { id });
state.selected.clear();
state.currentUserID = "";
await fetchUsers();
setTab("add");
toast(`Deleted ${ids.length} user(s)`);
break; break;
case "export": }
toast("Mock CSV export complete"); case "clear-filters":
break; search.value = "";
case "bulk-disable": statusFilter.value = "all";
applyBulk("disabled"); sort.value = "username";
break;
case "bulk-enable":
applyBulk("active");
break;
case "bulk-revoke":
toast("Mock session revocation queued");
break;
case "refresh":
toast("Users list refreshed");
render();
break;
case "pending-only":
status.value = "pending";
state.page = 1; state.page = 1;
render(); render();
break; break;
case "clear-filters":
clearFilters();
break;
case "policy-help":
toast("Policy editor will be added in user details later.");
break;
case "mock-note":
toast("Mock-only page: no backend writes yet.");
break;
default: default:
toast(`Mock action: ${command}`);
break; break;
} }
} }
[search, status, role, sort, size].forEach((el) => { document.querySelectorAll(".users-tab").forEach((tab) => {
el.addEventListener(el.tagName === "INPUT" ? "input" : "change", () => { tab.addEventListener("click", () => setTab(tab.dataset.tab));
});
[search, statusFilter, sort, size].forEach((element) => {
element.addEventListener(element.tagName === "INPUT" ? "input" : "change", () => {
state.page = 1; state.page = 1;
render(); render();
}); });
@@ -231,74 +445,131 @@
masterCheck.addEventListener("change", () => { masterCheck.addEventListener("change", () => {
Array.from(body.querySelectorAll("tr")).forEach((row) => { Array.from(body.querySelectorAll("tr")).forEach((row) => {
const userID = row.dataset.userId || "";
const checkbox = row.querySelector(".row-check"); const checkbox = row.querySelector(".row-check");
if (!checkbox) return; if (!checkbox || !userID) return;
checkbox.checked = masterCheck.checked; checkbox.checked = masterCheck.checked;
const userID = row.querySelector(".users-muted")?.textContent || "";
if (masterCheck.checked) state.selected.add(userID); if (masterCheck.checked) state.selected.add(userID);
else state.selected.delete(userID); else state.selected.delete(userID);
}); });
syncSelected(); if (state.selected.size === 1) state.currentUserID = Array.from(state.selected)[0];
populateSelectedPanels();
render();
}); });
selectVisible.addEventListener("click", () => { selectVisible.addEventListener("click", () => {
Array.from(body.querySelectorAll("tr")).forEach((row) => { Array.from(body.querySelectorAll("tr")).forEach((row) => {
const userID = row.dataset.userId || "";
const checkbox = row.querySelector(".row-check"); const checkbox = row.querySelector(".row-check");
const userID = row.querySelector(".users-muted")?.textContent || ""; if (!checkbox || !userID) return;
if (!checkbox) return;
checkbox.checked = true; checkbox.checked = true;
state.selected.add(userID); state.selected.add(userID);
}); });
syncSelected(); if (state.selected.size === 1) state.currentUserID = Array.from(state.selected)[0];
syncMasterCheck(); populateSelectedPanels();
render();
}); });
form.addEventListener("submit", (event) => { addForm.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
const username = usernameInput.value.trim(); const username = fields.add.username.value.trim();
const email = emailInput.value.trim(); const email = fields.add.email.value.trim();
const mode = modeInput.value;
if (!username || !email) { if (!username || !email) {
toast("Username and email are required", "warning"); toast("Username and email are required", "warning");
return; return;
} }
users.unshift({ try {
id: `u_${username.toLowerCase().replaceAll(/[^a-z0-9]+/g, "_")}`, await saveUser({
username, username,
email, email,
status: mode === "invite" ? "pending" : "active", status: fields.add.status.value,
role: roleInput.value, max_file_size_mb: numericMB(fields.add.maxFile),
plan: planInput.value, max_box_size_mb: numericMB(fields.add.maxBox),
boxes: 0, permissions: permissionPayload(fields.add),
created: new Date().toISOString().slice(0, 10), }, "User created");
lastSeen: "never" addForm.reset();
fields.add.status.value = "active";
fields.add.maxFile.value = "0";
fields.add.maxBox.value = "0";
[fields.add.web, fields.add.api, fields.add.create, fields.add.upload].forEach((item) => { item.checked = true; });
setTab("edit");
} catch (error) {
toast(error.message || "Could not create user", "warning");
}
}); });
form.reset();
modeInput.value = "invite"; editForm.addEventListener("submit", async (event) => {
renderStats(); event.preventDefault();
render(); const user = selectedUser();
toast(mode === "invite" ? "Mock invite created" : "Mock user created"); if (!user) return;
try {
await saveUser(payloadFromUser(user, {
username: fields.edit.username.value.trim(),
email: fields.edit.email.value.trim(),
status: fields.edit.status.value,
}), "User updated");
} catch (error) {
toast(error.message || "Could not update user", "warning");
}
});
fields.edit.delete.addEventListener("click", () => {
const user = selectedUser();
if (user) deleteUser(user.id);
});
policiesForm.addEventListener("submit", async (event) => {
event.preventDefault();
const user = selectedUser();
if (!user) return;
try {
await saveUser(payloadFromUser(user, {
max_file_size_mb: numericMB(fields.policies.maxFile),
max_box_size_mb: numericMB(fields.policies.maxBox),
permissions: permissionPayload(fields.policies),
}), "Policies updated");
} catch (error) {
toast(error.message || "Could not update policies", "warning");
}
});
apiKeyForm.addEventListener("submit", async (event) => {
event.preventDefault();
const user = selectedUser();
if (!user) return;
try {
const result = await api("/admin/users/api-keys/create", "POST", {
user_id: user.id,
name: fields.keys.name.value.trim() || "default",
});
apiKeyReveal.hidden = false;
apiKeyValue.value = result.api_key || "";
await fetchUsers();
setTab("keys");
toast("API key generated");
} catch (error) {
toast(error.message || "Could not generate API key", "warning");
}
}); });
document.querySelectorAll("[data-command]").forEach((button) => { document.querySelectorAll("[data-command]").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", async () => {
menuController.close(); menuController.close();
runCommand(button.dataset.command); try {
await runCommand(button.dataset.command);
} catch (error) {
toast(error.message || "Action failed", "warning");
}
}); });
}); });
document.addEventListener("keydown", (event) => { document.addEventListener("keydown", async (event) => {
if (event.key === "Escape") menuController.close(); if (event.key === "Escape") menuController.close();
if (event.key === "F5") { if (event.key === "F5") {
event.preventDefault(); event.preventDefault();
runCommand("refresh"); await runCommand("refresh");
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "i") {
event.preventDefault();
runCommand("invite");
} }
}); });
renderStats(); fetchUsers().catch((error) => toast(error.message || "Failed to load users", "warning"));
render();
})(); })();

View File

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

View File

@@ -34,8 +34,10 @@ function setBoxOptionsLocked(locked) {
function updateDisabledReasons() { function updateDisabledReasons() {
if (el.startButton) { if (el.startButton) {
let reason = ""; let reason = "";
const policyMessage = apiKeyPolicyMessage();
if (!uploadsEnabled) reason = "Guest uploads are disabled."; if (!uploadsEnabled) reason = "Guest uploads are disabled.";
else if (uploadLocked) reason = "This upload already started. Press Clear to create another box."; else if (uploadLocked) reason = "This upload already started. Press Clear to create another box.";
else if (policyMessage) reason = policyMessage;
else if (hasQuotaError()) reason = "Over maximum upload size. Remove highlighted files or clear some files."; else if (hasQuotaError()) reason = "Over maximum upload size. Remove highlighted files or clear some files.";
else if (!files.length) reason = "There are no files selected. Please select files to upload."; else if (!files.length) reason = "There are no files selected. Please select files to upload.";
el.startButton.disabled = false; el.startButton.disabled = false;
@@ -101,6 +103,13 @@ function syncMenuChecks() {
function syncApiKeyField() { function syncApiKeyField() {
const enabled = Boolean(el.apiKeyMode?.checked) && !uploadLocked; const enabled = Boolean(el.apiKeyMode?.checked) && !uploadLocked;
el.apiKeyRow?.classList.toggle("is-visible", Boolean(el.apiKeyMode?.checked)); el.apiKeyRow?.classList.toggle("is-visible", Boolean(el.apiKeyMode?.checked));
if (!el.apiKeyMode?.checked) {
clearTimeout(apiKeyTimer);
apiKeyValidationRun += 1;
resetAccountLimits();
updateLimitHint();
renderFiles();
}
if (el.apiKeyInput) { if (el.apiKeyInput) {
el.apiKeyInput.disabled = !enabled; el.apiKeyInput.disabled = !enabled;
el.apiKeyInput.dataset.disabledReason = enabled ? "" : "Enable Use API key for larger quota before typing an API key."; el.apiKeyInput.dataset.disabledReason = enabled ? "" : "Enable Use API key for larger quota before typing an API key.";
@@ -115,30 +124,83 @@ function validateApiKeyField() {
wrapper?.classList.remove("is-checking"); wrapper?.classList.remove("is-checking");
if (!el.apiKeyMode?.checked) { if (!el.apiKeyMode?.checked) {
apiKeyValidationRun += 1;
resetAccountLimits();
el.apiKeyState.textContent = ""; el.apiKeyState.textContent = "";
updateLimitHint();
renderFiles();
return; return;
} }
const value = el.apiKeyInput.value.trim(); const value = el.apiKeyInput.value.trim();
if (!value) { if (!value) {
apiKeyValidationRun += 1;
resetAccountLimits();
el.apiKeyState.textContent = "waiting"; el.apiKeyState.textContent = "waiting";
updateLimitHint();
renderFiles();
saveSettings(); saveSettings();
return; return;
} }
if (!validApiKey(value)) {
apiKeyValidationRun += 1;
resetAccountLimits();
el.apiKeyInput.value = "";
el.apiKeyState.textContent = "invalid";
updateLimitHint();
renderFiles();
saveSettings();
showToast("Invalid API key removed. Paste a valid API key to save it.", "warning");
return;
}
const runID = apiKeyValidationRun + 1;
apiKeyValidationRun = runID;
el.apiKeyInput.disabled = true; el.apiKeyInput.disabled = true;
wrapper?.classList.add("is-checking"); wrapper?.classList.add("is-checking");
el.apiKeyState.textContent = "checking"; el.apiKeyState.textContent = "checking";
apiKeyTimer = setTimeout(() => { apiKeyTimer = setTimeout(async () => {
try {
const response = await fetch("/auth/me", {
headers: { Authorization: `Bearer ${value}` },
});
let payload = {};
try {
payload = await response.json();
} catch (_) {}
if (runID !== apiKeyValidationRun) return;
wrapper?.classList.remove("is-checking"); wrapper?.classList.remove("is-checking");
el.apiKeyInput.disabled = uploadLocked; el.apiKeyInput.disabled = uploadLocked;
if (validApiKey(value)) { if (!response.ok || !payload.user) {
el.apiKeyState.textContent = "saved locally"; resetAccountLimits();
saveSettings();
} else {
el.apiKeyInput.value = ""; el.apiKeyInput.value = "";
el.apiKeyState.textContent = "invalid"; el.apiKeyState.textContent = "invalid";
updateLimitHint();
renderFiles();
saveSettings(); saveSettings();
showToast("Invalid API key removed. Paste a valid API key to save it.", "warning"); showToast(payload.error || "API key was not accepted.", "warning");
return;
}
applyAccountLimits(payload.user);
const policyMessage = apiKeyPolicyMessage();
const fileText = maxFileBytes ? formatBytes(maxFileBytes) : "unlimited";
const boxText = maxBoxBytes ? formatBytes(maxBoxBytes) : "unlimited";
el.apiKeyState.textContent = policyMessage ? "limited by policy" : "account limits applied";
updateLimitHint();
renderFiles();
saveSettings();
setStatus(`${payload.user.username || payload.user.email} limits: file ${fileText}, box ${boxText}`);
if (policyMessage) showToast(policyMessage, "warning");
} catch (_) {
if (runID !== apiKeyValidationRun) return;
wrapper?.classList.remove("is-checking");
el.apiKeyInput.disabled = uploadLocked;
resetAccountLimits();
updateLimitHint();
renderFiles();
el.apiKeyState.textContent = "check failed";
showToast("Could not check API key limits.", "warning");
} }
}, 650); }, 650);
} }

View File

@@ -44,16 +44,20 @@ const el = {
const uploadsEnabled = el.form?.dataset.uploadsEnabled === "true"; const uploadsEnabled = el.form?.dataset.uploadsEnabled === "true";
const defaultRetention = el.form?.dataset.defaultRetention || "10s"; const defaultRetention = el.form?.dataset.defaultRetention || "10s";
const maxFileBytes = numberFromDataset(el.form?.dataset.maxFileBytes); const baseMaxFileBytes = numberFromDataset(el.form?.dataset.maxFileBytes);
const maxBoxBytes = numberFromDataset(el.form?.dataset.maxBoxBytes); const baseMaxBoxBytes = numberFromDataset(el.form?.dataset.maxBoxBytes);
const oneTimeRetentionKey = "one-time"; const oneTimeRetentionKey = "one-time";
let maxFileBytes = baseMaxFileBytes;
let maxBoxBytes = baseMaxBoxBytes;
let files = []; let files = [];
let shareUrl = ""; let shareUrl = "";
let uploadLocked = false; let uploadLocked = false;
let statusTimer = null; let statusTimer = null;
let pendingDuplicateFiles = []; let pendingDuplicateFiles = [];
let apiKeyTimer = null; let apiKeyTimer = null;
let apiKeyValidationRun = 0;
let authenticatedUser = null;
let completedImpactKeys = new Set(); let completedImpactKeys = new Set();
let overallImpactDone = false; let overallImpactDone = false;
@@ -105,6 +109,33 @@ function hasQuotaError() {
return isOverBoxQuota() || oversizedFiles().length > 0; return isOverBoxQuota() || oversizedFiles().length > 0;
} }
function effectiveLimit(baseLimit, userLimit) {
return numberFromDataset(userLimit);
}
function resetAccountLimits() {
authenticatedUser = null;
maxFileBytes = baseMaxFileBytes;
maxBoxBytes = baseMaxBoxBytes;
}
function applyAccountLimits(user) {
authenticatedUser = user || null;
const limits = authenticatedUser?.limits || {};
maxFileBytes = effectiveLimit(baseMaxFileBytes, limits.max_file_size_bytes);
maxBoxBytes = effectiveLimit(baseMaxBoxBytes, limits.max_box_size_bytes);
}
function apiKeyPolicyMessage() {
if (!el.apiKeyMode?.checked || !authenticatedUser) return "";
const permissions = authenticatedUser.permissions || {};
if (authenticatedUser.status && authenticatedUser.status !== "active") return "The API key belongs to a disabled account.";
if (!permissions.can_use_api) return "This account is not allowed to use the API.";
if (!permissions.can_create_box) return "This account is not allowed to create boxes.";
if (!permissions.can_upload_file) return "This account is not allowed to upload files.";
return "";
}
function normalizedFileName(name) { function normalizedFileName(name) {
return String(name || "").trim().toLowerCase(); return String(name || "").trim().toLowerCase();
} }

View File

@@ -35,100 +35,111 @@
<div class="menu-item"> <div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">File</button> <button class="menu-button" type="button" aria-expanded="false">File</button>
<div class="menu-popup"> <div class="menu-popup">
<button class="menu-action" type="button" data-command="invite"><span>I</span><span>Invite user</span><span>Ctrl+I</span></button> <button class="menu-action" type="button" data-command="tab-add"><span>N</span><span>New user</span><span></span></button>
<button class="menu-action" type="button" data-command="create"><span>C</span><span>Create local user</span><span></span></button>
<div class="menu-separator"></div> <div class="menu-separator"></div>
<button class="menu-action" type="button" data-command="export"><span>E</span><span>Export visible CSV</span><span></span></button> <button class="menu-action" type="button" data-command="refresh"><span>R</span><span>Refresh users</span><span>F5</span></button>
</div> </div>
</div> </div>
<div class="menu-item"> <div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">Users</button> <button class="menu-button" type="button" aria-expanded="false">Users</button>
<div class="menu-popup"> <div class="menu-popup">
<button class="menu-action" type="button" data-command="bulk-disable"><span>D</span><span>Disable selected</span><span></span></button> <button class="menu-action" type="button" data-command="bulk-disable"><span>D</span><span>Disable selected</span><span></span></button>
<button class="menu-action" type="button" data-command="bulk-enable"><span>U</span><span>Enable selected</span><span></span></button> <button class="menu-action" type="button" data-command="bulk-enable"><span>E</span><span>Enable selected</span><span></span></button>
<button class="menu-action" type="button" data-command="bulk-revoke"><span>R</span><span>Revoke sessions</span><span></span></button> <button class="menu-action" type="button" data-command="bulk-delete"><span>X</span><span>Delete selected</span><span></span></button>
</div> </div>
</div> </div>
<div class="menu-item"> <div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">View</button> <button class="menu-button" type="button" aria-expanded="false">View</button>
<div class="menu-popup"> <div class="menu-popup">
<button class="menu-action" type="button" data-command="refresh"><span>F</span><span>Refresh list</span><span>F5</span></button> <button class="menu-action" type="button" data-command="refresh"><span>F</span><span>Refresh list</span><span>F5</span></button>
<button class="menu-action" type="button" data-command="pending-only"><span>P</span><span>Show pending invites</span><span></span></button>
<button class="menu-action" type="button" data-command="clear-filters"><span>X</span><span>Clear filters</span><span></span></button> <button class="menu-action" type="button" data-command="clear-filters"><span>X</span><span>Clear filters</span><span></span></button>
</div> </div>
</div> </div>
<div class="menu-item">
<button class="menu-button" type="button" aria-expanded="false">Help</button>
<div class="menu-popup">
<button class="menu-action" type="button" data-command="policy-help"><span>?</span><span>User policy notes</span><span></span></button>
<button class="menu-action" type="button" data-command="mock-note"><span>M</span><span>Mock-only notes</span><span></span></button>
</div>
</div>
</nav> </nav>
<div class="admin-workspace-body users-page-body"> <div class="admin-workspace-body users-page-body">
<section class="users-hero">
<div>
<h2>Accounts, invites, and access</h2>
<p>Mock administrative users view for creation, invitation, filtering, and safe bulk actions.</p>
</div>
<div class="users-hero-actions">
<button class="win98-button users-action-button" type="button" data-command="invite">Invite user</button>
<button class="win98-button users-action-button" type="button" data-command="create">Create local user</button>
<button class="win98-button users-action-button" type="button" data-command="export">Export CSV</button>
<button class="win98-button users-action-button" type="button" data-command="policy-help">Policy notes</button>
</div>
</section>
<section class="users-summary-grid"> <section class="users-summary-grid">
<article class="users-stat-card is-info"><p>Total users</p><strong id="stat-total">0</strong></article> <article class="users-stat-card is-info"><p>Total users</p><strong id="stat-total">0</strong></article>
<article class="users-stat-card is-ok"><p>Active</p><strong id="stat-active">0</strong></article> <article class="users-stat-card is-ok"><p>Active</p><strong id="stat-active">0</strong></article>
<article class="users-stat-card is-warning"><p>Pending invites</p><strong id="stat-pending">0</strong></article> <article class="users-stat-card is-warning"><p>With API keys</p><strong id="stat-keys">0</strong></article>
<article class="users-stat-card is-danger"><p>Disabled</p><strong id="stat-disabled">0</strong></article> <article class="users-stat-card is-danger"><p>Disabled</p><strong id="stat-disabled">0</strong></article>
</section> </section>
<section class="users-main-grid"> <section class="users-main-grid">
<section class="users-panel"> <aside class="users-control-panel" aria-label="User actions">
<div class="users-panel-header"> <div class="users-selected-card">
<div class="users-panel-title">Create or invite <span>mock only</span></div> <span>Selected user</span>
<strong id="selected-user-name">None</strong>
<small id="selected-user-meta">Choose a row to edit policies and keys.</small>
</div> </div>
<div class="users-panel-body"> <div class="users-side-tabs" role="tablist" aria-label="User panels">
<form id="users-form" class="users-form-grid"> <button class="users-tab is-active" type="button" data-tab="add">Add New</button>
<label class="users-field">Mode <button class="users-tab" type="button" data-tab="edit">Edit</button>
<select class="users-select" id="users-mode"> <button class="users-tab" type="button" data-tab="policies">Policies</button>
<option value="invite">Send invite</option> <button class="users-tab" type="button" data-tab="keys">API Keys</button>
<option value="create">Create local user</option> </div>
</select>
</label> <section class="users-tab-panel is-active" data-panel="add">
<label class="users-field">Username<input class="users-input" id="users-username" type="text" autocomplete="off"></label> <div class="users-panel-header compact"><div class="users-panel-title">Add New</div></div>
<label class="users-field">Email<input class="users-input" id="users-email" type="email" autocomplete="off"></label> <form id="add-user-form" class="users-form-grid">
<label class="users-field">Username<input class="users-input" id="add-username" type="text" autocomplete="off"></label>
<label class="users-field">Email<input class="users-input" id="add-email" type="email" autocomplete="off"></label>
<div class="users-row-two"> <div class="users-row-two">
<label class="users-field">Role <label class="users-field">Status<select class="users-select" id="add-status"><option value="active">active</option><option value="disabled">disabled</option></select></label>
<select class="users-select" id="users-role"> <label class="users-field">Max file MB<input class="users-input" id="add-max-file" type="number" min="0" step="1" value="0"></label>
<option value="uploader">uploader</option>
<option value="operator">operator</option>
<option value="viewer">viewer</option>
<option value="admin">admin</option>
</select>
</label>
<label class="users-field">Plan
<select class="users-select" id="users-plan">
<option value="standard">standard</option>
<option value="trusted">trusted</option>
<option value="guest-like">guest-like</option>
<option value="unlimited">unlimited</option>
</select>
</label>
</div> </div>
<label class="users-check"><input type="checkbox" id="users-send-setup" checked>Send setup instructions</label> <label class="users-field">Max box MB<input class="users-input" id="add-max-box" type="number" min="0" step="1" value="0"></label>
<label class="users-check"><input type="checkbox" id="add-perm-web" checked>Allow web session login</label>
<label class="users-check"><input type="checkbox" id="add-perm-api" checked>Allow API access</label>
<label class="users-check"><input type="checkbox" id="add-perm-create" checked>Allow box creation</label>
<label class="users-check"><input type="checkbox" id="add-perm-upload" checked>Allow file uploads</label>
<div class="users-form-actions"> <div class="users-form-actions">
<button class="win98-button users-action-button" type="reset">Clear</button> <button class="win98-button users-action-button" type="reset">Clear</button>
<button class="win98-button users-action-button" type="submit">Apply</button> <button class="win98-button users-action-button" type="submit">Create User</button>
</div> </div>
</form> </form>
</div>
</section> </section>
<section class="users-tab-panel" data-panel="edit">
<div class="users-panel-header compact"><div class="users-panel-title">Edit Identity</div></div>
<form id="edit-user-form" class="users-form-grid">
<label class="users-field">Username<input class="users-input" id="edit-username" type="text" autocomplete="off" disabled></label>
<label class="users-field">Email<input class="users-input" id="edit-email" type="email" autocomplete="off" disabled></label>
<label class="users-field">Status<select class="users-select" id="edit-status" disabled><option value="active">active</option><option value="disabled">disabled</option></select></label>
<div class="users-form-actions">
<button class="win98-button users-action-button" type="button" id="delete-user-button" disabled>Delete</button>
<button class="win98-button users-action-button" type="submit" disabled id="save-edit-button">Save</button>
</div>
</form>
</section>
<section class="users-tab-panel" data-panel="policies">
<div class="users-panel-header compact"><div class="users-panel-title">Policies</div></div>
<form id="policies-form" class="users-form-grid">
<label class="users-field">Max file MB<input class="users-input" id="policy-max-file" type="number" min="0" step="1" disabled></label>
<label class="users-field">Max box MB<input class="users-input" id="policy-max-box" type="number" min="0" step="1" disabled></label>
<label class="users-check"><input type="checkbox" id="policy-perm-web" disabled>Allow web session login</label>
<label class="users-check"><input type="checkbox" id="policy-perm-api" disabled>Allow API access</label>
<label class="users-check"><input type="checkbox" id="policy-perm-create" disabled>Allow box creation</label>
<label class="users-check"><input type="checkbox" id="policy-perm-upload" disabled>Allow file uploads</label>
<div class="users-form-actions"><button class="win98-button users-action-button" type="submit" disabled id="save-policies-button">Save Policies</button></div>
</form>
</section>
<section class="users-tab-panel" data-panel="keys">
<div class="users-panel-header compact"><div class="users-panel-title">API Keys</div></div>
<form id="api-key-form" class="users-form-grid">
<label class="users-field">Key name<input class="users-input" id="api-key-name" type="text" value="default" disabled></label>
<button class="win98-button users-action-button" type="submit" disabled id="create-key-button">Generate Key</button>
</form>
<div class="users-key-reveal" id="api-key-reveal" hidden>
<span>New API key</span>
<input class="users-input" id="api-key-value" type="text" readonly>
</div>
<div class="users-key-list" id="api-key-list"></div>
</section>
</aside>
<section class="users-panel"> <section class="users-panel">
<div class="users-panel-header"> <div class="users-panel-header">
<div class="users-panel-title">Users <span id="visible-pill">0 visible</span></div> <div class="users-panel-title">Users <span id="visible-pill">0 visible</span></div>
@@ -136,15 +147,14 @@
<button class="win98-button users-tool-button" type="button" id="select-visible">Select visible</button> <button class="win98-button users-tool-button" type="button" id="select-visible">Select visible</button>
<button class="win98-button users-tool-button" type="button" data-command="bulk-disable">Disable</button> <button class="win98-button users-tool-button" type="button" data-command="bulk-disable">Disable</button>
<button class="win98-button users-tool-button" type="button" data-command="bulk-enable">Enable</button> <button class="win98-button users-tool-button" type="button" data-command="bulk-enable">Enable</button>
<button class="win98-button users-tool-button" type="button" data-command="bulk-revoke">Revoke</button> <button class="win98-button users-tool-button" type="button" data-command="bulk-delete">Delete</button>
</div> </div>
</div> </div>
<div class="users-panel-body users-list-body"> <div class="users-panel-body users-list-body">
<div class="users-toolbar-grid"> <div class="users-toolbar-grid">
<input class="users-input" id="users-search" type="search" placeholder="Search username or email"> <input class="users-input" id="users-search" type="search" placeholder="Search username or email">
<select class="users-select" id="users-status"><option value="all">all statuses</option><option value="active">active</option><option value="pending">pending</option><option value="disabled">disabled</option></select> <select class="users-select" id="users-status"><option value="all">all statuses</option><option value="active">active</option><option value="disabled">disabled</option></select>
<select class="users-select" id="users-role-filter"><option value="all">all roles</option><option value="admin">admin</option><option value="operator">operator</option><option value="uploader">uploader</option><option value="viewer">viewer</option></select> <select class="users-select" id="users-sort"><option value="username">sort username</option><option value="createdDesc">newest first</option><option value="lastSeenDesc">last seen</option><option value="keysDesc">api keys</option></select>
<select class="users-select" id="users-sort"><option value="username">sort username</option><option value="createdDesc">newest first</option><option value="lastSeenDesc">last seen</option><option value="boxesDesc">box count</option></select>
<select class="users-select" id="users-size"><option value="8">8 rows</option><option value="12" selected>12 rows</option><option value="20">20 rows</option></select> <select class="users-select" id="users-size"><option value="8">8 rows</option><option value="12" selected>12 rows</option><option value="20">20 rows</option></select>
</div> </div>
<div class="users-table-wrap"> <div class="users-table-wrap">
@@ -155,9 +165,9 @@
<th>User</th> <th>User</th>
<th>Email</th> <th>Email</th>
<th>Status</th> <th>Status</th>
<th>Role</th> <th>Permissions</th>
<th>Plan</th> <th>Limits</th>
<th>Boxes</th> <th>Keys</th>
<th>Last seen</th> <th>Last seen</th>
<th class="users-col-actions">Actions</th> <th class="users-col-actions">Actions</th>
</tr> </tr>
@@ -179,15 +189,15 @@
</div> </div>
<footer class="status-bar admin-dashboard-statusbar"> <footer class="status-bar admin-dashboard-statusbar">
<span id="users-status-left">Ready. Client-side mock data only.</span> <span id="users-status-left">Ready.</span>
<span>server paging planned</span> <span>real user store</span>
<span>admin only</span> <span>admin only</span>
</footer> </footer>
</div> </div>
</div> </div>
</div> </div>
<div id="toast" class="wb-toast" role="status" aria-live="polite"></div> <div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="/static/js/warpbox-ui.js"></script> <script src="/static/js/warpbox-ui.js"></script>
<script src="/static/js/admin/users.js"></script> <script src="/static/js/admin/users.js"></script>
</body> </body>

View File

@@ -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>