From 47daca6eea5944e8114ca9e4f705ed5303c237ba Mon Sep 17 00:00:00 2001 From: Asher Date: Sat, 21 Mar 2026 16:58:45 -0800 Subject: [PATCH] feat: add filtering to org members (#23334) Continuation of https://github.com/coder/coder/pull/23067 Add filtering to the paginated org member endpoint (pretty much the same as what I did in the previous PR with group members, except there I also had to add pagination since it was missing). --- ...r_organizations_members_list_--help.golden | 2 +- coderd/apidoc/docs.go | 39 +++++ coderd/apidoc/swagger.json | 36 ++++ coderd/database/queries.sql.go | 162 ++++++++++++++++-- .../database/queries/organizationmembers.sql | 106 +++++++++++- coderd/members.go | 56 ++++-- coderd/members_test.go | 63 +++++++ codersdk/organizations.go | 5 + codersdk/users.go | 19 ++ docs/reference/api/members.md | 119 ++++++++----- docs/reference/api/schemas.md | 21 +++ .../cli/organizations_members_list.md | 8 +- site/src/api/typesGenerated.ts | 5 + site/src/testHelpers/entities.ts | 10 ++ 14 files changed, 576 insertions(+), 75 deletions(-) diff --git a/cli/testdata/coder_organizations_members_list_--help.golden b/cli/testdata/coder_organizations_members_list_--help.golden index 51ca3c2108..c2cb5022ab 100644 --- a/cli/testdata/coder_organizations_members_list_--help.golden +++ b/cli/testdata/coder_organizations_members_list_--help.golden @@ -6,7 +6,7 @@ USAGE: List all organization members OPTIONS: - -c, --column [username|name|user id|organization id|created at|updated at|organization roles] (default: username,organization roles) + -c, --column [username|name|last seen at|user created at|user updated at|user id|organization id|created at|updated at|organization roles] (default: username,organization roles) Columns to display in table output. -o, --output table|json (default: table) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 22e7e3feeb..88acfc1c6f 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -4082,6 +4082,19 @@ const docTemplate = `{ "in": "path", "required": true }, + { + "type": "string", + "description": "Member search query", + "name": "q", + "in": "query" + }, + { + "type": "string", + "format": "uuid", + "description": "After ID", + "name": "after_id", + "in": "query" + }, { "type": "integer", "description": "Page limit, if 0 returns all members", @@ -17313,6 +17326,13 @@ const docTemplate = `{ "$ref": "#/definitions/codersdk.SlimRole" } }, + "last_seen_at": { + "type": "string", + "format": "date-time" + }, + "login_type": { + "$ref": "#/definitions/codersdk.LoginType" + }, "name": { "type": "string" }, @@ -17326,14 +17346,33 @@ const docTemplate = `{ "$ref": "#/definitions/codersdk.SlimRole" } }, + "status": { + "enum": [ + "active", + "suspended" + ], + "allOf": [ + { + "$ref": "#/definitions/codersdk.UserStatus" + } + ] + }, "updated_at": { "type": "string", "format": "date-time" }, + "user_created_at": { + "type": "string", + "format": "date-time" + }, "user_id": { "type": "string", "format": "uuid" }, + "user_updated_at": { + "type": "string", + "format": "date-time" + }, "username": { "type": "string" } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 569adac412..27e9509448 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -3603,6 +3603,19 @@ "in": "path", "required": true }, + { + "type": "string", + "description": "Member search query", + "name": "q", + "in": "query" + }, + { + "type": "string", + "format": "uuid", + "description": "After ID", + "name": "after_id", + "in": "query" + }, { "type": "integer", "description": "Page limit, if 0 returns all members", @@ -15740,6 +15753,13 @@ "$ref": "#/definitions/codersdk.SlimRole" } }, + "last_seen_at": { + "type": "string", + "format": "date-time" + }, + "login_type": { + "$ref": "#/definitions/codersdk.LoginType" + }, "name": { "type": "string" }, @@ -15753,14 +15773,30 @@ "$ref": "#/definitions/codersdk.SlimRole" } }, + "status": { + "enum": ["active", "suspended"], + "allOf": [ + { + "$ref": "#/definitions/codersdk.UserStatus" + } + ] + }, "updated_at": { "type": "string", "format": "date-time" }, + "user_created_at": { + "type": "string", + "format": "date-time" + }, "user_id": { "type": "string", "format": "uuid" }, + "user_updated_at": { + "type": "string", + "format": "date-time" + }, "username": { "type": "string" } diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index f07d0dad46..afd9a676c8 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -12429,7 +12429,9 @@ func (q *sqlQuerier) InsertOrganizationMember(ctx context.Context, arg InsertOrg const organizationMembers = `-- name: OrganizationMembers :many SELECT organization_members.user_id, organization_members.organization_id, organization_members.created_at, organization_members.updated_at, organization_members.roles, - users.username, users.avatar_url, users.name, users.email, users.rbac_roles as "global_roles" + users.username, users.avatar_url, users.name, users.email, users.rbac_roles as "global_roles", + users.last_seen_at, users.status, users.login_type, + users.created_at as user_created_at, users.updated_at as user_updated_at FROM organization_members INNER JOIN @@ -12475,6 +12477,11 @@ type OrganizationMembersRow struct { Name string `db:"name" json:"name"` Email string `db:"email" json:"email"` GlobalRoles pq.StringArray `db:"global_roles" json:"global_roles"` + LastSeenAt time.Time `db:"last_seen_at" json:"last_seen_at"` + Status UserStatus `db:"status" json:"status"` + LoginType LoginType `db:"login_type" json:"login_type"` + UserCreatedAt time.Time `db:"user_created_at" json:"user_created_at"` + UserUpdatedAt time.Time `db:"user_updated_at" json:"user_updated_at"` } // Arguments are optional with uuid.Nil to ignore. @@ -12506,6 +12513,11 @@ func (q *sqlQuerier) OrganizationMembers(ctx context.Context, arg OrganizationMe &i.Name, &i.Email, &i.GlobalRoles, + &i.LastSeenAt, + &i.Status, + &i.LoginType, + &i.UserCreatedAt, + &i.UserUpdatedAt, ); err != nil { return nil, err } @@ -12524,33 +12536,136 @@ const paginatedOrganizationMembers = `-- name: PaginatedOrganizationMembers :man SELECT organization_members.user_id, organization_members.organization_id, organization_members.created_at, organization_members.updated_at, organization_members.roles, users.username, users.avatar_url, users.name, users.email, users.rbac_roles as "global_roles", + users.last_seen_at, users.status, users.login_type, + users.created_at as user_created_at, users.updated_at as user_updated_at, COUNT(*) OVER() AS count FROM organization_members - INNER JOIN +INNER JOIN users ON organization_members.user_id = users.id AND users.deleted = false WHERE - -- Filter by organization id CASE - WHEN $1 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN - organization_id = $1 + -- This allows using the last element on a page as effectively a cursor. + -- This is an important option for scripts that need to paginate without + -- duplicating or missing data. + WHEN $1 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN ( + -- The pagination cursor is the last ID of the previous page. + -- The query is ordered by the username field, so select all + -- rows after the cursor. + (LOWER(users.username)) > ( + SELECT + LOWER(users.username) + FROM + organization_members + INNER JOIN + users ON organization_members.user_id = users.id + WHERE + organization_members.user_id = $1 + ) + ) ELSE true END - -- Filter by system type - AND CASE WHEN $2::bool THEN TRUE ELSE is_system = false END + -- Start filters + -- Filter by organization id + AND CASE + WHEN $2 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + organization_id = $2 + ELSE true + END + -- Filter by email or username + AND CASE + WHEN $3 :: text != '' THEN ( + users.email ILIKE concat('%', $3, '%') + OR users.username ILIKE concat('%', $3, '%') + ) + ELSE true + END + -- Filter by name (display name) + AND CASE + WHEN $4 :: text != '' THEN + users.name ILIKE concat('%', $4, '%') + 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[]) + 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[] + 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 + ELSE true + END + AND CASE + WHEN $10 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + users.created_at >= $10 + ELSE true + END + -- Filter by system type + AND CASE + WHEN $11::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 + ELSE true + END + -- Filter by login_type + AND CASE + WHEN cardinality($13 :: login_type[]) > 0 THEN + users.login_type = ANY($13 :: login_type[]) + ELSE true + END + -- End of filters ORDER BY -- Deterministic and consistent ordering of all users. This is to ensure consistent pagination. - LOWER(username) ASC OFFSET $3 + LOWER(users.username) ASC OFFSET $14 LIMIT -- A null limit means "no limit", so 0 means return all - NULLIF($4 :: int, 0) + NULLIF($15 :: int, 0) ` type PaginatedOrganizationMembersParams struct { - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - IncludeSystem bool `db:"include_system" json:"include_system"` - OffsetOpt int32 `db:"offset_opt" json:"offset_opt"` - LimitOpt int32 `db:"limit_opt" json:"limit_opt"` + AfterID uuid.UUID `db:"after_id" json:"after_id"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + Search string `db:"search" json:"search"` + Name string `db:"name" json:"name"` + 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"` + 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"` + IncludeSystem bool `db:"include_system" json:"include_system"` + GithubComUserID int64 `db:"github_com_user_id" json:"github_com_user_id"` + LoginType []LoginType `db:"login_type" json:"login_type"` + OffsetOpt int32 `db:"offset_opt" json:"offset_opt"` + LimitOpt int32 `db:"limit_opt" json:"limit_opt"` } type PaginatedOrganizationMembersRow struct { @@ -12560,13 +12675,29 @@ type PaginatedOrganizationMembersRow struct { Name string `db:"name" json:"name"` Email string `db:"email" json:"email"` GlobalRoles pq.StringArray `db:"global_roles" json:"global_roles"` + LastSeenAt time.Time `db:"last_seen_at" json:"last_seen_at"` + Status UserStatus `db:"status" json:"status"` + LoginType LoginType `db:"login_type" json:"login_type"` + UserCreatedAt time.Time `db:"user_created_at" json:"user_created_at"` + UserUpdatedAt time.Time `db:"user_updated_at" json:"user_updated_at"` Count int64 `db:"count" json:"count"` } func (q *sqlQuerier) PaginatedOrganizationMembers(ctx context.Context, arg PaginatedOrganizationMembersParams) ([]PaginatedOrganizationMembersRow, error) { rows, err := q.db.QueryContext(ctx, paginatedOrganizationMembers, + arg.AfterID, arg.OrganizationID, + arg.Search, + arg.Name, + pq.Array(arg.Status), + pq.Array(arg.RbacRole), + arg.LastSeenBefore, + arg.LastSeenAfter, + arg.CreatedBefore, + arg.CreatedAfter, arg.IncludeSystem, + arg.GithubComUserID, + pq.Array(arg.LoginType), arg.OffsetOpt, arg.LimitOpt, ) @@ -12588,6 +12719,11 @@ func (q *sqlQuerier) PaginatedOrganizationMembers(ctx context.Context, arg Pagin &i.Name, &i.Email, &i.GlobalRoles, + &i.LastSeenAt, + &i.Status, + &i.LoginType, + &i.UserCreatedAt, + &i.UserUpdatedAt, &i.Count, ); err != nil { return nil, err diff --git a/coderd/database/queries/organizationmembers.sql b/coderd/database/queries/organizationmembers.sql index c4002259dc..7154a68d76 100644 --- a/coderd/database/queries/organizationmembers.sql +++ b/coderd/database/queries/organizationmembers.sql @@ -5,7 +5,9 @@ -- - Use both to get a specific org member row SELECT sqlc.embed(organization_members), - users.username, users.avatar_url, users.name, users.email, users.rbac_roles as "global_roles" + users.username, users.avatar_url, users.name, users.email, users.rbac_roles as "global_roles", + users.last_seen_at, users.status, users.login_type, + users.created_at as user_created_at, users.updated_at as user_updated_at FROM organization_members INNER JOIN @@ -83,23 +85,115 @@ RETURNING *; SELECT sqlc.embed(organization_members), users.username, users.avatar_url, users.name, users.email, users.rbac_roles as "global_roles", + users.last_seen_at, users.status, users.login_type, + users.created_at as user_created_at, users.updated_at as user_updated_at, COUNT(*) OVER() AS count FROM organization_members - INNER JOIN +INNER JOIN users ON organization_members.user_id = users.id AND users.deleted = false WHERE - -- Filter by organization id CASE + -- This allows using the last element on a page as effectively a cursor. + -- This is an important option for scripts that need to paginate without + -- duplicating or missing data. + WHEN @after_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN ( + -- The pagination cursor is the last ID of the previous page. + -- The query is ordered by the username field, so select all + -- rows after the cursor. + (LOWER(users.username)) > ( + SELECT + LOWER(users.username) + FROM + organization_members + INNER JOIN + users ON organization_members.user_id = users.id + WHERE + organization_members.user_id = @after_id + ) + ) + ELSE true + END + -- Start filters + -- Filter by organization id + AND CASE WHEN @organization_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN organization_id = @organization_id ELSE true END - -- Filter by system type - AND CASE WHEN @include_system::bool THEN TRUE ELSE is_system = false END + -- Filter by email or username + AND CASE + WHEN @search :: text != '' THEN ( + users.email ILIKE concat('%', @search, '%') + OR users.username ILIKE concat('%', @search, '%') + ) + ELSE true + END + -- Filter by name (display name) + AND CASE + WHEN @name :: text != '' THEN + users.name ILIKE concat('%', @name, '%') + 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(@status :: user_status[]) > 0 THEN + users.status = ANY(@status :: 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(@rbac_role :: text[]) > 0 AND 'member' != ANY(@rbac_role :: text[]) THEN + users.rbac_roles && @rbac_role :: text[] + ELSE true + END + -- Filter by last_seen + AND CASE + WHEN @last_seen_before :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + users.last_seen_at <= @last_seen_before + ELSE true + END + AND CASE + WHEN @last_seen_after :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + users.last_seen_at >= @last_seen_after + ELSE true + END + -- Filter by created_at (user creation date, not date added to org) + AND CASE + WHEN @created_before :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + users.created_at <= @created_before + ELSE true + END + AND CASE + WHEN @created_after :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN + users.created_at >= @created_after + ELSE true + END + -- Filter by system type + AND CASE + WHEN @include_system::bool THEN TRUE + ELSE users.is_system = false + END + -- Filter by github.com user ID + AND CASE + WHEN @github_com_user_id :: bigint != 0 THEN + users.github_com_user_id = @github_com_user_id + ELSE true + END + -- Filter by login_type + AND CASE + WHEN cardinality(@login_type :: login_type[]) > 0 THEN + users.login_type = ANY(@login_type :: login_type[]) + ELSE true + END + -- End of filters ORDER BY -- Deterministic and consistent ordering of all users. This is to ensure consistent pagination. - LOWER(username) ASC OFFSET @offset_opt + LOWER(users.username) ASC OFFSET @offset_opt LIMIT -- A null limit means "no limit", so 0 means return all NULLIF(@limit_opt :: int, 0); diff --git a/coderd/members.go b/coderd/members.go index 0a7f8985d4..6de4cde143 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -242,27 +242,51 @@ func (api *API) listMembers(rw http.ResponseWriter, r *http.Request) { // @Produce json // @Tags Members // @Param organization path string true "Organization ID" +// @Param q query string false "Member search query" +// @Param after_id query string false "After ID" format(uuid) // @Param limit query int false "Page limit, if 0 returns all members" // @Param offset query int false "Page offset" // @Success 200 {object} []codersdk.PaginatedMembersResponse // @Router /organizations/{organization}/paginated-members [get] func (api *API) paginatedMembers(rw http.ResponseWriter, r *http.Request) { var ( - ctx = r.Context() - organization = httpmw.OrganizationParam(r) - paginationParams, ok = ParsePagination(rw, r) + ctx = r.Context() + organization = httpmw.OrganizationParam(r) ) + + filterQuery := r.URL.Query().Get("q") + userFilterParams, filterErrs := searchquery.Users(filterQuery) + if len(filterErrs) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid member search query.", + Validations: filterErrs, + }) + return + } + + paginationParams, ok := ParsePagination(rw, r) if !ok { return } paginatedMemberRows, err := api.Database.PaginatedOrganizationMembers(ctx, database.PaginatedOrganizationMembersParams{ - OrganizationID: organization.ID, - IncludeSystem: false, - // #nosec G115 - Pagination limits are small and fit in int32 - LimitOpt: int32(paginationParams.Limit), + AfterID: paginationParams.AfterID, + OrganizationID: organization.ID, + IncludeSystem: false, + Search: userFilterParams.Search, + Name: userFilterParams.Name, + Status: userFilterParams.Status, + RbacRole: userFilterParams.RbacRole, + LastSeenBefore: userFilterParams.LastSeenBefore, + LastSeenAfter: userFilterParams.LastSeenAfter, + CreatedAfter: userFilterParams.CreatedAfter, + CreatedBefore: userFilterParams.CreatedBefore, + GithubComUserID: userFilterParams.GithubComUserID, + LoginType: userFilterParams.LoginType, // #nosec G115 - Pagination offsets are small and fit in int32 OffsetOpt: int32(paginationParams.Offset), + // #nosec G115 - Pagination limits are small and fit in int32 + LimitOpt: int32(paginationParams.Limit), }) if httpapi.Is404Error(err) { httpapi.ResourceNotFound(rw) @@ -273,18 +297,21 @@ func (api *API) paginatedMembers(rw http.ResponseWriter, r *http.Request) { return } - memberRows := make([]database.OrganizationMembersRow, 0) - for _, pRow := range paginatedMemberRows { - row := database.OrganizationMembersRow{ + memberRows := make([]database.OrganizationMembersRow, len(paginatedMemberRows)) + for i, pRow := range paginatedMemberRows { + memberRows[i] = database.OrganizationMembersRow{ OrganizationMember: pRow.OrganizationMember, Username: pRow.Username, AvatarURL: pRow.AvatarURL, Name: pRow.Name, Email: pRow.Email, GlobalRoles: pRow.GlobalRoles, + LastSeenAt: pRow.LastSeenAt, + Status: pRow.Status, + LoginType: pRow.LoginType, + UserCreatedAt: pRow.UserCreatedAt, + UserUpdatedAt: pRow.UserUpdatedAt, } - - memberRows = append(memberRows, row) } if len(paginatedMemberRows) == 0 { @@ -501,6 +528,11 @@ func convertOrganizationMembersWithUserData(ctx context.Context, db database.Sto Name: rows[i].Name, Email: rows[i].Email, GlobalRoles: db2sdk.SlimRolesFromNames(rows[i].GlobalRoles), + LastSeenAt: rows[i].LastSeenAt, + Status: codersdk.UserStatus(rows[i].Status), + LoginType: codersdk.LoginType(rows[i].LoginType), + UserCreatedAt: rows[i].UserCreatedAt, + UserUpdatedAt: rows[i].UserUpdatedAt, OrganizationMember: convertedMembers[i], }) } diff --git a/coderd/members_test.go b/coderd/members_test.go index c7d9cad1da..086d960580 100644 --- a/coderd/members_test.go +++ b/coderd/members_test.go @@ -1,12 +1,14 @@ package coderd_test import ( + "context" "database/sql" "testing" "github.com/google/uuid" "github.com/stretchr/testify/require" + "github.com/coder/coder/v2/coderd" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbgen" @@ -132,6 +134,67 @@ func TestListMembers(t *testing.T) { }) } +func TestGetOrgMembersFilter(t *testing.T) { + t.Parallel() + + client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + OIDCConfig: &coderd.OIDCConfig{ + AllowSignups: true, + }, + }) + first := coderdtest.CreateFirstUser(t, client) + + setupCtx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + coderdtest.UsersFilter(setupCtx, t, client, api.Database, nil, func(testCtx context.Context, req codersdk.UsersRequest) []codersdk.ReducedUser { + res, err := client.OrganizationMembersPaginated(testCtx, first.OrganizationID, req) + require.NoError(t, err) + reduced := make([]codersdk.ReducedUser, len(res.Members)) + for i, user := range res.Members { + reduced[i] = orgMemberToReducedUser(user) + } + return reduced + }) +} + +func TestGetOrgMembersPagination(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + first := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + coderdtest.UsersPagination(ctx, t, client, nil, func(req codersdk.UsersRequest) ([]codersdk.ReducedUser, int) { + res, err := client.OrganizationMembersPaginated(ctx, first.OrganizationID, req) + require.NoError(t, err) + reduced := make([]codersdk.ReducedUser, len(res.Members)) + for i, user := range res.Members { + reduced[i] = orgMemberToReducedUser(user) + } + return reduced, res.Count + }) +} + func onlyIDs(u codersdk.OrganizationMemberWithUserData) uuid.UUID { return u.UserID } + +func orgMemberToReducedUser(user codersdk.OrganizationMemberWithUserData) codersdk.ReducedUser { + return codersdk.ReducedUser{ + MinimalUser: codersdk.MinimalUser{ + ID: user.UserID, + Username: user.Username, + Name: user.Name, + AvatarURL: user.AvatarURL, + }, + Email: user.Email, + CreatedAt: user.UserCreatedAt, + UpdatedAt: user.UserUpdatedAt, + LastSeenAt: user.LastSeenAt, + Status: user.Status, + LoginType: user.LoginType, + } +} diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 823169d385..555a9100c6 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -77,6 +77,11 @@ type OrganizationMemberWithUserData struct { Name string `table:"name" json:"name,omitempty"` AvatarURL string `json:"avatar_url,omitempty"` Email string `json:"email"` + Status UserStatus `json:"status" enums:"active,suspended"` + LoginType LoginType `json:"login_type"` + LastSeenAt time.Time `table:"last seen at" json:"last_seen_at,omitempty" format:"date-time"` + UserCreatedAt time.Time `table:"user created at" json:"user_created_at" format:"date-time"` + UserUpdatedAt time.Time `table:"user updated at" json:"user_updated_at" format:"date-time"` GlobalRoles []SlimRole `json:"global_roles"` OrganizationMember `table:"m,recursive_inline"` } diff --git a/codersdk/users.go b/codersdk/users.go index 16575351cc..b4d7ef9d0f 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -714,6 +714,25 @@ func (c *Client) OrganizationMembers(ctx context.Context, organizationID uuid.UU return members, json.NewDecoder(res.Body).Decode(&members) } +// OrganizationMembers lists filtered and paginated members in an organization +func (c *Client) OrganizationMembersPaginated(ctx context.Context, organizationID uuid.UUID, req UsersRequest) (PaginatedMembersResponse, error) { + res, err := c.Request(ctx, http.MethodGet, + fmt.Sprintf("/api/v2/organizations/%s/paginated-members", organizationID), + nil, + req.Pagination.asRequestOption(), + req.asRequestOption(), + ) + if err != nil { + return PaginatedMembersResponse{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return PaginatedMembersResponse{}, ReadBodyAsError(res) + } + var membersRes PaginatedMembersResponse + return membersRes, json.NewDecoder(res.Body).Decode(&membersRes) +} + // UpdateUserRoles grants the userID the specified roles. // Include ALL roles the user has. func (c *Client) UpdateUserRoles(ctx context.Context, user string, req UpdateRoles) (User, error) { diff --git a/docs/reference/api/members.md b/docs/reference/api/members.md index d697662a6a..ca97f41682 100644 --- a/docs/reference/api/members.md +++ b/docs/reference/api/members.md @@ -36,6 +36,8 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members "organization_id": "string" } ], + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "roles": [ @@ -45,8 +47,11 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members "organization_id": "string" } ], + "status": "active", "updated_at": "2019-08-24T14:15:22Z", + "user_created_at": "2019-08-24T14:15:22Z", "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5", + "user_updated_at": "2019-08-24T14:15:22Z", "username": "string" } ] @@ -62,22 +67,34 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members Status Code **200** -| Name | Type | Required | Restrictions | Description | -|----------------------|-------------------|----------|--------------|-------------| -| `[array item]` | array | false | | | -| `» avatar_url` | string | false | | | -| `» created_at` | string(date-time) | false | | | -| `» email` | string | false | | | -| `» global_roles` | array | false | | | -| `»» display_name` | string | false | | | -| `»» name` | string | false | | | -| `»» organization_id` | string | false | | | -| `» name` | string | false | | | -| `» organization_id` | string(uuid) | false | | | -| `» roles` | array | false | | | -| `» updated_at` | string(date-time) | false | | | -| `» user_id` | string(uuid) | false | | | -| `» username` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|----------------------|------------------------------------------------------|----------|--------------|-------------| +| `[array item]` | array | false | | | +| `» avatar_url` | string | false | | | +| `» created_at` | string(date-time) | false | | | +| `» email` | string | false | | | +| `» global_roles` | array | false | | | +| `»» display_name` | string | false | | | +| `»» name` | string | false | | | +| `»» organization_id` | string | false | | | +| `» last_seen_at` | string(date-time) | false | | | +| `» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | | +| `» name` | string | false | | | +| `» organization_id` | string(uuid) | false | | | +| `» roles` | array | false | | | +| `» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | | +| `» updated_at` | string(date-time) | false | | | +| `» user_created_at` | string(date-time) | false | | | +| `» user_id` | string(uuid) | false | | | +| `» user_updated_at` | string(date-time) | false | | | +| `» username` | string | false | | | + +#### Enumerated Values + +| Property | Value(s) | +|--------------|---------------------------------------------------| +| `login_type` | ``, `github`, `none`, `oidc`, `password`, `token` | +| `status` | `active`, `suspended` | To perform this operation, you must be authenticated. [Learn more](authentication.md). @@ -576,6 +593,8 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members "organization_id": "string" } ], + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "roles": [ @@ -585,8 +604,11 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members "organization_id": "string" } ], + "status": "active", "updated_at": "2019-08-24T14:15:22Z", + "user_created_at": "2019-08-24T14:15:22Z", "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5", + "user_updated_at": "2019-08-24T14:15:22Z", "username": "string" } ``` @@ -749,11 +771,13 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/paginat ### Parameters -| Name | In | Type | Required | Description | -|----------------|-------|---------|----------|--------------------------------------| -| `organization` | path | string | true | Organization ID | -| `limit` | query | integer | false | Page limit, if 0 returns all members | -| `offset` | query | integer | false | Page offset | +| Name | In | Type | Required | Description | +|----------------|-------|--------------|----------|--------------------------------------| +| `organization` | path | string | true | Organization ID | +| `q` | query | string | false | Member search query | +| `after_id` | query | string(uuid) | false | After ID | +| `limit` | query | integer | false | Page limit, if 0 returns all members | +| `offset` | query | integer | false | Page offset | ### Example responses @@ -775,6 +799,8 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/paginat "organization_id": "string" } ], + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "roles": [ @@ -784,8 +810,11 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/paginat "organization_id": "string" } ], + "status": "active", "updated_at": "2019-08-24T14:15:22Z", + "user_created_at": "2019-08-24T14:15:22Z", "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5", + "user_updated_at": "2019-08-24T14:15:22Z", "username": "string" } ] @@ -803,24 +832,36 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/paginat Status Code **200** -| Name | Type | Required | Restrictions | Description | -|-----------------------|-------------------|----------|--------------|-------------| -| `[array item]` | array | false | | | -| `» count` | integer | false | | | -| `» members` | array | false | | | -| `»» avatar_url` | string | false | | | -| `»» created_at` | string(date-time) | false | | | -| `»» email` | string | false | | | -| `»» global_roles` | array | false | | | -| `»»» display_name` | string | false | | | -| `»»» name` | string | false | | | -| `»»» organization_id` | string | false | | | -| `»» name` | string | false | | | -| `»» organization_id` | string(uuid) | false | | | -| `»» roles` | array | false | | | -| `»» updated_at` | string(date-time) | false | | | -| `»» user_id` | string(uuid) | false | | | -| `»» username` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|-----------------------|------------------------------------------------------|----------|--------------|-------------| +| `[array item]` | array | false | | | +| `» count` | integer | false | | | +| `» members` | array | false | | | +| `»» avatar_url` | string | false | | | +| `»» created_at` | string(date-time) | false | | | +| `»» email` | string | false | | | +| `»» global_roles` | array | false | | | +| `»»» display_name` | string | false | | | +| `»»» name` | string | false | | | +| `»»» organization_id` | string | false | | | +| `»» last_seen_at` | string(date-time) | false | | | +| `»» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | | +| `»» name` | string | false | | | +| `»» organization_id` | string(uuid) | false | | | +| `»» roles` | array | false | | | +| `»» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | | +| `»» updated_at` | string(date-time) | false | | | +| `»» user_created_at` | string(date-time) | false | | | +| `»» user_id` | string(uuid) | false | | | +| `»» user_updated_at` | string(date-time) | false | | | +| `»» username` | string | false | | | + +#### Enumerated Values + +| Property | Value(s) | +|--------------|---------------------------------------------------| +| `login_type` | ``, `github`, `none`, `oidc`, `password`, `token` | +| `status` | `active`, `suspended` | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index b4b050805b..2956c2e7f8 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -6016,6 +6016,8 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "organization_id": "string" } ], + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "roles": [ @@ -6025,8 +6027,11 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "organization_id": "string" } ], + "status": "active", "updated_at": "2019-08-24T14:15:22Z", + "user_created_at": "2019-08-24T14:15:22Z", "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5", + "user_updated_at": "2019-08-24T14:15:22Z", "username": "string" } ``` @@ -6039,13 +6044,24 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | `created_at` | string | false | | | | `email` | string | false | | | | `global_roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | | +| `last_seen_at` | string | false | | | +| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | | | `name` | string | false | | | | `organization_id` | string | false | | | | `roles` | array of [codersdk.SlimRole](#codersdkslimrole) | false | | | +| `status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | | | `updated_at` | string | false | | | +| `user_created_at` | string | false | | | | `user_id` | string | false | | | +| `user_updated_at` | string | false | | | | `username` | string | false | | | +#### Enumerated Values + +| Property | Value(s) | +|----------|-----------------------| +| `status` | `active`, `suspended` | + ## codersdk.OrganizationSyncSettings ```json @@ -6302,6 +6318,8 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "organization_id": "string" } ], + "last_seen_at": "2019-08-24T14:15:22Z", + "login_type": "", "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "roles": [ @@ -6311,8 +6329,11 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "organization_id": "string" } ], + "status": "active", "updated_at": "2019-08-24T14:15:22Z", + "user_created_at": "2019-08-24T14:15:22Z", "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5", + "user_updated_at": "2019-08-24T14:15:22Z", "username": "string" } ] diff --git a/docs/reference/cli/organizations_members_list.md b/docs/reference/cli/organizations_members_list.md index 270fb1d49e..510a28e511 100644 --- a/docs/reference/cli/organizations_members_list.md +++ b/docs/reference/cli/organizations_members_list.md @@ -13,10 +13,10 @@ coder organizations members list [flags] ### -c, --column -| | | -|---------|-----------------------------------------------------------------------------------------------------| -| Type | [username\|name\|user id\|organization id\|created at\|updated at\|organization roles] | -| Default | username,organization roles | +| | | +|---------|-----------------------------------------------------------------------------------------------------------------------------------------------------| +| Type | [username\|name\|last seen at\|user created at\|user updated at\|user id\|organization id\|created at\|updated at\|organization roles] | +| Default | username,organization roles | Columns to display in table output. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 147ede6945..6a421e5161 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -4551,6 +4551,11 @@ export interface OrganizationMemberWithUserData extends OrganizationMember { readonly name?: string; readonly avatar_url?: string; readonly email: string; + readonly status: UserStatus; + readonly login_type: LoginType; + readonly last_seen_at?: string; + readonly user_created_at: string; + readonly user_updated_at: string; readonly global_roles: readonly SlimRole[]; } diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 1d30491ca0..ddb2b43a91 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -568,6 +568,11 @@ export const MockOrganizationMember: TypesGen.OrganizationMemberWithUserData = { email: MockUserOwner.email, updated_at: "2025-05-22T17:51:49.49745Z", created_at: "2025-05-22T17:51:49.497449Z", + user_updated_at: MockUserMember.updated_at, + user_created_at: MockUserMember.created_at, + last_seen_at: MockUserMember.last_seen_at, + status: MockUserMember.status, + login_type: MockUserMember.login_type, name: MockUserOwner.name, avatar_url: MockUserOwner.avatar_url, global_roles: MockUserOwner.roles, @@ -582,6 +587,11 @@ export const MockOrganizationMember2: TypesGen.OrganizationMemberWithUserData = email: MockUserMember.email, updated_at: "2025-05-22T17:51:49.49745Z", created_at: "2025-05-22T17:51:49.497449Z", + user_updated_at: MockUserMember.updated_at, + user_created_at: MockUserMember.created_at, + last_seen_at: MockUserMember.last_seen_at, + status: MockUserMember.status, + login_type: MockUserMember.login_type, name: MockUserMember.name, avatar_url: MockUserMember.avatar_url, global_roles: MockUserMember.roles,