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

Skip to content

Add reset user password action #1320

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
May 6, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions coderd/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -361,10 +361,10 @@ func (api *api) putUserSuspend(rw http.ResponseWriter, r *http.Request) {
}

func (api *api) putUserPassword(rw http.ResponseWriter, r *http.Request) {
var (
user = httpmw.UserParam(r)
params codersdk.UpdateUserPasswordRequest
)
var (
user = httpmw.UserParam(r)
params codersdk.UpdateUserPasswordRequest
)
if !httpapi.Read(rw, r, &params) {
return
}
Expand Down
10 changes: 10 additions & 0 deletions site/jest.setup.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import "@testing-library/jest-dom"
import crypto from "crypto"
import { server } from "./src/testHelpers/server"

// Polyfill the getRandomValues that is used on utils/random.ts
Object.defineProperty(global.self, "crypto", {
value: {
getRandomValues: function (buffer: Buffer) {
return crypto.randomFillSync(buffer)
},
},
})

// Establish API mocking before all tests through MSW.
beforeAll(() =>
server.listen({
Expand Down
3 changes: 3 additions & 0 deletions site/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,6 @@ export const suspendUser = async (userId: TypesGen.User["id"]): Promise<TypesGen
const response = await axios.put<TypesGen.User>(`/api/v2/users/${userId}/suspend`)
return response.data
}

export const updateUserPassword = async (password: string, userId: TypesGen.User["id"]): Promise<undefined> =>
axios.put(`/api/v2/users/${userId}/password`, { password })
6 changes: 4 additions & 2 deletions site/src/components/CodeBlock/CodeBlock.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { makeStyles } from "@material-ui/core/styles"
import React from "react"
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
import { combineClasses } from "../../util/combineClasses"

export interface CodeBlockProps {
lines: string[]
className?: string
}

export const CodeBlock: React.FC<CodeBlockProps> = ({ lines }) => {
export const CodeBlock: React.FC<CodeBlockProps> = ({ lines, className = "" }) => {
const styles = useStyles()

return (
<div className={styles.root}>
<div className={combineClasses([styles.root, className])}>
{lines.map((line, idx) => (
<div className={styles.line} key={idx}>
{line}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Story } from "@storybook/react"
import React from "react"
import { MockUser } from "../../testHelpers"
import { generateRandomString } from "../../util/random"
import { ResetPasswordDialog, ResetPasswordDialogProps } from "./ResetPasswordDialog"

export default {
title: "components/ResetPasswordDialog",
component: ResetPasswordDialog,
argTypes: {
onClose: { action: "onClose" },
onConfirm: { action: "onConfirm" },
},
}

const Template: Story<ResetPasswordDialogProps> = (args: ResetPasswordDialogProps) => <ResetPasswordDialog {...args} />

export const Example = Template.bind({})
Example.args = {
open: true,
user: MockUser,
newPassword: generateRandomString(12),
}
69 changes: 69 additions & 0 deletions site/src/components/ResetPasswordDialog/ResetPasswordDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import DialogActions from "@material-ui/core/DialogActions"
import DialogContent from "@material-ui/core/DialogContent"
import DialogContentText from "@material-ui/core/DialogContentText"
import { makeStyles } from "@material-ui/core/styles"
import React from "react"
import * as TypesGen from "../../api/typesGenerated"
import { CodeBlock } from "../CodeBlock/CodeBlock"
import { Dialog, DialogActionButtons, DialogTitle } from "../Dialog/Dialog"

export interface ResetPasswordDialogProps {
open: boolean
onClose: () => void
onConfirm: () => void
user?: TypesGen.User
newPassword?: string
loading: boolean
}

export const Language = {
title: "Reset password",
message: (username?: string): JSX.Element => (
<>
You will need to send <strong>{username}</strong> the following password:
</>
),
confirmText: "Reset password",
}

export const ResetPasswordDialog: React.FC<ResetPasswordDialogProps> = ({
open,
onClose,
onConfirm,
user,
newPassword,
loading,
}) => {
const styles = useStyles()

return (
<Dialog open={open} onClose={onClose}>
<DialogTitle title={Language.title} />

<DialogContent>
<DialogContentText variant="subtitle2">{Language.message(user?.username)}</DialogContentText>

<DialogContentText component="div">
<CodeBlock lines={[newPassword ?? ""]} className={styles.codeBlock} />
</DialogContentText>
</DialogContent>

<DialogActions>
<DialogActionButtons
onCancel={onClose}
confirmText={Language.confirmText}
onConfirm={onConfirm}
confirmLoading={loading}
/>
</DialogActions>
</Dialog>
)
}

const useStyles = makeStyles(() => ({
codeBlock: {
minHeight: "auto",
userSelect: "all",
width: "100%",
},
}))
8 changes: 7 additions & 1 deletion site/src/components/UsersTable/UsersTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const Language = {
emptyMessage: "No users found",
usernameLabel: "User",
suspendMenuItem: "Suspend",
resetPasswordMenuItem: "Reset password",
}

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

export const UsersTable: React.FC<UsersTableProps> = ({ users, onSuspendUser }) => {
export const UsersTable: React.FC<UsersTableProps> = ({ users, onSuspendUser, onResetUserPassword }) => {
return (
<Table
columns={columns}
Expand All @@ -45,6 +47,10 @@ export const UsersTable: React.FC<UsersTableProps> = ({ users, onSuspendUser })
label: Language.suspendMenuItem,
onClick: onSuspendUser,
},
{
label: Language.resetPasswordMenuItem,
onClick: onResetUserPassword,
},
]}
/>
)}
Expand Down
76 changes: 75 additions & 1 deletion site/src/pages/UsersPage/UsersPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { fireEvent, screen, waitFor, within } from "@testing-library/react"
import React from "react"
import * as API from "../../api"
import { GlobalSnackbar } from "../../components/GlobalSnackbar/GlobalSnackbar"
import { Language as ResetPasswordDialogLanguage } from "../../components/ResetPasswordDialog/ResetPasswordDialog"
import { Language as UsersTableLanguage } from "../../components/UsersTable/UsersTable"
import { MockUser, MockUser2, render } from "../../testHelpers"
import { Language as usersXServiceLanguage } from "../../xServices/users/usersXService"
Expand Down Expand Up @@ -34,6 +35,33 @@ const suspendUser = async (setupActionSpies: () => void) => {
fireEvent.click(confirmButton)
}

const resetUserPassword = 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 = screen.getByRole("menu")
const resetPasswordButton = within(menu).getByText(UsersTableLanguage.resetPasswordMenuItem)
fireEvent.click(resetPasswordButton)

// Check if the confirm message is displayed
const confirmDialog = screen.getByRole("dialog")
expect(confirmDialog).toHaveTextContent(`You will need to send ${MockUser.username} the following password:`)

// Setup spies to check the actions after
setupActionSpies()

// Click on the "Confirm" button
const confirmButton = within(confirmDialog).getByRole("button", { name: ResetPasswordDialogLanguage.confirmText })
fireEvent.click(confirmButton)
}

describe("Users Page", () => {
it("shows users", async () => {
render(<UsersPage />)
Expand Down Expand Up @@ -81,7 +109,7 @@ describe("Users Page", () => {
jest.spyOn(API, "suspendUser").mockRejectedValueOnce({})
})

// Check if the success message is displayed
// Check if the error message is displayed
await screen.findByText(usersXServiceLanguage.suspendUserError)

// Check if the API was called correctly
Expand All @@ -90,4 +118,50 @@ describe("Users Page", () => {
})
})
})

describe("reset user password", () => {
describe("when it is success", () => {
it("shows a success message", async () => {
render(
<>
<UsersPage />
<GlobalSnackbar />
</>,
)

await resetUserPassword(() => {
jest.spyOn(API, "updateUserPassword").mockResolvedValueOnce(undefined)
})

// Check if the success message is displayed
await screen.findByText(usersXServiceLanguage.resetUserPasswordSuccess)

// Check if the API was called correctly
expect(API.updateUserPassword).toBeCalledTimes(1)
expect(API.updateUserPassword).toBeCalledWith(expect.any(String), MockUser.id)
})
})

describe("when it fails", () => {
it("shows an error message", async () => {
render(
<>
<UsersPage />
<GlobalSnackbar />
</>,
)

await resetUserPassword(() => {
jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce({})
})

// Check if the error message is displayed
await screen.findByText(usersXServiceLanguage.resetUserPasswordError)

// Check if the API was called correctly
expect(API.updateUserPassword).toBeCalledTimes(1)
expect(API.updateUserPassword).toBeCalledWith(expect.any(String), MockUser.id)
})
})
})
})
20 changes: 19 additions & 1 deletion site/src/pages/UsersPage/UsersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React, { useContext, useEffect } from "react"
import { useNavigate } from "react-router"
import { ConfirmDialog } from "../../components/ConfirmDialog/ConfirmDialog"
import { FullScreenLoader } from "../../components/Loader/FullScreenLoader"
import { ResetPasswordDialog } from "../../components/ResetPasswordDialog/ResetPasswordDialog"
import { XServiceContext } from "../../xServices/StateContext"
import { UsersPageView } from "./UsersPageView"

Expand All @@ -15,9 +16,10 @@ export const Language = {
export const UsersPage: React.FC = () => {
const xServices = useContext(XServiceContext)
const [usersState, usersSend] = useActor(xServices.usersXService)
const { users, getUsersError, userIdToSuspend } = usersState.context
const { users, getUsersError, userIdToSuspend, userIdToResetPassword, newUserPassword } = usersState.context
const navigate = useNavigate()
const userToBeSuspended = users?.find((u) => u.id === userIdToSuspend)
const userToResetPassword = users?.find((u) => u.id === userIdToResetPassword)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the future we may want to find a way to make the XService give the component the user instead of the user id, but this is fine for now.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I've been wondering about that, instead of passing a userId, we could just send the full user object to make things easier but it makes it hard to sync, if we need it, in case the same user is updated by any other action. Using the ID we keep the reference to the user in the list which is always updated since it is the source of the truth. Makes sense?


/**
* Fetch users on component mount
Expand All @@ -39,6 +41,9 @@ export const UsersPage: React.FC = () => {
onSuspendUser={(user) => {
usersSend({ type: "SUSPEND_USER", userId: user.id })
}}
onResetUserPassword={(user) => {
usersSend({ type: "RESET_USER_PASSWORD", userId: user.id })
}}
error={getUsersError}
/>

Expand All @@ -61,6 +66,19 @@ export const UsersPage: React.FC = () => {
</>
}
/>

<ResetPasswordDialog
loading={usersState.matches("resettingUserPassword")}
user={userToResetPassword}
newPassword={newUserPassword}
open={usersState.matches("confirmUserPasswordReset")}
onClose={() => {
usersSend("CANCEL_USER_PASSWORD_RESET")
}}
onConfirm={() => {
usersSend("CONFIRM_USER_PASSWORD_RESET")
}}
/>
</>
)
}
Expand Down
8 changes: 7 additions & 1 deletion site/src/pages/UsersPage/UsersPageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,26 @@ export interface UsersPageViewProps {
users: UserResponse[]
openUserCreationDialog: () => void
onSuspendUser: (user: UserResponse) => void
onResetUserPassword: (user: UserResponse) => void
error?: unknown
}

export const UsersPageView: React.FC<UsersPageViewProps> = ({
users,
openUserCreationDialog,
onSuspendUser,
onResetUserPassword,
error,
}) => {
return (
<Stack spacing={4}>
<Header title={Language.pageTitle} action={{ text: Language.newUserButton, onClick: openUserCreationDialog }} />
<Margins>
{error ? <ErrorSummary error={error} /> : <UsersTable users={users} onSuspendUser={onSuspendUser} />}
{error ? (
<ErrorSummary error={error} />
) : (
<UsersTable users={users} onSuspendUser={onSuspendUser} onResetUserPassword={onResetUserPassword} />
)}
</Margins>
</Stack>
)
Expand Down
19 changes: 19 additions & 0 deletions site/src/util/random.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Generate a cryptographically secure random string using the specified number
* of bytes then encode with base64.
*
* Base64 encodes 6 bits per character and pads with = so the length will not
* equal the number of randomly generated bytes.
* @see <https://developer.mozilla.org/en-US/docs/Glossary/Base64#encoded_size_increase>
*/
export const generateRandomString = (bytes: number): string => {
const byteArr = window.crypto.getRandomValues(new Uint8Array(bytes))
// The types for `map` don't seem to support mapping from one array type to
// another and `String.fromCharCode.apply` wants `number[]` so loop like this
// instead.
const strArr: string[] = []
for (const byte of byteArr) {
strArr.push(String.fromCharCode(byte))
}
return btoa(strArr.join(""))
}
Loading