Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add user status filter to admin user management page #16770

Merged
merged 40 commits into from
Oct 12, 2021
Merged
Show file tree
Hide file tree
Changes from 37 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
d5adeee
Admin can filter user list by status
wxiaoguang Aug 22, 2021
0b2604b
introduce window.config.PageData to pass template data to javascript …
wxiaoguang Aug 25, 2021
921e3d8
Merge branch 'main' into admin-user-list-filter
wxiaoguang Aug 25, 2021
dbf21d2
revert ActivityTopAuthors related changes, maybe a new PR is needed
wxiaoguang Aug 25, 2021
3f2a934
Merge branch 'admin-user-list-filter' of github.com:wxiaoguang/gitea …
wxiaoguang Aug 25, 2021
30f7430
use LEFT JOIN instead of SubQuery when admin filters users by 2fa. re…
wxiaoguang Aug 26, 2021
79a2be0
Merge remote-tracking branch 'go-gitea/main' into admin-user-list-filter
wxiaoguang Aug 26, 2021
6ec918e
use OptionalBool instead of status map
wxiaoguang Aug 26, 2021
e133612
refactor SearchUserOptions.toConds to SearchUserOptions.toSearchQuery…
wxiaoguang Aug 26, 2021
86a245a
add unit test for user search
wxiaoguang Aug 26, 2021
eba00ca
Merge branch 'main' into admin-user-list-filter
wxiaoguang Aug 31, 2021
5cf8f29
Merge branch 'main' into admin-user-list-filter
wxiaoguang Sep 1, 2021
3296672
only allow admin to use filters to search users
wxiaoguang Sep 3, 2021
21869fa
Merge branch 'main' into admin-user-list-filter
wxiaoguang Sep 3, 2021
f6f06f2
reformat
wxiaoguang Sep 3, 2021
df32fb4
fix search query: Where and Join
wxiaoguang Sep 4, 2021
3e9a588
Merge branch 'main' into admin-user-list-filter
wxiaoguang Sep 4, 2021
9ddc61c
Merge branch 'main' into admin-user-list-filter
wxiaoguang Sep 18, 2021
db75013
Merge branch 'main' into admin-user-list-filter
wxiaoguang Sep 22, 2021
89b3564
fix merge conflict
wxiaoguang Sep 22, 2021
a660168
Merge branch 'main' into admin-user-list-filter
wxiaoguang Sep 26, 2021
6bf9c07
fix merge
wxiaoguang Sep 26, 2021
a0a963a
fix unit test
wxiaoguang Sep 26, 2021
2148ce5
refactor
wxiaoguang Sep 27, 2021
1fe17b3
Merge branch 'main' into admin-user-list-filter
wxiaoguang Sep 27, 2021
892613d
fix lint
wxiaoguang Sep 27, 2021
d76cd6d
fix sort order
wxiaoguang Sep 27, 2021
96cc571
fix sql table name quote, clean up
wxiaoguang Sep 28, 2021
1806f1a
only query fields of `user` table
wxiaoguang Sep 28, 2021
508fded
Merge branch 'main' into admin-user-list-filter
wxiaoguang Sep 28, 2021
7f35e9c
clean
wxiaoguang Sep 28, 2021
a2bbf4c
fix comment and lint
wxiaoguang Sep 28, 2021
8b4c540
clean and try unit test for mssql
wxiaoguang Sep 28, 2021
c8c5263
Merge branch 'main' into admin-user-list-filter
wxiaoguang Oct 6, 2021
65c2378
Merge branch 'main' into admin-user-list-filter
wxiaoguang Oct 9, 2021
fb92420
Merge branch 'main' into admin-user-list-filter
wxiaoguang Oct 11, 2021
821685e
Merge branch 'main' into admin-user-list-filter
6543 Oct 11, 2021
c0a5a39
refactor
wxiaoguang Oct 12, 2021
4b90372
Merge branch 'main' into admin-user-list-filter
wxiaoguang Oct 12, 2021
31135e5
Merge branch 'main' into admin-user-list-filter
lunny Oct 12, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ reportUnusedDisableDirectives: true

ignorePatterns:
- /web_src/js/vendor
- /templates/base/head.tmpl
- /templates/repo/activity.tmpl
- /templates/repo/view_file.tmpl

Expand Down
1 change: 1 addition & 0 deletions models/fixtures/user.yml
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,7 @@
avatar_email: user30@example.com
num_repos: 2
is_active: true
prohibit_login: true

-
id: 31
Expand Down
53 changes: 44 additions & 9 deletions models/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ import (
"golang.org/x/crypto/bcrypt"
"golang.org/x/crypto/pbkdf2"
"golang.org/x/crypto/scrypt"

"xorm.io/builder"
"xorm.io/xorm"
)

// UserType defines the user type
Expand Down Expand Up @@ -1600,11 +1602,16 @@ type SearchUserOptions struct {
OrderBy SearchOrderBy
Visible []structs.VisibleType
Actor *User // The user doing the search
IsActive util.OptionalBool
SearchByEmail bool // Search by email as well as username/full name
SearchByEmail bool // Search by email as well as username/full name

IsActive util.OptionalBool
IsAdmin util.OptionalBool
IsRestricted util.OptionalBool
IsTwoFactorEnabled util.OptionalBool
IsProhibitLogin util.OptionalBool
}

func (opts *SearchUserOptions) toConds() builder.Cond {
func (opts *SearchUserOptions) toSearchQueryBase() (sess *xorm.Session) {
var cond builder.Cond = builder.Eq{"type": opts.Type}
if len(opts.Keyword) > 0 {
lowerKeyword := strings.ToLower(opts.Keyword)
Expand Down Expand Up @@ -1658,14 +1665,39 @@ func (opts *SearchUserOptions) toConds() builder.Cond {
cond = cond.And(builder.Eq{"is_active": opts.IsActive.IsTrue()})
}

return cond
if !opts.IsAdmin.IsNone() {
cond = cond.And(builder.Eq{"is_admin": opts.IsAdmin.IsTrue()})
}

if !opts.IsRestricted.IsNone() {
cond = cond.And(builder.Eq{"is_restricted": opts.IsRestricted.IsTrue()})
}

if !opts.IsProhibitLogin.IsNone() {
cond = cond.And(builder.Eq{"prohibit_login": opts.IsProhibitLogin.IsTrue()})
}

sess = db.NewSession(db.DefaultContext)
if !opts.IsTwoFactorEnabled.IsNone() {
// 2fa filter uses LEFT JOIN to check whether a user has a 2fa record
// TODO: bad performance here, maybe there will be a column "is_2fa_enabled" in the future
if opts.IsTwoFactorEnabled.IsTrue() {
cond = cond.And(builder.Expr("two_factor.uid IS NOT NULL"))
} else {
cond = cond.And(builder.Expr("two_factor.uid IS NULL"))
}
sess = sess.Join("LEFT OUTER", "two_factor", "two_factor.uid = `user`.id")
}
sess = sess.Where(cond)
return sess
}

// SearchUsers takes options i.e. keyword and part of user name to search,
// it returns results in given range and number of total results.
func SearchUsers(opts *SearchUserOptions) (users []*User, _ int64, _ error) {
cond := opts.toConds()
count, err := db.GetEngine(db.DefaultContext).Where(cond).Count(new(User))
sessCount := opts.toSearchQueryBase()
defer sessCount.Close()
count, err := sessCount.Count(new(User))
if err != nil {
return nil, 0, fmt.Errorf("Count: %v", err)
}
Expand All @@ -1674,13 +1706,16 @@ func SearchUsers(opts *SearchUserOptions) (users []*User, _ int64, _ error) {
opts.OrderBy = SearchOrderByAlphabetically
}

sess := db.GetEngine(db.DefaultContext).Where(cond).OrderBy(opts.OrderBy.String())
sessQuery := opts.toSearchQueryBase().OrderBy(opts.OrderBy.String())
defer sessQuery.Close()
if opts.Page != 0 {
sess = db.SetSessionPagination(sess, opts)
sessQuery = db.SetSessionPagination(sessQuery, opts)
}

// the sql may contain JOIN, so we must only select User related columns
sessQuery = sessQuery.Select("`user`.*")
users = make([]*User, 0, opts.PageSize)
return users, count, sess.Find(&users)
return users, count, sessQuery.Find(&users)
}

// GetStarredRepos returns the repos starred by a particular user
Expand Down
12 changes: 12 additions & 0 deletions models/user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,18 @@ func TestSearchUsers(t *testing.T) {
// order by name asc default
testUserSuccess(&SearchUserOptions{Keyword: "user1", ListOptions: db.ListOptions{Page: 1}, IsActive: util.OptionalBoolTrue},
[]int64{1, 10, 11, 12, 13, 14, 15, 16, 18})

testUserSuccess(&SearchUserOptions{ListOptions: db.ListOptions{Page: 1}, IsAdmin: util.OptionalBoolTrue},
[]int64{1})

testUserSuccess(&SearchUserOptions{ListOptions: db.ListOptions{Page: 1}, IsRestricted: util.OptionalBoolTrue},
[]int64{29, 30})

testUserSuccess(&SearchUserOptions{ListOptions: db.ListOptions{Page: 1}, IsProhibitLogin: util.OptionalBoolTrue},
[]int64{30})

testUserSuccess(&SearchUserOptions{ListOptions: db.ListOptions{Page: 1}, IsTwoFactorEnabled: util.OptionalBoolTrue},
[]int64{24})
}

func TestDeleteUser(t *testing.T) {
Expand Down
12 changes: 8 additions & 4 deletions modules/context/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,11 @@ type Render interface {

// Context represents context of a request.
type Context struct {
Resp ResponseWriter
Req *http.Request
Data map[string]interface{}
Render Render
Resp ResponseWriter
Req *http.Request
Data map[string]interface{} // data used by MVC templates
PageData map[string]interface{} // data used by JavaScript modules in one page
Render Render
translation.Locale
Cache cache.Cache
csrf CSRF
Expand Down Expand Up @@ -646,6 +647,9 @@ func Contexter() func(next http.Handler) http.Handler {
"Link": link,
},
}
// PageData is passed by reference, and it will be rendered to `window.config.PageData` in `head.tmpl` for JavaScript modules
ctx.PageData = map[string]interface{}{}
ctx.Data["PageData"] = ctx.PageData

ctx.Req = WithContext(req, &ctx)
ctx.csrf = Csrfer(csrfOpts, &ctx)
Expand Down
5 changes: 3 additions & 2 deletions modules/templates/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -351,12 +351,13 @@ func NewFuncMap() []template.FuncMap {
}
} else {
// if sort arg is in url test if it correlates with column header sort arguments
// the direction of the arrow should indicate the "current sort order", up means ASC(normal), down means DESC(rev)
if urlSort == normSort {
// the table is sorted with this header normal
return SVG("octicon-triangle-down", 16)
return SVG("octicon-triangle-up", 16)
} else if urlSort == revSort {
// the table is sorted with this header reverse
return SVG("octicon-triangle-up", 16)
return SVG("octicon-triangle-down", 16)
}
}
// the table is NOT sorted with this header
Expand Down
12 changes: 11 additions & 1 deletion modules/util/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"crypto/rand"
"errors"
"math/big"
"strconv"
"strings"
)

Expand All @@ -17,7 +18,7 @@ type OptionalBool byte

const (
// OptionalBoolNone a "null" boolean value
OptionalBoolNone = iota
OptionalBoolNone OptionalBool = iota
// OptionalBoolTrue a "true" boolean value
OptionalBoolTrue
// OptionalBoolFalse a "false" boolean value
Expand Down Expand Up @@ -47,6 +48,15 @@ func OptionalBoolOf(b bool) OptionalBool {
return OptionalBoolFalse
}

// OptionalBoolParse get the corresponding OptionalBool of a string using strconv.ParseBool
func OptionalBoolParse(s string) OptionalBool {
lunny marked this conversation as resolved.
Show resolved Hide resolved
b, e := strconv.ParseBool(s)
if e != nil {
return OptionalBoolNone
}
return OptionalBoolOf(b)
}

// Max max of two ints
func Max(a, b int) int {
if a < b {
Expand Down
13 changes: 13 additions & 0 deletions modules/util/util_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,16 @@ func Test_RandomString(t *testing.T) {

assert.NotEqual(t, str3, str4)
}

func Test_OptionalBool(t *testing.T) {
assert.Equal(t, OptionalBoolNone, OptionalBoolParse(""))
assert.Equal(t, OptionalBoolNone, OptionalBoolParse("x"))

assert.Equal(t, OptionalBoolFalse, OptionalBoolParse("0"))
assert.Equal(t, OptionalBoolFalse, OptionalBoolParse("f"))
assert.Equal(t, OptionalBoolFalse, OptionalBoolParse("False"))

assert.Equal(t, OptionalBoolTrue, OptionalBoolParse("1"))
assert.Equal(t, OptionalBoolTrue, OptionalBoolParse("t"))
assert.Equal(t, OptionalBoolTrue, OptionalBoolParse("True"))
}
12 changes: 12 additions & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -2371,6 +2371,18 @@ users.still_own_repo = This user still owns one or more repositories. Delete or
users.still_has_org = This user is a member of an organization. Remove the user from any organizations first.
users.deletion_success = The user account has been deleted.
users.reset_2fa = Reset 2FA
users.list_status_filter.menu_text = Filter
users.list_status_filter.reset = Reset
users.list_status_filter.is_active = Active
users.list_status_filter.not_active = Inactive
users.list_status_filter.is_admin = Admin
users.list_status_filter.not_admin = Not Admin
users.list_status_filter.is_restricted = Restricted
users.list_status_filter.not_restricted = Not Restricted
users.list_status_filter.is_prohibit_login = Prohibit Login
users.list_status_filter.not_prohibit_login = Allow Login
users.list_status_filter.is_2fa_enabled = 2FA Enabled
users.list_status_filter.not_2fa_enabled = 2FA Disabled

emails.email_manage_panel = User Email Management
emails.primary = Primary
Expand Down
28 changes: 26 additions & 2 deletions routers/web/admin/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/password"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/web/explore"
router_user_setting "code.gitea.io/gitea/routers/web/user/setting"
Expand All @@ -38,14 +39,37 @@ func Users(ctx *context.Context) {
ctx.Data["PageIsAdmin"] = true
ctx.Data["PageIsAdminUsers"] = true

explore.RenderUserSearch(ctx, &models.SearchUserOptions{
searchOpts := &models.SearchUserOptions{
Actor: ctx.User,
Type: models.UserTypeIndividual,
ListOptions: db.ListOptions{
PageSize: setting.UI.Admin.UserPagingNum,
},
SearchByEmail: true,
}, tplUsers)
}

statusFilterKeys := []string{"is_active", "is_admin", "is_restricted", "is_2fa_enabled", "is_prohibit_login"}
wxiaoguang marked this conversation as resolved.
Show resolved Hide resolved
statusFilterMap := map[string]string{}
for _, filterKey := range statusFilterKeys {
statusFilterMap[filterKey] = ctx.FormString("status_filter[" + filterKey + "]")
}

searchOpts.IsActive = util.OptionalBoolParse(statusFilterMap["is_active"])
searchOpts.IsAdmin = util.OptionalBoolParse(statusFilterMap["is_admin"])
searchOpts.IsRestricted = util.OptionalBoolParse(statusFilterMap["is_restricted"])
searchOpts.IsTwoFactorEnabled = util.OptionalBoolParse(statusFilterMap["is_2fa_enabled"])
searchOpts.IsProhibitLogin = util.OptionalBoolParse(statusFilterMap["is_prohibit_login"])

sortType := ctx.FormString("sort")
if sortType == "" {
sortType = explore.UserSearchDefaultSortType
}
ctx.PageData["AdminUserListSearchForm"] = map[string]interface{}{
"StatusFilterMap": statusFilterMap,
"SortType": sortType,
}

explore.RenderUserSearch(ctx, searchOpts, tplUsers)
}

// NewUser render adding a new user page
Expand Down
21 changes: 12 additions & 9 deletions routers/web/explore/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ const (
tplExploreUsers base.TplName = "explore/users"
)

// UserSearchDefaultSortType is the default sort type for user search
const UserSearchDefaultSortType = "alphabetically"

var (
nullByte = []byte{0x00}
)
Expand All @@ -44,23 +47,23 @@ func RenderUserSearch(ctx *context.Context, opts *models.SearchUserOptions, tplN
orderBy models.SearchOrderBy
)

// we can not set orderBy to `models.SearchOrderByXxx`, because there may be a JOIN in the statement, different tables may have the same name columns
ctx.Data["SortType"] = ctx.FormString("sort")
switch ctx.FormString("sort") {
case "newest":
orderBy = models.SearchOrderByIDReverse
orderBy = "`user`.id DESC"
case "oldest":
orderBy = models.SearchOrderByID
orderBy = "`user`.id ASC"
case "recentupdate":
orderBy = models.SearchOrderByRecentUpdated
orderBy = "`user`.updated_unix DESC"
case "leastupdate":
orderBy = models.SearchOrderByLeastUpdated
orderBy = "`user`.updated_unix ASC"
case "reversealphabetically":
orderBy = models.SearchOrderByAlphabeticallyReverse
case "alphabetically":
orderBy = models.SearchOrderByAlphabetically
orderBy = "`user`.name DESC"
case UserSearchDefaultSortType: // "alphabetically"
default:
ctx.Data["SortType"] = "alphabetically"
orderBy = models.SearchOrderByAlphabetically
orderBy = "`user`.name ASC"
ctx.Data["SortType"] = UserSearchDefaultSortType
}

opts.Keyword = ctx.FormTrim("q")
Expand Down
2 changes: 1 addition & 1 deletion templates/admin/base/search.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
</div>
</div>
</div>
<form class="ui form ignore-dirty" style="max-width: 90%">
<form class="ui form ignore-dirty" style="max-width: 90%;">
<div class="ui fluid action input">
<input name="q" value="{{.Keyword}}" placeholder="{{.i18n.Tr "explore.search"}}..." autofocus>
<button class="ui blue button">{{.i18n.Tr "explore.search"}}</button>
Expand Down
Loading