feat(users): implement comprehensive user listing and control
This commit is contained in:
557
lib/server/account_user_edit.go
Normal file
557
lib/server/account_user_edit.go
Normal file
@@ -0,0 +1,557 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user