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:
2
admin.go
2
admin.go
@@ -12,7 +12,7 @@ import (
|
||||
// adminFS holds the admin YAML and partials, file by file: the controllers
|
||||
// directory also holds files that are not admin assets.
|
||||
//
|
||||
//go:embed controllers/users/config_list.yaml controllers/users/config_form.yaml controllers/users/config_filter.yaml models/user/fields.yaml models/user/columns.yaml
|
||||
//go:embed controllers/users/config_list.yaml controllers/users/config_form.yaml controllers/users/config_filter.yaml controllers/users/_hint.htm models/user/fields.yaml models/user/columns.yaml
|
||||
var adminFS embed.FS
|
||||
|
||||
func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
|
||||
@@ -389,15 +389,14 @@ func (e *adminEnv) join(t *testing.T, user *models.User, groups ...*models.UserG
|
||||
// reload reads a user row, deactivated or not; nil when it is gone.
|
||||
func (e *adminEnv) reload(t *testing.T, id uint) *models.User {
|
||||
t.Helper()
|
||||
var user models.User
|
||||
err := e.gdb.Unscoped().Where("id = ?", id).Take(&user).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
var users []models.User
|
||||
if err := e.gdb.Unscoped().Where("id = ?", id).Limit(1).Find(&users).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &user
|
||||
if len(users) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &users[0]
|
||||
}
|
||||
|
||||
// groupIDs returns the user's users_groups rows, ordered.
|
||||
@@ -410,6 +409,110 @@ func (e *adminEnv) groupIDs(t *testing.T, userID uint) []uint {
|
||||
return ids
|
||||
}
|
||||
|
||||
// seedAvatar stores an avatar for the user the way the user API does: one
|
||||
// system_files row (field avatar) and its blob. It returns the blob key.
|
||||
func (e *adminEnv) seedAvatar(t *testing.T, user *models.User) string {
|
||||
t.Helper()
|
||||
disk := "avatar" + adminStamp() + ".jpg"
|
||||
key := attach.BlobKey(disk)
|
||||
if err := e.bucket.WriteAll(context.Background(), key, []byte("avatar-bytes"), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row := attach.File{
|
||||
DiskName: disk,
|
||||
FileName: "avatar.jpg",
|
||||
FileSize: 12,
|
||||
ContentType: "image/jpeg",
|
||||
Field: "avatar",
|
||||
AttachmentID: strconv.FormatUint(uint64(user.ID), 10),
|
||||
AttachmentType: user.MorphName(),
|
||||
}
|
||||
if err := e.gdb.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// blobExists reports whether the bucket still holds key.
|
||||
func (e *adminEnv) blobExists(t *testing.T, key string) bool {
|
||||
t.Helper()
|
||||
ok, err := e.bucket.Exists(context.Background(), key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// seedThrottle stores one user_throttle row. ip may be empty for the row
|
||||
// without an address.
|
||||
func (e *adminEnv) seedThrottle(t *testing.T, user *models.User, ip string, banned bool, suspendedAt *time.Time) {
|
||||
t.Helper()
|
||||
row := models.Throttle{UserID: user.ID, IsBanned: banned}
|
||||
if ip != "" {
|
||||
row.IPAddress = &ip
|
||||
}
|
||||
if suspendedAt != nil {
|
||||
row.IsSuspended, row.SuspendedAt, row.Attempts = true, suspendedAt, 5
|
||||
}
|
||||
if err := e.gdb.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// hangers counts what hangs off a user: throttle rows, group memberships
|
||||
// and attachment rows.
|
||||
type hangers struct{ Throttle, Groups, Files int64 }
|
||||
|
||||
func (e *adminEnv) hangers(t *testing.T, user *models.User) hangers {
|
||||
t.Helper()
|
||||
var out hangers
|
||||
count := func(into *int64, query string, args ...any) {
|
||||
t.Helper()
|
||||
if err := e.gdb.Raw(query, args...).Scan(into).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
count(&out.Throttle, `SELECT COUNT(*) FROM user_throttle WHERE user_id = ?`, user.ID)
|
||||
count(&out.Groups, `SELECT COUNT(*) FROM users_groups WHERE user_id = ?`, user.ID)
|
||||
count(&out.Files, `SELECT COUNT(*) FROM system_files WHERE attachment_type = ? AND attachment_id = ?`,
|
||||
user.MorphName(), strconv.FormatUint(uint64(user.ID), 10))
|
||||
return out
|
||||
}
|
||||
|
||||
// bulk posts a declared bulk action, or the built-in bulk delete when action
|
||||
// is "delete", for the given users.
|
||||
func (e *adminEnv) bulk(t *testing.T, token, action string, users ...*models.User) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
ids := make([]uint, len(users))
|
||||
for i, user := range users {
|
||||
ids[i] = user.ID
|
||||
}
|
||||
path := adminAPI(adminUsersPath + "/bulk/" + action)
|
||||
if action == "delete" {
|
||||
path = adminAPI(adminUsersPath + "/bulk-delete")
|
||||
}
|
||||
return e.call(t, http.MethodPost, path, token, map[string]any{"ids": ids})
|
||||
}
|
||||
|
||||
// offered returns the record actions the show response of the user offers.
|
||||
func (e *adminEnv) offered(t *testing.T, token string, id uint) []string {
|
||||
t.Helper()
|
||||
rec := e.expect(t, http.StatusOK, http.MethodGet, userPath(id, ""), token, nil)
|
||||
var meta struct {
|
||||
Actions []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"actions"`
|
||||
}
|
||||
if err := json.Unmarshal(adminDecode(t, rec).Meta, &meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := []string{}
|
||||
for _, action := range meta.Actions {
|
||||
names = append(names, action.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// userLogin signs a frontend user in through the user API route and returns
|
||||
// the status and the token.
|
||||
func (e *adminEnv) userLogin(t *testing.T, email, password string) (int, string) {
|
||||
|
||||
@@ -6,10 +6,14 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-user-plugin/controllers"
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -374,9 +378,80 @@ func TestAdminPrivilegedMember(t *testing.T) {
|
||||
if got := env.reload(t, member.ID); got.Email != memberEmail {
|
||||
t.Fatalf("manager email change = %q", got.Email)
|
||||
}
|
||||
|
||||
// The delete is permanent, so the refusal must leave everything that
|
||||
// hangs off the member in place: throttle rows, memberships, the avatar
|
||||
// row and its blob.
|
||||
env.seedThrottle(t, member, "203.0.113.7", false, nil)
|
||||
memberAvatar := env.seedAvatar(t, member)
|
||||
memberHangers := env.hangers(t, member)
|
||||
if memberHangers.Throttle != 1 || memberHangers.Groups != 2 || memberHangers.Files != 1 {
|
||||
t.Fatalf("member seed = %+v", memberHangers)
|
||||
}
|
||||
rec := env.expect(t, http.StatusForbidden, http.MethodDelete, userPath(member.ID, ""), limited, nil)
|
||||
if answer := adminDecode(t, rec); answer.Error.Message != privilegedMemberDeleteForbidden {
|
||||
t.Fatalf("delete refusal = %s", rec.Body.String())
|
||||
}
|
||||
if env.reload(t, member.ID) == nil || env.hangers(t, member) != memberHangers || !env.blobExists(t, memberAvatar) {
|
||||
t.Fatalf("a refused delete changed the member: %+v", env.hangers(t, member))
|
||||
}
|
||||
|
||||
// A bulk delete with one privileged member in the selection is refused as
|
||||
// a whole: the two ordinary users and their rows stay as well.
|
||||
first := env.seedUser(t, "bulk-first", nil)
|
||||
second := env.seedUser(t, "bulk-second", nil)
|
||||
env.join(t, first, ordinary)
|
||||
env.seedThrottle(t, first, "", false, nil)
|
||||
firstAvatar := env.seedAvatar(t, first)
|
||||
firstHangers := env.hangers(t, first)
|
||||
// Ids are processed in ascending order, so the member (the lowest id) is
|
||||
// refused first; the reverse order is covered by the second selection.
|
||||
late := env.seedUser(t, "late-member", nil)
|
||||
env.join(t, late, adminGroup)
|
||||
for _, selection := range [][]*models.User{{member, first, second}, {first, second, late}} {
|
||||
rec = env.bulk(t, limited, "delete", selection...)
|
||||
if rec.Code != http.StatusForbidden || adminDecode(t, rec).Error.Message != privilegedMemberDeleteForbidden {
|
||||
t.Fatalf("bulk delete refusal = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
for _, user := range selection {
|
||||
if env.reload(t, user.ID) == nil {
|
||||
t.Fatalf("a refused bulk delete removed user %d", user.ID)
|
||||
}
|
||||
}
|
||||
if env.hangers(t, first) != firstHangers || env.hangers(t, member) != memberHangers ||
|
||||
!env.blobExists(t, firstAvatar) || !env.blobExists(t, memberAvatar) {
|
||||
t.Fatal("a refused bulk delete removed rows or blobs")
|
||||
}
|
||||
}
|
||||
|
||||
// Deactivating a privileged member is not a permanent delete and needs
|
||||
// only the users permission; so does restoring them.
|
||||
if rec = env.bulk(t, limited, "deactivate", late); rec.Code != http.StatusOK {
|
||||
t.Fatalf("bulk deactivate of a privileged member = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := env.reload(t, late.ID); got == nil || !got.DeletedAt.Valid {
|
||||
t.Fatalf("privileged member after deactivate = %+v", got)
|
||||
}
|
||||
if rec = env.bulk(t, limited, "restore", late); rec.Code != http.StatusOK {
|
||||
t.Fatalf("bulk restore of a privileged member = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// With the permission the form delete and the bulk delete remove the
|
||||
// member for good, with the full cleanup.
|
||||
env.expect(t, http.StatusOK, http.MethodDelete, userPath(member.ID, ""), manager, nil)
|
||||
if got := env.reload(t, member.ID); got != nil && !got.DeletedAt.Valid {
|
||||
t.Fatalf("manager delete left the user active: %+v", got)
|
||||
if env.reload(t, member.ID) != nil || env.hangers(t, member) != (hangers{}) || env.blobExists(t, memberAvatar) {
|
||||
t.Fatalf("manager delete left something of the member: %+v", env.hangers(t, member))
|
||||
}
|
||||
if rec = env.bulk(t, manager, "delete", first, second, late); rec.Code != http.StatusOK {
|
||||
t.Fatalf("manager bulk delete = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
for _, user := range []*models.User{first, second, late} {
|
||||
if env.reload(t, user.ID) != nil || env.hangers(t, user) != (hangers{}) {
|
||||
t.Fatalf("manager bulk delete left user %d: %+v", user.ID, env.hangers(t, user))
|
||||
}
|
||||
}
|
||||
if env.blobExists(t, firstAvatar) {
|
||||
t.Fatal("manager bulk delete left the avatar blob")
|
||||
}
|
||||
|
||||
// The privileged list is config, read per request: with the ordinary
|
||||
@@ -388,7 +463,7 @@ func TestAdminPrivilegedMember(t *testing.T) {
|
||||
}
|
||||
})
|
||||
movedLimited := moved.admin(t, permAccessUsers)
|
||||
rec := moved.expect(t, http.StatusForbidden, http.MethodPut, userPath(plain.ID, ""), movedLimited, map[string]any{"name": "Plain", "email": "plain-moved-" + stamp + "@example.test"})
|
||||
rec = moved.expect(t, http.StatusForbidden, http.MethodPut, userPath(plain.ID, ""), movedLimited, map[string]any{"name": "Plain", "email": "plain-moved-" + stamp + "@example.test"})
|
||||
if answer := adminDecode(t, rec); answer.Error.Message != privilegedMemberForbidden || len(answer.Error.Details["email"]) != 1 {
|
||||
t.Fatalf("moved refusal = %s", rec.Body.String())
|
||||
}
|
||||
@@ -404,3 +479,398 @@ func TestAdminPrivilegedMember(t *testing.T) {
|
||||
t.Fatal("a refusal carries the admin secret")
|
||||
}
|
||||
}
|
||||
|
||||
// bulkAnswer is the data of a bulk action answer.
|
||||
type bulkAnswer struct {
|
||||
Message string `json:"message"`
|
||||
Affected int `json:"affected"`
|
||||
}
|
||||
|
||||
// TestAdminUserActions covers the five bulk actions, the three record
|
||||
// actions and the preview status hint (D-11, D-14), and that none of them
|
||||
// writes users_groups.
|
||||
func TestAdminUserActions(t *testing.T) {
|
||||
env := newAdminEnv(t)
|
||||
token := env.admin(t, permAccessUsers)
|
||||
stranger := env.admin(t)
|
||||
|
||||
// Nothing but the ported actions is offered: no impersonate, no guest.
|
||||
var schema struct {
|
||||
RecordURL string `json:"recordUrl"`
|
||||
BulkActions []struct {
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
Confirm string `json:"confirm"`
|
||||
} `json:"bulkActions"`
|
||||
ToolbarButtons []string `json:"toolbarButtons"`
|
||||
}
|
||||
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/schema/list"), token, nil), &schema)
|
||||
var bulkNames []string
|
||||
for _, action := range schema.BulkActions {
|
||||
bulkNames = append(bulkNames, action.Name)
|
||||
if action.Label == "" || strings.Contains(action.Label, "::") || strings.Contains(action.Confirm, "::") {
|
||||
t.Fatalf("bulk action copy is not translated: %+v", action)
|
||||
}
|
||||
}
|
||||
if want := []string{"delete", "activate", "deactivate", "restore", "ban", "unban"}; !reflect.DeepEqual(bulkNames, want) {
|
||||
t.Fatalf("bulk actions = %v, want %v", bulkNames, want)
|
||||
}
|
||||
if schema.RecordURL != "golem15/user/users/preview/:id" {
|
||||
t.Fatalf("recordUrl = %q", schema.RecordURL)
|
||||
}
|
||||
var registered []string
|
||||
for _, ctl := range controllers.AdminControllers(func() *backpack.App { return env.app }) {
|
||||
if ctl.ID() != "golem15.user.users" {
|
||||
continue
|
||||
}
|
||||
for _, action := range ctl.(pact.HasAdminRecordActions).AdminRecordActions() {
|
||||
registered = append(registered, action.Name)
|
||||
}
|
||||
}
|
||||
if want := []string{"activate", "unban", "unsuspend"}; !reflect.DeepEqual(registered, want) {
|
||||
t.Fatalf("record actions = %v, want %v", registered, want)
|
||||
}
|
||||
var form struct {
|
||||
Preview *struct {
|
||||
HeaderPartial string `json:"headerPartial"`
|
||||
} `json:"preview"`
|
||||
Fields []struct {
|
||||
Name string `json:"name"`
|
||||
Context any `json:"context"`
|
||||
} `json:"fields"`
|
||||
Messages map[string]map[string]string `json:"messages"`
|
||||
}
|
||||
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/schema/form"), token, nil), &form)
|
||||
if form.Preview == nil || form.Preview.HeaderPartial != "hint" || form.Messages["edit"]["other"] != "Update details" {
|
||||
t.Fatalf("form preview = %+v messages = %v", form.Preview, form.Messages["edit"])
|
||||
}
|
||||
previewOnly := map[string]bool{}
|
||||
for _, field := range form.Fields {
|
||||
if field.Context == "preview" {
|
||||
previewOnly[field.Name] = true
|
||||
}
|
||||
}
|
||||
if !previewOnly["created_ip_address"] || !previewOnly["last_ip_address"] || len(previewOnly) != 2 {
|
||||
t.Fatalf("preview-only fields = %v", previewOnly)
|
||||
}
|
||||
|
||||
group := env.seedGroup(t, "Actions "+adminStamp(), "actions-"+adminStamp())
|
||||
seed := func(label string, mutate func(*models.User)) *models.User {
|
||||
user := env.seedUser(t, label, mutate)
|
||||
env.join(t, user, group)
|
||||
return user
|
||||
}
|
||||
notActive := func(u *models.User) { u.IsActivated = false }
|
||||
idleA, idleB, live := seed("idle-a", notActive), seed("idle-b", notActive), seed("live", nil)
|
||||
code := "pending-code"
|
||||
if err := env.gdb.Exec(`UPDATE users SET activation_code = ?, activation_code_issued_at = NOW() WHERE id = ?`, code, idleA.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
everyone := []*models.User{idleA, idleB, live}
|
||||
memberships := func() map[uint][]uint {
|
||||
out := map[uint][]uint{}
|
||||
for _, user := range everyone {
|
||||
out[user.ID] = env.groupIDs(t, user.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
before := memberships()
|
||||
untouched := func(step string) {
|
||||
t.Helper()
|
||||
if got := memberships(); !reflect.DeepEqual(got, before) {
|
||||
t.Fatalf("%s changed users_groups: %v, want %v", step, got, before)
|
||||
}
|
||||
}
|
||||
run := func(action string, users ...*models.User) bulkAnswer {
|
||||
t.Helper()
|
||||
rec := env.bulk(t, token, action, users...)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("bulk %s = %d %s", action, rec.Code, rec.Body.String())
|
||||
}
|
||||
var answer bulkAnswer
|
||||
adminInto(t, rec, &answer)
|
||||
untouched("bulk " + action)
|
||||
return answer
|
||||
}
|
||||
|
||||
// Activate skips the user who is already active and reports the count
|
||||
// without the fixed success phrase, which would overstate.
|
||||
if answer := run("activate", everyone...); answer.Affected != 2 || answer.Message != "" {
|
||||
t.Fatalf("bulk activate = %+v", answer)
|
||||
}
|
||||
for _, user := range everyone {
|
||||
got := env.reload(t, user.ID)
|
||||
if !got.IsActivated || (user.ID != live.ID && got.ActivatedAt == nil) || got.ActivationCode != nil {
|
||||
t.Fatalf("user %d after bulk activate = %+v", user.ID, got)
|
||||
}
|
||||
}
|
||||
if answer := run("activate", everyone...); answer.Affected != 0 || answer.Message != "" {
|
||||
t.Fatalf("second bulk activate = %+v", answer)
|
||||
}
|
||||
// When every selected user changes, the fixed phrase is the message.
|
||||
fresh := seed("fresh", notActive)
|
||||
everyone = append(everyone, fresh)
|
||||
before = memberships()
|
||||
if answer := run("activate", fresh); answer.Affected != 1 || answer.Message != "Successfully activated the selected users." {
|
||||
t.Fatalf("full bulk activate = %+v", answer)
|
||||
}
|
||||
|
||||
// Deactivate is the soft delete; the users stay listed as deleted.
|
||||
if answer := run("deactivate", idleA, idleB); answer.Affected != 2 || answer.Message != "Successfully deactivated the selected users." {
|
||||
t.Fatalf("bulk deactivate = %+v", answer)
|
||||
}
|
||||
page := env.users(t, token, "search="+idleA.Email)
|
||||
if !reflect.DeepEqual(page.states(idleA.ID), []string{"deleted"}) {
|
||||
t.Fatalf("deactivated row = %s", page.Raw)
|
||||
}
|
||||
if got := env.reload(t, idleB.ID); !got.DeletedAt.Valid {
|
||||
t.Fatal("bulk deactivate did not set deleted_at")
|
||||
}
|
||||
if answer := run("deactivate", idleA, live); answer.Affected != 1 || answer.Message != "" {
|
||||
t.Fatalf("mixed bulk deactivate = %+v", answer)
|
||||
}
|
||||
// Restore brings them back.
|
||||
if answer := run("restore", idleA, idleB, live); answer.Affected != 3 || answer.Message != "Successfully restored the selected users." {
|
||||
t.Fatalf("bulk restore = %+v", answer)
|
||||
}
|
||||
for _, user := range []*models.User{idleA, idleB, live} {
|
||||
if got := env.reload(t, user.ID); got.DeletedAt.Valid {
|
||||
t.Fatalf("user %d is still deactivated", user.ID)
|
||||
}
|
||||
}
|
||||
// Activating a deactivated, not activated user restores them as well.
|
||||
sleeper := seed("sleeper", notActive)
|
||||
everyone = append(everyone, sleeper)
|
||||
before = memberships()
|
||||
run("deactivate", sleeper)
|
||||
if answer := run("activate", sleeper); answer.Affected != 1 {
|
||||
t.Fatalf("activate of a deactivated user = %+v", answer)
|
||||
}
|
||||
if got := env.reload(t, sleeper.ID); !got.IsActivated || got.DeletedAt.Valid {
|
||||
t.Fatalf("sleeper after activate = %+v", got)
|
||||
}
|
||||
|
||||
// Ban refuses the login from any address and marks the row; unban lifts it.
|
||||
if status, _ := env.userLogin(t, live.Email, "correct-horse"); status != http.StatusOK {
|
||||
t.Fatalf("login before the ban = %d", status)
|
||||
}
|
||||
if answer := run("ban", live); answer.Affected != 1 || answer.Message != "Successfully banned the selected users." {
|
||||
t.Fatalf("bulk ban = %+v", answer)
|
||||
}
|
||||
for range 2 {
|
||||
if status, token := env.userLogin(t, live.Email, "correct-horse"); status != http.StatusUnauthorized || token != "" {
|
||||
t.Fatalf("banned login = %d", status)
|
||||
}
|
||||
}
|
||||
page = env.users(t, token, "search="+live.Email)
|
||||
if !reflect.DeepEqual(page.states(live.ID), []string{"negative"}) {
|
||||
t.Fatalf("banned row = %s", page.Raw)
|
||||
}
|
||||
if answer := run("ban", live, idleA); answer.Affected != 1 || answer.Message != "" {
|
||||
t.Fatalf("second bulk ban = %+v", answer)
|
||||
}
|
||||
if answer := run("unban", live, idleA, idleB); answer.Affected != 2 || answer.Message != "" {
|
||||
t.Fatalf("bulk unban = %+v", answer)
|
||||
}
|
||||
if status, _ := env.userLogin(t, live.Email, "correct-horse"); status != http.StatusOK {
|
||||
t.Fatalf("login after the unban = %d", status)
|
||||
}
|
||||
|
||||
// Record actions are offered only while they apply.
|
||||
pending := seed("pending", notActive)
|
||||
everyone = append(everyone, pending)
|
||||
before = memberships()
|
||||
if got := env.offered(t, token, pending.ID); !reflect.DeepEqual(got, []string{"activate"}) {
|
||||
t.Fatalf("offered for a not activated user = %v", got)
|
||||
}
|
||||
if got := env.offered(t, token, live.ID); len(got) != 0 {
|
||||
t.Fatalf("offered for an ordinary user = %v", got)
|
||||
}
|
||||
act := func(status int, action string, user *models.User) string {
|
||||
t.Helper()
|
||||
rec := env.expect(t, status, http.MethodPost, userPath(user.ID, "/actions/"+action), token, map[string]any{})
|
||||
untouched("record " + action)
|
||||
if status != http.StatusOK {
|
||||
return ""
|
||||
}
|
||||
var answer struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
adminInto(t, rec, &answer)
|
||||
return answer.Message
|
||||
}
|
||||
if message := act(http.StatusOK, "activate", pending); message != "User has been activated" {
|
||||
t.Fatalf("activate message = %q", message)
|
||||
}
|
||||
if got := env.reload(t, pending.ID); !got.IsActivated || got.ActivatedAt == nil {
|
||||
t.Fatalf("pending after activate = %+v", got)
|
||||
}
|
||||
act(http.StatusConflict, "activate", pending)
|
||||
act(http.StatusConflict, "unban", pending)
|
||||
act(http.StatusConflict, "unsuspend", pending)
|
||||
|
||||
run("ban", pending)
|
||||
if got := env.offered(t, token, pending.ID); !reflect.DeepEqual(got, []string{"unban"}) {
|
||||
t.Fatalf("offered for a banned user = %v", got)
|
||||
}
|
||||
if message := act(http.StatusOK, "unban", pending); message != "User has been unbanned" {
|
||||
t.Fatalf("unban message = %q", message)
|
||||
}
|
||||
if got := env.offered(t, token, pending.ID); len(got) != 0 {
|
||||
t.Fatalf("offered after unban = %v", got)
|
||||
}
|
||||
|
||||
// Suspended means suspended inside the window; an expired suspension
|
||||
// offers nothing and is not lifted by reading it.
|
||||
now, longAgo := time.Now(), time.Now().Add(-2*time.Hour)
|
||||
suspended := seed("suspended", nil)
|
||||
expired := seed("expired", nil)
|
||||
everyone = append(everyone, suspended, expired)
|
||||
before = memberships()
|
||||
env.seedThrottle(t, suspended, "203.0.113.9", false, &now)
|
||||
env.seedThrottle(t, expired, "203.0.113.9", false, &longAgo)
|
||||
if got := env.offered(t, token, suspended.ID); !reflect.DeepEqual(got, []string{"unsuspend"}) {
|
||||
t.Fatalf("offered for a suspended user = %v", got)
|
||||
}
|
||||
if got := env.offered(t, token, expired.ID); len(got) != 0 {
|
||||
t.Fatalf("offered for an expired suspension = %v", got)
|
||||
}
|
||||
var stillFlagged int64
|
||||
if err := env.gdb.Raw(`SELECT COUNT(*) FROM user_throttle WHERE user_id = ? AND is_suspended`, expired.ID).Scan(&stillFlagged).Error; err != nil || stillFlagged != 1 {
|
||||
t.Fatalf("reading an expired suspension changed it: %d %v", stillFlagged, err)
|
||||
}
|
||||
if message := act(http.StatusOK, "unsuspend", suspended); message != "User has been unsuspended." {
|
||||
t.Fatalf("unsuspend message = %q", message)
|
||||
}
|
||||
var row models.Throttle
|
||||
if err := env.gdb.Where("user_id = ?", suspended.ID).Take(&row).Error; err != nil || row.IsSuspended || row.SuspendedAt != nil || row.Attempts != 0 {
|
||||
t.Fatalf("throttle row after unsuspend = %+v err=%v", row, err)
|
||||
}
|
||||
act(http.StatusConflict, "unsuspend", suspended)
|
||||
|
||||
// The preview hint: one callout, by precedence banned, deactivated, not
|
||||
// activated, suspended.
|
||||
hint := func(user *models.User) string {
|
||||
t.Helper()
|
||||
rec := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/partials/hint?id="+strconv.FormatUint(uint64(user.ID), 10)), token, nil)
|
||||
return rec.Body.String()
|
||||
}
|
||||
worst := seed("worst", notActive)
|
||||
everyone = append(everyone, worst)
|
||||
before = memberships()
|
||||
env.seedThrottle(t, worst, "203.0.113.9", false, &now)
|
||||
if body := hint(worst); !strings.Contains(body, "User not activated!") || !strings.Contains(body, "summer-callout--warning") || strings.Contains(body, "suspended") {
|
||||
t.Fatalf("not activated beats suspended: %s", body)
|
||||
}
|
||||
run("deactivate", worst)
|
||||
if body := hint(worst); !strings.Contains(body, "User has deactivated their account") || !strings.Contains(body, "summer-callout--danger") || strings.Contains(body, "not activated") {
|
||||
t.Fatalf("deactivated beats not activated: %s", body)
|
||||
}
|
||||
run("ban", worst)
|
||||
if body := hint(worst); !strings.Contains(body, "User has been banned") || strings.Contains(body, "deactivated their account") {
|
||||
t.Fatalf("banned beats deactivated: %s", body)
|
||||
}
|
||||
lifted := seed("lifted", nil)
|
||||
everyone = append(everyone, lifted)
|
||||
before = memberships()
|
||||
env.seedThrottle(t, lifted, "203.0.113.9", false, &now)
|
||||
if body := hint(lifted); !strings.Contains(body, "User is temporarily suspended") || !strings.Contains(body, "summer-callout--warning") {
|
||||
t.Fatalf("suspended hint: %s", body)
|
||||
}
|
||||
if body := hint(live); strings.Contains(body, "summer-callout") {
|
||||
t.Fatalf("hint for an ordinary user: %s", body)
|
||||
}
|
||||
|
||||
// Every action needs the users permission.
|
||||
if rec := env.bulk(t, stranger, "ban", live); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("bulk ban without the permission = %d", rec.Code)
|
||||
}
|
||||
env.expect(t, http.StatusForbidden, http.MethodPost, userPath(lifted.ID, "/actions/unsuspend"), stranger, map[string]any{})
|
||||
env.expect(t, http.StatusForbidden, http.MethodGet, adminAPI(adminUsersPath+"/partials/hint?id=1"), stranger, nil)
|
||||
if status, _ := env.userLogin(t, live.Email, "correct-horse"); status != http.StatusOK {
|
||||
t.Fatal("a refused ban changed the user")
|
||||
}
|
||||
// An undeclared action name is not run.
|
||||
if rec := env.bulk(t, token, "impersonate", live); rec.Code == http.StatusOK {
|
||||
t.Fatalf("an unknown bulk action answered %d", rec.Code)
|
||||
}
|
||||
if rec := env.call(t, http.MethodPost, userPath(live.ID, "/actions/impersonate"), token, map[string]any{}); rec.Code == http.StatusOK {
|
||||
t.Fatalf("an unknown record action answered %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminUserForceDelete covers the permanent delete (D-13): the form
|
||||
// button and the bulk delete remove the user row with its throttle rows,
|
||||
// group memberships and attachments, and the blobs after the commit.
|
||||
func TestAdminUserForceDelete(t *testing.T) {
|
||||
env := newAdminEnv(t)
|
||||
token := env.admin(t, permAccessUsers)
|
||||
stranger := env.admin(t)
|
||||
group := env.seedGroup(t, "Delete "+adminStamp(), "delete-"+adminStamp())
|
||||
|
||||
loaded := func(label string) (*models.User, string) {
|
||||
user := env.seedUser(t, label, nil)
|
||||
env.join(t, user, group)
|
||||
// The throttle foreign key has no ON DELETE: without the cleanup the
|
||||
// delete would fail on it.
|
||||
env.seedThrottle(t, user, "203.0.113.20", false, nil)
|
||||
env.seedThrottle(t, user, "", true, nil)
|
||||
return user, env.seedAvatar(t, user)
|
||||
}
|
||||
assertGone := func(user *models.User, key string) {
|
||||
t.Helper()
|
||||
if env.reload(t, user.ID) != nil {
|
||||
t.Fatalf("user %d still exists", user.ID)
|
||||
}
|
||||
if left := env.hangers(t, user); left != (hangers{}) {
|
||||
t.Fatalf("user %d left orphans: %+v", user.ID, left)
|
||||
}
|
||||
if env.blobExists(t, key) {
|
||||
t.Fatalf("user %d left its avatar blob", user.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Without the permission nothing is deleted.
|
||||
victim, victimKey := loaded("victim")
|
||||
env.expect(t, http.StatusForbidden, http.MethodDelete, userPath(victim.ID, ""), stranger, nil)
|
||||
if rec := env.bulk(t, stranger, "delete", victim); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("bulk delete without the permission = %d", rec.Code)
|
||||
}
|
||||
if env.reload(t, victim.ID) == nil || env.hangers(t, victim) != (hangers{Throttle: 2, Groups: 1, Files: 1}) || !env.blobExists(t, victimKey) {
|
||||
t.Fatalf("a refused delete changed the user: %+v", env.hangers(t, victim))
|
||||
}
|
||||
|
||||
// The form delete.
|
||||
env.expect(t, http.StatusOK, http.MethodDelete, userPath(victim.ID, ""), token, nil)
|
||||
assertGone(victim, victimKey)
|
||||
env.expect(t, http.StatusNotFound, http.MethodGet, userPath(victim.ID, ""), token, nil)
|
||||
|
||||
// The bulk delete, with a deactivated user in the selection.
|
||||
one, oneKey := loaded("bulk-one")
|
||||
two, twoKey := loaded("bulk-two")
|
||||
bystander, bystanderKey := loaded("bystander")
|
||||
if err := env.gdb.Exec(`UPDATE users SET deleted_at = NOW() WHERE id = ?`, two.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := env.bulk(t, token, "delete", one, two)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("bulk delete = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var deleted struct {
|
||||
Deleted int `json:"deleted"`
|
||||
}
|
||||
adminInto(t, rec, &deleted)
|
||||
if deleted.Deleted != 2 {
|
||||
t.Fatalf("bulk delete count = %d", deleted.Deleted)
|
||||
}
|
||||
assertGone(one, oneKey)
|
||||
assertGone(two, twoKey)
|
||||
if env.reload(t, bystander.ID) == nil || env.hangers(t, bystander) != (hangers{Throttle: 2, Groups: 1, Files: 1}) || !env.blobExists(t, bystanderKey) {
|
||||
t.Fatal("the bulk delete touched a user outside the selection")
|
||||
}
|
||||
// The group itself is untouched.
|
||||
var groups int64
|
||||
if err := env.gdb.Raw(`SELECT COUNT(*) FROM user_groups WHERE id = ?`, group.ID).Scan(&groups).Error; err != nil || groups != 1 {
|
||||
t.Fatalf("the delete removed the group: %d %v", groups, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,22 @@ package classes
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
"gocloud.dev/blob"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// The functions in this file are the admin screen's user actions. Each takes
|
||||
// the handle of the transaction the admin write runs in and never opens one
|
||||
// of its own. None of them writes users_groups, except the cleanup of a user
|
||||
// who is being deleted for good.
|
||||
|
||||
// ThrottleStates reads the ban and suspension state of userIDs from
|
||||
// user_throttle in one query. A user is banned when any of their rows is
|
||||
// banned, and suspended when a row is suspended and its suspended_at lies
|
||||
@@ -29,7 +39,7 @@ func ThrottleStates(ctx context.Context, db *gorm.DB, userIDs []uint) (banned, s
|
||||
}
|
||||
window := time.Duration(minutes) * time.Minute
|
||||
var rows []models.Throttle
|
||||
err = db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).
|
||||
err = fresh(ctx, db).
|
||||
Where("user_id IN ? AND (is_banned OR is_suspended)", userIDs).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
@@ -46,3 +56,213 @@ func ThrottleStates(ctx context.Context, db *gorm.DB, userIDs []uint) (banned, s
|
||||
}
|
||||
return banned, suspended, nil
|
||||
}
|
||||
|
||||
// ActivateUsers activates the users that are not active yet and returns how
|
||||
// many it changed. Like VerifyActivationCode it sets is_activated, stamps
|
||||
// activated_at, clears the activation code and restores a deactivated user.
|
||||
// Users that are already active are skipped.
|
||||
func ActivateUsers(ctx context.Context, db *gorm.DB, users []*models.User) (int, error) {
|
||||
if db == nil {
|
||||
return 0, fmt.Errorf("golem15.user: gorm handle is missing")
|
||||
}
|
||||
changed := 0
|
||||
for _, user := range users {
|
||||
if user == nil || user.ID == 0 || user.IsActivated {
|
||||
continue
|
||||
}
|
||||
now := time.Now()
|
||||
err := fresh(ctx, db).Unscoped().Model(&models.User{}).Where("id = ?", user.ID).UpdateColumns(map[string]any{
|
||||
"is_activated": true,
|
||||
"activated_at": now,
|
||||
"activation_code": nil,
|
||||
"activation_code_issued_at": nil,
|
||||
"deleted_at": nil,
|
||||
}).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
user.IsActivated = true
|
||||
user.ActivatedAt = &now
|
||||
user.ActivationCode = nil
|
||||
user.ActivationCodeIssuedAt = nil
|
||||
user.DeletedAt = gorm.DeletedAt{}
|
||||
changed++
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// DeactivateUsers soft-deletes the users that are not deactivated yet, the
|
||||
// PHP $user->delete(), and returns how many it changed.
|
||||
func DeactivateUsers(ctx context.Context, db *gorm.DB, users []*models.User) (int, error) {
|
||||
if db == nil {
|
||||
return 0, fmt.Errorf("golem15.user: gorm handle is missing")
|
||||
}
|
||||
changed := 0
|
||||
for _, user := range users {
|
||||
if user == nil || user.ID == 0 || user.DeletedAt.Valid {
|
||||
continue
|
||||
}
|
||||
now := time.Now()
|
||||
err := fresh(ctx, db).Unscoped().Model(&models.User{}).Where("id = ?", user.ID).UpdateColumn("deleted_at", now).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
user.DeletedAt = gorm.DeletedAt{Time: now, Valid: true}
|
||||
changed++
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// RestoreUsers brings deactivated users back, the PHP $user->restore(), and
|
||||
// returns how many it changed.
|
||||
func RestoreUsers(ctx context.Context, db *gorm.DB, users []*models.User) (int, error) {
|
||||
if db == nil {
|
||||
return 0, fmt.Errorf("golem15.user: gorm handle is missing")
|
||||
}
|
||||
changed := 0
|
||||
for _, user := range users {
|
||||
if user == nil || user.ID == 0 || !user.DeletedAt.Valid {
|
||||
continue
|
||||
}
|
||||
err := fresh(ctx, db).Unscoped().Model(&models.User{}).Where("id = ?", user.ID).UpdateColumn("deleted_at", nil).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
user.DeletedAt = gorm.DeletedAt{}
|
||||
changed++
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// BanUsers bans the users and returns how many were not banned before. A ban
|
||||
// is set on every user_throttle row of the user, and a row without an IP
|
||||
// address is created when the user has none, so a login from any address is
|
||||
// refused (RejectIfThrottled falls back to that row).
|
||||
func BanUsers(ctx context.Context, db *gorm.DB, users []*models.User) (int, error) {
|
||||
ids, err := userIDs(db, users)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
banned, _, err := ThrottleStates(ctx, db, ids)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
changed := 0
|
||||
for _, id := range ids {
|
||||
if err := fresh(ctx, db).Model(&models.Throttle{}).Where("user_id = ?", id).UpdateColumn("is_banned", true).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var anyAddress int64
|
||||
if err := fresh(ctx, db).Model(&models.Throttle{}).Where("user_id = ? AND ip_address IS NULL", id).Count(&anyAddress).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if anyAddress == 0 {
|
||||
if err := fresh(ctx, db).Create(&models.Throttle{UserID: id, IsBanned: true}).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if !banned[id] {
|
||||
changed++
|
||||
}
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// UnbanUsers clears the ban on every user_throttle row of the users and
|
||||
// returns how many were banned before.
|
||||
func UnbanUsers(ctx context.Context, db *gorm.DB, users []*models.User) (int, error) {
|
||||
ids, err := userIDs(db, users)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
banned, _, err := ThrottleStates(ctx, db, ids)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
changed := 0
|
||||
for _, id := range ids {
|
||||
if !banned[id] {
|
||||
continue
|
||||
}
|
||||
if err := fresh(ctx, db).Model(&models.Throttle{}).Where("user_id = ?", id).UpdateColumn("is_banned", false).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
changed++
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// UnsuspendUser lifts a suspension: it clears is_suspended, suspended_at and
|
||||
// the attempt counter on every user_throttle row of the user.
|
||||
func UnsuspendUser(ctx context.Context, db *gorm.DB, userID uint) error {
|
||||
if db == nil {
|
||||
return fmt.Errorf("golem15.user: gorm handle is missing")
|
||||
}
|
||||
return fresh(ctx, db).Model(&models.Throttle{}).Where("user_id = ?", userID).UpdateColumns(map[string]any{
|
||||
"is_suspended": false,
|
||||
"suspended_at": nil,
|
||||
"attempts": 0,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// ForceDeleteCleanup removes what hangs off a user who is being deleted for
|
||||
// good: the user_throttle rows (their foreign key has no ON DELETE), the
|
||||
// users_groups rows and the attachments. It runs inside the delete's
|
||||
// transaction and does not delete the user row; the caller does that with
|
||||
// the same handle. The attachments' blobs are removed only after the
|
||||
// transaction commits, so a rollback leaves the files in place. bucket may be
|
||||
// nil: the rows are still deleted.
|
||||
func ForceDeleteCleanup(ctx context.Context, db *gorm.DB, bucket *blob.Bucket, user *models.User) error {
|
||||
if db == nil {
|
||||
return fmt.Errorf("golem15.user: gorm handle is missing")
|
||||
}
|
||||
if user == nil || user.ID == 0 {
|
||||
return fmt.Errorf("golem15.user: cannot clean up a user without an id")
|
||||
}
|
||||
if err := fresh(ctx, db).Exec(`DELETE FROM user_throttle WHERE user_id = ?`, user.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fresh(ctx, db).Exec(`DELETE FROM users_groups WHERE user_id = ?`, user.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var keys []string
|
||||
err := attach.DeleteForOwner(fresh(ctx, db), user, strconv.FormatUint(uint64(user.ID), 10), func(blobKeys []string) error {
|
||||
keys = append(keys, blobKeys...)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if bucket == nil || len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
userID := user.ID
|
||||
lagoon.AfterCommit(ctx, db, func(ctx context.Context, _ *gorm.DB) {
|
||||
// The rows are gone by now, so a blob that cannot be removed is
|
||||
// orphaned, not lost data: log it and carry on.
|
||||
if err := attach.DeleteKeys(ctx, bucket, keys); err != nil {
|
||||
slog.Default().Warn("golem15.user: remove attachment blobs of a deleted user", "user_id", userID, "err", err)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// fresh returns db with an empty statement on ctx, so a query never continues
|
||||
// from conditions the caller's handle already carries.
|
||||
func fresh(ctx context.Context, db *gorm.DB) *gorm.DB {
|
||||
return db.Session(&gorm.Session{NewDB: true}).WithContext(ctx)
|
||||
}
|
||||
|
||||
// userIDs returns the ids of users, skipping nil and unsaved entries.
|
||||
func userIDs(db *gorm.DB, users []*models.User) ([]uint, error) {
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("golem15.user: gorm handle is missing")
|
||||
}
|
||||
ids := make([]uint, 0, len(users))
|
||||
for _, user := range users {
|
||||
if user != nil && user.ID != 0 {
|
||||
ids = append(ids, user.ID)
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
@@ -25,4 +25,8 @@ func init() {
|
||||
var _ pact.FilterOptions = usersAdminController{}
|
||||
var _ pact.FormBeforeUpdate = usersAdminController{}
|
||||
var _ pact.FormBeforeDelete = usersAdminController{}
|
||||
var _ pact.FormAfterDelete = usersAdminController{}
|
||||
var _ pact.HasAdminBulkActions = usersAdminController{}
|
||||
var _ pact.HasAdminRecordActions = usersAdminController{}
|
||||
var _ pact.AdminPartialData = usersAdminController{}
|
||||
}
|
||||
|
||||
7
controllers/users/_hint.htm
Normal file
7
controllers/users/_hint.htm
Normal file
@@ -0,0 +1,7 @@
|
||||
{{- /* Status hint above the user preview: one callout, or nothing (usersHintView). */ -}}
|
||||
{{- if .Data.Title -}}
|
||||
<div class="summer-callout summer-callout--{{ .Data.Kind }}" role="status">
|
||||
<p class="summer-callout__title">{{ trans .Data.Title }}</p>
|
||||
<p class="summer-callout__text">{{ trans .Data.Text }}</p>
|
||||
</div>
|
||||
{{- end -}}
|
||||
@@ -5,10 +5,16 @@ defaultRedirect: golem15/user/users
|
||||
|
||||
update:
|
||||
redirect: golem15/user/users/update/:id
|
||||
redirectClose: golem15/user/users
|
||||
redirectClose: golem15/user/users/preview/:id
|
||||
|
||||
preview:
|
||||
headerPartial: hint
|
||||
|
||||
recordActions: [activate, unban, unsuspend]
|
||||
|
||||
messages:
|
||||
update: backend::lang.messages.form.update
|
||||
saved: backend::lang.messages.form.saved
|
||||
deleteConfirm: golem15.user::lang.users.delete_confirm
|
||||
deleted: backend::lang.messages.form.deleted
|
||||
edit: golem15.user::lang.users.update_details
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
list: ~/plugins/golem15/user/models/user/columns.yaml
|
||||
modelClass: Golem15\User\Models\User
|
||||
title: golem15.user::lang.users.list_title
|
||||
recordUrl: golem15/user/users/update/:id
|
||||
recordUrl: golem15/user/users/preview/:id
|
||||
noRecordsMessage: backend::lang.list.no_records
|
||||
recordsPerPage: 20
|
||||
showCheckboxes: true
|
||||
@@ -9,12 +9,16 @@ showSetup: true
|
||||
filter: config_filter.yaml
|
||||
|
||||
toolbar:
|
||||
buttons: []
|
||||
buttons: [delete]
|
||||
search:
|
||||
prompt: backend::lang.list.search_prompt
|
||||
|
||||
bulkActions: [activate, deactivate, restore, ban, unban]
|
||||
|
||||
messages:
|
||||
empty: golem15.user::lang.users.list_empty
|
||||
deleteConfirm: golem15.user::lang.users.delete_selected_confirm
|
||||
deleted: backend::lang.messages.list.deleted
|
||||
rowStateDeleted: golem15.user::lang.users.state_deactivated
|
||||
rowStateNegative: golem15.user::lang.users.state_banned
|
||||
rowStateDisabled: golem15.user::lang.users.state_not_activated
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -16,6 +16,40 @@ users:
|
||||
state_banned: Banned
|
||||
state_not_activated: Not activated
|
||||
delete_confirm: Do you really want to delete this user? This user will be permanently removed and unrecoverable.
|
||||
delete_selected_confirm: Delete the selected users? These users will be permanently removed and unrecoverable.
|
||||
update_details: Update details
|
||||
activate_selected: Activate selected
|
||||
activate_selected_confirm: Activate the selected users?
|
||||
activate_selected_success: Successfully activated the selected users.
|
||||
deactivate_selected: Deactivate selected
|
||||
deactivate_selected_confirm: Deactivate the selected users?
|
||||
deactivate_selected_success: Successfully deactivated the selected users.
|
||||
restore_selected: Restore selected
|
||||
restore_selected_confirm: Restore the selected users?
|
||||
restore_selected_success: Successfully restored the selected users.
|
||||
ban_selected: Ban selected
|
||||
ban_selected_confirm: Ban the selected users?
|
||||
ban_selected_success: Successfully banned the selected users.
|
||||
unban_selected: Unban selected
|
||||
unban_selected_confirm: Unban the selected users?
|
||||
unban_selected_success: Successfully unbanned the selected users.
|
||||
banned_hint_title: User has been banned
|
||||
banned_hint_desc: This user has been banned by an administrator and will be unable to sign in.
|
||||
trashed_hint_title: User has deactivated their account
|
||||
trashed_hint_desc: This user has deactivated their account and no longer wants to appear on the site. They can restore their account at any time by signing back in.
|
||||
activate_warning_title: User not activated!
|
||||
activate_warning_desc: This user has not been activated and may be unable to sign in.
|
||||
suspended_hint_title: User is temporarily suspended
|
||||
suspended_hint_desc: Too many failed sign-in attempts. The suspension ends on its own, or you can lift it now with Unsuspend user.
|
||||
activate_user: Activate user
|
||||
activate_confirm: Do you really want to activate this user?
|
||||
activated_success: User has been activated
|
||||
unban_user: Unban this user
|
||||
unban_confirm: Do you really want to unban this user?
|
||||
unbanned_success: User has been unbanned
|
||||
unsuspend_user: Unsuspend user
|
||||
unsuspend_confirm: Unsuspend this user?
|
||||
unsuspend_success: User has been unsuspended.
|
||||
privileged_member_forbidden: This user belongs to a privileged group. You do not have permission to change their email or password. Nothing was saved.
|
||||
privileged_member_delete_forbidden: Users who belong to a privileged group cannot be deleted without the permission to manage privileged groups. Nothing was deleted.
|
||||
user:
|
||||
|
||||
@@ -16,6 +16,40 @@ users:
|
||||
state_banned: Zbanowany
|
||||
state_not_activated: Nieaktywowany
|
||||
delete_confirm: Czy na pewno chcesz usunąć tego użytkownika? Zostanie trwale usunięty bez możliwości odzyskania.
|
||||
delete_selected_confirm: Usunąć wybranych użytkowników? Zostaną trwale usunięci bez możliwości odzyskania.
|
||||
update_details: Zaktualizuj szczegóły
|
||||
activate_selected: Aktywuj wybranych
|
||||
activate_selected_confirm: Aktywować wybranych użytkowników?
|
||||
activate_selected_success: Pomyślnie aktywowano wybranych użytkowników.
|
||||
deactivate_selected: Dezaktywuj wybranych
|
||||
deactivate_selected_confirm: Dezaktywować wybranych użytkowników?
|
||||
deactivate_selected_success: Pomyślnie dezaktywowano wybranych użytkowników.
|
||||
restore_selected: Przywróć wybranych
|
||||
restore_selected_confirm: Przywrócić wybranych użytkowników?
|
||||
restore_selected_success: Pomyślnie przywrócono wybranych użytkowników.
|
||||
ban_selected: Zbanuj wybranych
|
||||
ban_selected_confirm: Zbanować wybranych użytkowników?
|
||||
ban_selected_success: Pomyślnie zbanowano wybranych użytkowników.
|
||||
unban_selected: Odbanuj wybranych
|
||||
unban_selected_confirm: Odbanować wybranych użytkowników?
|
||||
unban_selected_success: Pomyślnie odbanowano wybranych użytkowników.
|
||||
banned_hint_title: Użytkownik został zbanowany
|
||||
banned_hint_desc: Ten użytkownik został zbanowany przez administratora i nie będzie mógł się zalogować.
|
||||
trashed_hint_title: Użytkownik dezaktywował swoje konto
|
||||
trashed_hint_desc: Ten użytkownik dezaktywował swoje konto i nie chce już wyświetlać się na stronie. Może przywrócić konto w dowolnym momencie, logując się z powrotem.
|
||||
activate_warning_title: Użytkownik nieaktywowany!
|
||||
activate_warning_desc: Ten użytkownik nie został aktywowany i może nie być w stanie się zalogować.
|
||||
suspended_hint_title: Użytkownik jest tymczasowo zawieszony
|
||||
suspended_hint_desc: Zbyt wiele nieudanych prób logowania. Blokada wygaśnie sama; możesz też zdjąć ją teraz przyciskiem Odblokuj użytkownika.
|
||||
activate_user: Aktywuj użytkownika
|
||||
activate_confirm: Czy na pewno chcesz aktywować tego użytkownika?
|
||||
activated_success: Użytkownik został aktywowany
|
||||
unban_user: Odbanuj tego użytkownika
|
||||
unban_confirm: Czy na pewno chcesz odbanować tego użytkownika?
|
||||
unbanned_success: Użytkownik został odbanowany
|
||||
unsuspend_user: Odblokuj użytkownika
|
||||
unsuspend_confirm: Odblokować tego użytkownika?
|
||||
unsuspend_success: Udało się odblokować użytkownika.
|
||||
privileged_member_forbidden: Ten użytkownik należy do grupy uprzywilejowanej. Nie masz uprawnień do zmiany jego adresu e-mail ani hasła. Nic nie zostało zapisane.
|
||||
privileged_member_delete_forbidden: Użytkowników należących do grupy uprzywilejowanej nie można usunąć bez uprawnienia do zarządzania grupami uprzywilejowanymi. Nic nie zostało usunięte.
|
||||
user:
|
||||
|
||||
@@ -14,3 +14,15 @@ fields:
|
||||
type: text
|
||||
span: full
|
||||
tab: golem15.user::lang.user.account
|
||||
created_ip_address:
|
||||
label: golem15.user::lang.user.created_ip_address
|
||||
type: text
|
||||
span: left
|
||||
tab: golem15.user::lang.user.account
|
||||
context: preview
|
||||
last_ip_address:
|
||||
label: golem15.user::lang.user.last_ip_address
|
||||
type: text
|
||||
span: right
|
||||
tab: golem15.user::lang.user.account
|
||||
context: preview
|
||||
|
||||
Reference in New Issue
Block a user