diff --git a/coderd/coderd.go b/coderd/coderd.go index d81373638cd2d..1eaaf01fbf10c 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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) { diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index e54b3104623ba..f7636604642f8 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -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 diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 24939e0bcff3d..db9719c11f4cf 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -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() diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 56cd7ff074622..968b8b7e5cb90 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -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 +} diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 7a4fa43ffc5a5..b2a1e7c3296cc 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -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) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index be2b720d95160..473634da9515f 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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 diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index da6b456e18e20..2c42aaa5f4188 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -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 ( diff --git a/coderd/users.go b/coderd/users.go index 1e1682dbfd912..91a2512b6ebc9 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -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() diff --git a/coderd/users_test.go b/coderd/users_test.go index bb55b909faf5b..e708380f99710 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -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) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 53e56be63fda4..fee5928996d80 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -165,7 +165,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 diff --git a/codersdk/users.go b/codersdk/users.go index b2452284a2412..c2162a6afd18f 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -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) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 3804e83aed2aa..bd7695aae490f 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -139,6 +139,14 @@ export const getUsers = async ( return response.data } +export const getUserCount = async ( + options: TypesGen.UserCountRequest, +): Promise => { + 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 => { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 26a8e79115a2b..166d21132eb4b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -740,6 +740,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[] diff --git a/site/src/pages/UsersPage/UsersPage.test.tsx b/site/src/pages/UsersPage/UsersPage.test.tsx index 766581ca2dcf2..a5f5cf7668442 100644 --- a/site/src/pages/UsersPage/UsersPage.test.tsx +++ b/site/src/pages/UsersPage/UsersPage.test.tsx @@ -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) }) }) diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index 66303ebb033c5..4142f9b1df1e6 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -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 ( <> @@ -81,6 +88,7 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { { 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 }> = () => { { @@ -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 && ( { diff --git a/site/src/pages/UsersPage/UsersPageView.tsx b/site/src/pages/UsersPage/UsersPageView.tsx index 9a80140a1d048..f9104e506a3de 100644 --- a/site/src/pages/UsersPage/UsersPageView.tsx +++ b/site/src/pages/UsersPage/UsersPageView.tsx @@ -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> = ({ users, + count, roles, onSuspendUser, onDeleteUser, @@ -76,7 +78,7 @@ export const UsersPageView: FC> = ({ isLoading={isLoading} /> - + ) } diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 8b93801dcd70b..8e7c27951b9ca 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -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", diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 599e6d88a00d3..d9a9060a743a0 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -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])) diff --git a/site/src/xServices/users/usersXService.ts b/site/src/xServices/users/usersXService.ts index f35c814a48b81..8c429c82501bb 100644 --- a/site/src/xServices/users/usersXService.ts +++ b/site/src/xServices/users/usersXService.ts @@ -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", + users: { + initial: "startingPagination", + states: { + startingPagination: { + entry: "assignPaginationRef", + always: { + target: "gettingUsers", + }, }, - }, - }, - confirmUserDeletion: { - on: { - CONFIRM_USER_DELETE: { - target: "deletingUser", + gettingUsers: { + entry: "clearGetUsersError", + invoke: { + src: "getUsers", + id: "getUsers", + onDone: [ + { + target: "idle", + actions: "assignUsers", + }, + ], + onError: [ + { + target: "idle", + actions: [ + "clearUsers", + "assignGetUsersError", + "displayGetUsersErrorMessage", + ], + }, + ], + }, + tags: "loading", }, - CANCEL_USER_DELETE: { - target: "idle", + 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", + }, + }, }, - }, - }, - confirmUserActivation: { - on: { - CONFIRM_USER_ACTIVATION: { - target: "activatingUser", + confirmUserSuspension: { + on: { + CONFIRM_USER_SUSPENSION: { + target: "suspendingUser", + }, + CANCEL_USER_SUSPENSION: { + target: "idle", + }, + }, }, - CANCEL_USER_ACTIVATION: { - target: "idle", + confirmUserDeletion: { + on: { + CONFIRM_USER_DELETE: { + target: "deletingUser", + }, + CANCEL_USER_DELETE: { + target: "idle", + }, + }, }, - }, - }, - suspendingUser: { - entry: "clearSuspendUserError", - invoke: { - src: "suspendUser", - id: "suspendUser", - onDone: [ - { - target: "gettingUsers", - actions: "displaySuspendSuccess", + confirmUserActivation: { + on: { + CONFIRM_USER_ACTIVATION: { + target: "activatingUser", + }, + CANCEL_USER_ACTIVATION: { + target: "idle", + }, }, - ], - onError: [ - { - target: "idle", - actions: [ - "assignSuspendUserError", - "displaySuspendedErrorMessage", + }, + suspendingUser: { + entry: "clearSuspendUserError", + invoke: { + src: "suspendUser", + id: "suspendUser", + onDone: [ + { + 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", + }, + deletingUser: { + entry: "clearDeleteUserError", + invoke: { + src: "deleteUser", + id: "deleteUser", + onDone: [ + { + target: "gettingUsers", + actions: "displayDeleteSuccess", + }, + ], + onError: [ + { + target: "idle", + actions: [ + "assignDeleteUserError", + "displayDeleteErrorMessage", + ], + }, ], }, - ], - }, - }, - confirmUserPasswordReset: { - on: { - CONFIRM_USER_PASSWORD_RESET: { - target: "resettingUserPassword", }, - CANCEL_USER_PASSWORD_RESET: { - target: "idle", + activatingUser: { + entry: "clearActivateUserError", + invoke: { + src: "activateUser", + id: "activateUser", + onDone: [ + { + target: "gettingUsers", + actions: "displayActivateSuccess", + }, + ], + onError: [ + { + target: "idle", + actions: [ + "assignActivateUserError", + "displayActivatedErrorMessage", + ], + }, + ], + }, }, - }, - }, - resettingUserPassword: { - entry: "clearResetUserPasswordError", - invoke: { - src: "resetUserPassword", - id: "resetUserPassword", - onDone: [ - { - target: "idle", - actions: "displayResetPasswordSuccess", + confirmUserPasswordReset: { + on: { + CONFIRM_USER_PASSWORD_RESET: { + target: "resettingUserPassword", + }, + CANCEL_USER_PASSWORD_RESET: { + target: "idle", + }, }, - ], - onError: [ - { - target: "idle", - actions: [ - "assignResetUserPasswordError", - "displayResetPasswordErrorMessage", + }, + 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", + }, + 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, }),