diff --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 ` +
+
${escapeHTML(key.name || "default")}${escapeHTML(key.prefix || key.id)}${revoked ? " · revoked" : ""}
+ +
+ `; + }).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) => { - if (sortBy === "createdDesc") return b.created.localeCompare(a.created); - if (sortBy === "lastSeenDesc") return b.lastSeen.localeCompare(a.lastSeen); - if (sortBy === "boxesDesc") return b.boxes - a.boxes; + if (sort.value === "createdDesc") return String(b.created_at).localeCompare(String(a.created_at)); + if (sort.value === "lastSeenDesc") return String(b.last_seen_at || "").localeCompare(String(a.last_seen_at || "")); + if (sort.value === "keysDesc") return (b.api_key_count || 0) - (a.api_key_count || 0); return a.username.localeCompare(b.username); }); return rows; @@ -77,40 +249,56 @@ function paged(rows) { const perPage = Number(size.value || 12); const pages = Math.max(1, Math.ceil(rows.length / perPage)); - if (state.page > pages) state.page = pages; - if (state.page < 1) state.page = 1; + state.page = Math.max(1, Math.min(state.page, pages)); 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) { - return `${value}`; + function permissionsSummary(permissions = {}) { + 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) { const checked = state.selected.has(user.id) ? " checked" : ""; + const active = user.id === state.currentUserID ? " is-selected" : ""; const row = document.createElement("tr"); + row.className = active; + row.dataset.userId = user.id; row.innerHTML = ` -
${user.username}${user.id}
- ${user.email} - ${statusPill(user.status)} - ${user.role} - ${user.plan} - ${user.boxes} - ${user.lastSeen} -
+
${escapeHTML(user.username)}${escapeHTML(user.id)}
+ ${escapeHTML(user.email)} + ${escapeHTML(user.status)} + ${escapeHTML(permissionsSummary(user.permissions))} + ${escapeHTML(limitsSummary(user.limits))} + ${user.api_key_count || 0} + ${escapeHTML(user.last_seen_at || "never")} +
`; - + 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) => { if (event.target.checked) state.selected.add(user.id); else state.selected.delete(user.id); + if (event.target.checked) state.currentUserID = user.id; + populateSelectedPanels(); syncSelected(); syncMasterCheck(); + render(); }); - row.querySelector('[data-action="open"]')?.addEventListener("click", () => { - toast(`Mock user preview: ${user.username}`); - }); + row.querySelector('[data-action="edit"]')?.addEventListener("click", () => setSelectedUser(user.id, "edit")); + row.querySelector('[data-action="keys"]')?.addEventListener("click", () => setSelectedUser(user.id, "keys")); return row; } @@ -123,19 +311,11 @@ 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() { - const rows = filtered(); + const rows = filteredUsers(); const page = paged(rows); body.innerHTML = ""; page.rows.forEach((user) => body.appendChild(renderRow(user))); - visiblePill.textContent = `${rows.length} visible`; pageInfo.textContent = `Page ${state.page} / ${page.pages}`; prevBtn.disabled = state.page <= 1; @@ -145,75 +325,109 @@ syncMasterCheck(); } - function clearFilters() { - search.value = ""; - status.value = "all"; - role.value = "all"; - sort.value = "username"; - state.page = 1; - render(); - } - - function applyBulk(nextStatus) { - const selected = users.filter((user) => state.selected.has(user.id)); - if (!selected.length) { - toast("Select one or more users first", "warning"); - return; + async function fetchUsers() { + const result = await api("/admin/users/list"); + state.users = result.users || []; + if (state.currentUserID && !state.users.some((user) => user.id === state.currentUserID)) { + state.currentUserID = ""; } - selected.forEach((user) => { user.status = nextStatus; }); - toast(`Updated ${selected.length} user(s) to ${nextStatus}`); + state.selected = new Set([...state.selected].filter((id) => state.users.some((user) => user.id === id))); renderStats(); + populateSelectedPanels(); render(); } - function runCommand(command) { + async function saveUser(payload, successMessage) { + const result = await api("/admin/users/save", "POST", payload); + 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 "invite": - modeInput.value = "invite"; - toast("Invite mode selected"); - break; + case "tab-add": case "create": - modeInput.value = "create"; - toast("Create mode selected"); - break; - case "export": - toast("Mock CSV export complete"); - break; - case "bulk-disable": - applyBulk("disabled"); - break; - case "bulk-enable": - applyBulk("active"); - break; - case "bulk-revoke": - toast("Mock session revocation queued"); + setTab("add"); break; case "refresh": + await fetchUsers(); toast("Users list refreshed"); - render(); break; - case "pending-only": - status.value = "pending"; + 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"); + return; + } + for (const id of ids) { + const user = state.users.find((item) => item.id === id); + if (!user) continue; + await api("/admin/users/save", "POST", payloadFromUser(user, { status: nextStatus })); + } + await fetchUsers(); + toast(`Updated ${ids.length} user(s)`); + break; + } + case "bulk-delete": { + 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; + } + case "clear-filters": + search.value = ""; + statusFilter.value = "all"; + sort.value = "username"; state.page = 1; render(); 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: - toast(`Mock action: ${command}`); break; } } - [search, status, role, sort, size].forEach((el) => { - el.addEventListener(el.tagName === "INPUT" ? "input" : "change", () => { + document.querySelectorAll(".users-tab").forEach((tab) => { + tab.addEventListener("click", () => setTab(tab.dataset.tab)); + }); + + [search, statusFilter, sort, size].forEach((element) => { + element.addEventListener(element.tagName === "INPUT" ? "input" : "change", () => { state.page = 1; render(); }); @@ -231,74 +445,131 @@ masterCheck.addEventListener("change", () => { Array.from(body.querySelectorAll("tr")).forEach((row) => { + const userID = row.dataset.userId || ""; const checkbox = row.querySelector(".row-check"); - if (!checkbox) return; + if (!checkbox || !userID) return; checkbox.checked = masterCheck.checked; - const userID = row.querySelector(".users-muted")?.textContent || ""; if (masterCheck.checked) state.selected.add(userID); else state.selected.delete(userID); }); - syncSelected(); + if (state.selected.size === 1) state.currentUserID = Array.from(state.selected)[0]; + populateSelectedPanels(); + render(); }); selectVisible.addEventListener("click", () => { Array.from(body.querySelectorAll("tr")).forEach((row) => { + const userID = row.dataset.userId || ""; const checkbox = row.querySelector(".row-check"); - const userID = row.querySelector(".users-muted")?.textContent || ""; - if (!checkbox) return; + if (!checkbox || !userID) return; checkbox.checked = true; state.selected.add(userID); }); - syncSelected(); - syncMasterCheck(); + if (state.selected.size === 1) state.currentUserID = Array.from(state.selected)[0]; + populateSelectedPanels(); + render(); }); - form.addEventListener("submit", (event) => { + addForm.addEventListener("submit", async (event) => { event.preventDefault(); - const username = usernameInput.value.trim(); - const email = emailInput.value.trim(); - const mode = modeInput.value; + const username = fields.add.username.value.trim(); + const email = fields.add.email.value.trim(); if (!username || !email) { toast("Username and email are required", "warning"); return; } - users.unshift({ - id: `u_${username.toLowerCase().replaceAll(/[^a-z0-9]+/g, "_")}`, - username, - email, - status: mode === "invite" ? "pending" : "active", - role: roleInput.value, - plan: planInput.value, - boxes: 0, - created: new Date().toISOString().slice(0, 10), - lastSeen: "never" - }); - form.reset(); - modeInput.value = "invite"; - renderStats(); - render(); - toast(mode === "invite" ? "Mock invite created" : "Mock user created"); + try { + await saveUser({ + username, + email, + status: fields.add.status.value, + max_file_size_mb: numericMB(fields.add.maxFile), + max_box_size_mb: numericMB(fields.add.maxBox), + permissions: permissionPayload(fields.add), + }, "User created"); + 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"); + } + }); + + editForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const user = selectedUser(); + 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) => { - button.addEventListener("click", () => { + button.addEventListener("click", async () => { 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 === "F5") { event.preventDefault(); - runCommand("refresh"); - } - if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "i") { - event.preventDefault(); - runCommand("invite"); + await runCommand("refresh"); } }); - renderStats(); - render(); + fetchUsers().catch((error) => toast(error.message || "Failed to load users", "warning")); })(); diff --git a/static/js/upload/api.js b/static/js/upload/api.js index 56ac531..eebb6ef 100644 --- a/static/js/upload/api.js +++ b/static/js/upload/api.js @@ -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() { const response = await fetch("/box", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...authHeaders() }, body: JSON.stringify({ retention_key: el.expiry?.value || defaultRetention, password: el.password?.value || "", @@ -28,7 +36,7 @@ async function markFileStatus(item, status) { try { await fetch(`/box/${item.boxID}/files/${item.boxFile.id}/status`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", ...authHeaders() }, body: JSON.stringify({ status }), }); } catch (_) { @@ -62,6 +70,8 @@ function uploadFile(item, onComplete) { formData.append("file", item.file, item.displayName); xhr.open("POST", item.boxFile.upload_path); + const headers = authHeaders(); + if (headers.Authorization) xhr.setRequestHeader("Authorization", headers.Authorization); xhr.upload.addEventListener("loadstart", () => { item.loaded = 0; diff --git a/static/js/upload/options.js b/static/js/upload/options.js index 504c775..cae498b 100644 --- a/static/js/upload/options.js +++ b/static/js/upload/options.js @@ -34,8 +34,10 @@ function setBoxOptionsLocked(locked) { function updateDisabledReasons() { if (el.startButton) { let reason = ""; + const policyMessage = apiKeyPolicyMessage(); if (!uploadsEnabled) reason = "Guest uploads are disabled."; 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 (!files.length) reason = "There are no files selected. Please select files to upload."; el.startButton.disabled = false; @@ -101,6 +103,13 @@ function syncMenuChecks() { function syncApiKeyField() { const enabled = Boolean(el.apiKeyMode?.checked) && !uploadLocked; el.apiKeyRow?.classList.toggle("is-visible", Boolean(el.apiKeyMode?.checked)); + if (!el.apiKeyMode?.checked) { + clearTimeout(apiKeyTimer); + apiKeyValidationRun += 1; + resetAccountLimits(); + updateLimitHint(); + renderFiles(); + } if (el.apiKeyInput) { el.apiKeyInput.disabled = !enabled; 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"); if (!el.apiKeyMode?.checked) { + apiKeyValidationRun += 1; + resetAccountLimits(); el.apiKeyState.textContent = ""; + updateLimitHint(); + renderFiles(); return; } const value = el.apiKeyInput.value.trim(); if (!value) { + apiKeyValidationRun += 1; + resetAccountLimits(); el.apiKeyState.textContent = "waiting"; + updateLimitHint(); + renderFiles(); saveSettings(); 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; wrapper?.classList.add("is-checking"); el.apiKeyState.textContent = "checking"; - apiKeyTimer = setTimeout(() => { - wrapper?.classList.remove("is-checking"); - el.apiKeyInput.disabled = uploadLocked; - if (validApiKey(value)) { - el.apiKeyState.textContent = "saved locally"; + 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"); + el.apiKeyInput.disabled = uploadLocked; + if (!response.ok || !payload.user) { + resetAccountLimits(); + el.apiKeyInput.value = ""; + el.apiKeyState.textContent = "invalid"; + updateLimitHint(); + renderFiles(); + saveSettings(); + 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(); - } else { - el.apiKeyInput.value = ""; - el.apiKeyState.textContent = "invalid"; - saveSettings(); - showToast("Invalid API key removed. Paste a valid API key to save it.", "warning"); + 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); } diff --git a/static/js/upload/state.js b/static/js/upload/state.js index fdffb0b..4f34e21 100644 --- a/static/js/upload/state.js +++ b/static/js/upload/state.js @@ -44,16 +44,20 @@ const el = { const uploadsEnabled = el.form?.dataset.uploadsEnabled === "true"; const defaultRetention = el.form?.dataset.defaultRetention || "10s"; -const maxFileBytes = numberFromDataset(el.form?.dataset.maxFileBytes); -const maxBoxBytes = numberFromDataset(el.form?.dataset.maxBoxBytes); +const baseMaxFileBytes = numberFromDataset(el.form?.dataset.maxFileBytes); +const baseMaxBoxBytes = numberFromDataset(el.form?.dataset.maxBoxBytes); const oneTimeRetentionKey = "one-time"; +let maxFileBytes = baseMaxFileBytes; +let maxBoxBytes = baseMaxBoxBytes; let files = []; let shareUrl = ""; let uploadLocked = false; let statusTimer = null; let pendingDuplicateFiles = []; let apiKeyTimer = null; +let apiKeyValidationRun = 0; +let authenticatedUser = null; let completedImpactKeys = new Set(); let overallImpactDone = false; @@ -105,6 +109,33 @@ function hasQuotaError() { 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) { return String(name || "").trim().toLowerCase(); } diff --git a/templates/admin/users.html b/templates/admin/users.html index 6764865..850ade0 100644 --- a/templates/admin/users.html +++ b/templates/admin/users.html @@ -35,99 +35,110 @@ -
-
-
-

Accounts, invites, and access

-

Mock administrative users view for creation, invitation, filtering, and safe bulk actions.

-
-
- - - - -
-
-

Total users

0

Active

0
-

Pending invites

0
+

With API keys

0

Disabled

0
-
-
-
Create or invite mock only
+
+
+ +
+
Edit Identity
+
+ + + +
+ + +
+
+
+ +
+
Policies
+
+ + + + + + +
+
+
+ +
+
API Keys
+
+ + +
+ +
+
+
@@ -136,15 +147,14 @@ - +
- - - + +
@@ -155,9 +165,9 @@ User Email Status - Role - Plan - Boxes + Permissions + Limits + Keys Last seen Actions @@ -179,15 +189,15 @@
-
+
diff --git a/templates/index.html b/templates/index.html index 33dd8ec..2856535 100644 --- a/templates/index.html +++ b/templates/index.html @@ -187,7 +187,7 @@