0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-05-13 15:26:12 +02:00

add search bar for org members

This commit is contained in:
Lunny Xiao 2026-04-21 12:43:29 -07:00
parent 3f3bebda0d
commit 1dbcf15896
No known key found for this signature in database
GPG Key ID: C3B7C91B632F738A
4 changed files with 137 additions and 2 deletions

View File

@ -184,12 +184,44 @@ type FindOrgMembersOpts struct {
Doer *user_model.User Doer *user_model.User
IsDoerMember bool IsDoerMember bool
OrgID int64 OrgID int64
Keyword string
SearchByEmail bool
} }
func (opts FindOrgMembersOpts) PublicOnly() bool { func (opts FindOrgMembersOpts) PublicOnly() bool {
return opts.Doer == nil || !(opts.IsDoerMember || opts.Doer.IsAdmin) return opts.Doer == nil || !(opts.IsDoerMember || opts.Doer.IsAdmin)
} }
func (opts FindOrgMembersOpts) applyKeywordFilter(sess *xorm.Session) (*xorm.Session, bool) {
if opts.Keyword == "" {
return sess, false
}
lowerKeyword := strings.ToLower(opts.Keyword)
keywordCond := builder.Or(
builder.Like{"`user`.lower_name", lowerKeyword},
builder.Like{"LOWER(`user`.full_name)", lowerKeyword},
)
if opts.SearchByEmail {
emailCond := builder.Like{"LOWER(`user`.email)", lowerKeyword}
switch {
case opts.Doer == nil:
emailCond = emailCond.And(builder.Eq{"`user`.keep_email_private": false})
case !opts.Doer.IsAdmin:
emailCond = emailCond.And(
builder.Or(
builder.Eq{"`user`.keep_email_private": false},
builder.Eq{"`user`.id": opts.Doer.ID},
),
)
}
keywordCond = keywordCond.Or(emailCond)
}
sess = sess.Join("INNER", "`user`", "org_user.uid = `user`.id").And(keywordCond)
return sess, true
}
// applyTeamMatesOnlyFilter make sure restricted users only see public team members and there own team mates // applyTeamMatesOnlyFilter make sure restricted users only see public team members and there own team mates
func (opts FindOrgMembersOpts) applyTeamMatesOnlyFilter(sess *xorm.Session) { func (opts FindOrgMembersOpts) applyTeamMatesOnlyFilter(sess *xorm.Session) {
if opts.Doer != nil && opts.IsDoerMember && opts.Doer.IsRestricted { if opts.Doer != nil && opts.IsDoerMember && opts.Doer.IsRestricted {
@ -213,6 +245,7 @@ func CountOrgMembers(ctx context.Context, opts *FindOrgMembersOpts) (int64, erro
} else { } else {
opts.applyTeamMatesOnlyFilter(sess) opts.applyTeamMatesOnlyFilter(sess)
} }
sess, _ = opts.applyKeywordFilter(sess)
return sess.Count(new(OrgUser)) return sess.Count(new(OrgUser))
} }
@ -461,6 +494,9 @@ func GetOrgUsersByOrgID(ctx context.Context, opts *FindOrgMembersOpts) ([]*OrgUs
} else { } else {
opts.applyTeamMatesOnlyFilter(sess) opts.applyTeamMatesOnlyFilter(sess)
} }
if keywordSess, hasKeyword := opts.applyKeywordFilter(sess); hasKeyword {
sess = keywordSess.Select("org_user.*")
}
if opts.ListOptions.PageSize > 0 { if opts.ListOptions.PageSize > 0 {
sess = db.SetSessionPagination(sess, opts) sess = db.SetSessionPagination(sess, opts)

View File

@ -288,6 +288,80 @@ func TestGetOrgUsersByOrgID(t *testing.T) {
assert.Empty(t, orgUsers) assert.Empty(t, orgUsers)
} }
func TestOrgMembersSearch(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
member := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
testCases := []struct {
name string
opts *organization.FindOrgMembersOpts
expectedUIDs []int64
}{
{
name: "match by username",
opts: &organization.FindOrgMembersOpts{
OrgID: 3,
Doer: member,
IsDoerMember: true,
Keyword: "user4",
SearchByEmail: true,
},
expectedUIDs: []int64{4},
},
{
name: "match by full name",
opts: &organization.FindOrgMembersOpts{
OrgID: 3,
Doer: member,
IsDoerMember: true,
Keyword: "user27",
SearchByEmail: true,
},
expectedUIDs: []int64{28},
},
{
name: "private email hidden",
opts: &organization.FindOrgMembersOpts{
OrgID: 3,
Doer: member,
IsDoerMember: true,
Keyword: "user2@example.com",
SearchByEmail: true,
},
expectedUIDs: []int64{},
},
{
name: "admin can search private email",
opts: &organization.FindOrgMembersOpts{
OrgID: 3,
Doer: admin,
Keyword: "user2@example.com",
SearchByEmail: true,
},
expectedUIDs: []int64{2},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
count, err := organization.CountOrgMembers(t.Context(), tc.opts)
assert.NoError(t, err)
assert.EqualValues(t, len(tc.expectedUIDs), count)
members, err := organization.GetOrgUsersByOrgID(t.Context(), tc.opts)
assert.NoError(t, err)
memberUIDs := make([]int64, 0, len(members))
for _, member := range members {
memberUIDs = append(memberUIDs, member.UID)
}
slices.Sort(memberUIDs)
assert.Equal(t, tc.expectedUIDs, memberUIDs)
})
}
}
func TestChangeOrgUserStatus(t *testing.T) { func TestChangeOrgUserStatus(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())

View File

@ -31,10 +31,14 @@ func Members(ctx *context.Context) {
ctx.Data["PageIsOrgMembers"] = true ctx.Data["PageIsOrgMembers"] = true
page := max(ctx.FormInt("page"), 1) page := max(ctx.FormInt("page"), 1)
keyword := ctx.FormTrim("q")
ctx.Data["Keyword"] = keyword
opts := &organization.FindOrgMembersOpts{ opts := &organization.FindOrgMembersOpts{
Doer: ctx.Doer, Doer: ctx.Doer,
OrgID: org.ID, OrgID: org.ID,
Keyword: keyword,
SearchByEmail: true,
} }
if ctx.Doer != nil { if ctx.Doer != nil {
@ -59,6 +63,7 @@ func Members(ctx *context.Context) {
} }
pager := context.NewPagination(total, setting.UI.MembersPagingNum, page, 5) pager := context.NewPagination(total, setting.UI.MembersPagingNum, page, 5)
pager.AddParamFromRequest(ctx.Req)
opts.ListOptions.Page = page opts.ListOptions.Page = page
opts.ListOptions.PageSize = setting.UI.MembersPagingNum opts.ListOptions.PageSize = setting.UI.MembersPagingNum
members, membersIsPublic, err := organization.FindOrgMembers(ctx, opts) members, membersIsPublic, err := organization.FindOrgMembers(ctx, opts)
@ -68,6 +73,8 @@ func Members(ctx *context.Context) {
} }
ctx.Data["Page"] = pager ctx.Data["Page"] = pager
ctx.Data["Members"] = members ctx.Data["Members"] = members
ctx.Data["MembersShown"] = len(members)
ctx.Data["MembersTotal"] = total
ctx.Data["MembersIsPublicMember"] = membersIsPublic ctx.Data["MembersIsPublicMember"] = membersIsPublic
ctx.Data["MembersIsUserOrgOwner"] = organization.IsUserOrgOwner(ctx, members, org.ID) ctx.Data["MembersIsUserOrgOwner"] = organization.IsUserOrgOwner(ctx, members, org.ID)
ctx.Data["MembersTwoFaStatus"] = members.GetTwoFaStatus(ctx) ctx.Data["MembersTwoFaStatus"] = members.GetTwoFaStatus(ctx)

View File

@ -11,6 +11,18 @@
</div> </div>
<div class="divider"></div> <div class="divider"></div>
{{end}} {{end}}
<div class="ui small secondary filter menu">
<form id="org-member-search-form" class="ui form ignore-dirty tw-flex-1 tw-flex tw-items-center tw-gap-x-2">
<div class="ui small fluid action input tw-flex-1">
{{template "shared/search/input" dict "Value" .Keyword "Placeholder" (ctx.Locale.Tr "search.user_kind")}}
{{template "shared/search/button"}}
</div>
</form>
<div class="item tw-text-text-light">
{{ctx.Locale.Tr "org.members"}}: {{CountFmt .MembersShown}} / {{CountFmt .MembersTotal}}
</div>
</div>
<div class="divider"></div>
<div class="flex-list"> <div class="flex-list">
{{range .Members}} {{range .Members}}
{{$isPublic := index $.MembersIsPublicMember .ID}} {{$isPublic := index $.MembersIsPublicMember .ID}}
@ -67,6 +79,12 @@
{{end}} {{end}}
</div> </div>
</div> </div>
{{else}}
<div class="flex-item">
<div class="flex-item-main">
{{ctx.Locale.Tr "search.no_results"}}
</div>
</div>
{{end}} {{end}}
</div> </div>
{{template "base/paginate" .}} {{template "base/paginate" .}}