|
|
|
|
@@ -3,6 +3,7 @@ package controllers
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"strconv"
|
|
|
|
|
|
|
|
|
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
|
|
|
|
@@ -12,6 +13,7 @@ import (
|
|
|
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
|
|
|
"git.golem15.com/golem15/summercms/modules/compass"
|
|
|
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
|
|
|
"gocloud.dev/blob"
|
|
|
|
|
"gorm.io/gorm"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@@ -237,3 +239,217 @@ func (c usersAdminController) FormBeforeDelete(ctx context.Context, model any) e
|
|
|
|
|
}
|
|
|
|
|
return c.guardPrivilegedMember(ctx, user.ID, usersLang+"privileged_member_delete_forbidden", nil)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// FormAfterDelete makes the delete permanent, the PHP update_onDelete and
|
|
|
|
|
// bulk delete forceDelete(): the list keeps deactivated users, so the
|
|
|
|
|
// framework's soft delete alone would change nothing an administrator can
|
|
|
|
|
// see. The user's throttle rows, group memberships and attachments go in the
|
|
|
|
|
// same transaction; the files' blobs are removed after it commits.
|
|
|
|
|
// FormBeforeDelete has already refused a privileged-group member.
|
|
|
|
|
func (c usersAdminController) FormAfterDelete(ctx context.Context, model any) error {
|
|
|
|
|
user, ok := model.(*models.User)
|
|
|
|
|
if !ok || user == nil {
|
|
|
|
|
return errors.New("users admin: form model is not a user")
|
|
|
|
|
}
|
|
|
|
|
tx, ok := cabana.TxFromContext(ctx)
|
|
|
|
|
if !ok {
|
|
|
|
|
return errors.New("users admin: a delete needs its transaction")
|
|
|
|
|
}
|
|
|
|
|
if err := classes.ForceDeleteCleanup(ctx, tx, c.bucket(), user); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
return tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped().
|
|
|
|
|
Where("id = ?", user.ID).Delete(&models.User{}).Error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// bucket is the application's blob bucket, or nil when none is published.
|
|
|
|
|
func (c usersAdminController) bucket() *blob.Bucket {
|
|
|
|
|
app := c.application()
|
|
|
|
|
if app == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
bucket, ok := app.Lookup[*blob.Bucket]()
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return bucket
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// usersOf asserts that every record of a bulk action is a user.
|
|
|
|
|
func usersOf(records []any) ([]*models.User, error) {
|
|
|
|
|
users := make([]*models.User, 0, len(records))
|
|
|
|
|
for _, record := range records {
|
|
|
|
|
user, ok := record.(*models.User)
|
|
|
|
|
if !ok || user == nil {
|
|
|
|
|
return nil, errors.New("users admin: bulk record is not a user")
|
|
|
|
|
}
|
|
|
|
|
users = append(users, user)
|
|
|
|
|
}
|
|
|
|
|
return users, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// AdminBulkActions are the PHP index_onBulkAction cases next to the built-in
|
|
|
|
|
// delete: activate, deactivate, restore, ban and unban. Each needs only
|
|
|
|
|
// golem15.users.access_users, changes the users it applies to and reports how
|
|
|
|
|
// many that were. The fixed success phrase is returned only when every
|
|
|
|
|
// selected user was changed; otherwise the framework shows its count text,
|
|
|
|
|
// which never overstates. None of them touches users_groups.
|
|
|
|
|
func (c usersAdminController) AdminBulkActions() []pact.AdminBulkAction {
|
|
|
|
|
bulk := func(name string, run func(ctx context.Context, tx *gorm.DB, users []*models.User) (int, error)) pact.AdminBulkAction {
|
|
|
|
|
return pact.AdminBulkAction{
|
|
|
|
|
Name: name,
|
|
|
|
|
Label: usersLang + name + "_selected",
|
|
|
|
|
Confirm: usersLang + name + "_selected_confirm",
|
|
|
|
|
Permissions: []string{PermissionAccessUsers},
|
|
|
|
|
Run: func(ctx context.Context, in pact.AdminBulkActionInput) (pact.AdminBulkActionResult, error) {
|
|
|
|
|
users, err := usersOf(in.Records)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return pact.AdminBulkActionResult{}, err
|
|
|
|
|
}
|
|
|
|
|
tx, ok := cabana.TxFromContext(ctx)
|
|
|
|
|
if !ok {
|
|
|
|
|
return pact.AdminBulkActionResult{}, errors.New("users admin: a bulk action needs its transaction")
|
|
|
|
|
}
|
|
|
|
|
affected, err := run(classes.ContextWithThrottle(ctx, c.application()), tx, users)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return pact.AdminBulkActionResult{}, err
|
|
|
|
|
}
|
|
|
|
|
result := pact.AdminBulkActionResult{Affected: affected}
|
|
|
|
|
if affected == len(users) {
|
|
|
|
|
result.Message = usersLang + name + "_selected_success"
|
|
|
|
|
}
|
|
|
|
|
return result, nil
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return []pact.AdminBulkAction{
|
|
|
|
|
bulk("activate", classes.ActivateUsers),
|
|
|
|
|
bulk("deactivate", classes.DeactivateUsers),
|
|
|
|
|
bulk("restore", classes.RestoreUsers),
|
|
|
|
|
bulk("ban", classes.BanUsers),
|
|
|
|
|
bulk("unban", classes.UnbanUsers),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// throttleState reads whether one user is banned and whether they are
|
|
|
|
|
// suspended, through the write's transaction when there is one.
|
|
|
|
|
func (c usersAdminController) throttleState(ctx context.Context, userID uint) (banned, suspended bool, err error) {
|
|
|
|
|
db := requestDB(ctx, c.db)
|
|
|
|
|
if db == nil {
|
|
|
|
|
return false, false, errors.New("users admin: gorm handle is missing")
|
|
|
|
|
}
|
|
|
|
|
bannedSet, suspendedSet, err := classes.ThrottleStates(classes.ContextWithThrottle(ctx, c.application()), db, []uint{userID})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false, false, err
|
|
|
|
|
}
|
|
|
|
|
return bannedSet[userID], suspendedSet[userID], nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// AdminRecordActions are the PHP preview handlers onActivate, onUnban and
|
|
|
|
|
// onUnsuspendUser. Each is offered only while it applies; the checks only
|
|
|
|
|
// read. There is no impersonate action and no guest action.
|
|
|
|
|
func (c usersAdminController) AdminRecordActions() []pact.AdminRecordAction {
|
|
|
|
|
asUser := func(record any) (*models.User, error) {
|
|
|
|
|
user, ok := record.(*models.User)
|
|
|
|
|
if !ok || user == nil {
|
|
|
|
|
return nil, errors.New("users admin: action record is not a user")
|
|
|
|
|
}
|
|
|
|
|
return user, nil
|
|
|
|
|
}
|
|
|
|
|
action := func(name, label, confirm, success string,
|
|
|
|
|
applies func(ctx context.Context, user *models.User) (bool, error),
|
|
|
|
|
run func(ctx context.Context, tx *gorm.DB, user *models.User) error,
|
|
|
|
|
) pact.AdminRecordAction {
|
|
|
|
|
return pact.AdminRecordAction{
|
|
|
|
|
Name: name,
|
|
|
|
|
Label: usersLang + label,
|
|
|
|
|
Confirm: usersLang + confirm,
|
|
|
|
|
Permissions: []string{PermissionAccessUsers},
|
|
|
|
|
Applies: func(ctx context.Context, record any) (bool, error) {
|
|
|
|
|
user, err := asUser(record)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false, err
|
|
|
|
|
}
|
|
|
|
|
return applies(ctx, user)
|
|
|
|
|
},
|
|
|
|
|
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
|
|
|
|
|
user, err := asUser(in.Record)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return pact.AdminRecordActionResult{}, err
|
|
|
|
|
}
|
|
|
|
|
tx, ok := cabana.TxFromContext(ctx)
|
|
|
|
|
if !ok {
|
|
|
|
|
return pact.AdminRecordActionResult{}, errors.New("users admin: a record action needs its transaction")
|
|
|
|
|
}
|
|
|
|
|
if err := run(ctx, tx, user); err != nil {
|
|
|
|
|
return pact.AdminRecordActionResult{}, err
|
|
|
|
|
}
|
|
|
|
|
return pact.AdminRecordActionResult{Message: usersLang + success}, nil
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return []pact.AdminRecordAction{
|
|
|
|
|
action("activate", "activate_user", "activate_confirm", "activated_success",
|
|
|
|
|
func(_ context.Context, user *models.User) (bool, error) { return !user.IsActivated, nil },
|
|
|
|
|
func(ctx context.Context, tx *gorm.DB, user *models.User) error {
|
|
|
|
|
_, err := classes.ActivateUsers(ctx, tx, []*models.User{user})
|
|
|
|
|
return err
|
|
|
|
|
}),
|
|
|
|
|
action("unban", "unban_user", "unban_confirm", "unbanned_success",
|
|
|
|
|
func(ctx context.Context, user *models.User) (bool, error) {
|
|
|
|
|
banned, _, err := c.throttleState(ctx, user.ID)
|
|
|
|
|
return banned, err
|
|
|
|
|
},
|
|
|
|
|
func(ctx context.Context, tx *gorm.DB, user *models.User) error {
|
|
|
|
|
_, err := classes.UnbanUsers(ctx, tx, []*models.User{user})
|
|
|
|
|
return err
|
|
|
|
|
}),
|
|
|
|
|
action("unsuspend", "unsuspend_user", "unsuspend_confirm", "unsuspend_success",
|
|
|
|
|
func(ctx context.Context, user *models.User) (bool, error) {
|
|
|
|
|
_, suspended, err := c.throttleState(ctx, user.ID)
|
|
|
|
|
return suspended, err
|
|
|
|
|
},
|
|
|
|
|
func(ctx context.Context, tx *gorm.DB, user *models.User) error {
|
|
|
|
|
return classes.UnsuspendUser(ctx, tx, user.ID)
|
|
|
|
|
}),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// usersHintView is the view model of controllers/users/_hint.htm, the status
|
|
|
|
|
// callout above the preview: the callout kind (danger or warning) and the
|
|
|
|
|
// phrase keys of its title and text. It is empty when no state applies, and
|
|
|
|
|
// the template then renders nothing.
|
|
|
|
|
type usersHintView struct {
|
|
|
|
|
Kind string
|
|
|
|
|
Title string
|
|
|
|
|
Text string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// PartialData serves the preview header partial `hint`: one callout by
|
|
|
|
|
// precedence banned, deactivated, not activated, suspended (the PHP
|
|
|
|
|
// _hint_banned, _hint_trashed and _hint_activate partials plus a suspension
|
|
|
|
|
// hint). Any other partial name is an error.
|
|
|
|
|
func (c usersAdminController) PartialData(ctx context.Context, name string, record any) (any, error) {
|
|
|
|
|
if name != "hint" {
|
|
|
|
|
return nil, fmt.Errorf("users admin: unknown partial %q", name)
|
|
|
|
|
}
|
|
|
|
|
user, ok := record.(*models.User)
|
|
|
|
|
if !ok || user == nil {
|
|
|
|
|
return usersHintView{}, nil
|
|
|
|
|
}
|
|
|
|
|
banned, suspended, err := c.throttleState(ctx, user.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
switch {
|
|
|
|
|
case banned:
|
|
|
|
|
return usersHintView{Kind: "danger", Title: usersLang + "banned_hint_title", Text: usersLang + "banned_hint_desc"}, nil
|
|
|
|
|
case user.DeletedAt.Valid:
|
|
|
|
|
return usersHintView{Kind: "danger", Title: usersLang + "trashed_hint_title", Text: usersLang + "trashed_hint_desc"}, nil
|
|
|
|
|
case !user.IsActivated:
|
|
|
|
|
return usersHintView{Kind: "warning", Title: usersLang + "activate_warning_title", Text: usersLang + "activate_warning_desc"}, nil
|
|
|
|
|
case suspended:
|
|
|
|
|
return usersHintView{Kind: "warning", Title: usersLang + "suspended_hint_title", Text: usersLang + "suspended_hint_desc"}, nil
|
|
|
|
|
}
|
|
|
|
|
return usersHintView{}, nil
|
|
|
|
|
}
|
|
|
|
|
|