Add reset user password action (#1320)

This commit is contained in:
Bruno Quaresma
2022-05-06 13:23:03 -05:00
committed by GitHub
parent 57bb108465
commit cf5aca799d
12 changed files with 313 additions and 12 deletions

View File

@ -361,10 +361,10 @@ func (api *api) putUserSuspend(rw http.ResponseWriter, r *http.Request) {
} }
func (api *api) putUserPassword(rw http.ResponseWriter, r *http.Request) { func (api *api) putUserPassword(rw http.ResponseWriter, r *http.Request) {
var ( var (
user = httpmw.UserParam(r) user = httpmw.UserParam(r)
params codersdk.UpdateUserPasswordRequest params codersdk.UpdateUserPasswordRequest
) )
if !httpapi.Read(rw, r, &params) { if !httpapi.Read(rw, r, &params) {
return return
} }

View File

@ -1,6 +1,16 @@
import "@testing-library/jest-dom" import "@testing-library/jest-dom"
import crypto from "crypto"
import { server } from "./src/testHelpers/server" 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. // Establish API mocking before all tests through MSW.
beforeAll(() => beforeAll(() =>
server.listen({ server.listen({

View File

@ -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`) const response = await axios.put<TypesGen.User>(`/api/v2/users/${userId}/suspend`)
return response.data return response.data
} }
export const updateUserPassword = async (password: string, userId: TypesGen.User["id"]): Promise<undefined> =>
axios.put(`/api/v2/users/${userId}/password`, { password })

View File

@ -1,16 +1,18 @@
import { makeStyles } from "@material-ui/core/styles" import { makeStyles } from "@material-ui/core/styles"
import React from "react" import React from "react"
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants" import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
import { combineClasses } from "../../util/combineClasses"
export interface CodeBlockProps { export interface CodeBlockProps {
lines: string[] lines: string[]
className?: string
} }
export const CodeBlock: React.FC<CodeBlockProps> = ({ lines }) => { export const CodeBlock: React.FC<CodeBlockProps> = ({ lines, className = "" }) => {
const styles = useStyles() const styles = useStyles()
return ( return (
<div className={styles.root}> <div className={combineClasses([styles.root, className])}>
{lines.map((line, idx) => ( {lines.map((line, idx) => (
<div className={styles.line} key={idx}> <div className={styles.line} key={idx}>
{line} {line}

View File

@ -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),
}

View File

@ -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%",
},
}))

View File

@ -11,6 +11,7 @@ export const Language = {
emptyMessage: "No users found", emptyMessage: "No users found",
usernameLabel: "User", usernameLabel: "User",
suspendMenuItem: "Suspend", suspendMenuItem: "Suspend",
resetPasswordMenuItem: "Reset password",
} }
const emptyState = <EmptyState message={Language.emptyMessage} /> const emptyState = <EmptyState message={Language.emptyMessage} />
@ -28,9 +29,10 @@ const columns: Column<UserResponse>[] = [
export interface UsersTableProps { export interface UsersTableProps {
users: UserResponse[] users: UserResponse[]
onSuspendUser: (user: UserResponse) => void 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 ( return (
<Table <Table
columns={columns} columns={columns}
@ -45,6 +47,10 @@ export const UsersTable: React.FC<UsersTableProps> = ({ users, onSuspendUser })
label: Language.suspendMenuItem, label: Language.suspendMenuItem,
onClick: onSuspendUser, onClick: onSuspendUser,
}, },
{
label: Language.resetPasswordMenuItem,
onClick: onResetUserPassword,
},
]} ]}
/> />
)} )}

View File

@ -2,6 +2,7 @@ import { fireEvent, screen, waitFor, within } from "@testing-library/react"
import React from "react" import React from "react"
import * as API from "../../api" import * as API from "../../api"
import { GlobalSnackbar } from "../../components/GlobalSnackbar/GlobalSnackbar" import { GlobalSnackbar } from "../../components/GlobalSnackbar/GlobalSnackbar"
import { Language as ResetPasswordDialogLanguage } from "../../components/ResetPasswordDialog/ResetPasswordDialog"
import { Language as UsersTableLanguage } from "../../components/UsersTable/UsersTable" import { Language as UsersTableLanguage } from "../../components/UsersTable/UsersTable"
import { MockUser, MockUser2, render } from "../../testHelpers" import { MockUser, MockUser2, render } from "../../testHelpers"
import { Language as usersXServiceLanguage } from "../../xServices/users/usersXService" import { Language as usersXServiceLanguage } from "../../xServices/users/usersXService"
@ -34,6 +35,33 @@ const suspendUser = async (setupActionSpies: () => void) => {
fireEvent.click(confirmButton) 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", () => { describe("Users Page", () => {
it("shows users", async () => { it("shows users", async () => {
render(<UsersPage />) render(<UsersPage />)
@ -81,7 +109,7 @@ describe("Users Page", () => {
jest.spyOn(API, "suspendUser").mockRejectedValueOnce({}) jest.spyOn(API, "suspendUser").mockRejectedValueOnce({})
}) })
// Check if the success message is displayed // Check if the error message is displayed
await screen.findByText(usersXServiceLanguage.suspendUserError) await screen.findByText(usersXServiceLanguage.suspendUserError)
// Check if the API was called correctly // Check if the API was called correctly
@ -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)
})
})
})
}) })

View File

@ -3,6 +3,7 @@ import React, { useContext, useEffect } from "react"
import { useNavigate } from "react-router" import { useNavigate } from "react-router"
import { ConfirmDialog } from "../../components/ConfirmDialog/ConfirmDialog" import { ConfirmDialog } from "../../components/ConfirmDialog/ConfirmDialog"
import { FullScreenLoader } from "../../components/Loader/FullScreenLoader" import { FullScreenLoader } from "../../components/Loader/FullScreenLoader"
import { ResetPasswordDialog } from "../../components/ResetPasswordDialog/ResetPasswordDialog"
import { XServiceContext } from "../../xServices/StateContext" import { XServiceContext } from "../../xServices/StateContext"
import { UsersPageView } from "./UsersPageView" import { UsersPageView } from "./UsersPageView"
@ -15,9 +16,10 @@ export const Language = {
export const UsersPage: React.FC = () => { export const UsersPage: React.FC = () => {
const xServices = useContext(XServiceContext) const xServices = useContext(XServiceContext)
const [usersState, usersSend] = useActor(xServices.usersXService) const [usersState, usersSend] = useActor(xServices.usersXService)
const { users, getUsersError, userIdToSuspend } = usersState.context const { users, getUsersError, userIdToSuspend, userIdToResetPassword, newUserPassword } = usersState.context
const navigate = useNavigate() const navigate = useNavigate()
const userToBeSuspended = users?.find((u) => u.id === userIdToSuspend) const userToBeSuspended = users?.find((u) => u.id === userIdToSuspend)
const userToResetPassword = users?.find((u) => u.id === userIdToResetPassword)
/** /**
* Fetch users on component mount * Fetch users on component mount
@ -39,6 +41,9 @@ export const UsersPage: React.FC = () => {
onSuspendUser={(user) => { onSuspendUser={(user) => {
usersSend({ type: "SUSPEND_USER", userId: user.id }) usersSend({ type: "SUSPEND_USER", userId: user.id })
}} }}
onResetUserPassword={(user) => {
usersSend({ type: "RESET_USER_PASSWORD", userId: user.id })
}}
error={getUsersError} error={getUsersError}
/> />
@ -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")
}}
/>
</> </>
) )
} }

View File

@ -15,6 +15,7 @@ export interface UsersPageViewProps {
users: UserResponse[] users: UserResponse[]
openUserCreationDialog: () => void openUserCreationDialog: () => void
onSuspendUser: (user: UserResponse) => void onSuspendUser: (user: UserResponse) => void
onResetUserPassword: (user: UserResponse) => void
error?: unknown error?: unknown
} }
@ -22,13 +23,18 @@ export const UsersPageView: React.FC<UsersPageViewProps> = ({
users, users,
openUserCreationDialog, openUserCreationDialog,
onSuspendUser, onSuspendUser,
onResetUserPassword,
error, error,
}) => { }) => {
return ( return (
<Stack spacing={4}> <Stack spacing={4}>
<Header title={Language.pageTitle} action={{ text: Language.newUserButton, onClick: openUserCreationDialog }} /> <Header title={Language.pageTitle} action={{ text: Language.newUserButton, onClick: openUserCreationDialog }} />
<Margins> <Margins>
{error ? <ErrorSummary error={error} /> : <UsersTable users={users} onSuspendUser={onSuspendUser} />} {error ? (
<ErrorSummary error={error} />
) : (
<UsersTable users={users} onSuspendUser={onSuspendUser} onResetUserPassword={onResetUserPassword} />
)}
</Margins> </Margins>
</Stack> </Stack>
) )

19
site/src/util/random.ts Normal file
View File

@ -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(""))
}

View File

@ -4,28 +4,42 @@ import { ApiError, FieldErrors, isApiError, mapApiErrorToFieldErrors } from "../
import * as Types from "../../api/types" import * as Types from "../../api/types"
import * as TypesGen from "../../api/typesGenerated" import * as TypesGen from "../../api/typesGenerated"
import { displayError, displaySuccess } from "../../components/GlobalSnackbar/utils" import { displayError, displaySuccess } from "../../components/GlobalSnackbar/utils"
import { generateRandomString } from "../../util/random"
export const Language = { export const Language = {
createUserSuccess: "Successfully created user.", createUserSuccess: "Successfully created user.",
suspendUserSuccess: "Successfully suspended the user.", suspendUserSuccess: "Successfully suspended the user.",
suspendUserError: "Error on suspend the user", suspendUserError: "Error on suspend the user.",
resetUserPasswordSuccess: "Successfully updated the user password.",
resetUserPasswordError: "Error on reset the user password.",
} }
export interface UsersContext { export interface UsersContext {
// Get users
users?: TypesGen.User[] users?: TypesGen.User[]
userIdToSuspend?: TypesGen.User["id"]
getUsersError?: Error | unknown getUsersError?: Error | unknown
createUserError?: Error | unknown createUserError?: Error | unknown
createUserFormErrors?: FieldErrors createUserFormErrors?: FieldErrors
// Suspend user
userIdToSuspend?: TypesGen.User["id"]
suspendUserError?: Error | unknown suspendUserError?: Error | unknown
// Reset user password
userIdToResetPassword?: TypesGen.User["id"]
resetUserPasswordError?: Error | unknown
newUserPassword?: string
} }
export type UsersEvent = export type UsersEvent =
| { type: "GET_USERS" } | { type: "GET_USERS" }
| { type: "CREATE"; user: Types.CreateUserRequest } | { type: "CREATE"; user: Types.CreateUserRequest }
// Suspend events
| { type: "SUSPEND_USER"; userId: TypesGen.User["id"] } | { type: "SUSPEND_USER"; userId: TypesGen.User["id"] }
| { type: "CONFIRM_USER_SUSPENSION" } | { type: "CONFIRM_USER_SUSPENSION" }
| { type: "CANCEL_USER_SUSPENSION" } | { type: "CANCEL_USER_SUSPENSION" }
// Reset password events
| { type: "RESET_USER_PASSWORD"; userId: TypesGen.User["id"] }
| { type: "CONFIRM_USER_PASSWORD_RESET" }
| { type: "CANCEL_USER_PASSWORD_RESET" }
export const usersMachine = createMachine( export const usersMachine = createMachine(
{ {
@ -43,6 +57,9 @@ export const usersMachine = createMachine(
suspendUser: { suspendUser: {
data: TypesGen.User data: TypesGen.User
} }
updateUserPassword: {
data: undefined
}
}, },
}, },
id: "usersState", id: "usersState",
@ -59,6 +76,10 @@ export const usersMachine = createMachine(
target: "confirmUserSuspension", target: "confirmUserSuspension",
actions: ["assignUserIdToSuspend"], actions: ["assignUserIdToSuspend"],
}, },
RESET_USER_PASSWORD: {
target: "confirmUserPasswordReset",
actions: ["assignUserIdToResetPassword", "generateRandomPassword"],
},
}, },
}, },
gettingUsers: { gettingUsers: {
@ -124,6 +145,27 @@ export const usersMachine = createMachine(
}, },
}, },
}, },
confirmUserPasswordReset: {
on: {
CONFIRM_USER_PASSWORD_RESET: "resettingUserPassword",
CANCEL_USER_PASSWORD_RESET: "idle",
},
},
resettingUserPassword: {
entry: "clearResetUserPasswordError",
invoke: {
src: "resetUserPassword",
id: "resetUserPassword",
onDone: {
target: "idle",
actions: ["displayResetPasswordSuccess"],
},
onError: {
target: "idle",
actions: ["assignResetUserPasswordError", "displayResetPasswordErrorMessage"],
},
},
},
error: { error: {
on: { on: {
GET_USERS: "gettingUsers", GET_USERS: "gettingUsers",
@ -145,6 +187,17 @@ export const usersMachine = createMachine(
return API.suspendUser(context.userIdToSuspend) return API.suspendUser(context.userIdToSuspend)
}, },
resetUserPassword: (context) => {
if (!context.userIdToResetPassword) {
throw new Error("userIdToResetPassword is undefined")
}
if (!context.newUserPassword) {
throw new Error("newUserPassword not generated")
}
return API.updateUserPassword(context.newUserPassword, context.userIdToResetPassword)
},
}, },
guards: { guards: {
isFormError: (_, event) => isApiError(event.data), isFormError: (_, event) => isApiError(event.data),
@ -159,6 +212,9 @@ export const usersMachine = createMachine(
assignUserIdToSuspend: assign({ assignUserIdToSuspend: assign({
userIdToSuspend: (_, event) => event.userId, userIdToSuspend: (_, event) => event.userId,
}), }),
assignUserIdToResetPassword: assign({
userIdToResetPassword: (_, event) => event.userId,
}),
clearGetUsersError: assign((context: UsersContext) => ({ clearGetUsersError: assign((context: UsersContext) => ({
...context, ...context,
getUsersError: undefined, getUsersError: undefined,
@ -173,6 +229,9 @@ export const usersMachine = createMachine(
assignSuspendUserError: assign({ assignSuspendUserError: assign({
suspendUserError: (_, event) => event.data, suspendUserError: (_, event) => event.data,
}), }),
assignResetUserPasswordError: assign({
resetUserPasswordError: (_, event) => event.data,
}),
clearCreateUserError: assign((context: UsersContext) => ({ clearCreateUserError: assign((context: UsersContext) => ({
...context, ...context,
createUserError: undefined, createUserError: undefined,
@ -180,6 +239,9 @@ export const usersMachine = createMachine(
clearSuspendUserError: assign({ clearSuspendUserError: assign({
suspendUserError: (_) => undefined, suspendUserError: (_) => undefined,
}), }),
clearResetUserPasswordError: assign({
resetUserPasswordError: (_) => undefined,
}),
displayCreateUserSuccess: () => { displayCreateUserSuccess: () => {
displaySuccess(Language.createUserSuccess) displaySuccess(Language.createUserSuccess)
}, },
@ -189,6 +251,15 @@ export const usersMachine = createMachine(
displaySuspendedErrorMessage: () => { displaySuspendedErrorMessage: () => {
displayError(Language.suspendUserError) displayError(Language.suspendUserError)
}, },
displayResetPasswordSuccess: () => {
displaySuccess(Language.resetUserPasswordSuccess)
},
displayResetPasswordErrorMessage: () => {
displayError(Language.resetUserPasswordError)
},
generateRandomPassword: assign({
newUserPassword: (_) => generateRandomString(12),
}),
}, },
}, },
) )