Files
sm-user-plugin/controllers/users_admin_controller.go
Jakub Zych 970eba7734 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
2026-10-05 13:10:12 +02:00

456 lines
16 KiB
Go

package controllers
import (
"context"
"errors"
"fmt"
"strconv"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"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"
)
// PermissionAccessUsers is the backend permission of the Users screen, the
// PHP Users::$requiredPermissions.
const PermissionAccessUsers = "golem15.users.access_users"
// usersLang prefixes the phrase keys of the Users screen.
const usersLang = "golem15.user::lang.users."
// usersAdminController is the Winter Users backend controller: the list with
// its filters and row states, and the form. The screen and every write need
// golem15.users.access_users.
//
// app is resolved per call from the booted plugin. Nothing is cached: the
// database handle, the config and the privileged group list are read when a
// request needs them.
type usersAdminController struct {
app func() *backpack.App
}
func (usersAdminController) ID() string { return "golem15.user.users" }
func (usersAdminController) ModelName() string { return `Golem15\User\Models\User` }
func (usersAdminController) ConfigDir() string { return "controllers/users" }
func (usersAdminController) RequiredPermissions() []string {
return []string{PermissionAccessUsers}
}
func (usersAdminController) NewRecord() any { return &models.User{} }
// application returns the booted app, or nil before boot.
func (c usersAdminController) application() *backpack.App {
if c.app == nil {
return nil
}
return c.app()
}
// db is the application pool, or nil when the app has none.
func (c usersAdminController) db() *gorm.DB {
app := c.application()
if app == nil {
return nil
}
gdb, ok := app.Lookup[*gorm.DB]()
if !ok {
return nil
}
return gdb
}
// config is the application config, or nil before boot.
func (c usersAdminController) config() *compass.Config {
app := c.application()
if app == nil {
return nil
}
return app.Config
}
// ListExtendQuery and FormExtendQuery include deactivated (soft-deleted)
// users, the PHP listExtendQuery and formExtendQuery withTrashed().
func (usersAdminController) ListExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
if db == nil {
return db
}
return db.Unscoped()
}
func (usersAdminController) FormExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
if db == nil {
return db
}
return db.Unscoped()
}
// ListRowStates is the PHP listInjectRowClass: deleted for a deactivated
// user, negative for a banned one, disabled for one who is not activated.
// The ban state of the whole page comes from one user_throttle query.
func (c usersAdminController) ListRowStates(ctx context.Context, db *gorm.DB, records []any) ([][]pact.RowState, error) {
ids := make([]uint, 0, len(records))
for _, record := range records {
user, ok := record.(*models.User)
if !ok || user == nil {
return nil, errors.New("users admin: list record is not a user")
}
ids = append(ids, user.ID)
}
banned, _, err := classes.ThrottleStates(classes.ContextWithThrottle(ctx, c.application()), db, ids)
if err != nil {
return nil, err
}
out := make([][]pact.RowState, len(records))
for i, record := range records {
user := record.(*models.User)
if user.DeletedAt.Valid {
out[i] = append(out[i], pact.RowStateDeleted)
}
if banned[user.ID] {
out[i] = append(out[i], pact.RowStateNegative)
}
if !user.IsActivated {
out[i] = append(out[i], pact.RowStateDisabled)
}
}
return out, nil
}
// FormRules are the rules of an admin save. They replace User.Rules(), which
// is the register contract and would ask every admin update for a confirmed
// password.
func (usersAdminController) FormRules(_ context.Context, _ string) map[string]string {
return map[string]string{
"email": "required|between:6,255|email|unique:users",
}
}
// FilterOptions serves the choices of the groups filter (scope
// filterByGroup): every user group by name. Any other scope has no choices.
func (c usersAdminController) FilterOptions(scope string) []pact.Option {
if scope != models.FilterByGroup {
return nil
}
db := c.db()
if db == nil {
return nil
}
var groups []models.UserGroup
if err := db.Session(&gorm.Session{NewDB: true}).Order("name ASC, id ASC").Find(&groups).Error; err != nil {
return nil
}
out := make([]pact.Option, 0, len(groups))
for _, group := range groups {
out = append(out, pact.Option{Value: strconv.FormatUint(uint64(group.ID), 10), Label: group.Name})
}
return out
}
// guardPrivilegedMember protects the account of a privileged-group member
// against takeover. It answers nil when the administrator on ctx holds
// golem15.users.manage_privileged_groups, or when the user is not a member of
// a group whose code is in golem15.user.privileged_groups; otherwise it
// refuses with a 403 carrying message and details. Membership is read
// through the write's transaction. A failed query is returned as it is.
func (c usersAdminController) guardPrivilegedMember(ctx context.Context, userID uint, message string, details map[string]any) error {
principal, _ := bouncer.User(ctx)
if cabana.Allows(principal, []string{classes.PermissionManagePrivilegedGroups}) {
return nil
}
db := requestDB(ctx, c.db)
if db == nil {
return errors.New("users admin: gorm handle is missing")
}
privileged, err := classes.IsPrivilegedMember(ctx, db, classes.PrivilegedGroupCodes(c.config()), userID)
if err != nil {
return err
}
if !privileged {
return nil
}
return &cabana.ForbiddenError{Message: message, Details: details}
}
// FormBeforeUpdate starts with the takeover guard: a changed email or a
// submitted password on a member of a privileged group needs
// golem15.users.manage_privileged_groups. Everything else an update does
// comes after it.
func (c usersAdminController) FormBeforeUpdate(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")
}
if err := c.guardCredentials(ctx, user); err != nil {
return err
}
return nil
}
// guardCredentials compares the email the save would write with the stored
// one and looks for a submitted password, then asks guardPrivilegedMember
// for the fields that would change.
func (c usersAdminController) guardCredentials(ctx context.Context, user *models.User) error {
db := requestDB(ctx, c.db)
if db == nil {
return errors.New("users admin: gorm handle is missing")
}
var stored []string
err := db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped().
Model(&models.User{}).Where("id = ?", user.ID).Pluck("email", &stored).Error
if err != nil {
return err
}
if len(stored) != 1 {
return errors.New("users admin: the user being updated was not found")
}
const message = usersLang + "privileged_member_forbidden"
details := map[string]any{}
if stored[0] != user.Email {
details["email"] = []string{message}
}
if values, ok := cabana.VirtualFieldsFromContext(ctx); ok {
if password, _ := values["password"].(string); password != "" {
details["password"] = []string{message}
}
}
if len(details) == 0 {
return nil
}
return c.guardPrivilegedMember(ctx, user.ID, message, details)
}
// FormBeforeDelete refuses the delete of a privileged-group member without
// golem15.users.manage_privileged_groups. The framework calls it for the
// form's delete and once per record inside the bulk delete's transaction, so
// one refused record rolls the whole selection back.
func (c usersAdminController) FormBeforeDelete(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")
}
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
}