diff --git a/admin.go b/admin.go index a988408..56f862e 100644 --- a/admin.go +++ b/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 } diff --git a/admin_harness_test.go b/admin_harness_test.go index 1fc157b..0c6f7a3 100644 --- a/admin_harness_test.go +++ b/admin_harness_test.go @@ -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) { diff --git a/admin_users_test.go b/admin_users_test.go index 9f6928b..75a7a90 100644 --- a/admin_users_test.go +++ b/admin_users_test.go @@ -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) + } +} diff --git a/classes/admin_actions.go b/classes/admin_actions.go index fb34cd5..946f125 100644 --- a/classes/admin_actions.go +++ b/classes/admin_actions.go @@ -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 +} diff --git a/controllers/admin_registry.go b/controllers/admin_registry.go index 460bb8b..278c3ba 100644 --- a/controllers/admin_registry.go +++ b/controllers/admin_registry.go @@ -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{} } diff --git a/controllers/users/_hint.htm b/controllers/users/_hint.htm new file mode 100644 index 0000000..c6993df --- /dev/null +++ b/controllers/users/_hint.htm @@ -0,0 +1,7 @@ +{{- /* Status hint above the user preview: one callout, or nothing (usersHintView). */ -}} +{{- if .Data.Title -}} +
+

{{ trans .Data.Title }}

+

{{ trans .Data.Text }}

+
+{{- end -}} diff --git a/controllers/users/config_form.yaml b/controllers/users/config_form.yaml index c929c8e..c01f5dc 100644 --- a/controllers/users/config_form.yaml +++ b/controllers/users/config_form.yaml @@ -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 diff --git a/controllers/users/config_list.yaml b/controllers/users/config_list.yaml index 589759b..16df43c 100644 --- a/controllers/users/config_list.yaml +++ b/controllers/users/config_list.yaml @@ -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 diff --git a/controllers/users_admin_controller.go b/controllers/users_admin_controller.go index f621fd9..ca4dc86 100644 --- a/controllers/users_admin_controller.go +++ b/controllers/users_admin_controller.go @@ -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 +} diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index f3cb673..5b76e48 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -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: diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index a2cbbb7..feb24ae 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -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: diff --git a/models/user/fields.yaml b/models/user/fields.yaml index 69b872b..1aa5c86 100644 --- a/models/user/fields.yaml +++ b/models/user/fields.yaml @@ -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