diff --git a/coderd/coderdtest/users.go b/coderd/coderdtest/users.go index 9b2130bcc7c..e09820305f2 100644 --- a/coderd/coderdtest/users.go +++ b/coderd/coderdtest/users.go @@ -425,6 +425,24 @@ func UsersFilter( return u.Username == "before1" }, }, + { + Name: "ExactUsername", + Filter: codersdk.UsersRequest{ + SearchQuery: "username:" + strings.ToUpper(users[0].Username), + }, + FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { + return strings.EqualFold(u.Username, users[0].Username) + }, + }, + { + Name: "ExactEmail", + Filter: codersdk.UsersRequest{ + SearchQuery: "email:" + strings.ToUpper(users[0].Email), + }, + FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { + return strings.EqualFold(u.Email, users[0].Email) + }, + }, { Name: "NameNoMatch", Filter: codersdk.UsersRequest{ diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index e15e05da2cb..31c13314262 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -14657,74 +14657,86 @@ WHERE user_name ILIKE concat('%', $4, '%') ELSE true END + -- Filter by exact username + AND CASE + WHEN $5 :: text != '' THEN + lower(user_username) = lower($5) + ELSE true + END + -- Filter by exact email + AND CASE + WHEN $6 :: text != '' THEN + lower(user_email) = lower($6) + ELSE true + END -- Filter by status AND CASE -- @status needs to be a text because it can be empty, If it was -- user_status enum, it would not. - WHEN cardinality($5 :: user_status[]) > 0 THEN - user_status = ANY($5 :: user_status[]) + WHEN cardinality($7 :: user_status[]) > 0 THEN + user_status = ANY($7 :: user_status[]) ELSE true END -- Filter by rbac_roles AND CASE -- @rbac_role allows filtering by rbac roles. If 'member' is included, show everyone, as -- everyone is a member. - WHEN cardinality($6 :: text[]) > 0 AND 'member' != ANY($6 :: text[]) THEN - user_rbac_roles && $6 :: text[] + WHEN cardinality($8 :: text[]) > 0 AND 'member' != ANY($8 :: text[]) THEN + user_rbac_roles && $8 :: text[] ELSE true END -- Filter by last_seen AND CASE - WHEN $7 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN - user_last_seen_at <= $7 + WHEN $9 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + user_last_seen_at <= $9 ELSE true END AND CASE - WHEN $8 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN - user_last_seen_at >= $8 + WHEN $10 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + user_last_seen_at >= $10 ELSE true END -- Filter by created_at AND CASE - WHEN $9 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN - user_created_at <= $9 + WHEN $11 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + user_created_at <= $11 ELSE true END AND CASE - WHEN $10 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN - user_created_at >= $10 + WHEN $12 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + user_created_at >= $12 ELSE true END -- Filter by system type AND CASE - WHEN $11::bool THEN TRUE + WHEN $13::bool THEN TRUE ELSE user_is_system = false END -- Filter by github.com user ID AND CASE - WHEN $12 :: bigint != 0 THEN - user_github_com_user_id = $12 + WHEN $14 :: bigint != 0 THEN + user_github_com_user_id = $14 ELSE true END -- Filter by login_type AND CASE - WHEN cardinality($13 :: login_type[]) > 0 THEN - user_login_type = ANY($13 :: login_type[]) + WHEN cardinality($15 :: login_type[]) > 0 THEN + user_login_type = ANY($15 :: login_type[]) ELSE true END -- Filter by service account. AND CASE - WHEN $14 :: boolean IS NOT NULL THEN - user_is_service_account = $14 :: boolean + WHEN $16 :: boolean IS NOT NULL THEN + user_is_service_account = $16 :: boolean ELSE true END -- End of filters ORDER BY -- Deterministic and consistent ordering of all users. This is to ensure consistent pagination. - LOWER(user_username) ASC OFFSET $15 + LOWER(user_username) ASC OFFSET $17 LIMIT -- A null limit means "no limit", so 0 means return all - NULLIF($16 :: int, 0) + NULLIF($18 :: int, 0) ` type GetGroupMembersByGroupIDPaginatedParams struct { @@ -14732,6 +14744,8 @@ type GetGroupMembersByGroupIDPaginatedParams struct { AfterID uuid.UUID `db:"after_id" json:"after_id"` Search string `db:"search" json:"search"` Name string `db:"name" json:"name"` + ExactUsername string `db:"exact_username" json:"exact_username"` + ExactEmail string `db:"exact_email" json:"exact_email"` Status []UserStatus `db:"status" json:"status"` RbacRole []string `db:"rbac_role" json:"rbac_role"` LastSeenBefore time.Time `db:"last_seen_before" json:"last_seen_before"` @@ -14776,6 +14790,8 @@ func (q *sqlQuerier) GetGroupMembersByGroupIDPaginated(ctx context.Context, arg arg.AfterID, arg.Search, arg.Name, + arg.ExactUsername, + arg.ExactEmail, pq.Array(arg.Status), pq.Array(arg.RbacRole), arg.LastSeenBefore, @@ -20176,74 +20192,86 @@ WHERE users.name ILIKE concat('%', $4, '%') ELSE true END + -- Filter by exact username + AND CASE + WHEN $5 :: text != '' THEN + lower(users.username) = lower($5) + ELSE true + END + -- Filter by exact email + AND CASE + WHEN $6 :: text != '' THEN + lower(users.email) = lower($6) + ELSE true + END -- Filter by status AND CASE -- @status needs to be a text because it can be empty, If it was -- user_status enum, it would not. - WHEN cardinality($5 :: user_status[]) > 0 THEN - users.status = ANY($5 :: user_status[]) + WHEN cardinality($7 :: user_status[]) > 0 THEN + users.status = ANY($7 :: user_status[]) ELSE true END -- Filter by global rbac_roles AND CASE -- @rbac_role allows filtering by rbac roles. If 'member' is included, show everyone, as -- everyone is a member. - WHEN cardinality($6 :: text[]) > 0 AND 'member' != ANY($6 :: text[]) THEN - users.rbac_roles && $6 :: text[] + WHEN cardinality($8 :: text[]) > 0 AND 'member' != ANY($8 :: text[]) THEN + users.rbac_roles && $8 :: text[] ELSE true END -- Filter by last_seen AND CASE - WHEN $7 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN - users.last_seen_at <= $7 + WHEN $9 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + users.last_seen_at <= $9 ELSE true END AND CASE - WHEN $8 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN - users.last_seen_at >= $8 + WHEN $10 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + users.last_seen_at >= $10 ELSE true END -- Filter by created_at (user creation date, not date added to org) AND CASE - WHEN $9 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN - users.created_at <= $9 + WHEN $11 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + users.created_at <= $11 ELSE true END AND CASE - WHEN $10 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN - users.created_at >= $10 + WHEN $12 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + users.created_at >= $12 ELSE true END -- Filter by system type AND CASE - WHEN $11::bool THEN TRUE + WHEN $13::bool THEN TRUE ELSE users.is_system = false END -- Filter by github.com user ID AND CASE - WHEN $12 :: bigint != 0 THEN - users.github_com_user_id = $12 + WHEN $14 :: bigint != 0 THEN + users.github_com_user_id = $14 ELSE true END -- Filter by login_type AND CASE - WHEN cardinality($13 :: login_type[]) > 0 THEN - users.login_type = ANY($13 :: login_type[]) + WHEN cardinality($15 :: login_type[]) > 0 THEN + users.login_type = ANY($15 :: login_type[]) ELSE true END -- Filter by service account. AND CASE - WHEN $14 :: boolean IS NOT NULL THEN - users.is_service_account = $14 :: boolean + WHEN $16 :: boolean IS NOT NULL THEN + users.is_service_account = $16 :: boolean ELSE true END -- End of filters ORDER BY -- Deterministic and consistent ordering of all users. This is to ensure consistent pagination. - LOWER(users.username) ASC OFFSET $15 + LOWER(users.username) ASC OFFSET $17 LIMIT -- A null limit means "no limit", so 0 means return all - NULLIF($16 :: int, 0) + NULLIF($18 :: int, 0) ` type PaginatedOrganizationMembersParams struct { @@ -20251,6 +20279,8 @@ type PaginatedOrganizationMembersParams struct { OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` Search string `db:"search" json:"search"` Name string `db:"name" json:"name"` + ExactUsername string `db:"exact_username" json:"exact_username"` + ExactEmail string `db:"exact_email" json:"exact_email"` Status []UserStatus `db:"status" json:"status"` RbacRole []string `db:"rbac_role" json:"rbac_role"` LastSeenBefore time.Time `db:"last_seen_before" json:"last_seen_before"` @@ -20287,6 +20317,8 @@ func (q *sqlQuerier) PaginatedOrganizationMembers(ctx context.Context, arg Pagin arg.OrganizationID, arg.Search, arg.Name, + arg.ExactUsername, + arg.ExactEmail, pq.Array(arg.Status), pq.Array(arg.RbacRole), arg.LastSeenBefore, diff --git a/coderd/database/queries/groupmembers.sql b/coderd/database/queries/groupmembers.sql index 1be98155217..7add762556a 100644 --- a/coderd/database/queries/groupmembers.sql +++ b/coderd/database/queries/groupmembers.sql @@ -60,6 +60,18 @@ WHERE user_name ILIKE concat('%', @name, '%') ELSE true END + -- Filter by exact username + AND CASE + WHEN @exact_username :: text != '' THEN + lower(user_username) = lower(@exact_username) + ELSE true + END + -- Filter by exact email + AND CASE + WHEN @exact_email :: text != '' THEN + lower(user_email) = lower(@exact_email) + ELSE true + END -- Filter by status AND CASE -- @status needs to be a text because it can be empty, If it was diff --git a/coderd/database/queries/organizationmembers.sql b/coderd/database/queries/organizationmembers.sql index bf75d1e5d72..2857e61efd0 100644 --- a/coderd/database/queries/organizationmembers.sql +++ b/coderd/database/queries/organizationmembers.sql @@ -136,6 +136,18 @@ WHERE users.name ILIKE concat('%', @name, '%') ELSE true END + -- Filter by exact username + AND CASE + WHEN @exact_username :: text != '' THEN + lower(users.username) = lower(@exact_username) + ELSE true + END + -- Filter by exact email + AND CASE + WHEN @exact_email :: text != '' THEN + lower(users.email) = lower(@exact_email) + ELSE true + END -- Filter by status AND CASE -- @status needs to be a text because it can be empty, If it was diff --git a/coderd/members.go b/coderd/members.go index 7f1511bebb9..13918163205 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -300,6 +300,8 @@ func (api *API) paginatedMembers(rw http.ResponseWriter, r *http.Request) { IncludeSystem: false, Search: userFilterParams.Search, Name: userFilterParams.Name, + ExactUsername: userFilterParams.ExactUsername, + ExactEmail: userFilterParams.ExactEmail, Status: userFilterParams.Status, IsServiceAccount: userFilterParams.IsServiceAccount, RbacRole: userFilterParams.RbacRole, diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 290b58485d1..e8f91d2dbab 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -160,6 +160,8 @@ func Users(query string) (database.GetUsersParams, []codersdk.ValidationError) { filter := database.GetUsersParams{ Search: parser.String(values, "", "search"), Name: parser.String(values, "", "name"), + ExactUsername: parser.String(values, "", "username"), + ExactEmail: parser.String(values, "", "email"), Status: httpapi.ParseCustomList(parser, values, []database.UserStatus{}, "status", httpapi.ParseEnum[database.UserStatus]), IsServiceAccount: parser.NullableBoolean(values, sql.NullBool{}, "service_account"), RbacRole: parser.Strings(values, []string{}, "role"), diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index dd89ad8527c..8138780e3c6 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -864,6 +864,26 @@ func TestSearchUsers(t *testing.T) { LoginType: []database.LoginType{}, }, }, + { + Name: "UsernameFilter", + Query: "username:Alice", + Expected: database.GetUsersParams{ + ExactUsername: "alice", + Status: []database.UserStatus{}, + RbacRole: []string{}, + LoginType: []database.LoginType{}, + }, + }, + { + Name: "EmailFilter", + Query: "email:Alice@Example.com", + Expected: database.GetUsersParams{ + ExactEmail: "alice@example.com", + Status: []database.UserStatus{}, + RbacRole: []string{}, + LoginType: []database.LoginType{}, + }, + }, { Name: "NameFilterWithOtherParams", Query: "name:John status:active role:owner", diff --git a/coderd/users.go b/coderd/users.go index b9872661971..8dae291a5bd 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -390,6 +390,8 @@ func (api *API) GetUsers(rw http.ResponseWriter, r *http.Request) ([]database.Us AfterID: paginationParams.AfterID, Search: params.Search, Name: params.Name, + ExactUsername: params.ExactUsername, + ExactEmail: params.ExactEmail, Status: params.Status, IsServiceAccount: params.IsServiceAccount, RbacRole: params.RbacRole, diff --git a/docs/admin/users/index.md b/docs/admin/users/index.md index 0beaf5255a9..17cebb4cac7 100644 --- a/docs/admin/users/index.md +++ b/docs/admin/users/index.md @@ -201,6 +201,12 @@ contains `jane`. The following filters are supported: +- `username` - Matches the exact username of the user. The match ignores + letter case. +- `email` - Matches the exact email address of the user. The match ignores + letter case. +- `name` - Matches part of the display name of the user. The match ignores + letter case. - `status` - Indicates the status of the user. It can be either `active`, `dormant` or `suspended`. - `role` - Represents the role of the user. You can refer to the diff --git a/enterprise/coderd/groups.go b/enterprise/coderd/groups.go index 839a55e3647..24589dd5cd8 100644 --- a/enterprise/coderd/groups.go +++ b/enterprise/coderd/groups.go @@ -500,6 +500,8 @@ func (api *API) groupMembers(rw http.ResponseWriter, r *http.Request) { IncludeSystem: false, Search: userFilterParams.Search, Name: userFilterParams.Name, + ExactUsername: userFilterParams.ExactUsername, + ExactEmail: userFilterParams.ExactEmail, Status: userFilterParams.Status, IsServiceAccount: userFilterParams.IsServiceAccount, RbacRole: userFilterParams.RbacRole,