Skip to content

Commit 6acab7c

Browse files
wxiaoguangStelios Malathouras
authored and
Stelios Malathouras
committed
Add user status filter to admin user management page (go-gitea#16770)
It makes Admin's life easier to filter users by various status. * introduce window.config.PageData to pass template data to javascript module and small refactor move legacy window.ActivityTopAuthors to window.config.PageData.ActivityTopAuthors make HTML structure more IDE-friendly in footer.tmpl and head.tmpl remove incorrect <style class="list-search-style"></style> in head.tmpl use log.Error instead of log.Critical in admin user search * use LEFT JOIN instead of SubQuery when admin filters users by 2fa. revert non-en locale. * use OptionalBool instead of status map * refactor SearchUserOptions.toConds to SearchUserOptions.toSearchQueryBase * add unit test for user search * only allow admin to use filters to search users
1 parent 16928a7 commit 6acab7c

File tree

17 files changed

+233
-36
lines changed

17 files changed

+233
-36
lines changed

.eslintrc

-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ reportUnusedDisableDirectives: true
33

44
ignorePatterns:
55
- /web_src/js/vendor
6-
- /templates/base/head.tmpl
76
- /templates/repo/activity.tmpl
87
- /templates/repo/view_file.tmpl
98

models/fixtures/user.yml

+1
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,7 @@
524524
avatar_email: user30@example.com
525525
num_repos: 2
526526
is_active: true
527+
prohibit_login: true
527528

528529
-
529530
id: 31

models/user.go

+44-9
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ import (
3535
"golang.org/x/crypto/bcrypt"
3636
"golang.org/x/crypto/pbkdf2"
3737
"golang.org/x/crypto/scrypt"
38+
3839
"xorm.io/builder"
40+
"xorm.io/xorm"
3941
)
4042

4143
// UserType defines the user type
@@ -1600,11 +1602,16 @@ type SearchUserOptions struct {
16001602
OrderBy SearchOrderBy
16011603
Visible []structs.VisibleType
16021604
Actor *User // The user doing the search
1603-
IsActive util.OptionalBool
1604-
SearchByEmail bool // Search by email as well as username/full name
1605+
SearchByEmail bool // Search by email as well as username/full name
1606+
1607+
IsActive util.OptionalBool
1608+
IsAdmin util.OptionalBool
1609+
IsRestricted util.OptionalBool
1610+
IsTwoFactorEnabled util.OptionalBool
1611+
IsProhibitLogin util.OptionalBool
16051612
}
16061613

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

1661-
return cond
1668+
if !opts.IsAdmin.IsNone() {
1669+
cond = cond.And(builder.Eq{"is_admin": opts.IsAdmin.IsTrue()})
1670+
}
1671+
1672+
if !opts.IsRestricted.IsNone() {
1673+
cond = cond.And(builder.Eq{"is_restricted": opts.IsRestricted.IsTrue()})
1674+
}
1675+
1676+
if !opts.IsProhibitLogin.IsNone() {
1677+
cond = cond.And(builder.Eq{"prohibit_login": opts.IsProhibitLogin.IsTrue()})
1678+
}
1679+
1680+
sess = db.NewSession(db.DefaultContext)
1681+
if !opts.IsTwoFactorEnabled.IsNone() {
1682+
// 2fa filter uses LEFT JOIN to check whether a user has a 2fa record
1683+
// TODO: bad performance here, maybe there will be a column "is_2fa_enabled" in the future
1684+
if opts.IsTwoFactorEnabled.IsTrue() {
1685+
cond = cond.And(builder.Expr("two_factor.uid IS NOT NULL"))
1686+
} else {
1687+
cond = cond.And(builder.Expr("two_factor.uid IS NULL"))
1688+
}
1689+
sess = sess.Join("LEFT OUTER", "two_factor", "two_factor.uid = `user`.id")
1690+
}
1691+
sess = sess.Where(cond)
1692+
return sess
16621693
}
16631694

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

1677-
sess := db.GetEngine(db.DefaultContext).Where(cond).OrderBy(opts.OrderBy.String())
1709+
sessQuery := opts.toSearchQueryBase().OrderBy(opts.OrderBy.String())
1710+
defer sessQuery.Close()
16781711
if opts.Page != 0 {
1679-
sess = db.SetSessionPagination(sess, opts)
1712+
sessQuery = db.SetSessionPagination(sessQuery, opts)
16801713
}
16811714

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

16861721
// GetStarredRepos returns the repos starred by a particular user

models/user_test.go

+12
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,18 @@ func TestSearchUsers(t *testing.T) {
161161
// order by name asc default
162162
testUserSuccess(&SearchUserOptions{Keyword: "user1", ListOptions: db.ListOptions{Page: 1}, IsActive: util.OptionalBoolTrue},
163163
[]int64{1, 10, 11, 12, 13, 14, 15, 16, 18})
164+
165+
testUserSuccess(&SearchUserOptions{ListOptions: db.ListOptions{Page: 1}, IsAdmin: util.OptionalBoolTrue},
166+
[]int64{1})
167+
168+
testUserSuccess(&SearchUserOptions{ListOptions: db.ListOptions{Page: 1}, IsRestricted: util.OptionalBoolTrue},
169+
[]int64{29, 30})
170+
171+
testUserSuccess(&SearchUserOptions{ListOptions: db.ListOptions{Page: 1}, IsProhibitLogin: util.OptionalBoolTrue},
172+
[]int64{30})
173+
174+
testUserSuccess(&SearchUserOptions{ListOptions: db.ListOptions{Page: 1}, IsTwoFactorEnabled: util.OptionalBoolTrue},
175+
[]int64{24})
164176
}
165177

166178
func TestDeleteUser(t *testing.T) {

modules/context/context.go

+8-4
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,11 @@ type Render interface {
4848

4949
// Context represents context of a request.
5050
type Context struct {
51-
Resp ResponseWriter
52-
Req *http.Request
53-
Data map[string]interface{}
54-
Render Render
51+
Resp ResponseWriter
52+
Req *http.Request
53+
Data map[string]interface{} // data used by MVC templates
54+
PageData map[string]interface{} // data used by JavaScript modules in one page
55+
Render Render
5556
translation.Locale
5657
Cache cache.Cache
5758
csrf CSRF
@@ -646,6 +647,9 @@ func Contexter() func(next http.Handler) http.Handler {
646647
"Link": link,
647648
},
648649
}
650+
// PageData is passed by reference, and it will be rendered to `window.config.PageData` in `head.tmpl` for JavaScript modules
651+
ctx.PageData = map[string]interface{}{}
652+
ctx.Data["PageData"] = ctx.PageData
649653

650654
ctx.Req = WithContext(req, &ctx)
651655
ctx.csrf = Csrfer(csrfOpts, &ctx)

modules/templates/helper.go

+3-2
Original file line numberDiff line numberDiff line change
@@ -351,12 +351,13 @@ func NewFuncMap() []template.FuncMap {
351351
}
352352
} else {
353353
// if sort arg is in url test if it correlates with column header sort arguments
354+
// the direction of the arrow should indicate the "current sort order", up means ASC(normal), down means DESC(rev)
354355
if urlSort == normSort {
355356
// the table is sorted with this header normal
356-
return SVG("octicon-triangle-down", 16)
357+
return SVG("octicon-triangle-up", 16)
357358
} else if urlSort == revSort {
358359
// the table is sorted with this header reverse
359-
return SVG("octicon-triangle-up", 16)
360+
return SVG("octicon-triangle-down", 16)
360361
}
361362
}
362363
// the table is NOT sorted with this header

modules/util/util.go

+11-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"crypto/rand"
1010
"errors"
1111
"math/big"
12+
"strconv"
1213
"strings"
1314
)
1415

@@ -17,7 +18,7 @@ type OptionalBool byte
1718

1819
const (
1920
// OptionalBoolNone a "null" boolean value
20-
OptionalBoolNone = iota
21+
OptionalBoolNone OptionalBool = iota
2122
// OptionalBoolTrue a "true" boolean value
2223
OptionalBoolTrue
2324
// OptionalBoolFalse a "false" boolean value
@@ -47,6 +48,15 @@ func OptionalBoolOf(b bool) OptionalBool {
4748
return OptionalBoolFalse
4849
}
4950

51+
// OptionalBoolParse get the corresponding OptionalBool of a string using strconv.ParseBool
52+
func OptionalBoolParse(s string) OptionalBool {
53+
b, e := strconv.ParseBool(s)
54+
if e != nil {
55+
return OptionalBoolNone
56+
}
57+
return OptionalBoolOf(b)
58+
}
59+
5060
// Max max of two ints
5161
func Max(a, b int) int {
5262
if a < b {

modules/util/util_test.go

+13
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,16 @@ func Test_RandomString(t *testing.T) {
156156

157157
assert.NotEqual(t, str3, str4)
158158
}
159+
160+
func Test_OptionalBool(t *testing.T) {
161+
assert.Equal(t, OptionalBoolNone, OptionalBoolParse(""))
162+
assert.Equal(t, OptionalBoolNone, OptionalBoolParse("x"))
163+
164+
assert.Equal(t, OptionalBoolFalse, OptionalBoolParse("0"))
165+
assert.Equal(t, OptionalBoolFalse, OptionalBoolParse("f"))
166+
assert.Equal(t, OptionalBoolFalse, OptionalBoolParse("False"))
167+
168+
assert.Equal(t, OptionalBoolTrue, OptionalBoolParse("1"))
169+
assert.Equal(t, OptionalBoolTrue, OptionalBoolParse("t"))
170+
assert.Equal(t, OptionalBoolTrue, OptionalBoolParse("True"))
171+
}

options/locale/locale_en-US.ini

+12
Original file line numberDiff line numberDiff line change
@@ -2371,6 +2371,18 @@ users.still_own_repo = This user still owns one or more repositories. Delete or
23712371
users.still_has_org = This user is a member of an organization. Remove the user from any organizations first.
23722372
users.deletion_success = The user account has been deleted.
23732373
users.reset_2fa = Reset 2FA
2374+
users.list_status_filter.menu_text = Filter
2375+
users.list_status_filter.reset = Reset
2376+
users.list_status_filter.is_active = Active
2377+
users.list_status_filter.not_active = Inactive
2378+
users.list_status_filter.is_admin = Admin
2379+
users.list_status_filter.not_admin = Not Admin
2380+
users.list_status_filter.is_restricted = Restricted
2381+
users.list_status_filter.not_restricted = Not Restricted
2382+
users.list_status_filter.is_prohibit_login = Prohibit Login
2383+
users.list_status_filter.not_prohibit_login = Allow Login
2384+
users.list_status_filter.is_2fa_enabled = 2FA Enabled
2385+
users.list_status_filter.not_2fa_enabled = 2FA Disabled
23742386

23752387
emails.email_manage_panel = User Email Management
23762388
emails.primary = Primary

routers/web/admin/users.go

+22-1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"code.gitea.io/gitea/modules/log"
2020
"code.gitea.io/gitea/modules/password"
2121
"code.gitea.io/gitea/modules/setting"
22+
"code.gitea.io/gitea/modules/util"
2223
"code.gitea.io/gitea/modules/web"
2324
"code.gitea.io/gitea/routers/web/explore"
2425
router_user_setting "code.gitea.io/gitea/routers/web/user/setting"
@@ -38,13 +39,33 @@ func Users(ctx *context.Context) {
3839
ctx.Data["PageIsAdmin"] = true
3940
ctx.Data["PageIsAdminUsers"] = true
4041

42+
statusFilterKeys := []string{"is_active", "is_admin", "is_restricted", "is_2fa_enabled", "is_prohibit_login"}
43+
statusFilterMap := map[string]string{}
44+
for _, filterKey := range statusFilterKeys {
45+
statusFilterMap[filterKey] = ctx.FormString("status_filter[" + filterKey + "]")
46+
}
47+
48+
sortType := ctx.FormString("sort")
49+
if sortType == "" {
50+
sortType = explore.UserSearchDefaultSortType
51+
}
52+
ctx.PageData["adminUserListSearchForm"] = map[string]interface{}{
53+
"StatusFilterMap": statusFilterMap,
54+
"SortType": sortType,
55+
}
56+
4157
explore.RenderUserSearch(ctx, &models.SearchUserOptions{
4258
Actor: ctx.User,
4359
Type: models.UserTypeIndividual,
4460
ListOptions: db.ListOptions{
4561
PageSize: setting.UI.Admin.UserPagingNum,
4662
},
47-
SearchByEmail: true,
63+
SearchByEmail: true,
64+
IsActive: util.OptionalBoolParse(statusFilterMap["is_active"]),
65+
IsAdmin: util.OptionalBoolParse(statusFilterMap["is_admin"]),
66+
IsRestricted: util.OptionalBoolParse(statusFilterMap["is_restricted"]),
67+
IsTwoFactorEnabled: util.OptionalBoolParse(statusFilterMap["is_2fa_enabled"]),
68+
IsProhibitLogin: util.OptionalBoolParse(statusFilterMap["is_prohibit_login"]),
4869
}, tplUsers)
4970
}
5071

routers/web/explore/user.go

+12-9
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ const (
2222
tplExploreUsers base.TplName = "explore/users"
2323
)
2424

25+
// UserSearchDefaultSortType is the default sort type for user search
26+
const UserSearchDefaultSortType = "alphabetically"
27+
2528
var (
2629
nullByte = []byte{0x00}
2730
)
@@ -44,23 +47,23 @@ func RenderUserSearch(ctx *context.Context, opts *models.SearchUserOptions, tplN
4447
orderBy models.SearchOrderBy
4548
)
4649

50+
// 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
4751
ctx.Data["SortType"] = ctx.FormString("sort")
4852
switch ctx.FormString("sort") {
4953
case "newest":
50-
orderBy = models.SearchOrderByIDReverse
54+
orderBy = "`user`.id DESC"
5155
case "oldest":
52-
orderBy = models.SearchOrderByID
56+
orderBy = "`user`.id ASC"
5357
case "recentupdate":
54-
orderBy = models.SearchOrderByRecentUpdated
58+
orderBy = "`user`.updated_unix DESC"
5559
case "leastupdate":
56-
orderBy = models.SearchOrderByLeastUpdated
60+
orderBy = "`user`.updated_unix ASC"
5761
case "reversealphabetically":
58-
orderBy = models.SearchOrderByAlphabeticallyReverse
59-
case "alphabetically":
60-
orderBy = models.SearchOrderByAlphabetically
62+
orderBy = "`user`.name DESC"
63+
case UserSearchDefaultSortType: // "alphabetically"
6164
default:
62-
ctx.Data["SortType"] = "alphabetically"
63-
orderBy = models.SearchOrderByAlphabetically
65+
orderBy = "`user`.name ASC"
66+
ctx.Data["SortType"] = UserSearchDefaultSortType
6467
}
6568

6669
opts.Keyword = ctx.FormTrim("q")

templates/admin/base/search.tmpl

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
</div>
1616
</div>
1717
</div>
18-
<form class="ui form ignore-dirty" style="max-width: 90%">
18+
<form class="ui form ignore-dirty" style="max-width: 90%;">
1919
<div class="ui fluid action input">
2020
<input name="q" value="{{.Keyword}}" placeholder="{{.i18n.Tr "explore.search"}}..." autofocus>
2121
<button class="ui blue button">{{.i18n.Tr "explore.search"}}</button>

0 commit comments

Comments
 (0)