Skip to content

Commit a6a06d4

Browse files
authored
feat: ability to activate suspended users (#2344)
* add ability to activate users resolves #2254 * added test * PR feedback
1 parent d48ab96 commit a6a06d4

File tree

7 files changed

+190
-11
lines changed

7 files changed

+190
-11
lines changed

site/src/components/UsersTable/UsersTable.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export interface UsersTableProps {
3333
canEditUsers?: boolean
3434
isLoading?: boolean
3535
onSuspendUser: (user: TypesGen.User) => void
36+
onActivateUser: (user: TypesGen.User) => void
3637
onResetUserPassword: (user: TypesGen.User) => void
3738
onUpdateUserRoles: (user: TypesGen.User, roles: TypesGen.Role["name"][]) => void
3839
}
@@ -41,6 +42,7 @@ export const UsersTable: FC<UsersTableProps> = ({
4142
users,
4243
roles,
4344
onSuspendUser,
45+
onActivateUser,
4446
onResetUserPassword,
4547
onUpdateUserRoles,
4648
isUpdatingUserRoles,
@@ -115,12 +117,10 @@ export const UsersTable: FC<UsersTableProps> = ({
115117
},
116118
]
117119
: [
118-
// TODO: Uncomment this and add activate user functionality.
119-
// {
120-
// label: Language.activateMenuItem,
121-
// // eslint-disable-next-line @typescript-eslint/no-empty-function
122-
// onClick: function () {},
123-
// },
120+
{
121+
label: Language.activateMenuItem,
122+
onClick: onActivateUser,
123+
},
124124
]
125125
).concat({
126126
label: Language.resetPasswordMenuItem,

site/src/pages/UsersPage/UsersPage.test.tsx

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { GlobalSnackbar } from "../../components/GlobalSnackbar/GlobalSnackbar"
66
import { Language as ResetPasswordDialogLanguage } from "../../components/ResetPasswordDialog/ResetPasswordDialog"
77
import { Language as RoleSelectLanguage } from "../../components/RoleSelect/RoleSelect"
88
import { Language as UsersTableLanguage } from "../../components/UsersTable/UsersTable"
9-
import { MockAuditorRole, MockUser, MockUser2, render } from "../../testHelpers/renderHelpers"
9+
import { MockAuditorRole, MockUser, MockUser2, render, SuspendedMockUser } from "../../testHelpers/renderHelpers"
1010
import { server } from "../../testHelpers/server"
1111
import { permissionsToCheck } from "../../xServices/auth/authXService"
1212
import { Language as usersXServiceLanguage } from "../../xServices/users/usersXService"
@@ -40,6 +40,35 @@ const suspendUser = async (setupActionSpies: () => void) => {
4040
fireEvent.click(confirmButton)
4141
}
4242

43+
const activateUser = async (setupActionSpies: () => void) => {
44+
// Get the first user in the table
45+
const users = await screen.findAllByText(/.*@coder.com/)
46+
const firstUserRow = users[2].closest("tr")
47+
if (!firstUserRow) {
48+
throw new Error("Error on get the first user row")
49+
}
50+
51+
// Click on the "more" button to display the "Activate" option
52+
const moreButton = within(firstUserRow).getByLabelText("more")
53+
fireEvent.click(moreButton)
54+
const menu = screen.getByRole("menu")
55+
const activateButton = within(menu).getByText(UsersTableLanguage.activateMenuItem)
56+
fireEvent.click(activateButton)
57+
58+
// Check if the confirm message is displayed
59+
const confirmDialog = screen.getByRole("dialog")
60+
expect(confirmDialog).toHaveTextContent(
61+
`${UsersPageLanguage.activateDialogMessagePrefix} ${SuspendedMockUser.username}?`,
62+
)
63+
64+
// Setup spies to check the actions after
65+
setupActionSpies()
66+
67+
// Click on the "Confirm" button
68+
const confirmButton = within(confirmDialog).getByText(UsersPageLanguage.activateDialogAction)
69+
fireEvent.click(confirmButton)
70+
}
71+
4372
const resetUserPassword = async (setupActionSpies: () => void) => {
4473
// Get the first user in the table
4574
const users = await screen.findAllByText(/.*@coder.com/)
@@ -99,7 +128,7 @@ describe("Users Page", () => {
99128
it("shows users", async () => {
100129
render(<UsersPage />)
101130
const users = await screen.findAllByText(/.*@coder.com/)
102-
expect(users.length).toEqual(2)
131+
expect(users.length).toEqual(3)
103132
})
104133

105134
it("shows 'Create user' button to an authorized user", () => {
@@ -178,6 +207,54 @@ describe("Users Page", () => {
178207
})
179208
})
180209

210+
describe("activate user", () => {
211+
describe("when user is successfully activated", () => {
212+
it("shows a success message and refreshes the page", async () => {
213+
render(
214+
<>
215+
<UsersPage />
216+
<GlobalSnackbar />
217+
</>,
218+
)
219+
220+
await activateUser(() => {
221+
jest.spyOn(API, "activateUser").mockResolvedValueOnce(SuspendedMockUser)
222+
jest
223+
.spyOn(API, "getUsers")
224+
.mockImplementationOnce(() => Promise.resolve([MockUser, MockUser2, SuspendedMockUser]))
225+
})
226+
227+
// Check if the success message is displayed
228+
await screen.findByText(usersXServiceLanguage.activateUserSuccess)
229+
230+
// Check if the API was called correctly
231+
expect(API.activateUser).toBeCalledTimes(1)
232+
expect(API.activateUser).toBeCalledWith(SuspendedMockUser.id)
233+
})
234+
})
235+
describe("when activation fails", () => {
236+
it("shows an error message", async () => {
237+
render(
238+
<>
239+
<UsersPage />
240+
<GlobalSnackbar />
241+
</>,
242+
)
243+
244+
await activateUser(() => {
245+
jest.spyOn(API, "activateUser").mockRejectedValueOnce({})
246+
})
247+
248+
// Check if the error message is displayed
249+
await screen.findByText(usersXServiceLanguage.activateUserError)
250+
251+
// Check if the API was called correctly
252+
expect(API.activateUser).toBeCalledTimes(1)
253+
expect(API.activateUser).toBeCalledWith(SuspendedMockUser.id)
254+
})
255+
})
256+
})
257+
181258
describe("reset user password", () => {
182259
describe("when it is success", () => {
183260
it("shows a success message", async () => {

site/src/pages/UsersPage/UsersPage.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,20 @@ export const Language = {
1313
suspendDialogTitle: "Suspend user",
1414
suspendDialogAction: "Suspend",
1515
suspendDialogMessagePrefix: "Do you want to suspend the user",
16+
activateDialogTitle: "Activate user",
17+
activateDialogAction: "Activate",
18+
activateDialogMessagePrefix: "Do you want to activate the user",
1619
}
1720

1821
export const UsersPage: React.FC = () => {
1922
const xServices = useContext(XServiceContext)
2023
const [usersState, usersSend] = useActor(xServices.usersXService)
2124
const [rolesState, rolesSend] = useActor(xServices.siteRolesXService)
22-
const { users, getUsersError, userIdToSuspend, userIdToResetPassword, newUserPassword } = usersState.context
25+
const { users, getUsersError, userIdToSuspend, userIdToActivate, userIdToResetPassword, newUserPassword } =
26+
usersState.context
2327
const navigate = useNavigate()
2428
const userToBeSuspended = users?.find((u) => u.id === userIdToSuspend)
29+
const userToBeActivated = users?.find((u) => u.id === userIdToActivate)
2530
const userToResetPassword = users?.find((u) => u.id === userIdToResetPassword)
2631
const permissions = useSelector(xServices.authXService, selectPermissions)
2732
const canEditUsers = permissions && permissions.updateUsers
@@ -62,6 +67,9 @@ export const UsersPage: React.FC = () => {
6267
onSuspendUser={(user) => {
6368
usersSend({ type: "SUSPEND_USER", userId: user.id })
6469
}}
70+
onActivateUser={(user) => {
71+
usersSend({ type: "ACTIVATE_USER", userId: user.id })
72+
}}
6573
onResetUserPassword={(user) => {
6674
usersSend({ type: "RESET_USER_PASSWORD", userId: user.id })
6775
}}
@@ -99,6 +107,26 @@ export const UsersPage: React.FC = () => {
99107
}
100108
/>
101109

110+
<ConfirmDialog
111+
type="success"
112+
hideCancel={false}
113+
open={usersState.matches("confirmUserActivation")}
114+
confirmLoading={usersState.matches("activatingUser")}
115+
title={Language.activateDialogTitle}
116+
confirmText={Language.activateDialogAction}
117+
onConfirm={() => {
118+
usersSend("CONFIRM_USER_ACTIVATION")
119+
}}
120+
onClose={() => {
121+
usersSend("CANCEL_USER_ACTIVATION")
122+
}}
123+
description={
124+
<>
125+
{Language.activateDialogMessagePrefix} <strong>{userToBeActivated?.username}</strong>?
126+
</>
127+
}
128+
/>
129+
102130
<ResetPasswordDialog
103131
loading={usersState.matches("resettingUserPassword")}
104132
user={userToResetPassword}

site/src/pages/UsersPage/UsersPageView.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface UsersPageViewProps {
2222
isLoading?: boolean
2323
openUserCreationDialog: () => void
2424
onSuspendUser: (user: TypesGen.User) => void
25+
onActivateUser: (user: TypesGen.User) => void
2526
onResetUserPassword: (user: TypesGen.User) => void
2627
onUpdateUserRoles: (user: TypesGen.User, roles: TypesGen.Role["name"][]) => void
2728
}
@@ -31,6 +32,7 @@ export const UsersPageView: FC<UsersPageViewProps> = ({
3132
roles,
3233
openUserCreationDialog,
3334
onSuspendUser,
35+
onActivateUser,
3436
onResetUserPassword,
3537
onUpdateUserRoles,
3638
error,
@@ -60,6 +62,7 @@ export const UsersPageView: FC<UsersPageViewProps> = ({
6062
users={users}
6163
roles={roles}
6264
onSuspendUser={onSuspendUser}
65+
onActivateUser={onActivateUser}
6366
onResetUserPassword={onResetUserPassword}
6467
onUpdateUserRoles={onUpdateUserRoles}
6568
isUpdatingUserRoles={isUpdatingUserRoles}

site/src/testHelpers/entities.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,16 @@ export const MockUser2: TypesGen.User = {
5151
roles: [],
5252
}
5353

54+
export const SuspendedMockUser: TypesGen.User = {
55+
id: "suspended-mock-user",
56+
username: "SuspendedMockUser",
57+
email: "iamsuspendedsad!@coder.com",
58+
created_at: "",
59+
status: "suspended",
60+
organization_ids: ["fc0774ce-cc9e-48d4-80ae-88f7a4d4a8b0"],
61+
roles: [],
62+
}
63+
5464
export const MockOrganization: TypesGen.Organization = {
5565
id: "test-org",
5666
name: "Test Organization",

site/src/testHelpers/handlers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export const handlers = [
3737

3838
// users
3939
rest.get("/api/v2/users", async (req, res, ctx) => {
40-
return res(ctx.status(200), ctx.json([M.MockUser, M.MockUser2]))
40+
return res(ctx.status(200), ctx.json([M.MockUser, M.MockUser2, M.SuspendedMockUser]))
4141
}),
4242
rest.post("/api/v2/users", async (req, res, ctx) => {
4343
return res(ctx.status(200), ctx.json(M.MockUser))

site/src/xServices/users/usersXService.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ export const Language = {
1616
createUserSuccess: "Successfully created user.",
1717
createUserError: "Error on creating the user.",
1818
suspendUserSuccess: "Successfully suspended the user.",
19-
suspendUserError: "Error on suspending the user.",
19+
suspendUserError: "Error suspending user.",
20+
activateUserSuccess: "Successfully activated the user.",
21+
activateUserError: "Error activating user.",
2022
resetUserPasswordSuccess: "Successfully updated the user password.",
2123
resetUserPasswordError: "Error on resetting the user password.",
2224
updateUserRolesSuccess: "Successfully updated the user roles.",
@@ -32,6 +34,9 @@ export interface UsersContext {
3234
// Suspend user
3335
userIdToSuspend?: TypesGen.User["id"]
3436
suspendUserError?: Error | unknown
37+
// Activate user
38+
userIdToActivate?: TypesGen.User["id"]
39+
activateUserError?: Error | unknown
3540
// Reset user password
3641
userIdToResetPassword?: TypesGen.User["id"]
3742
resetUserPasswordError?: Error | unknown
@@ -49,6 +54,10 @@ export type UsersEvent =
4954
| { type: "SUSPEND_USER"; userId: TypesGen.User["id"] }
5055
| { type: "CONFIRM_USER_SUSPENSION" }
5156
| { type: "CANCEL_USER_SUSPENSION" }
57+
// Activate events
58+
| { type: "ACTIVATE_USER"; userId: TypesGen.User["id"] }
59+
| { type: "CONFIRM_USER_ACTIVATION" }
60+
| { type: "CANCEL_USER_ACTIVATION" }
5261
// Reset password events
5362
| { type: "RESET_USER_PASSWORD"; userId: TypesGen.User["id"] }
5463
| { type: "CONFIRM_USER_PASSWORD_RESET" }
@@ -72,6 +81,9 @@ export const usersMachine = createMachine(
7281
suspendUser: {
7382
data: TypesGen.User
7483
}
84+
activateUser: {
85+
data: TypesGen.User
86+
}
7587
updateUserPassword: {
7688
data: undefined
7789
}
@@ -92,6 +104,10 @@ export const usersMachine = createMachine(
92104
target: "confirmUserSuspension",
93105
actions: ["assignUserIdToSuspend"],
94106
},
107+
ACTIVATE_USER: {
108+
target: "confirmUserActivation",
109+
actions: ["assignUserIdToActivate"],
110+
},
95111
RESET_USER_PASSWORD: {
96112
target: "confirmUserPasswordReset",
97113
actions: ["assignUserIdToResetPassword", "generateRandomPassword"],
@@ -150,6 +166,12 @@ export const usersMachine = createMachine(
150166
CANCEL_USER_SUSPENSION: "idle",
151167
},
152168
},
169+
confirmUserActivation: {
170+
on: {
171+
CONFIRM_USER_ACTIVATION: "activatingUser",
172+
CANCEL_USER_ACTIVATION: "idle",
173+
},
174+
},
153175
suspendingUser: {
154176
entry: "clearSuspendUserError",
155177
invoke: {
@@ -166,6 +188,22 @@ export const usersMachine = createMachine(
166188
},
167189
},
168190
},
191+
activatingUser: {
192+
entry: "clearActivateUserError",
193+
invoke: {
194+
src: "activateUser",
195+
id: "activateUser",
196+
onDone: {
197+
// Update users list
198+
target: "gettingUsers",
199+
actions: ["displayActivateSuccess"],
200+
},
201+
onError: {
202+
target: "idle",
203+
actions: ["assignActivateUserError", "displayActivatedErrorMessage"],
204+
},
205+
},
206+
},
169207
confirmUserPasswordReset: {
170208
on: {
171209
CONFIRM_USER_PASSWORD_RESET: "resettingUserPassword",
@@ -223,6 +261,13 @@ export const usersMachine = createMachine(
223261

224262
return API.suspendUser(context.userIdToSuspend)
225263
},
264+
activateUser: (context) => {
265+
if (!context.userIdToActivate) {
266+
throw new Error("userIdToActivate is undefined")
267+
}
268+
269+
return API.activateUser(context.userIdToActivate)
270+
},
226271
resetUserPassword: (context) => {
227272
if (!context.userIdToResetPassword) {
228273
throw new Error("userIdToResetPassword is undefined")
@@ -258,6 +303,9 @@ export const usersMachine = createMachine(
258303
assignUserIdToSuspend: assign({
259304
userIdToSuspend: (_, event) => event.userId,
260305
}),
306+
assignUserIdToActivate: assign({
307+
userIdToActivate: (_, event) => event.userId,
308+
}),
261309
assignUserIdToResetPassword: assign({
262310
userIdToResetPassword: (_, event) => event.userId,
263311
}),
@@ -278,6 +326,9 @@ export const usersMachine = createMachine(
278326
assignSuspendUserError: assign({
279327
suspendUserError: (_, event) => event.data,
280328
}),
329+
assignActivateUserError: assign({
330+
activateUserError: (_, event) => event.data,
331+
}),
281332
assignResetUserPasswordError: assign({
282333
resetUserPasswordError: (_, event) => event.data,
283334
}),
@@ -292,6 +343,9 @@ export const usersMachine = createMachine(
292343
clearSuspendUserError: assign({
293344
suspendUserError: (_) => undefined,
294345
}),
346+
clearActivateUserError: assign({
347+
activateUserError: (_) => undefined,
348+
}),
295349
clearResetUserPasswordError: assign({
296350
resetUserPasswordError: (_) => undefined,
297351
}),
@@ -308,6 +362,13 @@ export const usersMachine = createMachine(
308362
const message = getErrorMessage(context.suspendUserError, Language.suspendUserError)
309363
displayError(message)
310364
},
365+
displayActivateSuccess: () => {
366+
displaySuccess(Language.activateUserSuccess)
367+
},
368+
displayActivatedErrorMessage: (context) => {
369+
const message = getErrorMessage(context.activateUserError, Language.activateUserError)
370+
displayError(message)
371+
},
311372
displayResetPasswordSuccess: () => {
312373
displaySuccess(Language.resetUserPasswordSuccess)
313374
},

0 commit comments

Comments
 (0)