mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
@@ -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)
|
||||
|
||||
Generated
+39
@@ -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"
|
||||
}
|
||||
|
||||
Generated
+36
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Generated
+80
-39
@@ -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).
|
||||
|
||||
|
||||
Generated
+21
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
+4
-4
@@ -13,10 +13,10 @@ coder organizations members list [flags]
|
||||
|
||||
### -c, --column
|
||||
|
||||
| | |
|
||||
|---------|-----------------------------------------------------------------------------------------------------|
|
||||
| Type | <code>[username\|name\|user id\|organization id\|created at\|updated at\|organization roles]</code> |
|
||||
| Default | <code>username,organization roles</code> |
|
||||
| | |
|
||||
|---------|-----------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Type | <code>[username\|name\|last seen at\|user created at\|user updated at\|user id\|organization id\|created at\|updated at\|organization roles]</code> |
|
||||
| Default | <code>username,organization roles</code> |
|
||||
|
||||
Columns to display in table output.
|
||||
|
||||
|
||||
Generated
+5
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user