mirror of
https://github.com/coder/coder.git
synced 2026-09-01 14:53:15 +08:00
feat: add username and email user search filters (#27922)
## Summary User search can now resolve exact `email:` and `username:` terms through `GET /api/v2/users` instead of only supporting fuzzy free-text matches. The database query already had exact email and username filters; this wires the public search parser and API handler to those filters so clients can ask for a single user by email without fetching every user or depending on substring matching. This is the API half of coder/terraform-provider-coderd#403: that provider PR adds `data.coderd_user.email`, and this PR gives it an efficient exact lookup path. ## Testing - `go test ./coderd/searchquery -run '^TestSearchUsers$' -count=1` - `go test ./coderd -run '^TestGetUsersFilter$' -count=1` - Live API test: - Built local enterprise Coder from this branch. - Started Coder on `http://127.0.0.1:39991` against a clean Postgres database. - Created `lookup-target@example.com`. - Verified `GET /api/v2/users?q=email:LOOKUP-TARGET@EXAMPLE.COM&limit=2` returned exactly one user: ```json { "count": 1, "users": [ { "id": "efc6f909-ce0a-4731-bd2f-6e4df417aaa7", "username": "lookup-target", "email": "lookup-target@example.com" } ] } ``` ---   --------- Co-authored-by: Ethan Dickson <ethanndickson@gmail.com>
This commit is contained in:
@@ -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{
|
||||
|
||||
Generated
+84
-52
@@ -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
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN $8 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
|
||||
user_last_seen_at >= $8
|
||||
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
|
||||
user_last_seen_at <= $9
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN $10 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
|
||||
user_created_at >= $10
|
||||
user_last_seen_at >= $10
|
||||
ELSE true
|
||||
END
|
||||
-- Filter by created_at
|
||||
AND CASE
|
||||
WHEN $11 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
|
||||
user_created_at <= $11
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
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
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN $8 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
|
||||
users.last_seen_at >= $8
|
||||
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
|
||||
users.last_seen_at <= $9
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
WHEN $10 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
|
||||
users.created_at >= $10
|
||||
users.last_seen_at >= $10
|
||||
ELSE true
|
||||
END
|
||||
-- Filter by created_at (user creation date, not date added to org)
|
||||
AND CASE
|
||||
WHEN $11 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
|
||||
users.created_at <= $11
|
||||
ELSE true
|
||||
END
|
||||
AND CASE
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user