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,