diff --git a/models/organization/org.go b/models/organization/org.go index 10a2c330e5..f012e1d636 100644 --- a/models/organization/org.go +++ b/models/organization/org.go @@ -181,15 +181,47 @@ func (org *Organization) HomeLink() string { // FindOrgMembersOpts represents find org members conditions type FindOrgMembersOpts struct { db.ListOptions - Doer *user_model.User - IsDoerMember bool - OrgID int64 + Doer *user_model.User + IsDoerMember bool + OrgID int64 + Keyword string + SearchByEmail bool } func (opts FindOrgMembersOpts) PublicOnly() bool { 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 { + var emailCond builder.Cond = 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 func (opts FindOrgMembersOpts) applyTeamMatesOnlyFilter(sess *xorm.Session) { if opts.Doer != nil && opts.IsDoerMember && opts.Doer.IsRestricted { @@ -213,6 +245,7 @@ func CountOrgMembers(ctx context.Context, opts *FindOrgMembersOpts) (int64, erro } else { opts.applyTeamMatesOnlyFilter(sess) } + sess, _ = opts.applyKeywordFilter(sess) return sess.Count(new(OrgUser)) } @@ -461,7 +494,11 @@ func GetOrgUsersByOrgID(ctx context.Context, opts *FindOrgMembersOpts) ([]*OrgUs } else { opts.applyTeamMatesOnlyFilter(sess) } + if keywordSess, hasKeyword := opts.applyKeywordFilter(sess); hasKeyword { + sess = keywordSess.Select("org_user.*") + } + sess = sess.OrderBy("org_user.uid ASC") if opts.ListOptions.PageSize > 0 { sess = db.SetSessionPagination(sess, opts) diff --git a/models/organization/org_test.go b/models/organization/org_test.go index 7a74c5f5fc..1bcd14e1fe 100644 --- a/models/organization/org_test.go +++ b/models/organization/org_test.go @@ -288,6 +288,80 @@ func TestGetOrgUsersByOrgID(t *testing.T) { 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) { assert.NoError(t, unittest.PrepareTestDatabase()) diff --git a/routers/web/org/members.go b/routers/web/org/members.go index 5d7a0a28cd..51884ceb9d 100644 --- a/routers/web/org/members.go +++ b/routers/web/org/members.go @@ -31,10 +31,14 @@ func Members(ctx *context.Context) { ctx.Data["PageIsOrgMembers"] = true page := max(ctx.FormInt("page"), 1) + keyword := ctx.FormTrim("q") + ctx.Data["Keyword"] = keyword opts := &organization.FindOrgMembersOpts{ - Doer: ctx.Doer, - OrgID: org.ID, + Doer: ctx.Doer, + OrgID: org.ID, + Keyword: keyword, + SearchByEmail: true, } if ctx.Doer != nil { @@ -58,9 +62,11 @@ func Members(ctx *context.Context) { return } - pager := context.NewPagination(total, setting.UI.MembersPagingNum, page, 5) + pageSize := setting.UI.MembersPagingNum + pager := context.NewPagination(total, pageSize, page, 5) + pager.AddParamFromRequest(ctx.Req) opts.ListOptions.Page = page - opts.ListOptions.PageSize = setting.UI.MembersPagingNum + opts.ListOptions.PageSize = pageSize members, membersIsPublic, err := organization.FindOrgMembers(ctx, opts) if err != nil { ctx.ServerError("GetMembers", err) @@ -68,6 +74,8 @@ func Members(ctx *context.Context) { } ctx.Data["Page"] = pager ctx.Data["Members"] = members + ctx.Data["MembersShown"] = len(members) + ctx.Data["MembersTotal"] = total ctx.Data["MembersIsPublicMember"] = membersIsPublic ctx.Data["MembersIsUserOrgOwner"] = organization.IsUserOrgOwner(ctx, members, org.ID) ctx.Data["MembersTwoFaStatus"] = members.GetTwoFaStatus(ctx) diff --git a/templates/org/member/members.tmpl b/templates/org/member/members.tmpl index b8e88b837d..ea637e76fb 100644 --- a/templates/org/member/members.tmpl +++ b/templates/org/member/members.tmpl @@ -11,6 +11,17 @@
{{end}} +
{{range .Members}} {{$isPublic := index $.MembersIsPublicMember .ID}} @@ -67,6 +78,12 @@ {{end}}
+ {{else}} +
+
+ {{ctx.Locale.Tr "search.no_results"}} +
+
{{end}} {{template "base/paginate" .}}