feat: Allow deleting users (#4028)

* Add deleted column to the users table

* Fix user indexes

* Add frontend

* Add test
This commit is contained in:
Kyle Carberry
2022-09-12 23:24:20 +00:00
committed by GitHub
parent a2098254cd
commit 850a83097c
26 changed files with 498 additions and 70 deletions
+1
View File
@@ -346,6 +346,7 @@ func New(options *Options) *API {
})
r.Route("/{user}", func(r chi.Router) {
r.Use(httpmw.ExtractUserParam(options.Database))
r.Delete("/", api.deleteUser)
r.Get("/", api.userByName)
r.Put("/profile", api.putUserProfile)
r.Route("/status", func(r chi.Router) {
+42 -9
View File
@@ -284,7 +284,7 @@ func (q *fakeQuerier) GetUserByEmailOrUsername(_ context.Context, arg database.G
defer q.mutex.RUnlock()
for _, user := range q.users {
if user.Email == arg.Email || user.Username == arg.Username {
if (user.Email == arg.Email || user.Username == arg.Username) && user.Deleted == arg.Deleted {
return user, nil
}
}
@@ -307,7 +307,13 @@ func (q *fakeQuerier) GetUserCount(_ context.Context) (int64, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
return int64(len(q.users)), nil
existing := int64(0)
for _, u := range q.users {
if !u.Deleted {
existing++
}
}
return existing, nil
}
func (q *fakeQuerier) GetActiveUserCount(_ context.Context) (int64, error) {
@@ -316,13 +322,27 @@ func (q *fakeQuerier) GetActiveUserCount(_ context.Context) (int64, error) {
active := int64(0)
for _, u := range q.users {
if u.Status == database.UserStatusActive {
if u.Status == database.UserStatusActive && !u.Deleted {
active++
}
}
return active, nil
}
func (q *fakeQuerier) UpdateUserDeletedByID(_ context.Context, params database.UpdateUserDeletedByIDParams) error {
q.mutex.Lock()
defer q.mutex.Unlock()
for i, u := range q.users {
if u.ID == params.ID {
u.Deleted = params.Deleted
q.users[i] = u
return nil
}
}
return sql.ErrNoRows
}
func (q *fakeQuerier) GetUsers(_ context.Context, params database.GetUsersParams) ([]database.User, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
@@ -341,6 +361,16 @@ func (q *fakeQuerier) GetUsers(_ context.Context, params database.GetUsersParams
return a.CreatedAt.Before(b.CreatedAt)
})
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.AfterID != uuid.Nil {
found := false
for i, v := range users {
@@ -409,16 +439,19 @@ func (q *fakeQuerier) GetUsers(_ context.Context, params database.GetUsersParams
return users, nil
}
func (q *fakeQuerier) GetUsersByIDs(_ context.Context, ids []uuid.UUID) ([]database.User, error) {
func (q *fakeQuerier) GetUsersByIDs(_ context.Context, params database.GetUsersByIDsParams) ([]database.User, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
users := make([]database.User, 0)
for _, user := range q.users {
for _, id := range ids {
for _, id := range params.IDs {
if user.ID.String() != id.String() {
continue
}
if user.Deleted != params.Deleted {
continue
}
users = append(users, user)
}
}
@@ -879,8 +912,8 @@ func (q *fakeQuerier) ParameterValues(_ context.Context, arg database.ParameterV
}
}
if len(arg.Ids) > 0 {
if !slice.Contains(arg.Ids, parameterValue.ID) {
if len(arg.IDs) > 0 {
if !slice.Contains(arg.IDs, parameterValue.ID) {
continue
}
}
@@ -961,9 +994,9 @@ func (q *fakeQuerier) GetTemplatesWithFilter(_ context.Context, arg database.Get
continue
}
if len(arg.Ids) > 0 {
if len(arg.IDs) > 0 {
match := false
for _, id := range arg.Ids {
for _, id := range arg.IDs {
if template.ID == id {
match = true
break
+5 -4
View File
@@ -301,7 +301,8 @@ CREATE TABLE users (
status user_status DEFAULT 'active'::public.user_status NOT NULL,
rbac_roles text[] DEFAULT '{}'::text[] NOT NULL,
login_type login_type DEFAULT 'password'::public.login_type NOT NULL,
avatar_url text
avatar_url text,
deleted boolean DEFAULT false NOT NULL
);
CREATE TABLE workspace_agents (
@@ -504,13 +505,13 @@ CREATE UNIQUE INDEX idx_organization_name ON organizations USING btree (name);
CREATE UNIQUE INDEX idx_organization_name_lower ON organizations USING btree (lower(name));
CREATE UNIQUE INDEX idx_users_email ON users USING btree (email);
CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE (deleted = false);
CREATE UNIQUE INDEX idx_users_username ON users USING btree (username);
CREATE UNIQUE INDEX idx_users_username ON users USING btree (username) WHERE (deleted = false);
CREATE UNIQUE INDEX templates_organization_id_name_idx ON templates USING btree (organization_id, lower((name)::text)) WHERE (deleted = false);
CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username));
CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username)) WHERE (deleted = false);
CREATE UNIQUE INDEX workspaces_owner_id_lower_idx ON workspaces USING btree (owner_id, lower((name)::text)) WHERE (deleted = false);
@@ -0,0 +1,2 @@
ALTER TABLE users
DROP COLUMN deleted;
@@ -0,0 +1,9 @@
ALTER TABLE users
ADD COLUMN deleted boolean DEFAULT false NOT NULL;
DROP INDEX idx_users_email;
DROP INDEX idx_users_username;
DROP INDEX users_username_lower_idx;
CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE deleted = false;
CREATE UNIQUE INDEX idx_users_username ON users USING btree (username) WHERE deleted = false;
CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username)) WHERE deleted = false;
+1
View File
@@ -506,6 +506,7 @@ type User struct {
RBACRoles []string `db:"rbac_roles" json:"rbac_roles"`
LoginType LoginType `db:"login_type" json:"login_type"`
AvatarURL sql.NullString `db:"avatar_url" json:"avatar_url"`
Deleted bool `db:"deleted" json:"deleted"`
}
type UserLink struct {
+2 -1
View File
@@ -74,7 +74,7 @@ type querier interface {
GetUserLinkByLinkedID(ctx context.Context, linkedID string) (UserLink, error)
GetUserLinkByUserIDLoginType(ctx context.Context, arg GetUserLinkByUserIDLoginTypeParams) (UserLink, error)
GetUsers(ctx context.Context, arg GetUsersParams) ([]User, error)
GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]User, error)
GetUsersByIDs(ctx context.Context, arg GetUsersByIDsParams) ([]User, error)
GetWorkspaceAgentByAuthToken(ctx context.Context, authToken uuid.UUID) (WorkspaceAgent, error)
GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (WorkspaceAgent, error)
GetWorkspaceAgentByInstanceID(ctx context.Context, authInstanceID string) (WorkspaceAgent, error)
@@ -137,6 +137,7 @@ type querier interface {
UpdateTemplateMetaByID(ctx context.Context, arg UpdateTemplateMetaByIDParams) (Template, error)
UpdateTemplateVersionByID(ctx context.Context, arg UpdateTemplateVersionByIDParams) error
UpdateTemplateVersionDescriptionByJobID(ctx context.Context, arg UpdateTemplateVersionDescriptionByJobIDParams) error
UpdateUserDeletedByID(ctx context.Context, arg UpdateUserDeletedByIDParams) error
UpdateUserHashedPassword(ctx context.Context, arg UpdateUserHashedPasswordParams) error
UpdateUserLink(ctx context.Context, arg UpdateUserLinkParams) (UserLink, error)
UpdateUserLinkedID(ctx context.Context, arg UpdateUserLinkedIDParams) (UserLink, error)
+67 -31
View File
@@ -1450,7 +1450,7 @@ WHERE
type ParameterValuesParams struct {
Scopes []ParameterScope `db:"scopes" json:"scopes"`
ScopeIds []uuid.UUID `db:"scope_ids" json:"scope_ids"`
Ids []uuid.UUID `db:"ids" json:"ids"`
IDs []uuid.UUID `db:"ids" json:"ids"`
Names []string `db:"names" json:"names"`
}
@@ -1458,7 +1458,7 @@ func (q *sqlQuerier) ParameterValues(ctx context.Context, arg ParameterValuesPar
rows, err := q.db.QueryContext(ctx, parameterValues,
pq.Array(arg.Scopes),
pq.Array(arg.ScopeIds),
pq.Array(arg.Ids),
pq.Array(arg.IDs),
pq.Array(arg.Names),
)
if err != nil {
@@ -2205,7 +2205,7 @@ type GetTemplatesWithFilterParams struct {
Deleted bool `db:"deleted" json:"deleted"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
ExactName string `db:"exact_name" json:"exact_name"`
Ids []uuid.UUID `db:"ids" json:"ids"`
IDs []uuid.UUID `db:"ids" json:"ids"`
}
func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplatesWithFilterParams) ([]Template, error) {
@@ -2213,7 +2213,7 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate
arg.Deleted,
arg.OrganizationID,
arg.ExactName,
pq.Array(arg.Ids),
pq.Array(arg.IDs),
)
if err != nil {
return nil, err
@@ -2884,7 +2884,7 @@ SELECT
FROM
users
WHERE
status = 'active'::public.user_status
status = 'active'::public.user_status AND deleted = false
`
func (q *sqlQuerier) GetActiveUserCount(ctx context.Context) (int64, error) {
@@ -2937,12 +2937,12 @@ func (q *sqlQuerier) GetAuthorizationUserRoles(ctx context.Context, userID uuid.
const getUserByEmailOrUsername = `-- name: GetUserByEmailOrUsername :one
SELECT
id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url
id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted
FROM
users
WHERE
LOWER(username) = LOWER($1)
OR email = $2
(LOWER(username) = LOWER($1) OR email = $2)
AND deleted = $3
LIMIT
1
`
@@ -2950,10 +2950,11 @@ LIMIT
type GetUserByEmailOrUsernameParams struct {
Username string `db:"username" json:"username"`
Email string `db:"email" json:"email"`
Deleted bool `db:"deleted" json:"deleted"`
}
func (q *sqlQuerier) GetUserByEmailOrUsername(ctx context.Context, arg GetUserByEmailOrUsernameParams) (User, error) {
row := q.db.QueryRowContext(ctx, getUserByEmailOrUsername, arg.Username, arg.Email)
row := q.db.QueryRowContext(ctx, getUserByEmailOrUsername, arg.Username, arg.Email, arg.Deleted)
var i User
err := row.Scan(
&i.ID,
@@ -2966,13 +2967,14 @@ func (q *sqlQuerier) GetUserByEmailOrUsername(ctx context.Context, arg GetUserBy
pq.Array(&i.RBACRoles),
&i.LoginType,
&i.AvatarURL,
&i.Deleted,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT
id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url
id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted
FROM
users
WHERE
@@ -2995,6 +2997,7 @@ func (q *sqlQuerier) GetUserByID(ctx context.Context, id uuid.UUID) (User, error
pq.Array(&i.RBACRoles),
&i.LoginType,
&i.AvatarURL,
&i.Deleted,
)
return i, err
}
@@ -3003,7 +3006,7 @@ const getUserCount = `-- name: GetUserCount :one
SELECT
COUNT(*)
FROM
users
users WHERE deleted = false
`
func (q *sqlQuerier) GetUserCount(ctx context.Context) (int64, error) {
@@ -3015,15 +3018,16 @@ func (q *sqlQuerier) GetUserCount(ctx context.Context) (int64, error) {
const getUsers = `-- name: GetUsers :many
SELECT
id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url
id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted
FROM
users
WHERE
CASE
users.deleted = $1
AND 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 $1 :: uuid != '00000000-00000000-00000000-00000000' THEN (
WHEN $2 :: uuid != '00000000-00000000-00000000-00000000' THEN (
-- The pagination cursor is the last ID of the previous page.
-- The query is ordered by the created_at field, so select all
-- rows after the cursor.
@@ -3033,7 +3037,7 @@ WHERE
FROM
users
WHERE
id = $1
id = $2
)
)
ELSE true
@@ -3041,9 +3045,9 @@ WHERE
-- Start filters
-- Filter by name, email or username
AND CASE
WHEN $2 :: text != '' THEN (
email ILIKE concat('%', $2, '%')
OR username ILIKE concat('%', $2, '%')
WHEN $3 :: text != '' THEN (
email ILIKE concat('%', $3, '%')
OR username ILIKE concat('%', $3, '%')
)
ELSE true
END
@@ -3051,29 +3055,30 @@ WHERE
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[])
WHEN cardinality($4 :: user_status[]) > 0 THEN
status = ANY($4 :: 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[]
WHEN cardinality($5 :: text[]) > 0 AND 'member' != ANY($5 :: text[]) THEN
rbac_roles && $5 :: text[]
ELSE true
END
-- End of filters
ORDER BY
-- Deterministic and consistent ordering of all users, even if they share
-- a timestamp. This is to ensure consistent pagination.
(created_at, id) ASC OFFSET $5
(created_at, id) ASC OFFSET $6
LIMIT
-- A null limit means "no limit", so 0 means return all
NULLIF($6 :: int, 0)
NULLIF($7 :: int, 0)
`
type GetUsersParams struct {
Deleted bool `db:"deleted" json:"deleted"`
AfterID uuid.UUID `db:"after_id" json:"after_id"`
Search string `db:"search" json:"search"`
Status []UserStatus `db:"status" json:"status"`
@@ -3084,6 +3089,7 @@ type GetUsersParams struct {
func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]User, error) {
rows, err := q.db.QueryContext(ctx, getUsers,
arg.Deleted,
arg.AfterID,
arg.Search,
pq.Array(arg.Status),
@@ -3109,6 +3115,7 @@ func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]User,
pq.Array(&i.RBACRoles),
&i.LoginType,
&i.AvatarURL,
&i.Deleted,
); err != nil {
return nil, err
}
@@ -3124,11 +3131,16 @@ func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]User,
}
const getUsersByIDs = `-- name: GetUsersByIDs :many
SELECT id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url FROM users WHERE id = ANY($1 :: uuid [ ])
SELECT id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted FROM users WHERE id = ANY($1 :: uuid [ ]) AND deleted = $2
`
func (q *sqlQuerier) GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]User, error) {
rows, err := q.db.QueryContext(ctx, getUsersByIDs, pq.Array(ids))
type GetUsersByIDsParams struct {
IDs []uuid.UUID `db:"ids" json:"ids"`
Deleted bool `db:"deleted" json:"deleted"`
}
func (q *sqlQuerier) GetUsersByIDs(ctx context.Context, arg GetUsersByIDsParams) ([]User, error) {
rows, err := q.db.QueryContext(ctx, getUsersByIDs, pq.Array(arg.IDs), arg.Deleted)
if err != nil {
return nil, err
}
@@ -3147,6 +3159,7 @@ func (q *sqlQuerier) GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]User
pq.Array(&i.RBACRoles),
&i.LoginType,
&i.AvatarURL,
&i.Deleted,
); err != nil {
return nil, err
}
@@ -3174,7 +3187,7 @@ INSERT INTO
login_type
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url
($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted
`
type InsertUserParams struct {
@@ -3211,10 +3224,30 @@ func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User
pq.Array(&i.RBACRoles),
&i.LoginType,
&i.AvatarURL,
&i.Deleted,
)
return i, err
}
const updateUserDeletedByID = `-- name: UpdateUserDeletedByID :exec
UPDATE
users
SET
deleted = $2
WHERE
id = $1
`
type UpdateUserDeletedByIDParams struct {
ID uuid.UUID `db:"id" json:"id"`
Deleted bool `db:"deleted" json:"deleted"`
}
func (q *sqlQuerier) UpdateUserDeletedByID(ctx context.Context, arg UpdateUserDeletedByIDParams) error {
_, err := q.db.ExecContext(ctx, updateUserDeletedByID, arg.ID, arg.Deleted)
return err
}
const updateUserHashedPassword = `-- name: UpdateUserHashedPassword :exec
UPDATE
users
@@ -3243,7 +3276,7 @@ SET
avatar_url = $4,
updated_at = $5
WHERE
id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url
id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted
`
type UpdateUserProfileParams struct {
@@ -3274,6 +3307,7 @@ func (q *sqlQuerier) UpdateUserProfile(ctx context.Context, arg UpdateUserProfil
pq.Array(&i.RBACRoles),
&i.LoginType,
&i.AvatarURL,
&i.Deleted,
)
return i, err
}
@@ -3286,7 +3320,7 @@ SET
rbac_roles = ARRAY(SELECT DISTINCT UNNEST($1 :: text[]))
WHERE
id = $2
RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url
RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted
`
type UpdateUserRolesParams struct {
@@ -3308,6 +3342,7 @@ func (q *sqlQuerier) UpdateUserRoles(ctx context.Context, arg UpdateUserRolesPar
pq.Array(&i.RBACRoles),
&i.LoginType,
&i.AvatarURL,
&i.Deleted,
)
return i, err
}
@@ -3319,7 +3354,7 @@ SET
status = $2,
updated_at = $3
WHERE
id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url
id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted
`
type UpdateUserStatusParams struct {
@@ -3342,6 +3377,7 @@ func (q *sqlQuerier) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusP
pq.Array(&i.RBACRoles),
&i.LoginType,
&i.AvatarURL,
&i.Deleted,
)
return i, err
}
+15 -6
View File
@@ -9,7 +9,7 @@ LIMIT
1;
-- name: GetUsersByIDs :many
SELECT * FROM users WHERE id = ANY(@ids :: uuid [ ]);
SELECT * FROM users WHERE id = ANY(@ids :: uuid [ ]) AND deleted = @deleted;
-- name: GetUserByEmailOrUsername :one
SELECT
@@ -17,8 +17,8 @@ SELECT
FROM
users
WHERE
LOWER(username) = LOWER(@username)
OR email = @email
(LOWER(username) = LOWER(@username) OR email = @email)
AND deleted = @deleted
LIMIT
1;
@@ -26,7 +26,7 @@ LIMIT
SELECT
COUNT(*)
FROM
users;
users WHERE deleted = false;
-- name: GetActiveUserCount :one
SELECT
@@ -34,7 +34,7 @@ SELECT
FROM
users
WHERE
status = 'active'::public.user_status;
status = 'active'::public.user_status AND deleted = false;
-- name: InsertUser :one
INSERT INTO
@@ -80,13 +80,22 @@ SET
WHERE
id = $1;
-- name: UpdateUserDeletedByID :exec
UPDATE
users
SET
deleted = $2
WHERE
id = $1;
-- name: GetUsers :many
SELECT
*
FROM
users
WHERE
CASE
users.deleted = @deleted
AND 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.
+1
View File
@@ -30,4 +30,5 @@ rename:
rbac_roles: RBACRoles
ip_address: IPAddress
ip_addresses: IPAddresses
ids: IDs
jwt: JWT
+3 -3
View File
@@ -17,9 +17,9 @@ const (
UniqueWorkspaceBuildsWorkspaceIDBuildNumberKey UniqueConstraint = "workspace_builds_workspace_id_build_number_key" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_workspace_id_build_number_key UNIQUE (workspace_id, build_number);
UniqueIndexOrganizationName UniqueConstraint = "idx_organization_name" // CREATE UNIQUE INDEX idx_organization_name ON organizations USING btree (name);
UniqueIndexOrganizationNameLower UniqueConstraint = "idx_organization_name_lower" // CREATE UNIQUE INDEX idx_organization_name_lower ON organizations USING btree (lower(name));
UniqueIndexUsersEmail UniqueConstraint = "idx_users_email" // CREATE UNIQUE INDEX idx_users_email ON users USING btree (email);
UniqueIndexUsersUsername UniqueConstraint = "idx_users_username" // CREATE UNIQUE INDEX idx_users_username ON users USING btree (username);
UniqueIndexUsersEmail UniqueConstraint = "idx_users_email" // CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE (deleted = false);
UniqueIndexUsersUsername UniqueConstraint = "idx_users_username" // CREATE UNIQUE INDEX idx_users_username ON users USING btree (username) WHERE (deleted = false);
UniqueTemplatesOrganizationIDNameIndex UniqueConstraint = "templates_organization_id_name_idx" // CREATE UNIQUE INDEX templates_organization_id_name_idx ON templates USING btree (organization_id, lower((name)::text)) WHERE (deleted = false);
UniqueUsersUsernameLowerIndex UniqueConstraint = "users_username_lower_idx" // CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username));
UniqueUsersUsernameLowerIndex UniqueConstraint = "users_username_lower_idx" // CREATE UNIQUE INDEX users_username_lower_idx ON users USING btree (lower(username)) WHERE (deleted = false);
UniqueWorkspacesOwnerIDLowerIndex UniqueConstraint = "workspaces_owner_id_lower_idx" // CREATE UNIQUE INDEX workspaces_owner_id_lower_idx ON workspaces USING btree (owner_id, lower((name)::text)) WHERE (deleted = false);
)
+1 -1
View File
@@ -707,7 +707,7 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht
}
inheritedParams, err := db.ParameterValues(r.Context(), database.ParameterValuesParams{
Ids: inherits,
IDs: inherits,
})
if err != nil {
return xerrors.Errorf("fetch inherited params: %w", err)
+51
View File
@@ -338,6 +338,57 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(rw, http.StatusCreated, convertUser(user, []uuid.UUID{req.OrganizationID}))
}
func (api *API) deleteUser(rw http.ResponseWriter, r *http.Request) {
user := httpmw.UserParam(r)
aReq, commitAudit := audit.InitRequest[database.User](rw, &audit.RequestParams{
Features: api.FeaturesService,
Log: api.Logger,
Request: r,
Action: database.AuditActionDelete,
})
aReq.Old = user
defer commitAudit()
if !api.Authorize(r, rbac.ActionDelete, rbac.ResourceUser) {
httpapi.Forbidden(rw)
return
}
workspaces, err := api.Database.GetWorkspaces(r.Context(), database.GetWorkspacesParams{
OwnerID: user.ID,
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspaces.",
Detail: err.Error(),
})
return
}
if len(workspaces) > 0 {
httpapi.Write(rw, http.StatusExpectationFailed, codersdk.Response{
Message: "You cannot delete a user that has workspaces. Delete their workspaces and try again!",
})
return
}
err = api.Database.UpdateUserDeletedByID(r.Context(), database.UpdateUserDeletedByIDParams{
ID: user.ID,
Deleted: true,
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error deleting user.",
Detail: err.Error(),
})
return
}
user.Deleted = true
aReq.New = user
httpapi.Write(rw, http.StatusOK, codersdk.Response{
Message: "User has been deleted!",
})
}
// Returns the parameterized user requested. All validation
// is completed in the middleware for this route.
func (api *API) userByName(rw http.ResponseWriter, r *http.Request) {
+46
View File
@@ -257,6 +257,52 @@ func TestPostLogin(t *testing.T) {
})
}
func TestDeleteUser(t *testing.T) {
t.Parallel()
t.Run("Works", func(t *testing.T) {
t.Parallel()
api := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, api)
_, another := coderdtest.CreateAnotherUserWithUser(t, api, user.OrganizationID)
err := api.DeleteUser(context.Background(), another.ID)
require.NoError(t, err)
// Attempt to create a user with the same email and username, and delete them again.
another, err = api.CreateUser(context.Background(), codersdk.CreateUserRequest{
Email: another.Email,
Username: another.Username,
Password: "testing",
OrganizationID: user.OrganizationID,
})
require.NoError(t, err)
err = api.DeleteUser(context.Background(), another.ID)
require.NoError(t, err)
})
t.Run("NoPermission", func(t *testing.T) {
t.Parallel()
api := coderdtest.New(t, nil)
firstUser := coderdtest.CreateFirstUser(t, api)
client, _ := coderdtest.CreateAnotherUserWithUser(t, api, firstUser.OrganizationID)
err := client.DeleteUser(context.Background(), firstUser.UserID)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusForbidden, apiErr.StatusCode())
})
t.Run("HasWorkspaces", func(t *testing.T) {
t.Parallel()
client, _ := coderdtest.NewWithProvisionerCloser(t, nil)
user := coderdtest.CreateFirstUser(t, client)
anotherClient, another := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
coderdtest.CreateWorkspace(t, anotherClient, user.OrganizationID, template.ID)
err := client.DeleteUser(context.Background(), another.ID)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusExpectationFailed, apiErr.StatusCode())
})
}
func TestPostLogout(t *testing.T) {
t.Parallel()
+14 -6
View File
@@ -38,7 +38,9 @@ func (api *API) workspaceBuild(rw http.ResponseWriter, r *http.Request) {
return
}
users, err := api.Database.GetUsersByIDs(r.Context(), []uuid.UUID{workspace.OwnerID, workspaceBuild.InitiatorID})
users, err := api.Database.GetUsersByIDs(r.Context(), database.GetUsersByIDsParams{
IDs: []uuid.UUID{workspace.OwnerID, workspaceBuild.InitiatorID},
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching user.",
@@ -135,7 +137,9 @@ func (api *API) workspaceBuilds(rw http.ResponseWriter, r *http.Request) {
for _, build := range builds {
userIDs = append(userIDs, build.InitiatorID)
}
users, err := api.Database.GetUsersByIDs(r.Context(), userIDs)
users, err := api.Database.GetUsersByIDs(r.Context(), database.GetUsersByIDsParams{
IDs: userIDs,
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching user.",
@@ -221,7 +225,9 @@ func (api *API) workspaceBuildByBuildNumber(rw http.ResponseWriter, r *http.Requ
return
}
users, err := api.Database.GetUsersByIDs(r.Context(), []uuid.UUID{workspace.OwnerID, workspaceBuild.InitiatorID})
users, err := api.Database.GetUsersByIDs(r.Context(), database.GetUsersByIDsParams{
IDs: []uuid.UUID{workspace.OwnerID, workspaceBuild.InitiatorID},
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching user.",
@@ -476,9 +482,11 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
return
}
users, err := api.Database.GetUsersByIDs(r.Context(), []uuid.UUID{
workspace.OwnerID,
workspaceBuild.InitiatorID,
users, err := api.Database.GetUsersByIDs(r.Context(), database.GetUsersByIDsParams{
IDs: []uuid.UUID{
workspace.OwnerID,
workspaceBuild.InitiatorID,
},
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
+13 -5
View File
@@ -98,7 +98,9 @@ func (api *API) workspace(rw http.ResponseWriter, r *http.Request) {
return err
})
group.Go(func() (err error) {
users, err = api.Database.GetUsersByIDs(r.Context(), []uuid.UUID{workspace.OwnerID, build.InitiatorID})
users, err = api.Database.GetUsersByIDs(r.Context(), database.GetUsersByIDsParams{
IDs: []uuid.UUID{workspace.OwnerID, build.InitiatorID},
})
return err
})
err = group.Wait()
@@ -470,7 +472,9 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req
}
aReq.New = workspace
users, err := api.Database.GetUsersByIDs(r.Context(), []uuid.UUID{apiKey.UserID, workspaceBuild.InitiatorID})
users, err := api.Database.GetUsersByIDs(r.Context(), database.GetUsersByIDsParams{
IDs: []uuid.UUID{apiKey.UserID, workspaceBuild.InitiatorID},
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching user.",
@@ -856,7 +860,9 @@ func (api *API) watchWorkspace(rw http.ResponseWriter, r *http.Request) {
return err
})
group.Go(func() (err error) {
users, err = api.Database.GetUsersByIDs(r.Context(), []uuid.UUID{workspace.OwnerID, build.InitiatorID})
users, err = api.Database.GetUsersByIDs(r.Context(), database.GetUsersByIDsParams{
IDs: []uuid.UUID{workspace.OwnerID, build.InitiatorID},
})
return err
})
err = group.Wait()
@@ -897,7 +903,7 @@ func convertWorkspaces(ctx context.Context, db database.Store, workspaces []data
return nil, xerrors.Errorf("get workspace builds: %w", err)
}
templates, err := db.GetTemplatesWithFilter(ctx, database.GetTemplatesWithFilterParams{
Ids: templateIDs,
IDs: templateIDs,
})
if errors.Is(err, sql.ErrNoRows) {
err = nil
@@ -905,7 +911,9 @@ func convertWorkspaces(ctx context.Context, db database.Store, workspaces []data
if err != nil {
return nil, xerrors.Errorf("get templates: %w", err)
}
users, err := db.GetUsersByIDs(ctx, userIDs)
users, err := db.GetUsersByIDs(ctx, database.GetUsersByIDsParams{
IDs: userIDs,
})
if err != nil {
return nil, xerrors.Errorf("get users: %w", err)
}
+13
View File
@@ -224,6 +224,19 @@ func (c *Client) CreateUser(ctx context.Context, req CreateUserRequest) (User, e
return user, json.NewDecoder(res.Body).Decode(&user)
}
// DeleteUser deletes a user.
func (c *Client) DeleteUser(ctx context.Context, id uuid.UUID) error {
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/users/%s", id), nil)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return readBodyAsError(res)
}
return nil
}
// UpdateUserProfile enables callers to update profile information
func (c *Client) UpdateUserProfile(ctx context.Context, user string, req UpdateUserProfileRequest) (User, error) {
res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/profile", user), req)
+1
View File
@@ -84,6 +84,7 @@ var AuditableResources = auditMap(map[any]map[string]Action{
"rbac_roles": ActionTrack,
"login_type": ActionIgnore,
"avatar_url": ActionIgnore,
"deleted": ActionTrack,
},
&database.Workspace{}: {
"id": ActionTrack,
+4
View File
@@ -295,6 +295,10 @@ export const suspendUser = async (userId: TypesGen.User["id"]): Promise<TypesGen
return response.data
}
export const deleteUser = async (userId: TypesGen.User["id"]): Promise<undefined> => {
return await axios.delete(`/api/v2/users/${userId}`)
}
// API definition:
// https://github.com/coder/coder/blob/db665e7261f3c24a272ccec48233a3e276878239/coderd/users.go#L33-L53
export const hasFirstUser = async (): Promise<boolean> => {
@@ -10,6 +10,8 @@ describe("AuditPage", () => {
render(
<UsersTable
onSuspendUser={() => jest.fn()}
onDeleteUser={() => jest.fn()}
onListWorkspaces={() => jest.fn()}
onActivateUser={() => jest.fn()}
onResetUserPassword={() => jest.fn()}
onUpdateUserRoles={() => jest.fn()}
@@ -24,6 +24,8 @@ export interface UsersTableProps {
isLoading?: boolean
onSuspendUser: (user: TypesGen.User) => void
onActivateUser: (user: TypesGen.User) => void
onDeleteUser: (user: TypesGen.User) => void
onListWorkspaces: (user: TypesGen.User) => void
onResetUserPassword: (user: TypesGen.User) => void
onUpdateUserRoles: (user: TypesGen.User, roles: TypesGen.Role["name"][]) => void
}
@@ -32,6 +34,8 @@ export const UsersTable: FC<React.PropsWithChildren<UsersTableProps>> = ({
users,
roles,
onSuspendUser,
onDeleteUser,
onListWorkspaces,
onActivateUser,
onResetUserPassword,
onUpdateUserRoles,
@@ -64,6 +68,8 @@ export const UsersTable: FC<React.PropsWithChildren<UsersTableProps>> = ({
canEditUsers={canEditUsers}
isUpdatingUserRoles={isUpdatingUserRoles}
onActivateUser={onActivateUser}
onDeleteUser={onDeleteUser}
onListWorkspaces={onListWorkspaces}
onResetUserPassword={onResetUserPassword}
onSuspendUser={onSuspendUser}
onUpdateUserRoles={onUpdateUserRoles}
@@ -14,6 +14,8 @@ import { TableRowMenu } from "../TableRowMenu/TableRowMenu"
export const Language = {
emptyMessage: "No users found",
suspendMenuItem: "Suspend",
deleteMenuItem: "Delete",
listWorkspacesMenuItem: "View workspaces",
activateMenuItem: "Activate",
resetPasswordMenuItem: "Reset password",
}
@@ -25,6 +27,8 @@ interface UsersTableBodyProps {
canEditUsers?: boolean
isLoading?: boolean
onSuspendUser: (user: TypesGen.User) => void
onDeleteUser: (user: TypesGen.User) => void
onListWorkspaces: (user: TypesGen.User) => void
onActivateUser: (user: TypesGen.User) => void
onResetUserPassword: (user: TypesGen.User) => void
onUpdateUserRoles: (user: TypesGen.User, roles: TypesGen.Role["name"][]) => void
@@ -34,6 +38,8 @@ export const UsersTableBody: FC<React.PropsWithChildren<UsersTableBodyProps>> =
users,
roles,
onSuspendUser,
onDeleteUser,
onListWorkspaces,
onActivateUser,
onResetUserPassword,
onUpdateUserRoles,
@@ -130,10 +136,20 @@ export const UsersTableBody: FC<React.PropsWithChildren<UsersTableBodyProps>> =
onClick: onActivateUser,
},
]
).concat({
label: Language.resetPasswordMenuItem,
onClick: onResetUserPassword,
})
).concat(
{
label: Language.deleteMenuItem,
onClick: onDeleteUser,
},
{
label: Language.listWorkspacesMenuItem,
onClick: onListWorkspaces,
},
{
label: Language.resetPasswordMenuItem,
onClick: onResetUserPassword,
},
)
}
/>
</TableCell>
@@ -52,6 +52,38 @@ const suspendUser = async (setupActionSpies: () => void) => {
fireEvent.click(confirmButton)
}
const deleteUser = async (setupActionSpies: () => void) => {
// Get the first user in the table
const users = await screen.findAllByText(/.*@coder.com/)
const firstUserRow = users[0].closest("tr")
if (!firstUserRow) {
throw new Error("Error on get the first user row")
}
// Click on the "more" button to display the "Suspend" option
const moreButton = within(firstUserRow).getByLabelText("more")
fireEvent.click(moreButton)
const menu = await screen.findByRole("menu")
const suspendButton = within(menu).getByText(UsersTableBodyLanguage.deleteMenuItem)
fireEvent.click(suspendButton)
// Check if the confirm message is displayed
const confirmDialog = await screen.findByRole("dialog")
expect(confirmDialog).toHaveTextContent(
`${UsersPageLanguage.deleteDialogMessagePrefix} ${MockUser.username}?`,
)
// Setup spies to check the actions after
setupActionSpies()
// Click on the "Confirm" button
const confirmButton = within(confirmDialog).getByText(UsersPageLanguage.deleteDialogAction)
fireEvent.click(confirmButton)
}
const activateUser = async (setupActionSpies: () => void) => {
// Get the first user in the table
const users = await screen.findAllByText(/.*@coder.com/)
@@ -229,6 +261,57 @@ describe("UsersPage", () => {
})
})
describe("delete user", () => {
describe("when it is success", () => {
it("shows a success message and refresh the page", async () => {
render(
<>
<UsersPage />
<GlobalSnackbar />
</>,
)
await deleteUser(() => {
jest.spyOn(API, "deleteUser").mockResolvedValueOnce(undefined)
jest
.spyOn(API, "getUsers")
.mockImplementationOnce(() => Promise.resolve([MockUser, MockUser2]))
})
// Check if the success message is displayed
screen.findByText(usersXServiceLanguage.deleteUserSuccess)
// Check if the API was called correctly
expect(API.deleteUser).toBeCalledTimes(1)
expect(API.deleteUser).toBeCalledWith(MockUser.id)
// Check if the users list was reload
await waitFor(() => expect(API.getUsers).toBeCalledTimes(1))
})
})
describe("when it fails", () => {
it("shows an error message", async () => {
render(
<>
<UsersPage />
<GlobalSnackbar />
</>,
)
await deleteUser(() => {
jest.spyOn(API, "deleteUser").mockRejectedValueOnce({})
})
// Check if the error message is displayed
screen.findByText(usersXServiceLanguage.deleteUserError)
// Check if the API was called correctly
expect(API.deleteUser).toBeCalledTimes(1)
expect(API.deleteUser).toBeCalledWith(MockUser.id)
})
})
})
describe("activate user", () => {
describe("when user is successfully activated", () => {
it("shows a success message and refreshes the page", async () => {
+31
View File
@@ -11,6 +11,9 @@ import { XServiceContext } from "../../xServices/StateContext"
import { UsersPageView } from "./UsersPageView"
export const Language = {
deleteDialogTitle: "Delete user",
deleteDialogAction: "Delete",
deleteDialogMessagePrefix: "Do you want to delete the user",
suspendDialogTitle: "Suspend user",
suspendDialogAction: "Suspend",
suspendDialogMessagePrefix: "Do you want to suspend the user",
@@ -25,6 +28,7 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
const {
users,
getUsersError,
userIdToDelete,
userIdToSuspend,
userIdToActivate,
userIdToResetPassword,
@@ -33,6 +37,7 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const userToBeSuspended = users?.find((u) => u.id === userIdToSuspend)
const userToBeDeleted = users?.find((u) => u.id === userIdToDelete)
const userToBeActivated = users?.find((u) => u.id === userIdToActivate)
const userToResetPassword = users?.find((u) => u.id === userIdToResetPassword)
@@ -84,6 +89,12 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
openUserCreationDialog={() => {
navigate("/users/create")
}}
onListWorkspaces={(user) => {
navigate("/workspaces?filter=" + encodeURIComponent(`owner:${user.username}`))
}}
onDeleteUser={(user) => {
usersSend({ type: "DELETE_USER", userId: user.id })
}}
onSuspendUser={(user) => {
usersSend({ type: "SUSPEND_USER", userId: user.id })
}}
@@ -112,6 +123,26 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
}}
/>
<ConfirmDialog
type="delete"
hideCancel={false}
open={usersState.matches("confirmUserDeletion")}
confirmLoading={usersState.matches("deletingUser")}
title={Language.deleteDialogTitle}
confirmText={Language.deleteDialogAction}
onConfirm={() => {
usersSend("CONFIRM_USER_DELETE")
}}
onClose={() => {
usersSend("CANCEL_USER_DELETE")
}}
description={
<>
{Language.deleteDialogMessagePrefix} <strong>{userToBeDeleted?.username}</strong>?
</>
}
/>
<ConfirmDialog
type="delete"
hideCancel={false}
@@ -25,6 +25,8 @@ export interface UsersPageViewProps {
isLoading?: boolean
openUserCreationDialog: () => void
onSuspendUser: (user: TypesGen.User) => void
onDeleteUser: (user: TypesGen.User) => void
onListWorkspaces: (user: TypesGen.User) => void
onActivateUser: (user: TypesGen.User) => void
onResetUserPassword: (user: TypesGen.User) => void
onUpdateUserRoles: (user: TypesGen.User, roles: TypesGen.Role["name"][]) => void
@@ -36,6 +38,8 @@ export const UsersPageView: FC<React.PropsWithChildren<UsersPageViewProps>> = ({
roles,
openUserCreationDialog,
onSuspendUser,
onDeleteUser,
onListWorkspaces,
onActivateUser,
onResetUserPassword,
onUpdateUserRoles,
@@ -79,6 +83,8 @@ export const UsersPageView: FC<React.PropsWithChildren<UsersPageViewProps>> = ({
users={users}
roles={roles}
onSuspendUser={onSuspendUser}
onDeleteUser={onDeleteUser}
onListWorkspaces={onListWorkspaces}
onActivateUser={onActivateUser}
onResetUserPassword={onResetUserPassword}
onUpdateUserRoles={onUpdateUserRoles}
+59
View File
@@ -19,6 +19,8 @@ export const Language = {
createUserError: "Error on creating the user.",
suspendUserSuccess: "Successfully suspended the user.",
suspendUserError: "Error suspending user.",
deleteUserSuccess: "Successfully deleted the user.",
deleteUserError: "Error deleting user.",
activateUserSuccess: "Successfully activated the user.",
activateUserError: "Error activating user.",
resetUserPasswordSuccess: "Successfully updated the user password.",
@@ -37,6 +39,9 @@ export interface UsersContext {
// Suspend user
userIdToSuspend?: TypesGen.User["id"]
suspendUserError?: Error | unknown
// Delete user
userIdToDelete?: TypesGen.User["id"]
deleteUserError?: Error | unknown
// Activate user
userIdToActivate?: TypesGen.User["id"]
activateUserError?: Error | unknown
@@ -57,6 +62,10 @@ export type UsersEvent =
| { type: "SUSPEND_USER"; userId: TypesGen.User["id"] }
| { type: "CONFIRM_USER_SUSPENSION" }
| { type: "CANCEL_USER_SUSPENSION" }
// Delete events
| { type: "DELETE_USER"; userId: TypesGen.User["id"] }
| { type: "CONFIRM_USER_DELETE" }
| { type: "CANCEL_USER_DELETE" }
// Activate events
| { type: "ACTIVATE_USER"; userId: TypesGen.User["id"] }
| { type: "CONFIRM_USER_ACTIVATION" }
@@ -84,6 +93,9 @@ export const usersMachine = createMachine(
suspendUser: {
data: TypesGen.User
}
deleteUser: {
data: undefined
}
activateUser: {
data: TypesGen.User
}
@@ -110,6 +122,10 @@ export const usersMachine = createMachine(
target: "confirmUserSuspension",
actions: ["assignUserIdToSuspend"],
},
DELETE_USER: {
target: "confirmUserDeletion",
actions: ["assignUserIdToDelete"],
},
ACTIVATE_USER: {
target: "confirmUserActivation",
actions: ["assignUserIdToActivate"],
@@ -173,6 +189,12 @@ export const usersMachine = createMachine(
CANCEL_USER_SUSPENSION: "idle",
},
},
confirmUserDeletion: {
on: {
CONFIRM_USER_DELETE: "deletingUser",
CANCEL_USER_DELETE: "idle",
},
},
confirmUserActivation: {
on: {
CONFIRM_USER_ACTIVATION: "activatingUser",
@@ -195,6 +217,21 @@ export const usersMachine = createMachine(
},
},
},
deletingUser: {
entry: "clearDeleteUserError",
invoke: {
src: "deleteUser",
id: "deleteUser",
onDone: {
target: "gettingUsers",
actions: ["displayDeleteSuccess"],
},
onError: {
target: "idle",
actions: ["assignDeleteUserError", "displayDeleteErrorMessage"],
},
},
},
activatingUser: {
entry: "clearActivateUserError",
invoke: {
@@ -271,6 +308,12 @@ export const usersMachine = createMachine(
return API.suspendUser(context.userIdToSuspend)
},
deleteUser: (context) => {
if (!context.userIdToDelete) {
throw new Error("userIdToDelete is undefined")
}
return API.deleteUser(context.userIdToDelete)
},
activateUser: (context) => {
if (!context.userIdToActivate) {
throw new Error("userIdToActivate is undefined")
@@ -316,6 +359,9 @@ export const usersMachine = createMachine(
assignUserIdToSuspend: assign({
userIdToSuspend: (_, event) => event.userId,
}),
assignUserIdToDelete: assign({
userIdToDelete: (_, event) => event.userId,
}),
assignUserIdToActivate: assign({
userIdToActivate: (_, event) => event.userId,
}),
@@ -340,6 +386,9 @@ export const usersMachine = createMachine(
assignSuspendUserError: assign({
suspendUserError: (_, event) => event.data,
}),
assignDeleteUserError: assign({
deleteUserError: (_, event) => event.data,
}),
assignActivateUserError: assign({
activateUserError: (_, event) => event.data,
}),
@@ -361,6 +410,9 @@ export const usersMachine = createMachine(
clearSuspendUserError: assign({
suspendUserError: (_) => undefined,
}),
clearDeleteUserError: assign({
deleteUserError: (_) => undefined,
}),
clearActivateUserError: assign({
activateUserError: (_) => undefined,
}),
@@ -384,6 +436,13 @@ export const usersMachine = createMachine(
const message = getErrorMessage(context.suspendUserError, Language.suspendUserError)
displayError(message)
},
displayDeleteSuccess: () => {
displaySuccess(Language.deleteUserSuccess)
},
displayDeleteErrorMessage: (context) => {
const message = getErrorMessage(context.deleteUserError, Language.deleteUserError)
displayError(message)
},
displayActivateSuccess: () => {
displaySuccess(Language.activateUserSuccess)
},