- 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
456 lines
16 KiB
Go
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
|
|
}
|