- DeactivatingEvent and DeactivatingEventName in classes - FireDeactivatingEvent modelled on FireDeletingEvent - the deactivate bulk action runs DeactivateUsers unchanged, then fires the event per newly deactivated user in the action's transaction
748 lines
27 KiB
Go
748 lines
27 KiB
Go
package controllers
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"time"
|
|
|
|
"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/lagoon"
|
|
"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
|
|
}
|
|
|
|
// FormVirtualFields are the form fields that are not columns of the form:
|
|
// the password with its confirmation, and the create-only invitation
|
|
// checkbox. The framework never fills or returns them; the Form hooks read
|
|
// them with cabana.VirtualFieldsFromContext.
|
|
func (usersAdminController) FormVirtualFields() []string {
|
|
return []string{"password", "password_confirmation", "send_invite"}
|
|
}
|
|
|
|
// 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: a create needs a confirmed password, an update takes one only
|
|
// when the administrator types it (empty means unchanged). The minimum
|
|
// length is golem15.user.password.min_length.
|
|
func (c usersAdminController) FormRules(_ context.Context, op string) map[string]string {
|
|
length := "between:" + strconv.Itoa(c.configInt("golem15.user.password.min_length", 8)) + ",255"
|
|
password := "nullable|" + length + "|confirmed"
|
|
if op == "create" {
|
|
password = "required|" + length + "|confirmed"
|
|
}
|
|
return map[string]string{
|
|
"email": "required|between:6,255|email|unique:users",
|
|
"password": password,
|
|
}
|
|
}
|
|
|
|
// configInt reads a positive integer config value, else fallback.
|
|
func (c usersAdminController) configInt(key string, fallback int) int {
|
|
if cfg := c.config(); cfg != nil {
|
|
if n := cfg.Int(key); n > 0 {
|
|
return n
|
|
}
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// 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
|
|
}
|
|
if err := c.requireFreeEmail(ctx, user); err != nil {
|
|
return err
|
|
}
|
|
changed, err := c.storePassword(ctx, user)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if changed {
|
|
// An admin password reset ends every session of the user, as the
|
|
// user's own change-password does.
|
|
now := time.Now()
|
|
user.TokensValidAfter = &now
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FormBeforeCreate checks the email against deactivated users too and stores
|
|
// the hash of the submitted password. A user created here starts not
|
|
// activated: is_activated is never written from the form, and only the
|
|
// activate actions (or the invitation link) set it.
|
|
func (c usersAdminController) FormBeforeCreate(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.requireFreeEmail(ctx, user); err != nil {
|
|
return err
|
|
}
|
|
if _, err := c.storePassword(ctx, user); err != nil {
|
|
return err
|
|
}
|
|
user.IsActivated = false
|
|
// The column defaults to true; only an account created through a social
|
|
// login starts without a password of its own.
|
|
user.HasSelfSetPassword = true
|
|
return nil
|
|
}
|
|
|
|
// requireFreeEmail refuses an email another user already has, deactivated
|
|
// users included: the unique rule leaves deactivated rows out, and the
|
|
// database constraint does not.
|
|
func (c usersAdminController) requireFreeEmail(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 taken int64
|
|
err := db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped().
|
|
Model(&models.User{}).Where("email = ? AND id <> ?", user.Email, user.ID).Count(&taken).Error
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if taken == 0 {
|
|
return nil
|
|
}
|
|
message := "The email has already been taken."
|
|
if tr := appTranslator(c.application()); tr != nil {
|
|
const key = "lagoon::validate.unique"
|
|
if text := tr.Get(ctx, key, map[string]string{"attribute": "email"}); text != "" && text != key {
|
|
message = text
|
|
}
|
|
}
|
|
return &cabana.ValidationError{Details: map[string]any{"email": []string{message}}}
|
|
}
|
|
|
|
// storePassword hashes a submitted password onto the user with
|
|
// golem15.user.password.bcrypt_cost and reports whether one was submitted.
|
|
// An absent or empty password changes nothing.
|
|
func (c usersAdminController) storePassword(ctx context.Context, user *models.User) (bool, error) {
|
|
values, _ := cabana.VirtualFieldsFromContext(ctx)
|
|
plain, _ := values["password"].(string)
|
|
if plain == "" {
|
|
return false, nil
|
|
}
|
|
hashed, err := bouncer.HashPassword(c.configInt("golem15.user.password.bcrypt_cost", 10), plain)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
user.Password = hashed
|
|
return true, nil
|
|
}
|
|
|
|
// FormAfterCreate assigns a unique handle, fires user.Created
|
|
// (CreatedEventName) inside the create transaction, and sends the
|
|
// invitation when the send_invite checkbox was set: an activation code is
|
|
// issued inside the same transaction, and exactly one
|
|
// golem15.user::mail.invite message with the activation link leaves after
|
|
// the commit. A create that rolls back sends nothing.
|
|
func (c usersAdminController) FormAfterCreate(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 create needs its transaction")
|
|
}
|
|
if err := classes.AssignHandle(ctx, tx.Session(&gorm.Session{NewDB: true}), user); err != nil {
|
|
return err
|
|
}
|
|
if app := c.application(); app != nil && app.Events != nil {
|
|
if err := app.Events.Fire(ctx, &classes.CreatedEvent{User: user, Tx: tx}); err != nil {
|
|
return fmt.Errorf("%s: %w", classes.CreatedEventName, err)
|
|
}
|
|
}
|
|
values, _ := cabana.VirtualFieldsFromContext(ctx)
|
|
if invite, _ := values["send_invite"].(bool); !invite {
|
|
return nil
|
|
}
|
|
code, err := classes.IssueActivationCode(ctx, tx.Session(&gorm.Session{NewDB: true}), user)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
app := c.application()
|
|
id, email := user.ID, user.Email
|
|
vars := mailVars(displayName(user), activationLink(app, id, code), strconv.FormatUint(uint64(id), 10)+"!"+code)
|
|
vars["login"], vars["Login"] = email, email
|
|
hours := c.configInt("golem15.user.activation.activation_ttl_hours", 72)
|
|
vars["hours"], vars["Hours"] = hours, hours
|
|
template := mailName(ctx, user, "golem15.user::mail.invite")
|
|
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
|
|
sendTemplateVars(ctx, app, template, email, vars)
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// groupsField is the form's group membership field.
|
|
const groupsField = "groups"
|
|
|
|
// AdminFieldRelations binds the organisation picker and the groups field.
|
|
// organisation_id is a protected column, so the contract opts in to writing
|
|
// it through this one field; the submitted id is still checked against the
|
|
// options. groups is the users_groups pivot: a save that carries the field
|
|
// replaces the user's memberships, and AdminRelationLocks decides which of
|
|
// them the administrator may change.
|
|
func (usersAdminController) AdminFieldRelations() []cabana.FieldRelationContract {
|
|
return []cabana.FieldRelationContract{
|
|
{
|
|
Field: "organisation",
|
|
Kind: "belongsTo",
|
|
NewRelated: func() any { return &models.Organisation{} },
|
|
ForeignKey: "organisation_id",
|
|
LabelColumn: "name",
|
|
WritableForeignKey: true,
|
|
},
|
|
{
|
|
Field: groupsField,
|
|
Kind: "belongsToMany",
|
|
NewRelated: func() any { return &models.UserGroup{} },
|
|
NewPivot: func() any { return &models.UsersGroup{} },
|
|
ParentForeignKey: "user_id",
|
|
RelatedForeignKey: "user_group_id",
|
|
LabelColumn: "name",
|
|
},
|
|
}
|
|
}
|
|
|
|
// AdminRelationLocks locks the privileged groups in the groups field for an
|
|
// administrator who lacks golem15.users.manage_privileged_groups: the
|
|
// framework shows them locked and answers 403, before any row is written, to
|
|
// a create or update that would add or remove one. Membership of a
|
|
// privileged group makes a site administrator, so golem15.users.access_users
|
|
// alone must not be able to grant or revoke it. The privileged codes come
|
|
// from golem15.user.privileged_groups on every call and the group ids are
|
|
// read through the write's transaction when there is one. An administrator
|
|
// without a principal on the context gets the locks. Every other field has
|
|
// none.
|
|
func (c usersAdminController) AdminRelationLocks(ctx context.Context, field string) (cabana.RelationLock, error) {
|
|
if field != groupsField {
|
|
return cabana.RelationLock{}, nil
|
|
}
|
|
principal, _ := bouncer.User(ctx)
|
|
if cabana.Allows(principal, []string{classes.PermissionManagePrivilegedGroups}) {
|
|
return cabana.RelationLock{}, nil
|
|
}
|
|
db := requestDB(ctx, c.db)
|
|
if db == nil {
|
|
return cabana.RelationLock{}, errors.New("users admin: gorm handle is missing")
|
|
}
|
|
ids, err := classes.PrivilegedGroupIDs(ctx, db, classes.PrivilegedGroupCodes(c.config()))
|
|
if err != nil {
|
|
return cabana.RelationLock{}, err
|
|
}
|
|
return cabana.RelationLock{IDs: ids, Message: usersLang + "privileged_group_forbidden"}, nil
|
|
}
|
|
|
|
// permissionsField is the form's permission editor field.
|
|
const permissionsField = "permissions"
|
|
|
|
// AdminPermissionOptions offers the rows of
|
|
// golem15_user_frontend_permissions, by tab and code. None is locked.
|
|
func (c usersAdminController) AdminPermissionOptions(ctx context.Context, field string) ([]cabana.PermissionOption, error) {
|
|
if field != permissionsField {
|
|
return nil, fmt.Errorf("users admin: unknown permission field %q", field)
|
|
}
|
|
db := requestDB(ctx, c.db)
|
|
if db == nil {
|
|
return nil, errors.New("users admin: gorm handle is missing")
|
|
}
|
|
rows, err := classes.FrontendPermissionOptions(ctx, db)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
options := make([]cabana.PermissionOption, 0, len(rows))
|
|
for _, row := range rows {
|
|
option := cabana.PermissionOption{Code: row.Code, Label: row.Label, Tab: row.Tab}
|
|
if row.Comment != nil {
|
|
option.Comment = *row.Comment
|
|
}
|
|
options = append(options, option)
|
|
}
|
|
return options, nil
|
|
}
|
|
|
|
// AdminPermissionValues returns the user's own stored permissions.
|
|
func (usersAdminController) AdminPermissionValues(_ context.Context, field string, record any) (map[string]int, error) {
|
|
user, ok := record.(*models.User)
|
|
if field != permissionsField || !ok || user == nil {
|
|
return nil, fmt.Errorf("users admin: cannot read permission field %q", field)
|
|
}
|
|
values := make(map[string]int, len(user.Permissions))
|
|
for code, value := range user.Permissions {
|
|
values[code] = value
|
|
}
|
|
return values, nil
|
|
}
|
|
|
|
// AdminSetPermissionValues puts the validated set on the user; the save
|
|
// writes the row. Zero values are left out on write (PermissionSet.Value).
|
|
func (usersAdminController) AdminSetPermissionValues(_ context.Context, field string, record any, values map[string]int) error {
|
|
user, ok := record.(*models.User)
|
|
if field != permissionsField || !ok || user == nil {
|
|
return fmt.Errorf("users admin: cannot write permission field %q", field)
|
|
}
|
|
set := make(models.PermissionSet, len(values))
|
|
for code, value := range values {
|
|
set[code] = value
|
|
}
|
|
user.Permissions = set
|
|
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 := FireDeletingEvent(ctx, c.application(), user, tx); err != nil {
|
|
return fmt.Errorf("%s: %w", classes.DeletingEventName, err)
|
|
}
|
|
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", c.deactivateUsers),
|
|
bulk("restore", classes.RestoreUsers),
|
|
bulk("ban", classes.BanUsers),
|
|
bulk("unban", classes.UnbanUsers),
|
|
}
|
|
}
|
|
|
|
// deactivateUsers runs classes.DeactivateUsers unchanged and then fires
|
|
// user.Deactivating, in the action's transaction, for every selected user
|
|
// that was not deactivated before. A listener error rolls the action back.
|
|
func (c usersAdminController) deactivateUsers(ctx context.Context, tx *gorm.DB, users []*models.User) (int, error) {
|
|
pending := make([]*models.User, 0, len(users))
|
|
for _, user := range users {
|
|
if user != nil && user.ID != 0 && !user.DeletedAt.Valid {
|
|
pending = append(pending, user)
|
|
}
|
|
}
|
|
changed, err := classes.DeactivateUsers(ctx, tx, users)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
for _, user := range pending {
|
|
if err := FireDeactivatingEvent(ctx, c.application(), user, tx); err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
return changed, nil
|
|
}
|
|
|
|
// 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
|
|
}
|