Total users
0diff --git a/lib/routing/routes.go b/lib/routing/routes.go index f60185f..1768f5b 100644 --- a/lib/routing/routes.go +++ b/lib/routing/routes.go @@ -26,6 +26,11 @@ type Handlers struct { AdminBoxes gin.HandlerFunc AdminBoxesAction gin.HandlerFunc AdminUsers gin.HandlerFunc + AdminUsersList gin.HandlerFunc + AdminUsersSave gin.HandlerFunc + AdminUsersDelete gin.HandlerFunc + AdminUserKeyCreate gin.HandlerFunc + AdminUserKeyRevoke gin.HandlerFunc AdminActivity gin.HandlerFunc AdminSecurity gin.HandlerFunc AdminAlertsAction gin.HandlerFunc @@ -36,6 +41,10 @@ type Handlers struct { AdminSettingsImport gin.HandlerFunc AdminSettingsReset gin.HandlerFunc AdminAuth gin.HandlerFunc + UserLogin gin.HandlerFunc + UserLogout gin.HandlerFunc + UserMe gin.HandlerFunc + UserCreateAPIKey gin.HandlerFunc } 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. router.POST("/box/:id/upload", handlers.DirectBoxUpload) 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.GET("/login", handlers.AdminLogin) @@ -70,6 +83,11 @@ func Register(router *gin.Engine, handlers Handlers) { protected.GET("/boxes", handlers.AdminBoxes) protected.POST("/boxes/actions", handlers.AdminBoxesAction) 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("/security", handlers.AdminSecurity) protected.POST("/security/actions", handlers.AdminSecurityAction) diff --git a/lib/server/admin_users.go b/lib/server/admin_users.go index 30bbdd2..b5a4fd6 100644 --- a/lib/server/admin_users.go +++ b/lib/server/admin_users.go @@ -2,10 +2,82 @@ package server import ( "net/http" + "strconv" + "strings" + "time" "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) { if !app.adminLoginEnabled() { ctx.Redirect(http.StatusSeeOther, "/") @@ -18,3 +90,154 @@ func (app *App) handleAdminUsers(ctx *gin.Context) { "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}) +} diff --git a/lib/server/server.go b/lib/server/server.go index 20f85ff..6e070cb 100644 --- a/lib/server/server.go +++ b/lib/server/server.go @@ -17,6 +17,7 @@ import ( "warpbox/lib/config" "warpbox/lib/routing" "warpbox/lib/security" + "warpbox/lib/userstore" ) type App struct { @@ -26,6 +27,7 @@ type App struct { alertStore *alerts.Store securityGuard *security.Guard appVersion string + userStore *userstore.Store } func Run(addr string) error { @@ -61,6 +63,11 @@ func Run(addr string) error { securityGuard: security.NewGuard(), 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 { return err } @@ -99,6 +106,11 @@ func Run(addr string) error { AdminBoxes: app.handleAdminBoxes, AdminBoxesAction: app.handleAdminBoxesAction, AdminUsers: app.handleAdminUsers, + AdminUsersList: app.handleAdminUsersList, + AdminUsersSave: app.handleAdminUsersSave, + AdminUsersDelete: app.handleAdminUsersDelete, + AdminUserKeyCreate: app.handleAdminUserAPIKeyCreate, + AdminUserKeyRevoke: app.handleAdminUserAPIKeyRevoke, AdminActivity: app.handleAdminActivity, AdminSecurity: app.handleAdminSecurity, AdminAlertsAction: app.handleAdminAlertsAction, @@ -109,6 +121,10 @@ func Run(addr string) error { AdminSettingsImport: app.handleAdminSettingsImport, AdminSettingsReset: app.handleAdminSettingsReset, AdminAuth: app.adminAuthMiddleware, + UserLogin: app.handleUserLogin, + UserLogout: app.handleUserLogout, + UserMe: app.handleUserMe, + UserCreateAPIKey: app.handleSelfCreateAPIKey, }) compressed := router.Group("/", gzip.Gzip(gzip.DefaultCompression)) diff --git a/lib/server/uploads.go b/lib/server/uploads.go index d50777e..e5fbb17 100644 --- a/lib/server/uploads.go +++ b/lib/server/uploads.go @@ -17,7 +17,11 @@ func (app *App) handleCreateBox(ctx *gin.Context) { if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) { return } - app.limitRequestBody(ctx) + actor, ok := app.authorizeUpload(ctx) + if !ok { + return + } + app.limitRequestBodyForActor(ctx, actor) boxID, err := boxstore.NewBoxID() if err != nil { @@ -35,7 +39,7 @@ func (app *App) handleCreateBox(ctx *gin.Context) { ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid box payload"}) 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()}) return } @@ -60,7 +64,11 @@ func (app *App) handleManifestFileUpload(ctx *gin.Context) { if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) { return } - app.limitRequestBody(ctx) + actor, ok := app.authorizeUpload(ctx) + if !ok { + return + } + app.limitRequestBodyForActor(ctx, actor) boxID := ctx.Param("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"}) 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) ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -135,7 +143,11 @@ func (app *App) handleDirectBoxUpload(ctx *gin.Context) { if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) { return } - app.limitRequestBody(ctx) + actor, ok := app.authorizeUpload(ctx) + if !ok { + return + } + app.limitRequestBodyForActor(ctx, actor) boxID := ctx.Param("id") 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"}) 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()}) return } @@ -169,7 +181,11 @@ func (app *App) handleLegacyUpload(ctx *gin.Context) { if !app.requireAPI(ctx) || !app.requireGuestUploads(ctx) { return } - app.limitRequestBody(ctx) + actor, ok := app.authorizeUpload(ctx) + if !ok { + return + } + app.limitRequestBodyForActor(ctx, actor) form, err := ctx.MultipartForm() if err != nil { @@ -184,13 +200,13 @@ func (app *App) handleLegacyUpload(ctx *gin.Context) { } totalSize := int64(0) 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()}) return } 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()}) return } @@ -226,7 +242,7 @@ func (app *App) handleLegacyUpload(ctx *gin.Context) { for _, file := range files { 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()}) return } diff --git a/lib/server/user_auth.go b/lib/server/user_auth.go new file mode 100644 index 0000000..6d68770 --- /dev/null +++ b/lib/server/user_auth.go @@ -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)}) +} diff --git a/lib/server/validation.go b/lib/server/validation.go index b8cfdb1..f1f1191 100644 --- a/lib/server/validation.go +++ b/lib/server/validation.go @@ -29,6 +29,10 @@ func (app *App) requireGuestUploads(ctx *gin.Context) bool { } 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 { return nil } @@ -45,19 +49,23 @@ func (app *App) validateCreateBoxRequest(request *models.CreateBoxRequest) error totalSize := int64(0) for _, file := range request.Files { - if err := app.validateFileSize(file.Size); err != nil { + if err := app.validateFileSizeForActor(file.Size, actor); err != nil { return err } totalSize += file.Size } - return app.validateBoxSize(totalSize) + return app.validateBoxSizeForActor(totalSize, actor) } 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 } - if app.config.GlobalMaxBoxSizeBytes <= 0 { + if app.effectiveMaxBoxBytes(actor) <= 0 { return nil } @@ -69,23 +77,27 @@ func (app *App) validateIncomingFile(boxID string, size int64) error { for _, file := range files { totalSize += file.Size } - return app.validateBoxSize(totalSize) + return app.validateBoxSizeForActor(totalSize, actor) } 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 } manifest, err := boxstore.ReadManifest(boxID) if err != nil { - return app.validateIncomingFile(boxID, size) + return app.validateIncomingFileForActor(boxID, size, actor) } if boxstore.IsExpired(manifest) { _ = boxstore.DeleteBox(boxID) return fmt.Errorf("Box expired") } - if app.config.GlobalMaxBoxSizeBytes <= 0 { + if app.effectiveMaxBoxBytes(actor) <= 0 { return nil } totalSize := int64(0) @@ -101,24 +113,54 @@ func (app *App) validateManifestFileUpload(boxID string, fileID string, size int if !found { totalSize += size } - return app.validateBoxSize(totalSize) + return app.validateBoxSizeForActor(totalSize, actor) } 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 { 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 nil } func (app *App) validateBoxSize(size int64) error { + return app.validateBoxSizeForActor(size, nil) +} + +func (app *App) validateBoxSizeForActor(size int64, actor *requestActor) error { if size < 0 { 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 nil @@ -137,7 +179,11 @@ func (app *App) rejectExpiredManifestBox(boxID string) error { } 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 { return } @@ -145,9 +191,14 @@ func (app *App) limitRequestBody(ctx *gin.Context) { } func (app *App) maxRequestBodyBytes() int64 { - limit := app.config.GlobalMaxBoxSizeBytes - if limit <= 0 || app.config.GlobalMaxFileSizeBytes > limit { - limit = app.config.GlobalMaxFileSizeBytes + return app.maxRequestBodyBytesForActor(nil) +} + +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 { return 0 diff --git a/lib/userstore/store.go b/lib/userstore/store.go new file mode 100644 index 0000000..2f3b67d --- /dev/null +++ b/lib/userstore/store.go @@ -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() +} diff --git a/static/css/users.css b/static/css/users.css index a2272af..7a5d448 100644 --- a/static/css/users.css +++ b/static/css/users.css @@ -1,6 +1,7 @@ .users-page-body { display: grid; gap: 10px; + align-items: start; } .users-hero { @@ -69,11 +70,92 @@ .users-main-grid { display: grid; - grid-template-columns: minmax(320px, .65fr) minmax(0, 1.35fr); + grid-template-columns: 320px minmax(0, 1fr); gap: 10px; 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 { min-height: 0; display: flex; @@ -97,6 +179,11 @@ border-bottom: 1px solid #b0b0b0; } +.users-panel-header.compact { + margin: -8px -8px 0; + min-height: 30px; +} + .users-panel-title { display: flex; align-items: center; @@ -194,13 +281,13 @@ .users-toolbar-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; } .users-table-wrap { - min-height: 420px; - height: 420px; + min-height: 360px; + height: min(54vh, 520px); overflow: auto; background: #ffffff; 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(even) { background: rgba(240,244,255,.9); } .users-table tbody tr:hover { background: #d8e5f8; } +.users-table tbody tr.is-selected { background: #c8d8ff; } .users-col-check { width: 30px; } .users-col-actions { width: 136px; } @@ -301,6 +389,70 @@ 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) { .users-main-grid, .users-hero { diff --git a/static/js/admin/users.js b/static/js/admin/users.js index 69f703e..4b368a4 100644 --- a/static/js/admin/users.js +++ b/static/js/admin/users.js @@ -3,8 +3,7 @@ const toastTarget = document.getElementById("toast"); const body = document.getElementById("users-body"); const search = document.getElementById("users-search"); - const status = document.getElementById("users-status"); - const role = document.getElementById("users-role-filter"); + const statusFilter = document.getElementById("users-status"); const sort = document.getElementById("users-sort"); const size = document.getElementById("users-size"); const masterCheck = document.getElementById("users-master-check"); @@ -14,61 +13,234 @@ const prevBtn = document.getElementById("users-prev"); const nextBtn = document.getElementById("users-next"); const selectVisible = document.getElementById("select-visible"); - const form = document.getElementById("users-form"); - const modeInput = document.getElementById("users-mode"); - const usernameInput = document.getElementById("users-username"); - const emailInput = document.getElementById("users-email"); - const roleInput = document.getElementById("users-role"); - const planInput = document.getElementById("users-plan"); const statusLeft = document.getElementById("users-status-left"); + 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 = [ - { id: "u_admin", username: "admin", email: "admin@warpbox.local", status: "active", role: "admin", plan: "unlimited", boxes: 18, created: "2026-04-12", lastSeen: "active now" }, - { id: "u_geo", username: "geo", email: "geo@example.test", status: "active", role: "uploader", plan: "trusted", boxes: 7, created: "2026-04-21", lastSeen: "today 12:10" }, - { id: "u_reo", username: "reo", email: "reo@example.test", status: "active", role: "uploader", plan: "standard", boxes: 3, created: "2026-04-20", lastSeen: "today 09:44" }, - { id: "u_teo", username: "teo", email: "teo@example.test", status: "active", role: "uploader", plan: "trusted", boxes: 5, created: "2026-04-19", lastSeen: "yesterday" }, - { id: "u_mara", username: "mara", email: "mara@example.test", status: "pending", role: "viewer", plan: "guest-like", boxes: 0, created: "2026-04-28", lastSeen: "never" }, - { id: "u_ion", username: "ion", email: "ion@example.test", status: "disabled", role: "uploader", plan: "standard", boxes: 2, created: "2026-04-01", lastSeen: "2026-04-15" }, - { id: "u_sara", username: "sara", email: "sara@example.test", status: "active", role: "operator", plan: "trusted", boxes: 12, created: "2026-03-30", lastSeen: "today 08:25" }, - { id: "u_vlad", username: "vlad", email: "vlad@example.test", status: "pending", role: "uploader", plan: "standard", boxes: 0, created: "2026-04-27", lastSeen: "never" }, - { id: "u_lina", username: "lina", email: "lina@example.test", status: "active", role: "viewer", plan: "guest-like", boxes: 1, created: "2026-03-22", lastSeen: "2026-04-29" }, - { id: "u_adi", username: "adi", email: "adi@example.test", status: "active", role: "uploader", plan: "standard", boxes: 4, created: "2026-02-18", lastSeen: "2026-04-26" }, - { id: "u_nora", username: "nora", email: "nora@example.test", status: "disabled", role: "viewer", plan: "guest-like", boxes: 0, created: "2026-01-14", lastSeen: "2026-03-02" }, - { id: "u_alex", username: "alex", email: "alex@example.test", status: "active", role: "uploader", plan: "trusted", boxes: 9, created: "2026-04-10", lastSeen: "2026-04-30" }, - { id: "u_rina", username: "rina", email: "rina@example.test", status: "pending", role: "uploader", plan: "standard", boxes: 0, created: "2026-04-29", lastSeen: "never" }, - { id: "u_mihai", username: "mihai", email: "mihai@example.test", status: "active", role: "operator", plan: "trusted", boxes: 6, created: "2026-02-08", lastSeen: "2026-04-22" } - ]; + const state = { + page: 1, + users: [], + selected: new Set(), + currentUserID: "", + }; - 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") { if (window.WarpBoxUI) { - window.WarpBoxUI.toast(message, type, { target: toastTarget, duration: 2200 }); + window.WarpBoxUI.toast(message, type, { target: toastTarget, duration: 3200 }); return; } - if (!toastTarget) return; - toastTarget.textContent = message; - toastTarget.classList.add("is-visible"); + if (toastTarget) toastTarget.textContent = message; } - function filtered() { - const query = search.value.trim().toLowerCase(); - const statusFilter = status.value; - const roleFilter = role.value; - const sortBy = sort.value; - const rows = users.filter((user) => { - const matchesQuery = !query || user.username.toLowerCase().includes(query) || user.email.toLowerCase().includes(query); - const matchesStatus = statusFilter === "all" || user.status === statusFilter; - const matchesRole = roleFilter === "all" || user.role === roleFilter; - return matchesQuery && matchesStatus && matchesRole; - }); + function escapeHTML(value) { + return String(value || "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + } + 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 = `
Select a user to manage API keys.
`; + 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 = `No API keys yet.
`; + return; + } + apiKeyList.innerHTML = keys.map((key) => { + const revoked = Boolean(key.revoked_at); + return ` +Mock administrative users view for creation, invitation, filtering, and safe bulk actions.
-Total users
0Active
0Pending invites
0With API keys
0Disabled
0