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 15 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: 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
57 changes: 50 additions & 7 deletions models/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,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 @@ -1594,11 +1596,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 @@ -1652,14 +1659,50 @@ 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()})
}

type Join struct {
Operator string
Table interface{}
Condition string
Args []interface{}
}

var joins []*Join

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"))
}
joins = append(joins, &Join{Operator: "LEFT OUTER", Table: "two_factor", Condition: "two_factor.uid = `user`.id"})
}
wxiaoguang marked this conversation as resolved.
Show resolved Hide resolved

sess = x.Where(cond)
for _, join := range joins {
sess = sess.Join(join.Operator, join.Table, join.Condition, join.Args...)
}

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 := x.Where(cond).Count(new(User))
count, err := opts.toSearchQueryBase().Count(new(User))
if err != nil {
return nil, 0, fmt.Errorf("Count: %v", err)
}
Expand All @@ -1668,7 +1711,7 @@ func SearchUsers(opts *SearchUserOptions) (users []*User, _ int64, _ error) {
opts.OrderBy = SearchOrderByAlphabetically
}

sess := x.Where(cond).OrderBy(opts.OrderBy.String())
sess := opts.toSearchQueryBase().OrderBy(opts.OrderBy.String())
if opts.Page != 0 {
sess = opts.setSessionPagination(sess)
}
Expand Down
12 changes: 12 additions & 0 deletions models/user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,18 @@ func TestSearchUsers(t *testing.T) {
// order by name asc default
testUserSuccess(&SearchUserOptions{Keyword: "user1", ListOptions: ListOptions{Page: 1}, IsActive: util.OptionalBoolTrue},
[]int64{1, 10, 11, 12, 13, 14, 15, 16, 18})

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

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

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

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

func TestDeleteUser(t *testing.T) {
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 @@ -2352,6 +2352,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
21 changes: 19 additions & 2 deletions routers/web/admin/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,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 @@ -36,14 +37,30 @@ 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: models.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"])

ctx.Data["StatusFilterMap"] = statusFilterMap

explore.RenderUserSearch(ctx, searchOpts, tplUsers)
}

// NewUser render adding a new user page
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
61 changes: 60 additions & 1 deletion templates/admin/user/list.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,66 @@
</div>
</h4>
<div class="ui attached segment">
{{template "admin/base/search" .}}
<form class="ui form ignore-dirty" id="user-list-search-form">

<!-- Right Menu -->
<div class="ui right floated secondary filter menu">
<!-- Status Filter Menu Item -->
<div class="ui dropdown type jump item">
<span class="text">{{.i18n.Tr "admin.users.list_status_filter.menu_text"}} {{svg "octicon-triangle-down" 14 "dropdown icon"}}</span>
<div class="menu">
<a class="item j-reset-status-filter">{{.i18n.Tr "admin.users.list_status_filter.reset"}}</a>
<div class="ui divider"></div>
<label class="item"><input type="radio" name="status_filter[is_admin]" value="1"> {{.i18n.Tr "admin.users.list_status_filter.is_admin"}}</label>
<label class="item"><input type="radio" name="status_filter[is_admin]" value="0"> {{.i18n.Tr "admin.users.list_status_filter.not_admin"}}</label>
<div class="ui divider"></div>
<label class="item"><input type="radio" name="status_filter[is_active]" value="1"> {{.i18n.Tr "admin.users.list_status_filter.is_active"}}</label>
<label class="item"><input type="radio" name="status_filter[is_active]" value="0"> {{.i18n.Tr "admin.users.list_status_filter.not_active"}}</label>
<div class="ui divider"></div>
<label class="item"><input type="radio" name="status_filter[is_restricted]" value="0"> {{.i18n.Tr "admin.users.list_status_filter.not_restricted"}}</label>
<label class="item"><input type="radio" name="status_filter[is_restricted]" value="1"> {{.i18n.Tr "admin.users.list_status_filter.is_restricted"}}</label>
<div class="ui divider"></div>
<label class="item"><input type="radio" name="status_filter[is_prohibit_login]" value="0"> {{.i18n.Tr "admin.users.list_status_filter.not_prohibit_login"}}</label>
<label class="item"><input type="radio" name="status_filter[is_prohibit_login]" value="1"> {{.i18n.Tr "admin.users.list_status_filter.is_prohibit_login"}}</label>
<div class="ui divider"></div>
<label class="item"><input type="radio" name="status_filter[is_2fa_enabled]" value="1"> {{.i18n.Tr "admin.users.list_status_filter.is_2fa_enabled"}}</label>
<label class="item"><input type="radio" name="status_filter[is_2fa_enabled]" value="0"> {{.i18n.Tr "admin.users.list_status_filter.not_2fa_enabled"}}</label>
</div>
</div>

<!-- Sort Menu Item -->
<div class="ui dropdown type jump item">
<span class="text">
{{.i18n.Tr "repo.issues.filter_sort"}} {{svg "octicon-triangle-down" 14 "dropdown icon"}}
</span>
<div class="menu">
<button class="item" name="sort" value="oldest">{{.i18n.Tr "repo.issues.filter_sort.oldest"}}</button>
<button class="item" name="sort" value="newest">{{.i18n.Tr "repo.issues.filter_sort.latest"}}</button>
<button class="item" name="sort" value="alphabetically">{{.i18n.Tr "repo.issues.label.filter_sort.alphabetically"}}</button>
<button class="item" name="sort" value="reversealphabetically">{{.i18n.Tr "repo.issues.label.filter_sort.reverse_alphabetically"}}</button>
<button class="item" name="sort" value="recentupdate">{{.i18n.Tr "repo.issues.filter_sort.recentupdate"}}</button>
<button class="item" name="sort" value="leastupdate">{{.i18n.Tr "repo.issues.filter_sort.leastupdate"}}</button>
</div>
</div>
</div>

<!-- Search Text -->
<div class="ui fluid action input" style="max-width: 70%;">
<input name="q" value="{{.Keyword}}" placeholder="{{.i18n.Tr "explore.search"}}..." autofocus>
<button class="ui blue button">{{.i18n.Tr "explore.search"}}</button>
</div>

{{/* here we have valid go template syntax, but eslint doesn't like it and reports "error Parsing error: Unexpected token {" */}}
<script>
<!-- /* eslint-disable */ -->
(function() {
wxiaoguang marked this conversation as resolved.
Show resolved Hide resolved
window.config.PageData.adminUserListSearchForm = {
statusFilterMap: {{.StatusFilterMap}},
sortType: {{.SortType}} || 'oldest'
}
})();
</script>
wxiaoguang marked this conversation as resolved.
Show resolved Hide resolved
</form>
</div>
<div class="ui attached table segment">
<table class="ui very basic striped table">
Expand Down
5 changes: 3 additions & 2 deletions templates/base/footer.tmpl
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
{{/*
{{if false}}
{{/* to make html structure "likely" complete to prevent IDE warnings */}}
<html>
<body>
<div>
*/}}
{{end}}

{{template "custom/body_inner_post" .}}

Expand Down
8 changes: 5 additions & 3 deletions templates/base/head.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
},
EnableTimetracking: {{if EnableTimetracking}}true{{else}}false{{end}},
PageIsProjects: {{if .PageIsProjects }}true{{else}}false{{end}},
PageData: {}, // to store page related data, eg: pass variables to javascript module
{{if .RequireTribute}}
tributeValues: Array.from(new Map([
{{ range .Participants }}
Expand Down Expand Up @@ -75,7 +76,6 @@
.ui.secondary.menu .dropdown.item > .menu { margin-top: 0; }
</style>
</noscript>
<style class="list-search-style"></style>
{{if .PageIsUserProfile}}
<meta property="og:title" content="{{.Owner.Name}}" />
<meta property="og:type" content="profile" />
Expand Down Expand Up @@ -134,8 +134,10 @@
{{template "base/head_navbar" .}}
</div><!-- end bar -->
{{end}}
{{/*

{{if false}}
{{/* to make html structure "likely" complete to prevent IDE warnings */}}
</div>
</body>
</html>
*/}}
{{end}}
32 changes: 32 additions & 0 deletions web_src/js/features/admin-users.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export function initAdminUserListSearchForm() {
const searchForm = window.config.PageData.adminUserListSearchForm;
wxiaoguang marked this conversation as resolved.
Show resolved Hide resolved
if (!searchForm) return;

const $form = $('#user-list-search-form');
if (!$form.length) return;

$form.find(`button[name=sort][value=${searchForm.sortType}]`).addClass('active');

if (searchForm.statusFilterMap) {
for (const [k, v] of Object.entries(searchForm.statusFilterMap)) {
if (!v) continue;
$form.find(`input[name="status_filter[${k}]"][value=${v}]`).prop('checked', true);
}
}

$form.find(`input[type=radio]`).click(() => {
$form.submit();
return false;
});

$form.find('.j-reset-status-filter').click(() => {
$form.find(`input[type=radio]`).each((_, e) => {
const $e = $(e);
if ($e.attr('name').startsWith('status_filter[')) {
$e.prop('checked', false);
}
});
$form.submit();
return false;
});
}
2 changes: 2 additions & 0 deletions web_src/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import initMigration from './features/migration.js';
import initProject from './features/projects.js';
import initServiceWorker from './features/serviceworker.js';
import initTableSort from './features/tablesort.js';
import {initAdminUserListSearchForm} from './features/admin-users.js';
import {createCodeEditor, createMonaco} from './features/codeeditor.js';
import {initMarkupAnchors} from './markup/anchors.js';
import {initNotificationsTable, initNotificationCount} from './features/notification.js';
Expand Down Expand Up @@ -2860,6 +2861,7 @@ $(document).ready(async () => {
initFileViewToggle();
initReleaseEditor();
initRelease();
initAdminUserListSearchForm();

const routes = {
'div.user.settings': initUserSettings,
Expand Down