feat: add count endpoint for users, enabling better pagination (#4848)

* Start on backend

* Hook up frontend

* Add to frontend test

* Add go test, wip

* Fix some test bugs

* Fix test

* Format

* Add to authorize.go

* copy user array into local variable

* Authorize route

* Log count error

* Authorize better

* Tweaks to authorization

* More authorization tweaks

* Make gen

* Fix test

Co-authored-by: Garrett <garrett@coder.com>
This commit is contained in:
Presley Pizzo 2022-11-08 10:58:44 -05:00 committed by GitHub
parent a4fbc74751
commit f496b149df
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 620 additions and 216 deletions

View File

@ -437,6 +437,7 @@ func New(options *Options) *API {
)
r.Post("/", api.postUser)
r.Get("/", api.users)
r.Get("/count", api.userCount)
r.Post("/logout", api.postLogout)
// These routes query information about site wide roles.
r.Route("/roles", func(r chi.Router) {

View File

@ -246,6 +246,7 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) {
// Endpoints that use the SQLQuery filter.
"GET:/api/v2/workspaces/": {StatusCode: http.StatusOK, NoAuthorize: true},
"GET:/api/v2/workspaces/count": {StatusCode: http.StatusOK, NoAuthorize: true},
"GET:/api/v2/users/count": {StatusCode: http.StatusOK, NoAuthorize: true},
}
// Routes like proxy routes support all HTTP methods. A helper func to expand

View File

@ -457,6 +457,72 @@ func (q *fakeQuerier) GetActiveUserCount(_ context.Context) (int64, error) {
return active, nil
}
func (q *fakeQuerier) GetFilteredUserCount(ctx context.Context, arg database.GetFilteredUserCountParams) (int64, error) {
count, err := q.GetAuthorizedUserCount(ctx, arg, nil)
return count, err
}
func (q *fakeQuerier) GetAuthorizedUserCount(_ context.Context, params database.GetFilteredUserCountParams, authorizedFilter rbac.AuthorizeFilter) (int64, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
users := append([]database.User{}, q.users...)
if params.Deleted {
tmp := make([]database.User, 0, len(users))
for _, user := range users {
if user.Deleted {
tmp = append(tmp, user)
}
}
users = tmp
}
if params.Search != "" {
tmp := make([]database.User, 0, len(users))
for i, user := range users {
if strings.Contains(strings.ToLower(user.Email), strings.ToLower(params.Search)) {
tmp = append(tmp, users[i])
} else if strings.Contains(strings.ToLower(user.Username), strings.ToLower(params.Search)) {
tmp = append(tmp, users[i])
}
}
users = tmp
}
if len(params.Status) > 0 {
usersFilteredByStatus := make([]database.User, 0, len(users))
for i, user := range users {
if slice.ContainsCompare(params.Status, user.Status, func(a, b database.UserStatus) bool {
return strings.EqualFold(string(a), string(b))
}) {
usersFilteredByStatus = append(usersFilteredByStatus, users[i])
}
}
users = usersFilteredByStatus
}
if len(params.RbacRole) > 0 && !slice.Contains(params.RbacRole, rbac.RoleMember()) {
usersFilteredByRole := make([]database.User, 0, len(users))
for i, user := range users {
if slice.OverlapCompare(params.RbacRole, user.RBACRoles, strings.EqualFold) {
usersFilteredByRole = append(usersFilteredByRole, users[i])
}
}
users = usersFilteredByRole
}
for _, user := range q.workspaces {
// If the filter exists, ensure the object is authorized.
if authorizedFilter != nil && !authorizedFilter.Eval(user.RBACObject()) {
continue
}
}
return int64(len(users)), nil
}
func (q *fakeQuerier) UpdateUserDeletedByID(_ context.Context, params database.UpdateUserDeletedByIDParams) error {
q.mutex.Lock()
defer q.mutex.Unlock()

View File

@ -19,6 +19,7 @@ import (
type customQuerier interface {
templateQuerier
workspaceQuerier
userQuerier
}
type templateQuerier interface {
@ -169,8 +170,6 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa
}
func (q *sqlQuerier) GetAuthorizedWorkspaceCount(ctx context.Context, arg GetWorkspaceCountParams, authorizedFilter rbac.AuthorizeFilter) (int64, error) {
// In order to properly use ORDER BY, OFFSET, and LIMIT, we need to inject the
// authorizedFilter between the end of the where clause and those statements.
filter := strings.Replace(getWorkspaceCount, "-- @authorize_filter", fmt.Sprintf(" AND %s", authorizedFilter.SQLString(rbac.NoACLConfig())), 1)
// The name comment is for metric tracking
query := fmt.Sprintf("-- name: GetAuthorizedWorkspaceCount :one\n%s", filter)
@ -187,3 +186,21 @@ func (q *sqlQuerier) GetAuthorizedWorkspaceCount(ctx context.Context, arg GetWor
err := row.Scan(&count)
return count, err
}
type userQuerier interface {
GetAuthorizedUserCount(ctx context.Context, arg GetFilteredUserCountParams, authorizedFilter rbac.AuthorizeFilter) (int64, error)
}
func (q *sqlQuerier) GetAuthorizedUserCount(ctx context.Context, arg GetFilteredUserCountParams, authorizedFilter rbac.AuthorizeFilter) (int64, error) {
filter := strings.Replace(getFilteredUserCount, "-- @authorize_filter", fmt.Sprintf(" AND %s", authorizedFilter.SQLString(rbac.NoACLConfig())), 1)
query := fmt.Sprintf("-- name: GetAuthorizedUserCount :one\n%s", filter)
row := q.db.QueryRowContext(ctx, query,
arg.Deleted,
arg.Search,
pq.Array(arg.Status),
pq.Array(arg.RbacRole),
)
var count int64
err := row.Scan(&count)
return count, err
}

View File

@ -44,6 +44,7 @@ type sqlcQuerier interface {
GetDeploymentID(ctx context.Context) (string, error)
GetFileByHashAndCreator(ctx context.Context, arg GetFileByHashAndCreatorParams) (File, error)
GetFileByID(ctx context.Context, id uuid.UUID) (File, error)
GetFilteredUserCount(ctx context.Context, arg GetFilteredUserCountParams) (int64, error)
GetGitAuthLink(ctx context.Context, arg GetGitAuthLinkParams) (GitAuthLink, error)
GetGitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, error)
GetGroupByID(ctx context.Context, id uuid.UUID) (Group, error)

View File

@ -3975,6 +3975,60 @@ func (q *sqlQuerier) GetAuthorizationUserRoles(ctx context.Context, userID uuid.
return i, err
}
const getFilteredUserCount = `-- name: GetFilteredUserCount :one
SELECT
COUNT(*)
FROM
users
WHERE
users.deleted = $1
-- Start filters
-- Filter by name, email or username
AND CASE
WHEN $2 :: text != '' THEN (
email ILIKE concat('%', $2, '%')
OR username ILIKE concat('%', $2, '%')
)
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($3 :: user_status[]) > 0 THEN
status = ANY($3 :: user_status[])
ELSE true
END
-- Filter by rbac_roles
AND CASE
-- @rbac_role allows filtering by rbac roles. If 'member' is included, show everyone, as everyone is a member.
WHEN cardinality($4 :: text[]) > 0 AND 'member' != ANY($4 :: text[])
THEN rbac_roles && $4 :: text[]
ELSE true
END
-- Authorize Filter clause will be injected below in GetAuthorizedUserCount
-- @authorize_filter
`
type GetFilteredUserCountParams struct {
Deleted bool `db:"deleted" json:"deleted"`
Search string `db:"search" json:"search"`
Status []UserStatus `db:"status" json:"status"`
RbacRole []string `db:"rbac_role" json:"rbac_role"`
}
func (q *sqlQuerier) GetFilteredUserCount(ctx context.Context, arg GetFilteredUserCountParams) (int64, error) {
row := q.db.QueryRowContext(ctx, getFilteredUserCount,
arg.Deleted,
arg.Search,
pq.Array(arg.Status),
pq.Array(arg.RbacRole),
)
var count int64
err := row.Scan(&count)
return count, err
}
const getUserByEmailOrUsername = `-- name: GetUserByEmailOrUsername :one
SELECT
id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at

View File

@ -39,6 +39,41 @@ FROM
WHERE
status = 'active'::user_status AND deleted = false;
-- name: GetFilteredUserCount :one
SELECT
COUNT(*)
FROM
users
WHERE
users.deleted = @deleted
-- Start filters
-- Filter by name, email or username
AND CASE
WHEN @search :: text != '' THEN (
email ILIKE concat('%', @search, '%')
OR username ILIKE concat('%', @search, '%')
)
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
status = ANY(@status :: user_status[])
ELSE true
END
-- Filter by 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 rbac_roles && @rbac_role :: text[]
ELSE true
END
-- Authorize Filter clause will be injected below in GetAuthorizedUserCount
-- @authorize_filter
;
-- name: InsertUser :one
INSERT INTO
users (

View File

@ -251,6 +251,42 @@ func (api *API) users(rw http.ResponseWriter, r *http.Request) {
render.JSON(rw, r, convertUsers(users, organizationIDsByUserID))
}
func (api *API) userCount(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
query := r.URL.Query().Get("q")
params, errs := userSearchQuery(query)
if len(errs) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid user search query.",
Validations: errs,
})
return
}
sqlFilter, err := api.HTTPAuth.AuthorizeSQLFilter(r, rbac.ActionRead, rbac.ResourceUser.Type)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error preparing sql filter.",
Detail: err.Error(),
})
return
}
count, err := api.Database.GetAuthorizedUserCount(ctx, database.GetFilteredUserCountParams{
Search: params.Search,
Status: params.Status,
RbacRole: params.RbacRole,
}, sqlFilter)
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserCountResponse{
Count: count,
})
}
// Creates a new user.
func (api *API) postUser(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()

View File

@ -1255,6 +1255,58 @@ func TestGetUsers(t *testing.T) {
})
}
func TestGetFilteredUserCount(t *testing.T) {
t.Parallel()
t.Run("AllUsers", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
client.CreateUser(ctx, codersdk.CreateUserRequest{
Email: "alice@email.com",
Username: "alice",
Password: "password",
OrganizationID: user.OrganizationID,
})
// No params is all users
response, err := client.UserCount(ctx, codersdk.UserCountRequest{})
require.NoError(t, err)
require.Equal(t, 2, int(response.Count))
})
t.Run("ActiveUsers", func(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()
_, err := client.User(ctx, first.UserID.String())
require.NoError(t, err, "")
// Alice will be suspended
alice, err := client.CreateUser(ctx, codersdk.CreateUserRequest{
Email: "alice@email.com",
Username: "alice",
Password: "password",
OrganizationID: first.OrganizationID,
})
require.NoError(t, err)
_, err = client.UpdateUserStatus(ctx, alice.Username, codersdk.UserStatusSuspended)
require.NoError(t, err)
response, err := client.UserCount(ctx, codersdk.UserCountRequest{
Status: codersdk.UserStatusActive,
})
require.NoError(t, err)
require.Equal(t, 1, int(response.Count))
})
}
func TestPostTokens(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)

View File

@ -166,7 +166,7 @@ func (api *API) workspaceCount(rw http.ResponseWriter, r *http.Request) {
filter, errs := workspaceSearchQuery(queryStr, codersdk.Pagination{})
if len(errs) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid audit search query.",
Message: "Invalid workspace search query.",
Validations: errs,
})
return

View File

@ -47,6 +47,20 @@ type User struct {
AvatarURL string `json:"avatar_url"`
}
type UserCountRequest struct {
Search string `json:"search,omitempty" typescript:"-"`
// Filter users by status.
Status UserStatus `json:"status,omitempty" typescript:"-"`
// Filter users that have the given role.
Role string `json:"role,omitempty" typescript:"-"`
SearchQuery string `json:"q,omitempty"`
}
type UserCountResponse struct {
Count int64 `json:"count"`
}
type CreateFirstUserRequest struct {
Email string `json:"email" validate:"required,email"`
Username string `json:"username" validate:"required,username"`
@ -345,6 +359,40 @@ func (c *Client) Users(ctx context.Context, req UsersRequest) ([]User, error) {
return users, json.NewDecoder(res.Body).Decode(&users)
}
func (c *Client) UserCount(ctx context.Context, req UserCountRequest) (UserCountResponse, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/users/count", nil,
func(r *http.Request) {
q := r.URL.Query()
var params []string
if req.Search != "" {
params = append(params, req.Search)
}
if req.Status != "" {
params = append(params, "status:"+string(req.Status))
}
if req.Role != "" {
params = append(params, "role:"+req.Role)
}
if req.SearchQuery != "" {
params = append(params, req.SearchQuery)
}
q.Set("q", strings.Join(params, " "))
r.URL.RawQuery = q.Encode()
},
)
if err != nil {
return UserCountResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return UserCountResponse{}, readBodyAsError(res)
}
var count UserCountResponse
return count, json.NewDecoder(res.Body).Decode(&count)
}
// OrganizationsByUser returns all organizations the user is a member of.
func (c *Client) OrganizationsByUser(ctx context.Context, user string) ([]Organization, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/organizations", user), nil)

View File

@ -139,6 +139,14 @@ export const getUsers = async (
return response.data
}
export const getUserCount = async (
options: TypesGen.UserCountRequest,
): Promise<TypesGen.UserCountResponse> => {
const url = getURLWithSearchParams("/api/v2/users/count", options)
const response = await axios.get(url.toString())
return response.data
}
export const getOrganization = async (
organizationId: string,
): Promise<TypesGen.Organization> => {

View File

@ -746,6 +746,16 @@ export interface User {
readonly avatar_url: string
}
// From codersdk/users.go
export interface UserCountRequest {
readonly q?: string
}
// From codersdk/users.go
export interface UserCountResponse {
readonly count: number
}
// From codersdk/users.go
export interface UserRoles {
readonly roles: string[]

View File

@ -240,7 +240,7 @@ describe("UsersPage", () => {
describe("pagination", () => {
it("goes to next and previous page", async () => {
renderPage()
const { container } = renderPage()
const user = userEvent.setup()
const mock = jest
@ -248,6 +248,9 @@ describe("UsersPage", () => {
.mockResolvedValueOnce([MockUser, MockUser2])
const nextButton = await screen.findByLabelText("Next page")
expect(nextButton).toBeEnabled()
const previousButton = await screen.findByLabelText("Previous page")
expect(previousButton).toBeDisabled()
await user.click(nextButton)
await waitFor(() =>
@ -255,12 +258,17 @@ describe("UsersPage", () => {
)
mock.mockClear()
const previousButton = await screen.findByLabelText("Previous page")
await user.click(previousButton)
await waitFor(() =>
expect(API.getUsers).toBeCalledWith({ offset: 0, limit: 25, q: "" }),
)
const pageButtons = await container.querySelectorAll(
`button[name="Page button"]`,
)
// count handler says there are 2 pages of results
expect(pageButtons.length).toBe(2)
})
})

View File

@ -1,4 +1,5 @@
import { useActor, useMachine } from "@xstate/react"
import { getErrorDetail } from "api/errors"
import { User } from "api/typesGenerated"
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"
import { getPaginationContext } from "components/PaginationWidget/utils"
@ -44,12 +45,14 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
const {
users,
getUsersError,
getCountError,
usernameToDelete,
usernameToSuspend,
usernameToActivate,
userIdToResetPassword,
newUserPassword,
paginationRef,
count,
} = usersState.context
const { updateUsers: canEditUsers } = usePermissions()
@ -60,7 +63,7 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
// - users are loading or
// - the user can edit the users but the roles are loading
const isLoading =
usersState.matches("gettingUsers") ||
usersState.matches("users.gettingUsers") ||
(canEditUsers && rolesState.matches("gettingRoles"))
// Fetch roles on component mount
@ -73,6 +76,10 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
}
}, [canEditUsers, rolesSend])
if (getCountError) {
console.error(getErrorDetail(getCountError))
}
return (
<>
<Helmet>
@ -81,6 +88,7 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
<UsersPageView
roles={roles}
users={users}
count={count}
onListWorkspaces={(user) => {
navigate(
"/workspaces?filter=" +
@ -119,7 +127,7 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
})
}}
error={getUsersError}
isUpdatingUserRoles={usersState.matches("updatingUserRoles")}
isUpdatingUserRoles={usersState.matches("users.updatingUserRoles")}
isLoading={isLoading}
canEditUsers={canEditUsers}
filter={usersState.context.filter}
@ -131,10 +139,10 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
<DeleteDialog
isOpen={
usersState.matches("confirmUserDeletion") ||
usersState.matches("deletingUser")
usersState.matches("users.confirmUserDeletion") ||
usersState.matches("users.deletingUser")
}
confirmLoading={usersState.matches("deletingUser")}
confirmLoading={usersState.matches("users.deletingUser")}
name={usernameToDelete ?? ""}
entity="user"
onConfirm={() => {
@ -149,10 +157,10 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
type="delete"
hideCancel={false}
open={
usersState.matches("confirmUserSuspension") ||
usersState.matches("suspendingUser")
usersState.matches("users.confirmUserSuspension") ||
usersState.matches("users.suspendingUser")
}
confirmLoading={usersState.matches("suspendingUser")}
confirmLoading={usersState.matches("users.suspendingUser")}
title={Language.suspendDialogTitle}
confirmText={Language.suspendDialogAction}
onConfirm={() => {
@ -174,10 +182,10 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
type="success"
hideCancel={false}
open={
usersState.matches("confirmUserActivation") ||
usersState.matches("activatingUser")
usersState.matches("users.confirmUserActivation") ||
usersState.matches("users.activatingUser")
}
confirmLoading={usersState.matches("activatingUser")}
confirmLoading={usersState.matches("users.activatingUser")}
title={Language.activateDialogTitle}
confirmText={Language.activateDialogAction}
onConfirm={() => {
@ -198,10 +206,10 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
{userIdToResetPassword && (
<ResetPasswordDialog
open={
usersState.matches("confirmUserPasswordReset") ||
usersState.matches("resettingUserPassword")
usersState.matches("users.confirmUserPasswordReset") ||
usersState.matches("users.resettingUserPassword")
}
loading={usersState.matches("resettingUserPassword")}
loading={usersState.matches("users.resettingUserPassword")}
user={getSelectedUser(userIdToResetPassword, users)}
newPassword={newUserPassword}
onClose={() => {

View File

@ -12,6 +12,7 @@ export const Language = {
}
export interface UsersPageViewProps {
users?: TypesGen.User[]
count?: number
roles?: TypesGen.AssignableRoles[]
filter?: string
error?: unknown
@ -33,6 +34,7 @@ export interface UsersPageViewProps {
export const UsersPageView: FC<React.PropsWithChildren<UsersPageViewProps>> = ({
users,
count,
roles,
onSuspendUser,
onDeleteUser,
@ -76,7 +78,7 @@ export const UsersPageView: FC<React.PropsWithChildren<UsersPageViewProps>> = ({
isLoading={isLoading}
/>
<PaginationWidget paginationRef={paginationRef} />
<PaginationWidget numRecords={count} paginationRef={paginationRef} />
</>
)
}

View File

@ -82,6 +82,10 @@ export const MockUser: TypesGen.User = {
last_seen_at: "",
}
export const MockUserCountResponse: TypesGen.UserCountResponse = {
count: 26,
}
export const MockUserAdmin: TypesGen.User = {
id: "test-user",
username: "TestUser",

View File

@ -74,8 +74,8 @@ export const handlers = [
ctx.json([M.MockUser, M.MockUser2, M.SuspendedMockUser]),
)
}),
rest.post("/api/v2/users", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockUser))
rest.get("/api/v2/users/count", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockUserCountResponse))
}),
rest.get("/api/v2/users/me/organizations", (req, res, ctx) => {
return res(ctx.status(200), ctx.json([M.MockOrganization]))

View File

@ -57,6 +57,8 @@ export interface UsersContext {
updateUserRolesError?: Error | unknown
paginationContext: PaginationContext
paginationRef: PaginationMachineRef
count: number
getCountError: Error | unknown
}
export type UsersEvent =
@ -101,7 +103,7 @@ export type UsersEvent =
| { type: "UPDATE_PAGE"; page: string }
export const usersMachine =
/** @xstate-layout N4IgpgJg5mDOIC5QFdZgE6wMoBcCGOYAdLPujgJYB2UACnlNQRQPZUDEA2gAwC6ioAA4tYFSmwEgAHogCMADiLcA7AFYVAFgCcW5bNkaAbACZDAGhABPOVsNEAzN1XGDhw92PduW+wF9fFqgY2PiERDA4lDQAqmiY7BBsxNQAbiwA1sQRscE8-EggwqLiVJIyCPKyRHryyk4m9lrGzfYW1ggaGqpERvaq9soasqrKfgEgQZi4BFlgkdRQOfEY6CzoRIIANgQAZmsAtuFzS7B5kkVirKUF5QC0ssrGRLV1rs2VyrptiJ3K1cbyHQaUyqSoDVT+QJxEIzIgUCCbMDsLDRLC0ACiADkACIAfVR6IASmcChcSmVEMZOkRDPpgYZ5J4NNx5PZWlY5A8qvZjH15Nx7IZVFp5KoNJCJtDpmF4Yj2Nj0QAZdEAFXR+KwRJJQhElwkN0phi0RAMqncjIZo0Z3wqtSIOgZGg+3HNsglkxhMoRSIAggBhFUASQAaj61RqtXxzrryQaEKZ7Pb7KLBQCTLJ3Kobe5E2adINjE1DE7jO6paFkt72IT0ZqVRHCbjaD6sFgAOoAeUJ2O1hRjVwp8dkxq6A2UFuTyhMWY5CFcdlkhbB8n5vNBZeC0srcuitGxYfVBMbhI7yqwvbJA7jtwBfwF3lG-WHPhtjO4NIZ7lk9Q0fI3UwrOEq13fdw2bABxdEL37fVQDuHk7BUTQnEcAEdGzLoaQtdQ+h8Ywp3-T1tyRECD1xAAxQNFTVYko1JGDrjgxB7m6LQjABfCtBUYduFkV9h2qAZv3w5wuIhcYPS3IgAGM2B2Ch0H2JYsFQQQwCoUQ2HYP0O0xSjCQAWQbXEUTRLEsEDXToOKK8mNtRMCyFXpPjcLQbX0FcTU+EtnFwhlCKk2SqHkxTlNU9TNI4P0fUxP0lWM0yMUxCyrLonUbNg6REFBbpmU+LiHipbhOnc-D3xXUURVpWlioCwCgpCpS4mxMBERKbTdP0oyj1xBVlTVay9UYrKKh8e1vHkbRgRUBobS0fQelFRc9F-Nj5EMOrYQahSmowFq2qubSYrixVjL61UoLSvsMuG8paSeYZ7AzNlDHHN65rcGliv5AVPjNExNrCbbQriH1pMoFJmC0nS9MDQzjP9INQyDVL8nSobBxeD8BnsTops6NzZ0MXHFrZKk2NUfQNok8strknaljBiGoai474p6xGQzDSzMUG2M7OFVjuMXQVeNUSmbTqRNlt-NwnuUR5xRpzdANgcKqAgBYlgSJI4SoNJMhIdWICWPnbJG4ZFxNcX+XHbxhzUOa6i8o1vBFdRaTdZWANhNXYDUjWtbidgVjWDZthwPZFKN-31JNuIzcy8oPPfEYXEm3R8NGPjZ1kRwNCUYU8-w2Xv3EqEVdhCBWrmIOMB1qhkn1jJiGrtqwFNq7LyTuRKeNAxmS0UENEeXjJZGapmRGEfVrTwHW5rqJFmD0P1i2XYDiINu5g7hOu4Ywd9CH6oWVFV4uItdzeONMU6TZWpFxH+eiDwcGKEhpftcSRu9YN4hX+ZoQTuaNroYzjJbRMPgeQujUOob85hZxmlTrSEYLpTAKDNM-AB79mAxBXugVYa8I5R0ONgj+u8MCJ1upyWwk9vwsmTMVAw48C7aAZFyMUU59DP2BrtdA9BYCwAAO5rAgISOAcwOqw3hj1ZsrZOzdlxDWOsVDMZimqPAgwxhKbixFO5HCX1ibaABJTV6ygeH0xBhgARwjRHiLQDgI6sV2aakbHI9sXY8TKNVKouMad7T9FsMTI0Csnr6LtAYUWahFxMnMd7IiRB0ASPmHg6xeBBEiPQBABuTc-6JOSUsGxmSIC+LsnndRecmjqD0KMfC7lxwLlMLofot5RTUwrj7MISSHGfziEU0RIcCFh3XpHTe3Tjh9PSbYrJpSLYPGNCoRwoJ3iihXO5IUTwRS6BqMWAYwJn7IEEBAXBy8MCEhYIiWAOTf4tyIIc45QC4jnMubMu4VoTTFSFG+SpGgPp-CnFA4SzhtDl0lJXMI9yTlLGeXAQZhDw4b2jpCx5ZyLlwFecxL8Dghi6FegoPogp+LGlGHoDwahCxOH8OMKgLBq7wAKJJVWZAl70EYFQFm0YbqDluM4E+nxPgsmLCKXkr5uiFktDob8zJXpKw6QkiIvTgicrAXZW47geiSqGHigEi4ZztBBEQfobIjWeBiVoZ+sowDKv5hbHkzwmiDB5M0NwYpfmzjUAXZwZ9OjfjYk9CxwUGZxBUrHDS5tu7UIQKCRQXEhgjzluUnO7Qj4F0qBgqBeF2lgs6cQXhSx9q10yhGwcJgnhGlpI8IeT5xZzQWk6dQ6h+QPHFmMOVgVLF8KZjgm1xa4zVUnkaZkuMvBDD1YgIxpMeRTUphmZ+fsA6a1Sega15tk4ZkUEXV2TgxTCnkO5YYfwnoKHFVSSccS22AW3oq5d9EuXgIUFUHQvFTCvAMHo2cK4C4eCEn0dwXQHhYLfh-OuN70Y2rXWNbRDribzSNUm8dngTTfkBLyFB8aA2NUKVM4p9i5grp7lG+ahq1AijzsCcl8G5wGPcEYpoS0zHP3GSk05-DsOiPw5GjyC5nBeFZOaDwY65xsI-GoWoDb1pqAOUcqFTy0X0rA6u5inR3xrlsCyOoAohhzSMPaGpr03C8TZPPDj3KGkfKMMswzbEbS3AcgKRcSEnArkGPIKlvggA */
/** @xstate-layout N4IgpgJg5mDOIC5QFdZgE6wMoBcCGOYAdAMYD2yAdjkTDjgJaVQDCF1AxBGZcUwG5kA1sToBVNOjZUcAbQAMAXUSgADmVgNGPFSAAeiAIwBWQ-KIBmABwAWCwHYrAJhcA2YwE4ANCACeiZ0Miew8LBxsrCw8Pe1MAXzifVAxsfEJSdho6RmZpTgx0MnQiVQAbAgAzIoBbWjAcCQw8uSVddU1tSl0DBEMneSDXC0MrQ2G3KysffwRo4yJjYxtlh1c1m2NXBKTJVIJichkOMQAFABEAQQAVAFEAfQAxAEkAGVuAJQVlJBB2rQYdD8en1DB4iE5NvYbCMokNvH5EK4NkRbI5XIYbE5DGNDPZtiBkphcPsiITYERYPh0DkoCc8FAmAQAZQOF82hp-oDQD0ALRQ8zyEIWYz2dwhJw2VzTRGOIiGdyjJwWSXyZV4xIE3bE9Jkur0JhQRqYLg8PiUQQiPVG2Bsn5-TrdRA84wuYLyVxWexRDy2Vz2ezShCRGwLRZ+0VOWzGT34sna4i67IG60cApFErlHBVdC1cS7W1qDkOoFOxxOSxK7HySKReSmQNWd0LEJ9L1OWLGeQeWNatIJ3ZEBgQUpgDhYMRYE43AByZzuE5un1adqLzMdCB5SNcKOhNdcddiHsDGJCKOjHjGStsZicPZS8dJA6HI44ZxuLxut3nWEXBd+q65fQnSReZrEcKwfSsJZNgsY9hkGNUIklRZBQsO8iT7R8UkHYdRwuFgrieAA1a57gXJdvkLDo1xLDcQKId1llcGIoUiJx4RmbEbDBP1QXFGIIVFdC9h1J9cI4d4bh-K5v0XO4TguLAsAAdQAeXeM4-3tGjuWAmx7Dlex2MlCEPCGGxjyGbcbEFDxBRskx7FxYSH11Z9R1OS4v3Iu53lUj8sC0gCulonkfW3eUsRiDEfWMaxjxGAy4rDD1wwxLYNTjTC3PEzzSPki4AHEbiC6jAN5DxJTlDEIOhD15TsQMPE7Bi1n9Vs7MbdUdnvbKB3ISgKgYHMjSwVBVDAShNB4DgWFU6dnneABZWT3jucdJxnLAnnm0rORC3SNzMCxgghCw1ja7ioUMY9uKsBiJU2aElVFDEXL67CBqGkbJDG2AJqm5lZouacWHfVb1onKdp223blyo-b1x5Gz7rsMZ3XkeQbLA49NnmDwJXdF1qz9DKeowkldS+4bqiNM4wBHTpZvmxaVp8t8P1uPbi0OnkuIWJEoQ2DY4vsQU4OFOUlXkFw4osLtIneyn+p4b7ackenGaBlgQbBl4IY5z8Svh-8yoOoCNwcAyYjssJG0lFiLIRXobPLaIImDOFFiV0TPtVmmjQuEhGH4JkZrmhanmWiH8MIkjCLhyjTcR0KQSIAmhlFRsBkjetnexIYiFcdsBn9OEZbJzVeuVv3BoDyQg5DsOWR10HwZ82PiOuHbp25nSLZ5ax7sbSYsRcUZroDfOlW3CVIzM1UK5dH3+2w2BxsmiBk0kE1eEHc1hGIdf-s3o0+-KxAy4WAnVUjEY62hW7C-YjHOyWUYYhXrDMApDfKC35gRpUzoEKMUMolQai-xPv-M+JttIXwQKYAyZYGqhCGC4G6+dzrzFMJEHE1ZpaVyyjXH+EAGb1G3hgXeZoLTEDIYzMAsCk7wPNj0RwJ1oyqkSo4aITg4LYiLohX0KFYhf11PQihgCd5pjAZmbMtQJGECYeyM2644puxGJMJYJkHAcSMFiEM1ZMaRCHpiSUYiBx4GDgwUONIgHcD3gIQ+RArFNyUZIc+rDL6ynOpVAYSIIJlmPF2csY8RieGatCMYFjsKuJsUyKRVCZEZggTmFx1jbGMI8XA4KailQLHlJ2L0LYInBPlBWVU3ExjCkqTEn+1MfoYDpLAWAAB3IoEB3hwHqMzSO0cfIKSUmpDSvkpKfk8UjSqRB9K1X8ZKdwMRLJehRFGOsfirDMTqeSBp6sml4Bae09AnTuk4GBm3fWAzFIqXUnOSS0kJmhUxAZQIWjJhmBdHo3osQTomNMJjGIEE1hbKIOgE5djJDNLaR06h+9aEgpOUaSFhyIAPMOi6E6VYx7u2LljY8IQQwXiMshYu78bDAtBWgfUiT0BIuhck8BWZIEUvqIi-ZUKjmootm-HcUYqx1kjBsSyfQUSxDFtozO1hgXIFUBABJhpJDvDICOWAMKnGWmlbK9xGBFXKs5byPo8si4j0KcsMVx4JSDGvPpSCfQzJSplXKo0Oq4DANASkxlaSNX7CdUquAeqnRjFCNMuyr02JO04r44IJ5+jy09C6dUGpKBkDIfAH4xD0iHGoDhEcKiU6HRMNESwywuwDEqh6eKztmpBFxFBFY8tzpDC-pmrI9QaTNFzTzC22J8lz2hO-Wy2JAw8LlOiT0VazK+ibZkDt-dgSCnmL2kwEQB2YJmGGHcaJkqRj9GhTKvYSHkkpHgakBo6QMkoM3GdCC+aYyCHWasFgXD9BcCEQMSIQzywhMXZw+lVREP3b7H+SZqWpoRp23kxdQkQQhJ6dsEYp4zDWPdAmEFGwE2hLw4F7kr1eLohecEazoxREFGLWC+csYhickhFD3p3TAp2aNP+01zYsKRudcs2dGINrhGEXGqoi1OU9PLfcOd6P+0aegTWFDAKsdCuxhifEnJ+icCBVdRgwjzANTLM68tYpibrhJxu8TO2yd5uda2HpYp+gcjLcNRh5QnWLmLDYlUoSxDJXu6ugHD1-wAfKjAOH1wjDBOh8z0Z0r+mMLdJyCwNhOS7EMdR3ZPMU280QRRlD0CBdosF8E98ohl0bH0KY08YhF2lpU5YUFJjAribYzL2X82nigg+5dthmrFwSpLNYMtlTth6xefTatWUHI6V0yljWB7YKLi6Eu8ozIuhK5xHdKzljcRFA4JUxhyVgsy7So5k22EhnYt+3ORlFgYjxcXQynCRZmGOvazVmXnWgeTuBgNjE5Sobiu6R6dnejCjvS2GW0QLxvRSyJVemBDsBuWEEbigpR2Pv+4sUCMtNj8o2VjZL5NIcw6OhEMECPQ3I8DPMyw6PPQLKhPubbCQ4hAA */
createMachine(
{
tsTypes: {} as import("./usersXService.typegen").Typegen0,
@ -130,222 +132,261 @@ export const usersMachine =
updateUserRoles: {
data: TypesGen.User
}
getUserCount: {
data: TypesGen.UserCountResponse
}
},
},
predictableActionArguments: true,
id: "usersState",
initial: "startingPagination",
type: "parallel",
states: {
startingPagination: {
entry: "assignPaginationRef",
always: {
target: "gettingUsers",
},
},
gettingUsers: {
entry: "clearGetUsersError",
invoke: {
src: "getUsers",
id: "getUsers",
onDone: [
{
target: "idle",
actions: "assignUsers",
},
],
onError: [
{
target: "idle",
actions: [
"clearUsers",
"assignGetUsersError",
"displayGetUsersErrorMessage",
count: {
initial: "gettingCount",
states: {
idle: {},
gettingCount: {
entry: "clearGetCountError",
invoke: {
src: "getUserCount",
id: "getUserCount",
onDone: [
{
target: "idle",
actions: "assignCount",
},
],
onError: [
{
target: "idle",
actions: "assignGetCountError",
},
],
},
],
},
},
tags: "loading",
},
idle: {
entry: "clearSelectedUser",
on: {
SUSPEND_USER: {
target: "confirmUserSuspension",
actions: "assignUserToSuspend",
},
DELETE_USER: {
target: "confirmUserDeletion",
actions: "assignUserToDelete",
},
ACTIVATE_USER: {
target: "confirmUserActivation",
actions: "assignUserToActivate",
},
RESET_USER_PASSWORD: {
target: "confirmUserPasswordReset",
actions: [
"assignUserIdToResetPassword",
"generateRandomPassword",
],
},
UPDATE_USER_ROLES: {
target: "updatingUserRoles",
actions: "assignUserIdToUpdateRoles",
},
UPDATE_PAGE: {
target: "gettingUsers",
actions: "updateURL",
},
UPDATE_FILTER: {
target: ".gettingCount",
actions: ["assignFilter", "sendResetPage"],
},
},
},
confirmUserSuspension: {
on: {
CONFIRM_USER_SUSPENSION: {
target: "suspendingUser",
},
CANCEL_USER_SUSPENSION: {
target: "idle",
},
},
},
confirmUserDeletion: {
on: {
CONFIRM_USER_DELETE: {
target: "deletingUser",
},
CANCEL_USER_DELETE: {
target: "idle",
},
},
},
confirmUserActivation: {
on: {
CONFIRM_USER_ACTIVATION: {
target: "activatingUser",
},
CANCEL_USER_ACTIVATION: {
target: "idle",
},
},
},
suspendingUser: {
entry: "clearSuspendUserError",
invoke: {
src: "suspendUser",
id: "suspendUser",
onDone: [
{
users: {
initial: "startingPagination",
states: {
startingPagination: {
entry: "assignPaginationRef",
always: {
target: "gettingUsers",
actions: "displaySuspendSuccess",
},
],
onError: [
{
target: "idle",
actions: [
"assignSuspendUserError",
"displaySuspendedErrorMessage",
],
},
],
},
},
deletingUser: {
entry: "clearDeleteUserError",
invoke: {
src: "deleteUser",
id: "deleteUser",
onDone: [
{
target: "gettingUsers",
actions: "displayDeleteSuccess",
},
],
onError: [
{
target: "idle",
actions: ["assignDeleteUserError", "displayDeleteErrorMessage"],
},
],
},
},
activatingUser: {
entry: "clearActivateUserError",
invoke: {
src: "activateUser",
id: "activateUser",
onDone: [
{
target: "gettingUsers",
actions: "displayActivateSuccess",
},
],
onError: [
{
target: "idle",
actions: [
"assignActivateUserError",
"displayActivatedErrorMessage",
],
},
],
},
},
confirmUserPasswordReset: {
on: {
CONFIRM_USER_PASSWORD_RESET: {
target: "resettingUserPassword",
},
CANCEL_USER_PASSWORD_RESET: {
target: "idle",
gettingUsers: {
entry: "clearGetUsersError",
invoke: {
src: "getUsers",
id: "getUsers",
onDone: [
{
target: "idle",
actions: "assignUsers",
},
],
onError: [
{
target: "idle",
actions: [
"clearUsers",
"assignGetUsersError",
"displayGetUsersErrorMessage",
],
},
],
},
tags: "loading",
},
},
},
resettingUserPassword: {
entry: "clearResetUserPasswordError",
invoke: {
src: "resetUserPassword",
id: "resetUserPassword",
onDone: [
{
target: "idle",
actions: "displayResetPasswordSuccess",
idle: {
entry: "clearSelectedUser",
on: {
SUSPEND_USER: {
target: "confirmUserSuspension",
actions: "assignUserToSuspend",
},
DELETE_USER: {
target: "confirmUserDeletion",
actions: "assignUserToDelete",
},
ACTIVATE_USER: {
target: "confirmUserActivation",
actions: "assignUserToActivate",
},
RESET_USER_PASSWORD: {
target: "confirmUserPasswordReset",
actions: [
"assignUserIdToResetPassword",
"generateRandomPassword",
],
},
UPDATE_USER_ROLES: {
target: "updatingUserRoles",
actions: "assignUserIdToUpdateRoles",
},
UPDATE_PAGE: {
target: "gettingUsers",
actions: "updateURL",
},
},
],
onError: [
{
target: "idle",
actions: [
"assignResetUserPasswordError",
"displayResetPasswordErrorMessage",
},
confirmUserSuspension: {
on: {
CONFIRM_USER_SUSPENSION: {
target: "suspendingUser",
},
CANCEL_USER_SUSPENSION: {
target: "idle",
},
},
},
confirmUserDeletion: {
on: {
CONFIRM_USER_DELETE: {
target: "deletingUser",
},
CANCEL_USER_DELETE: {
target: "idle",
},
},
},
confirmUserActivation: {
on: {
CONFIRM_USER_ACTIVATION: {
target: "activatingUser",
},
CANCEL_USER_ACTIVATION: {
target: "idle",
},
},
},
suspendingUser: {
entry: "clearSuspendUserError",
invoke: {
src: "suspendUser",
id: "suspendUser",
onDone: [
{
target: "gettingUsers",
actions: "displaySuspendSuccess",
},
],
onError: [
{
target: "idle",
actions: [
"assignSuspendUserError",
"displaySuspendedErrorMessage",
],
},
],
},
],
},
},
updatingUserRoles: {
entry: "clearUpdateUserRolesError",
invoke: {
src: "updateUserRoles",
id: "updateUserRoles",
onDone: [
{
target: "idle",
actions: "updateUserRolesInTheList",
},
],
onError: [
{
target: "idle",
actions: [
"assignUpdateRolesError",
"displayUpdateRolesErrorMessage",
},
deletingUser: {
entry: "clearDeleteUserError",
invoke: {
src: "deleteUser",
id: "deleteUser",
onDone: [
{
target: "gettingUsers",
actions: "displayDeleteSuccess",
},
],
onError: [
{
target: "idle",
actions: [
"assignDeleteUserError",
"displayDeleteErrorMessage",
],
},
],
},
],
},
activatingUser: {
entry: "clearActivateUserError",
invoke: {
src: "activateUser",
id: "activateUser",
onDone: [
{
target: "gettingUsers",
actions: "displayActivateSuccess",
},
],
onError: [
{
target: "idle",
actions: [
"assignActivateUserError",
"displayActivatedErrorMessage",
],
},
],
},
},
confirmUserPasswordReset: {
on: {
CONFIRM_USER_PASSWORD_RESET: {
target: "resettingUserPassword",
},
CANCEL_USER_PASSWORD_RESET: {
target: "idle",
},
},
},
resettingUserPassword: {
entry: "clearResetUserPasswordError",
invoke: {
src: "resetUserPassword",
id: "resetUserPassword",
onDone: [
{
target: "idle",
actions: "displayResetPasswordSuccess",
},
],
onError: [
{
target: "idle",
actions: [
"assignResetUserPasswordError",
"displayResetPasswordErrorMessage",
],
},
],
},
},
updatingUserRoles: {
entry: "clearUpdateUserRolesError",
invoke: {
src: "updateUserRoles",
id: "updateUserRoles",
onDone: [
{
target: "idle",
actions: "updateUserRolesInTheList",
},
],
onError: [
{
target: "idle",
actions: [
"assignUpdateRolesError",
"displayUpdateRolesErrorMessage",
],
},
],
},
},
},
},
},
@ -363,6 +404,9 @@ export const usersMachine =
limit,
})
},
getUserCount: (context) => {
return API.getUserCount(queryToFilter(context.filter))
},
suspendUser: (context) => {
if (!context.userIdToSuspend) {
throw new Error("userIdToSuspend is undefined")
@ -420,6 +464,15 @@ export const usersMachine =
assignUsers: assign({
users: (_, event) => event.data,
}),
assignCount: assign({
count: (_, event) => event.data.count,
}),
assignGetCountError: assign({
getCountError: (_, event) => event.data,
}),
clearGetCountError: assign({
getCountError: (_) => undefined,
}),
assignFilter: assign({
filter: (_, event) => event.query,
}),