Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit ad1f076

Browse files
BrunoQuaresmakylecarbs
authored andcommitted
Add reset user password action (#1320)
1 parent 89b7f3f commit ad1f076

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
)

site/src/util/random.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Generate a cryptographically secure random string using the specified number
3+
* of bytes then encode with base64.
4+
*
5+
* Base64 encodes 6 bits per character and pads with = so the length will not
6+
* equal the number of randomly generated bytes.
7+
* @see <https://developer.mozilla.org/en-US/docs/Glossary/Base64#encoded_size_increase>
8+
*/
9+
export const generateRandomString = (bytes: number): string => {
10+
const byteArr = window.crypto.getRandomValues(new Uint8Array(bytes))
11+
// The types for `map` don't seem to support mapping from one array type to
12+
// another and `String.fromCharCode.apply` wants `number[]` so loop like this
13+
// instead.
14+
const strArr: string[] = []
15+
for (const byte of byteArr) {
16+
strArr.push(String.fromCharCode(byte))
17+
}
18+
return btoa(strArr.join(""))
19+
}

0 commit comments

Comments
 (0)