feat: user preview, bulk and record actions and the permanent delete

- the list opens the preview, which shows one status hint (banned,
  deactivated, not activated, suspended) and the IP address fields
- bulk actions activate, deactivate, restore, ban and unban; record
  actions activate, unban and unsuspend, offered while they apply
- delete (form button and bulk) removes the user for good with the
  throttle rows, group memberships and attachments in one transaction;
  blobs go after the commit
- a privileged-group member is still refused, a bulk delete as a whole
This commit is contained in:
Jakub Zych
2026-10-05 13:10:12 +02:00
parent ebaf3a0817
commit 970eba7734
12 changed files with 1125 additions and 15 deletions

View File

@@ -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
}