Skip to content

Commit cf5aca7

Browse files
Add reset user password action (#1320)
1 parent 57bb108 commit cf5aca7

File tree

12 files changed

+313
-12
lines changed

12 files changed

+313
-12
lines changed

coderd/users.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -361,10 +361,10 @@ func (api *api) putUserSuspend(rw http.ResponseWriter, r *http.Request) {
361361
}
362362

363363
func (api *api) putUserPassword(rw http.ResponseWriter, r *http.Request) {
364-
var (
365-
user = httpmw.UserParam(r)
366-
params codersdk.UpdateUserPasswordRequest
367-
)
364+
var (
365+
user = httpmw.UserParam(r)
366+
params codersdk.UpdateUserPasswordRequest
367+
)
368368
if !httpapi.Read(rw, r, &params) {
369369
return
370370
}

site/jest.setup.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
import "@testing-library/jest-dom"
2+
import crypto from "crypto"
23
import { server } from "./src/testHelpers/server"
34

5+
// Polyfill the getRandomValues that is used on utils/random.ts
6+
Object.defineProperty(global.self, "crypto", {
7+
value: {
8+
getRandomValues: function (buffer: Buffer) {
9+
return crypto.randomFillSync(buffer)
10+
},
11+
},
12+
})
13+
414
// Establish API mocking before all tests through MSW.
515
beforeAll(() =>
616
server.listen({

site/src/api/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,3 +155,6 @@ export const suspendUser = async (userId: TypesGen.User["id"]): Promise<TypesGen
155155
const response = await axios.put<TypesGen.User>(`/api/v2/users/${userId}/suspend`)
156156
return response.data
157157
}
158+
159+
export const updateUserPassword = async (password: string, userId: TypesGen.User["id"]): Promise<undefined> =>
160+
axios.put(`/api/v2/users/${userId}/password`, { password })

site/src/components/CodeBlock/CodeBlock.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import { makeStyles } from "@material-ui/core/styles"
22
import React from "react"
33
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
4+
import { combineClasses } from "../../util/combineClasses"
45

56
export interface CodeBlockProps {
67
lines: string[]
8+
className?: string
79
}
810

9-
export const CodeBlock: React.FC<CodeBlockProps> = ({ lines }) => {
11+
export const CodeBlock: React.FC<CodeBlockProps> = ({ lines, className = "" }) => {
1012
const styles = useStyles()
1113

1214
return (
13-
<div className={styles.root}>
15+
<div className={combineClasses([styles.root, className])}>
1416
{lines.map((line, idx) => (
1517
<div className={styles.line} key={idx}>
1618
{line}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Story } from "@storybook/react"
2+
import React from "react"
3+
import { MockUser } from "../../testHelpers"
4+
import { generateRandomString } from "../../util/random"
5+
import { ResetPasswordDialog, ResetPasswordDialogProps } from "./ResetPasswordDialog"
6+
7+
export default {
8+
title: "components/ResetPasswordDialog",
9+
component: ResetPasswordDialog,
10+
argTypes: {
11+
onClose: { action: "onClose" },
12+
onConfirm: { action: "onConfirm" },
13+
},
14+
}
15+
16+
const Template: Story<ResetPasswordDialogProps> = (args: ResetPasswordDialogProps) => <ResetPasswordDialog {...args} />
17+
18+
export const Example = Template.bind({})
19+
Example.args = {
20+
open: true,
21+
user: MockUser,
22+
newPassword: generateRandomString(12),
23+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import DialogActions from "@material-ui/core/DialogActions"
2+
import DialogContent from "@material-ui/core/DialogContent"
3+
import DialogContentText from "@material-ui/core/DialogContentText"
4+
import { makeStyles } from "@material-ui/core/styles"
5+
import React from "react"
6+
import * as TypesGen from "../../api/typesGenerated"
7+
import { CodeBlock } from "../CodeBlock/CodeBlock"
8+
import { Dialog, DialogActionButtons, DialogTitle } from "../Dialog/Dialog"
9+
10+
export interface ResetPasswordDialogProps {
11+
open: boolean
12+
onClose: () => void
13+
onConfirm: () => void
14+
user?: TypesGen.User
15+
newPassword?: string
16+
loading: boolean
17+
}
18+
19+
export const Language = {
20+
title: "Reset password",
21+
message: (username?: string): JSX.Element => (
22+
<>
23+
You will need to send <strong>{username}</strong> the following password:
24+
</>
25+
),
26+
confirmText: "Reset password",
27+
}
28+
29+
export const ResetPasswordDialog: React.FC<ResetPasswordDialogProps> = ({
30+
open,
31+
onClose,
32+
onConfirm,
33+
user,
34+
newPassword,
35+
loading,
36+
}) => {
37+
const styles = useStyles()
38+
39+
return (
40+
<Dialog open={open} onClose={onClose}>
41+
<DialogTitle title={Language.title} />
42+
43+
<DialogContent>
44+
<DialogContentText variant="subtitle2">{Language.message(user?.username)}</DialogContentText>
45+
46+
<DialogContentText component="div">
47+
<CodeBlock lines={[newPassword ?? ""]} className={styles.codeBlock} />
48+
</DialogContentText>
49+
</DialogContent>
50+
51+
<DialogActions>
52+
<DialogActionButtons
53+
onCancel={onClose}
54+
confirmText={Language.confirmText}
55+
onConfirm={onConfirm}
56+
confirmLoading={loading}
57+
/>
58+
</DialogActions>
59+
</Dialog>
60+
)
61+
}
62+
63+
const useStyles = makeStyles(() => ({
64+
codeBlock: {
65+
minHeight: "auto",
66+
userSelect: "all",
67+
width: "100%",
68+
},
69+
}))

site/src/components/UsersTable/UsersTable.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const Language = {
1111
emptyMessage: "No users found",
1212
usernameLabel: "User",
1313
suspendMenuItem: "Suspend",
14+
resetPasswordMenuItem: "Reset password",
1415
}
1516

1617
const emptyState = <EmptyState message={Language.emptyMessage} />
@@ -28,9 +29,10 @@ const columns: Column<UserResponse>[] = [
2829
export interface UsersTableProps {
2930
users: UserResponse[]
3031
onSuspendUser: (user: UserResponse) => void
32+
onResetUserPassword: (user: UserResponse) => void
3133
}
3234

33-
export const UsersTable: React.FC<UsersTableProps> = ({ users, onSuspendUser }) => {
35+
export const UsersTable: React.FC<UsersTableProps> = ({ users, onSuspendUser, onResetUserPassword }) => {
3436
return (
3537
<Table
3638
columns={columns}
@@ -45,6 +47,10 @@ export const UsersTable: React.FC<UsersTableProps> = ({ users, onSuspendUser })
4547
label: Language.suspendMenuItem,
4648
onClick: onSuspendUser,
4749
},
50+
{
51+
label: Language.resetPasswordMenuItem,
52+
onClick: onResetUserPassword,
53+
},
4854
]}
4955
/>
5056
)}

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

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { fireEvent, screen, waitFor, within } from "@testing-library/react"
22
import React from "react"
33
import * as API from "../../api"
44
import { GlobalSnackbar } from "../../components/GlobalSnackbar/GlobalSnackbar"
5+
import { Language as ResetPasswordDialogLanguage } from "../../components/ResetPasswordDialog/ResetPasswordDialog"
56
import { Language as UsersTableLanguage } from "../../components/UsersTable/UsersTable"
67
import { MockUser, MockUser2, render } from "../../testHelpers"
78
import { Language as usersXServiceLanguage } from "../../xServices/users/usersXService"
@@ -34,6 +35,33 @@ const suspendUser = async (setupActionSpies: () => void) => {
3435
fireEvent.click(confirmButton)
3536
}
3637

38+
const resetUserPassword = async (setupActionSpies: () => void) => {
39+
// Get the first user in the table
40+
const users = await screen.findAllByText(/.*@coder.com/)
41+
const firstUserRow = users[0].closest("tr")
42+
if (!firstUserRow) {
43+
throw new Error("Error on get the first user row")
44+
}
45+
46+
// Click on the "more" button to display the "Suspend" option
47+
const moreButton = within(firstUserRow).getByLabelText("more")
48+
fireEvent.click(moreButton)
49+
const menu = screen.getByRole("menu")
50+
const resetPasswordButton = within(menu).getByText(UsersTableLanguage.resetPasswordMenuItem)
51+
fireEvent.click(resetPasswordButton)
52+
53+
// Check if the confirm message is displayed
54+
const confirmDialog = screen.getByRole("dialog")
55+
expect(confirmDialog).toHaveTextContent(`You will need to send ${MockUser.username} the following password:`)
56+
57+
// Setup spies to check the actions after
58+
setupActionSpies()
59+
60+
// Click on the "Confirm" button
61+
const confirmButton = within(confirmDialog).getByRole("button", { name: ResetPasswordDialogLanguage.confirmText })
62+
fireEvent.click(confirmButton)
63+
}
64+
3765
describe("Users Page", () => {
3866
it("shows users", async () => {
3967
render(<UsersPage />)
@@ -81,7 +109,7 @@ describe("Users Page", () => {
81109
jest.spyOn(API, "suspendUser").mockRejectedValueOnce({})
82110
})
83111

84-
// Check if the success message is displayed
112+
// Check if the error message is displayed
85113
await screen.findByText(usersXServiceLanguage.suspendUserError)
86114

87115
// Check if the API was called correctly
@@ -90,4 +118,50 @@ describe("Users Page", () => {
90118
})
91119
})
92120
})
121+
122+
describe("reset user password", () => {
123+
describe("when it is success", () => {
124+
it("shows a success message", async () => {
125+
render(
126+
<>
127+
<UsersPage />
128+
<GlobalSnackbar />
129+
</>,
130+
)
131+
132+
await resetUserPassword(() => {
133+
jest.spyOn(API, "updateUserPassword").mockResolvedValueOnce(undefined)
134+
})
135+
136+
// Check if the success message is displayed
137+
await screen.findByText(usersXServiceLanguage.resetUserPasswordSuccess)
138+
139+
// Check if the API was called correctly
140+
expect(API.updateUserPassword).toBeCalledTimes(1)
141+
expect(API.updateUserPassword).toBeCalledWith(expect.any(String), MockUser.id)
142+
})
143+
})
144+
145+
describe("when it fails", () => {
146+
it("shows an error message", async () => {
147+
render(
148+
<>
149+
<UsersPage />
150+
<GlobalSnackbar />
151+
</>,
152+
)
153+
154+
await resetUserPassword(() => {
155+
jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce({})
156+
})
157+
158+
// Check if the error message is displayed
159+
await screen.findByText(usersXServiceLanguage.resetUserPasswordError)
160+
161+
// Check if the API was called correctly
162+
expect(API.updateUserPassword).toBeCalledTimes(1)
163+
expect(API.updateUserPassword).toBeCalledWith(expect.any(String), MockUser.id)
164+
})
165+
})
166+
})
93167
})

site/src/pages/UsersPage/UsersPage.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import React, { useContext, useEffect } from "react"
33
import { useNavigate } from "react-router"
44
import { ConfirmDialog } from "../../components/ConfirmDialog/ConfirmDialog"
55
import { FullScreenLoader } from "../../components/Loader/FullScreenLoader"
6+
import { ResetPasswordDialog } from "../../components/ResetPasswordDialog/ResetPasswordDialog"
67
import { XServiceContext } from "../../xServices/StateContext"
78
import { UsersPageView } from "./UsersPageView"
89

@@ -15,9 +16,10 @@ export const Language = {
1516
export const UsersPage: React.FC = () => {
1617
const xServices = useContext(XServiceContext)
1718
const [usersState, usersSend] = useActor(xServices.usersXService)
18-
const { users, getUsersError, userIdToSuspend } = usersState.context
19+
const { users, getUsersError, userIdToSuspend, userIdToResetPassword, newUserPassword } = usersState.context
1920
const navigate = useNavigate()
2021
const userToBeSuspended = users?.find((u) => u.id === userIdToSuspend)
22+
const userToResetPassword = users?.find((u) => u.id === userIdToResetPassword)
2123

2224
/**
2325
* Fetch users on component mount
@@ -39,6 +41,9 @@ export const UsersPage: React.FC = () => {
3941
onSuspendUser={(user) => {
4042
usersSend({ type: "SUSPEND_USER", userId: user.id })
4143
}}
44+
onResetUserPassword={(user) => {
45+
usersSend({ type: "RESET_USER_PASSWORD", userId: user.id })
46+
}}
4247
error={getUsersError}
4348
/>
4449

@@ -61,6 +66,19 @@ export const UsersPage: React.FC = () => {
6166
</>
6267
}
6368
/>
69+
70+
<ResetPasswordDialog
71+
loading={usersState.matches("resettingUserPassword")}
72+
user={userToResetPassword}
73+
newPassword={newUserPassword}
74+
open={usersState.matches("confirmUserPasswordReset")}
75+
onClose={() => {
76+
usersSend("CANCEL_USER_PASSWORD_RESET")
77+
}}
78+
onConfirm={() => {
79+
usersSend("CONFIRM_USER_PASSWORD_RESET")
80+
}}
81+
/>
6482
</>
6583
)
6684
}

site/src/pages/UsersPage/UsersPageView.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,26 @@ export interface UsersPageViewProps {
1515
users: UserResponse[]
1616
openUserCreationDialog: () => void
1717
onSuspendUser: (user: UserResponse) => void
18+
onResetUserPassword: (user: UserResponse) => void
1819
error?: unknown
1920
}
2021

2122
export const UsersPageView: React.FC<UsersPageViewProps> = ({
2223
users,
2324
openUserCreationDialog,
2425
onSuspendUser,
26+
onResetUserPassword,
2527
error,
2628
}) => {
2729
return (
2830
<Stack spacing={4}>
2931
<Header title={Language.pageTitle} action={{ text: Language.newUserButton, onClick: openUserCreationDialog }} />
3032
<Margins>
31-
{error ? <ErrorSummary error={error} /> : <UsersTable users={users} onSuspendUser={onSuspendUser} />}
33+
{error ? (
34+
<ErrorSummary error={error} />
35+
) : (
36+
<UsersTable users={users} onSuspendUser={onSuspendUser} onResetUserPassword={onResetUserPassword} />
37+
)}
3238
</Margins>
3339
</Stack>
3440
)

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy