package server import ( "encoding/json" "fmt" "net/http" "strings" "github.com/gin-gonic/gin" "warpbox/lib/metastore" ) type UserEditView struct { PageTitle string WindowTitle string WindowIcon string PageScripts []string AccountNav AccountNavView CSRFToken string Target metastore.User Tags []metastore.Tag AdminTagID string IsAdmin bool IsPending bool Status string Perms metastore.EffectivePermissions PolicyJSON string CanManage bool IsSelf bool Error string Success string // precomputed display values TagNames string CreatedAtStr string UpdatedAtStr string MaxFileSizeStr string MaxBoxSizeStr string MaxExpiryStr string // precomputed perm override checkbox states Check map[string]bool } func (app *App) handleAccountUserEdit(ctx *gin.Context) { actor, ok := currentAccountUser(ctx) if !ok { ctx.Redirect(http.StatusSeeOther, "/account/login") return } perms := currentAccountPermissions(ctx) if !perms.AdminUsersView && !perms.AdminUsersManage { ctx.String(http.StatusForbidden, "Permission denied") return } userID := strings.TrimSpace(ctx.Param("id")) view, err := app.buildUserEditView(ctx, actor, userID, perms.AdminUsersManage, "", "") if err != nil { ctx.String(http.StatusNotFound, "User not found") return } ctx.HTML(http.StatusOK, "account_user_edit.html", view) } func (app *App) handleAccountUserEditPost(ctx *gin.Context) { actor, ok := currentAccountUser(ctx) if !ok { ctx.Redirect(http.StatusSeeOther, "/account/login") return } perms := currentAccountPermissions(ctx) if !perms.AdminUsersManage { ctx.String(http.StatusForbidden, "Permission denied") return } userID := strings.TrimSpace(ctx.Param("id")) target, found, err := app.store.GetUser(userID) if err != nil || !found { redirectUserEdit(ctx, userID, "User not found.", "") return } // profile username := strings.TrimSpace(ctx.PostForm("username")) email := strings.TrimSpace(ctx.PostForm("email")) adminNote := strings.TrimSpace(ctx.PostForm("admin_note")) if username == "" { redirectUserEdit(ctx, userID, "Username is required.", "") return } // state (cannot change pending via this field) isPending := strings.HasPrefix(target.PasswordHash, "invite/") if !isPending { stateVal := ctx.PostForm("state") switch stateVal { case "disabled": if target.ID == actor.ID { redirectUserEdit(ctx, userID, "You cannot disable yourself.", "") return } if !target.Disabled { if err := app.checkLastAdminDisable([]string{target.ID}); err != nil { redirectUserEdit(ctx, userID, err.Error(), "") return } } target.Disabled = true case "active": target.Disabled = false } } // admin tag toggle adminTag, adminTagOK, err := app.store.GetTagByName(metastore.AdminTagName) if err != nil { redirectUserEdit(ctx, userID, "Could not verify admin tag.", "") return } wantsAdmin := ctx.PostForm("is_admin") == "1" if adminTagOK { hasAdmin := containsString(target.TagIDs, adminTag.ID) if wantsAdmin && !hasAdmin { target.TagIDs = append(target.TagIDs, adminTag.ID) } else if !wantsAdmin && hasAdmin { if err := app.checkLastAdminDisable([]string{target.ID}); err != nil { redirectUserEdit(ctx, userID, "Cannot remove admin from the last active administrator.", "") return } target.TagIDs = removeString(target.TagIDs, adminTag.ID) } } // per-user permission overrides target.PermOverrides = &metastore.UserPermOverrides{ UploadAllowed: boolPtr(ctx.PostForm("upload_allowed") == "1"), ManageOwnBoxes: boolPtr(ctx.PostForm("manage_own_boxes") == "1"), ZipDownloadAllowed: boolPtr(ctx.PostForm("zip_download_allowed") == "1"), OneTimeDownloadAllowed: boolPtr(ctx.PostForm("one_time_download_allowed") == "1"), RenewableAllowed: boolPtr(ctx.PostForm("renewable_allowed") == "1"), AllowPasswordProtected: boolPtr(ctx.PostForm("allow_password_protected") == "1"), RenewOnAccess: boolPtr(ctx.PostForm("renew_on_access") == "1"), RenewOnDownload: boolPtr(ctx.PostForm("renew_on_download") == "1"), AllowOwnerBoxEditing: boolPtr(ctx.PostForm("allow_owner_box_editing") == "1"), } // limits if raw := ctx.PostForm("max_file_size_bytes"); raw != "" { v, err := parseOptionalInt64(raw) if err != nil { redirectUserEdit(ctx, userID, "Max file size: "+err.Error(), "") return } target.MaxFileSizeBytes = v } else { target.MaxFileSizeBytes = nil } if raw := ctx.PostForm("max_box_size_bytes"); raw != "" { v, err := parseOptionalInt64(raw) if err != nil { redirectUserEdit(ctx, userID, "Max box size: "+err.Error(), "") return } target.MaxBoxSizeBytes = v } else { target.MaxBoxSizeBytes = nil } if raw := ctx.PostForm("max_expiry_seconds"); raw != "" { v, err := parseOptionalInt64(raw) if err != nil { redirectUserEdit(ctx, userID, "Max expiry: "+err.Error(), "") return } target.MaxExpirySeconds = v } else { target.MaxExpirySeconds = nil } target.Username = username target.Email = email target.AdminNote = adminNote if err := app.store.UpdateUser(target); err != nil { redirectUserEdit(ctx, userID, "Could not save user: "+err.Error(), "") return } redirectUserEdit(ctx, userID, "", "User saved.") } func (app *App) handleAccountUserDisable(ctx *gin.Context) { app.handleAccountUserSetDisabled(ctx, true) } func (app *App) handleAccountUserEnable(ctx *gin.Context) { app.handleAccountUserSetDisabled(ctx, false) } func (app *App) handleAccountUserSetDisabled(ctx *gin.Context, disabled bool) { actor, ok := currentAccountUser(ctx) if !ok { ctx.Redirect(http.StatusSeeOther, "/account/login") return } perms := currentAccountPermissions(ctx) if !perms.AdminUsersManage { ctx.String(http.StatusForbidden, "Permission denied") return } userID := strings.TrimSpace(ctx.Param("id")) if userID == actor.ID && disabled { redirectUserEdit(ctx, userID, "You cannot disable yourself.", "") return } if disabled { if err := app.checkLastAdminDisable([]string{userID}); err != nil { redirectUserEdit(ctx, userID, err.Error(), "") return } } target, found, err := app.store.GetUser(userID) if err != nil || !found { redirectUserEdit(ctx, userID, "User not found.", "") return } target.Disabled = disabled if err := app.store.UpdateUser(target); err != nil { redirectUserEdit(ctx, userID, "Could not update user.", "") return } action := "enabled" if disabled { action = "disabled" } redirectUserEdit(ctx, userID, "", "User "+action+".") } func (app *App) handleAccountUserPasswordReset(ctx *gin.Context) { _, ok := currentAccountUser(ctx) if !ok { ctx.Redirect(http.StatusSeeOther, "/account/login") return } perms := currentAccountPermissions(ctx) if !perms.AdminUsersManage { ctx.String(http.StatusForbidden, "Permission denied") return } userID := strings.TrimSpace(ctx.Param("id")) target, found, err := app.store.GetUser(userID) if err != nil || !found { redirectUserEdit(ctx, userID, "User not found.", "") return } newPassword := randomPassword() hash, err := metastore.HashPassword(newPassword) if err != nil { redirectUserEdit(ctx, userID, "Could not hash password.", "") return } target.PasswordHash = hash target.Disabled = false if err := app.store.UpdateUser(target); err != nil { redirectUserEdit(ctx, userID, "Could not reset password.", "") return } redirectUserEdit(ctx, userID, "", "Password reset. Temporary password: "+newPassword) } func (app *App) handleAccountUserRevokeSessions(ctx *gin.Context) { _, ok := currentAccountUser(ctx) if !ok { ctx.Redirect(http.StatusSeeOther, "/account/login") return } perms := currentAccountPermissions(ctx) if !perms.AdminUsersManage { ctx.String(http.StatusForbidden, "Permission denied") return } userID := strings.TrimSpace(ctx.Param("id")) if err := app.store.RevokeUserSessions(userID); err != nil { redirectUserEdit(ctx, userID, "Could not revoke sessions.", "") return } redirectUserEdit(ctx, userID, "", "All sessions revoked.") } func (app *App) buildUserEditView(ctx *gin.Context, actor metastore.User, userID string, canManage bool, errMsg string, successMsg string) (UserEditView, error) { target, found, err := app.store.GetUser(userID) if err != nil || !found { return UserEditView{}, err } tags, _ := app.store.ListTags() adminTagID := "" for _, t := range tags { if t.Name == metastore.AdminTagName { adminTagID = t.ID break } } isAdmin := containsString(target.TagIDs, adminTagID) isPending := strings.HasPrefix(target.PasswordHash, "invite/") status := "active" if isPending { status = "pending" } else if target.Disabled { status = "disabled" } effectivePerms, _ := app.permissionsForUser(target) policyJSON := buildPolicyJSON(target.Username, status, effectivePerms, target.PermOverrides) // tag names tagNames := make([]string, 0, len(target.TagIDs)) for _, tagID := range target.TagIDs { for _, t := range tags { if t.ID == tagID { tagNames = append(tagNames, t.Name) break } } } // perm override checkboxes checks := map[string]bool{ "upload_allowed": effectivePerms.UploadAllowed, "manage_own_boxes": false, "zip_download_allowed": effectivePerms.ZipDownloadAllowed, "one_time_download_allowed": effectivePerms.OneTimeDownloadAllowed, "renewable_allowed": effectivePerms.RenewableAllowed, "allow_password_protected": false, "renew_on_access": false, "renew_on_download": false, "allow_owner_box_editing": false, } if o := target.PermOverrides; o != nil { if o.UploadAllowed != nil { checks["upload_allowed"] = *o.UploadAllowed } if o.ManageOwnBoxes != nil { checks["manage_own_boxes"] = *o.ManageOwnBoxes } if o.ZipDownloadAllowed != nil { checks["zip_download_allowed"] = *o.ZipDownloadAllowed } if o.OneTimeDownloadAllowed != nil { checks["one_time_download_allowed"] = *o.OneTimeDownloadAllowed } if o.RenewableAllowed != nil { checks["renewable_allowed"] = *o.RenewableAllowed } if o.AllowPasswordProtected != nil { checks["allow_password_protected"] = *o.AllowPasswordProtected } if o.RenewOnAccess != nil { checks["renew_on_access"] = *o.RenewOnAccess } if o.RenewOnDownload != nil { checks["renew_on_download"] = *o.RenewOnDownload } if o.AllowOwnerBoxEditing != nil { checks["allow_owner_box_editing"] = *o.AllowOwnerBoxEditing } } return UserEditView{ PageTitle: "Edit User — " + target.Username, WindowTitle: "User Edit — " + target.Username, WindowIcon: "U", PageScripts: []string{"/static/js/account-user-edit.js"}, AccountNav: app.accountNavView(ctx, "users"), CSRFToken: app.currentCSRFToken(ctx), Target: target, Tags: tags, AdminTagID: adminTagID, IsAdmin: isAdmin, IsPending: isPending, Status: status, Perms: effectivePerms, PolicyJSON: policyJSON, CanManage: canManage, IsSelf: actor.ID == target.ID, Error: errMsg, Success: successMsg, TagNames: strings.Join(tagNames, ", "), CreatedAtStr: formatAdminTime(target.CreatedAt), UpdatedAtStr: formatAdminTime(target.UpdatedAt), MaxFileSizeStr: int64PtrStr(target.MaxFileSizeBytes), MaxBoxSizeStr: int64PtrStr(target.MaxBoxSizeBytes), MaxExpiryStr: int64PtrStr(target.MaxExpirySeconds), Check: checks, }, nil } func buildPolicyJSON(username string, status string, perms metastore.EffectivePermissions, overrides *metastore.UserPermOverrides) string { type permMap struct { BoxesCreate bool `json:"boxes.create"` ManageOwn bool `json:"boxes.manage_own"` RefreshOwn bool `json:"boxes.refresh_own"` DownloadsZip bool `json:"downloads.zip"` DownloadsOneTime bool `json:"downloads.one_time"` AdminAccess bool `json:"admin.access"` AdminUsers bool `json:"admin.users.manage"` AdminSettings bool `json:"admin.settings.manage"` } type limitsMap struct { MaxFileSizeBytes int64 `json:"max_file_size_bytes"` MaxBoxSizeBytes int64 `json:"max_box_size_bytes"` MaxExpirySeconds int64 `json:"max_expiry_seconds"` } type overridesMap struct { AllowPassword bool `json:"allow_password_protected"` RenewOnAccess bool `json:"renew_on_access"` RenewOnDownload bool `json:"renew_on_download"` AllowOwnerEdit bool `json:"allow_owner_box_editing"` } type preview struct { User string `json:"user"` Status string `json:"status"` Permissions permMap `json:"permissions"` Limits limitsMap `json:"limits"` Overrides overridesMap `json:"overrides"` } manageOwn := false allowPwd := false renewAccess := false renewDownload := false allowOwnerEdit := false if overrides != nil { if overrides.ManageOwnBoxes != nil { manageOwn = *overrides.ManageOwnBoxes } if overrides.AllowPasswordProtected != nil { allowPwd = *overrides.AllowPasswordProtected } if overrides.RenewOnAccess != nil { renewAccess = *overrides.RenewOnAccess } if overrides.RenewOnDownload != nil { renewDownload = *overrides.RenewOnDownload } if overrides.AllowOwnerBoxEditing != nil { allowOwnerEdit = *overrides.AllowOwnerBoxEditing } } p := preview{ User: username, Status: status, Permissions: permMap{ BoxesCreate: perms.UploadAllowed, ManageOwn: manageOwn, RefreshOwn: perms.RenewableAllowed, DownloadsZip: perms.ZipDownloadAllowed, DownloadsOneTime: perms.OneTimeDownloadAllowed, AdminAccess: perms.AdminAccess, AdminUsers: perms.AdminUsersManage, AdminSettings: perms.AdminSettingsManage, }, Limits: limitsMap{ MaxFileSizeBytes: perms.MaxFileSizeBytes, MaxBoxSizeBytes: perms.MaxBoxSizeBytes, MaxExpirySeconds: perms.MaxExpirySeconds, }, Overrides: overridesMap{ AllowPassword: allowPwd, RenewOnAccess: renewAccess, RenewOnDownload: renewDownload, AllowOwnerEdit: allowOwnerEdit, }, } data, err := json.MarshalIndent(p, "", " ") if err != nil { return "{}" } return string(data) } func (app *App) checkLastAdminDisable(ids []string) error { adminTag, ok, err := app.store.GetTagByName(metastore.AdminTagName) if err != nil || !ok { return nil } adminCount, err := app.store.CountAdminUsers(adminTag.ID) if err != nil { return err } removing := 0 for _, id := range ids { u, found, _ := app.store.GetUser(id) if found && !u.Disabled && containsString(u.TagIDs, adminTag.ID) { removing++ } } if adminCount-removing < 1 { return fmt.Errorf("cannot remove the last active administrator") } return nil } func int64PtrStr(v *int64) string { if v == nil { return "" } return fmt.Sprintf("%d", *v) } func redirectUserEdit(ctx *gin.Context, userID string, errMsg string, successMsg string) { base := "/account/users/" + userID if errMsg != "" { ctx.Redirect(http.StatusSeeOther, base+"?error="+errMsg) } else if successMsg != "" { ctx.Redirect(http.StatusSeeOther, base+"?success="+successMsg) } else { ctx.Redirect(http.StatusSeeOther, base) } } func containsString(slice []string, s string) bool { for _, v := range slice { if v == s { return true } } return false } func removeString(slice []string, s string) []string { out := make([]string, 0, len(slice)) for _, v := range slice { if v != s { out = append(out, v) } } return out } func boolPtr(b bool) *bool { return &b }