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).
This commit is contained in:
Asher
2026-03-21 16:58:45 -08:00
committed by GitHub
parent 4b707515c0
commit 47daca6eea
14 changed files with 576 additions and 75 deletions
+39
View File
@@ -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"
}
+36
View File
@@ -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"
}
+149 -13
View File
@@ -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
+100 -6
View File
@@ -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);
+44 -12
View File
@@ -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],
})
}
+63
View File
@@ -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,
}
}