diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 7f974aa1a76a2..7b19790a6d8ea 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -5800,6 +5800,26 @@ func (q *FakeQuerier) GetUsers(_ context.Context, params database.GetUsersParams users = usersFilteredByRole } + if !params.CreatedBefore.IsZero() { + usersFilteredByCreatedAt := make([]database.User, 0, len(users)) + for i, user := range users { + if user.CreatedAt.Before(params.CreatedBefore) { + usersFilteredByCreatedAt = append(usersFilteredByCreatedAt, users[i]) + } + } + users = usersFilteredByCreatedAt + } + + if !params.CreatedAfter.IsZero() { + usersFilteredByCreatedAt := make([]database.User, 0, len(users)) + for i, user := range users { + if user.CreatedAt.After(params.CreatedAfter) { + usersFilteredByCreatedAt = append(usersFilteredByCreatedAt, users[i]) + } + } + users = usersFilteredByCreatedAt + } + if !params.LastSeenBefore.IsZero() { usersFilteredByLastSeen := make([]database.User, 0, len(users)) for i, user := range users { diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 2a61f339398f2..78f6285e3c11a 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -391,6 +391,8 @@ func (q *sqlQuerier) GetAuthorizedUsers(ctx context.Context, arg GetUsersParams, pq.Array(arg.RbacRole), arg.LastSeenBefore, arg.LastSeenAfter, + arg.CreatedBefore, + arg.CreatedAfter, arg.OffsetOpt, arg.LimitOpt, ) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index fa448d35f0b8e..1a7911bc64b4d 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -10404,16 +10404,27 @@ WHERE last_seen_at >= $6 ELSE true END + -- Filter by created_at + AND CASE + WHEN $7 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + created_at <= $7 + ELSE true + END + AND CASE + WHEN $8 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + created_at >= $8 + ELSE true + END -- End of filters -- Authorize Filter clause will be injected below in GetAuthorizedUsers -- @authorize_filter ORDER BY -- Deterministic and consistent ordering of all users. This is to ensure consistent pagination. - LOWER(username) ASC OFFSET $7 + LOWER(username) ASC OFFSET $9 LIMIT -- A null limit means "no limit", so 0 means return all - NULLIF($8 :: int, 0) + NULLIF($10 :: int, 0) ` type GetUsersParams struct { @@ -10423,6 +10434,8 @@ type GetUsersParams struct { RbacRole []string `db:"rbac_role" json:"rbac_role"` LastSeenBefore time.Time `db:"last_seen_before" json:"last_seen_before"` LastSeenAfter time.Time `db:"last_seen_after" json:"last_seen_after"` + CreatedBefore time.Time `db:"created_before" json:"created_before"` + CreatedAfter time.Time `db:"created_after" json:"created_after"` OffsetOpt int32 `db:"offset_opt" json:"offset_opt"` LimitOpt int32 `db:"limit_opt" json:"limit_opt"` } @@ -10458,6 +10471,8 @@ func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUse pq.Array(arg.RbacRole), arg.LastSeenBefore, arg.LastSeenAfter, + arg.CreatedBefore, + arg.CreatedAfter, arg.OffsetOpt, arg.LimitOpt, ) diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index a4f8844fd2db5..1f30a2c2c1d24 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -199,6 +199,17 @@ WHERE last_seen_at >= @last_seen_after ELSE true END + -- Filter by created_at + AND CASE + WHEN @created_before :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + created_at <= @created_before + ELSE true + END + AND CASE + WHEN @created_after :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + created_at >= @created_after + ELSE true + END -- End of filters -- Authorize Filter clause will be injected below in GetAuthorizedUsers diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 707f32bfc7d32..a4fe5d4775d6c 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -70,6 +70,8 @@ func Users(query string) (database.GetUsersParams, []codersdk.ValidationError) { RbacRole: parser.Strings(values, []string{}, "role"), LastSeenAfter: parser.Time3339Nano(values, time.Time{}, "last_seen_after"), LastSeenBefore: parser.Time3339Nano(values, time.Time{}, "last_seen_before"), + CreatedAfter: parser.Time3339Nano(values, time.Time{}, "created_after"), + CreatedBefore: parser.Time3339Nano(values, time.Time{}, "created_before"), } parser.ErrorExcessParams(values) return filter, parser.Errors diff --git a/coderd/users.go b/coderd/users.go index 2fccef83f2013..56f295986859c 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -317,6 +317,8 @@ func (api *API) GetUsers(rw http.ResponseWriter, r *http.Request) ([]database.Us RbacRole: params.RbacRole, LastSeenBefore: params.LastSeenBefore, LastSeenAfter: params.LastSeenAfter, + CreatedAfter: params.CreatedAfter, + CreatedBefore: params.CreatedBefore, OffsetOpt: int32(paginationParams.Offset), LimitOpt: int32(paginationParams.Limit), }) diff --git a/coderd/users_test.go b/coderd/users_test.go index 617ab31259f91..1386d76f3e0bf 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -26,9 +26,11 @@ import ( "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/util/ptr" @@ -1515,6 +1517,73 @@ func TestUsersFilter(t *testing.T) { users = append(users, user) } + // Add users with different creation dates for testing date filters + for i := 0; i < 3; i++ { + // nolint:gocritic // Using system context is necessary to seed data in tests + user1, err := api.Database.InsertUser(dbauthz.AsSystemRestricted(ctx), database.InsertUserParams{ + ID: uuid.New(), + Email: fmt.Sprintf("before%d@coder.com", i), + Username: fmt.Sprintf("before%d", i), + LoginType: database.LoginTypeNone, + Status: string(codersdk.UserStatusActive), + RBACRoles: []string{codersdk.RoleMember}, + CreatedAt: dbtime.Time(time.Date(2022, 12, 15+i, 12, 0, 0, 0, time.UTC)), + }) + require.NoError(t, err) + + // The expected timestamps must be parsed from strings to compare equal during `ElementsMatch` + sdkUser1 := db2sdk.User(user1, nil) + sdkUser1.CreatedAt, err = time.Parse(time.RFC3339, sdkUser1.CreatedAt.Format(time.RFC3339)) + require.NoError(t, err) + sdkUser1.UpdatedAt, err = time.Parse(time.RFC3339, sdkUser1.UpdatedAt.Format(time.RFC3339)) + require.NoError(t, err) + sdkUser1.LastSeenAt, err = time.Parse(time.RFC3339, sdkUser1.LastSeenAt.Format(time.RFC3339)) + require.NoError(t, err) + users = append(users, sdkUser1) + + // nolint:gocritic //Using system context is necessary to seed data in tests + user2, err := api.Database.InsertUser(dbauthz.AsSystemRestricted(ctx), database.InsertUserParams{ + ID: uuid.New(), + Email: fmt.Sprintf("during%d@coder.com", i), + Username: fmt.Sprintf("during%d", i), + LoginType: database.LoginTypeNone, + Status: string(codersdk.UserStatusActive), + RBACRoles: []string{codersdk.RoleOwner}, + CreatedAt: dbtime.Time(time.Date(2023, 1, 15+i, 12, 0, 0, 0, time.UTC)), + }) + require.NoError(t, err) + + sdkUser2 := db2sdk.User(user2, nil) + sdkUser2.CreatedAt, err = time.Parse(time.RFC3339, sdkUser2.CreatedAt.Format(time.RFC3339)) + require.NoError(t, err) + sdkUser2.UpdatedAt, err = time.Parse(time.RFC3339, sdkUser2.UpdatedAt.Format(time.RFC3339)) + require.NoError(t, err) + sdkUser2.LastSeenAt, err = time.Parse(time.RFC3339, sdkUser2.LastSeenAt.Format(time.RFC3339)) + require.NoError(t, err) + users = append(users, sdkUser2) + + // nolint:gocritic // Using system context is necessary to seed data in tests + user3, err := api.Database.InsertUser(dbauthz.AsSystemRestricted(ctx), database.InsertUserParams{ + ID: uuid.New(), + Email: fmt.Sprintf("after%d@coder.com", i), + Username: fmt.Sprintf("after%d", i), + LoginType: database.LoginTypeNone, + Status: string(codersdk.UserStatusActive), + RBACRoles: []string{codersdk.RoleOwner}, + CreatedAt: dbtime.Time(time.Date(2023, 2, 15+i, 12, 0, 0, 0, time.UTC)), + }) + require.NoError(t, err) + + sdkUser3 := db2sdk.User(user3, nil) + sdkUser3.CreatedAt, err = time.Parse(time.RFC3339, sdkUser3.CreatedAt.Format(time.RFC3339)) + require.NoError(t, err) + sdkUser3.UpdatedAt, err = time.Parse(time.RFC3339, sdkUser3.UpdatedAt.Format(time.RFC3339)) + require.NoError(t, err) + sdkUser3.LastSeenAt, err = time.Parse(time.RFC3339, sdkUser3.LastSeenAt.Format(time.RFC3339)) + require.NoError(t, err) + users = append(users, sdkUser3) + } + // --- Setup done --- testCases := []struct { Name string @@ -1657,6 +1726,37 @@ func TestUsersFilter(t *testing.T) { return u.LastSeenAt.Before(end) && u.LastSeenAt.After(start) }, }, + { + Name: "CreatedAtBefore", + Filter: codersdk.UsersRequest{ + SearchQuery: `created_before:"2023-01-31T23:59:59Z"`, + }, + FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { + end := time.Date(2023, 1, 31, 23, 59, 59, 0, time.UTC) + return u.CreatedAt.Before(end) + }, + }, + { + Name: "CreatedAtAfter", + Filter: codersdk.UsersRequest{ + SearchQuery: `created_after:"2023-01-01T00:00:00Z"`, + }, + FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { + start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) + return u.CreatedAt.After(start) + }, + }, + { + Name: "CreatedAtRange", + Filter: codersdk.UsersRequest{ + SearchQuery: `created_after:"2023-01-01T00:00:00Z" created_before:"2023-01-31T23:59:59Z"`, + }, + FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { + start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(2023, 1, 31, 23, 59, 59, 0, time.UTC) + return u.CreatedAt.After(start) && u.CreatedAt.Before(end) + }, + }, } for _, c := range testCases { @@ -1677,6 +1777,16 @@ func TestUsersFilter(t *testing.T) { exp = append(exp, made) } } + + // TODO: This can be removed with dbmem + if !dbtestutil.WillUsePostgres() { + for i := range matched.Users { + if len(matched.Users[i].OrganizationIDs) == 0 { + matched.Users[i].OrganizationIDs = nil + } + } + } + require.ElementsMatch(t, exp, matched.Users, "expected users returned") }) } diff --git a/docs/admin/users/index.md b/docs/admin/users/index.md index a00030a514f05..9dcdb237eb764 100644 --- a/docs/admin/users/index.md +++ b/docs/admin/users/index.md @@ -185,8 +185,10 @@ to use the Coder's filter query: - To find active users, use the filter `status:active`. - To find admin users, use the filter `role:admin`. -- To find users have not been active since July 2023: +- To find users who have not been active since July 2023: `status:active last_seen_before:"2023-07-01T00:00:00Z"` +- To find users who were created between January 1 and January 18, 2023: + `created_before:"2023-01-18T00:00:00Z" created_after:"2023-01-01T23:59:59Z"` The following filters are supported: @@ -195,6 +197,8 @@ The following filters are supported: - `role` - Represents the role of the user. You can refer to the [TemplateRole documentation](https://pkg.go.dev/github.com/coder/coder/v2/codersdk#TemplateRole) for a list of supported user roles. -- `last_seen_before` and `last_seen_after` - The last time a used has used the +- `last_seen_before` and `last_seen_after` - The last time a user has used the platform (e.g. logging in, any API requests, connecting to workspaces). Uses the RFC3339Nano format. +- `created_before` and `created_after` - The time a user was created. Uses the + RFC3339Nano format.