diff --git a/coderd/users.go b/coderd/users.go index 108ad0813c..7841198558 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -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, ¶ms) { return } diff --git a/site/jest.setup.ts b/site/jest.setup.ts index 89b3bde768..52df1ac6ed 100644 --- a/site/jest.setup.ts +++ b/site/jest.setup.ts @@ -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({ diff --git a/site/src/api/index.ts b/site/src/api/index.ts index d0e275009f..5384d95304 100644 --- a/site/src/api/index.ts +++ b/site/src/api/index.ts @@ -155,3 +155,6 @@ export const suspendUser = async (userId: TypesGen.User["id"]): Promise(`/api/v2/users/${userId}/suspend`) return response.data } + +export const updateUserPassword = async (password: string, userId: TypesGen.User["id"]): Promise => + axios.put(`/api/v2/users/${userId}/password`, { password }) diff --git a/site/src/components/CodeBlock/CodeBlock.tsx b/site/src/components/CodeBlock/CodeBlock.tsx index e32468f705..a3dff970f4 100644 --- a/site/src/components/CodeBlock/CodeBlock.tsx +++ b/site/src/components/CodeBlock/CodeBlock.tsx @@ -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 = ({ lines }) => { +export const CodeBlock: React.FC = ({ lines, className = "" }) => { const styles = useStyles() return ( -
+
{lines.map((line, idx) => (
{line} diff --git a/site/src/components/ResetPasswordDialog/ResetPasswordDialog.stories.tsx b/site/src/components/ResetPasswordDialog/ResetPasswordDialog.stories.tsx new file mode 100644 index 0000000000..8a0c1f19a6 --- /dev/null +++ b/site/src/components/ResetPasswordDialog/ResetPasswordDialog.stories.tsx @@ -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 = (args: ResetPasswordDialogProps) => + +export const Example = Template.bind({}) +Example.args = { + open: true, + user: MockUser, + newPassword: generateRandomString(12), +} diff --git a/site/src/components/ResetPasswordDialog/ResetPasswordDialog.tsx b/site/src/components/ResetPasswordDialog/ResetPasswordDialog.tsx new file mode 100644 index 0000000000..472e233688 --- /dev/null +++ b/site/src/components/ResetPasswordDialog/ResetPasswordDialog.tsx @@ -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 {username} the following password: + + ), + confirmText: "Reset password", +} + +export const ResetPasswordDialog: React.FC = ({ + open, + onClose, + onConfirm, + user, + newPassword, + loading, +}) => { + const styles = useStyles() + + return ( + + + + + {Language.message(user?.username)} + + + + + + + + + + + ) +} + +const useStyles = makeStyles(() => ({ + codeBlock: { + minHeight: "auto", + userSelect: "all", + width: "100%", + }, +})) diff --git a/site/src/components/UsersTable/UsersTable.tsx b/site/src/components/UsersTable/UsersTable.tsx index 952c86c5b3..bf5fb2298d 100644 --- a/site/src/components/UsersTable/UsersTable.tsx +++ b/site/src/components/UsersTable/UsersTable.tsx @@ -11,6 +11,7 @@ export const Language = { emptyMessage: "No users found", usernameLabel: "User", suspendMenuItem: "Suspend", + resetPasswordMenuItem: "Reset password", } const emptyState = @@ -28,9 +29,10 @@ const columns: Column[] = [ export interface UsersTableProps { users: UserResponse[] onSuspendUser: (user: UserResponse) => void + onResetUserPassword: (user: UserResponse) => void } -export const UsersTable: React.FC = ({ users, onSuspendUser }) => { +export const UsersTable: React.FC = ({ users, onSuspendUser, onResetUserPassword }) => { return ( = ({ users, onSuspendUser }) label: Language.suspendMenuItem, onClick: onSuspendUser, }, + { + label: Language.resetPasswordMenuItem, + onClick: onResetUserPassword, + }, ]} /> )} diff --git a/site/src/pages/UsersPage/UsersPage.test.tsx b/site/src/pages/UsersPage/UsersPage.test.tsx index f7efb64cea..2aeb7f21ba 100644 --- a/site/src/pages/UsersPage/UsersPage.test.tsx +++ b/site/src/pages/UsersPage/UsersPage.test.tsx @@ -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" @@ -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() @@ -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 @@ -90,4 +118,50 @@ describe("Users Page", () => { }) }) }) + + describe("reset user password", () => { + describe("when it is success", () => { + it("shows a success message", async () => { + render( + <> + + + , + ) + + 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( + <> + + + , + ) + + 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) + }) + }) + }) }) diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index 2833223560..0ce0983172 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -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" @@ -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) /** * Fetch users on component mount @@ -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} /> @@ -61,6 +66,19 @@ export const UsersPage: React.FC = () => { } /> + + { + usersSend("CANCEL_USER_PASSWORD_RESET") + }} + onConfirm={() => { + usersSend("CONFIRM_USER_PASSWORD_RESET") + }} + /> ) } diff --git a/site/src/pages/UsersPage/UsersPageView.tsx b/site/src/pages/UsersPage/UsersPageView.tsx index 855f15a1d7..4872e02d76 100644 --- a/site/src/pages/UsersPage/UsersPageView.tsx +++ b/site/src/pages/UsersPage/UsersPageView.tsx @@ -15,6 +15,7 @@ export interface UsersPageViewProps { users: UserResponse[] openUserCreationDialog: () => void onSuspendUser: (user: UserResponse) => void + onResetUserPassword: (user: UserResponse) => void error?: unknown } @@ -22,13 +23,18 @@ export const UsersPageView: React.FC = ({ users, openUserCreationDialog, onSuspendUser, + onResetUserPassword, error, }) => { return (
- {error ? : } + {error ? ( + + ) : ( + + )} ) diff --git a/site/src/util/random.ts b/site/src/util/random.ts new file mode 100644 index 0000000000..e4d51da67b --- /dev/null +++ b/site/src/util/random.ts @@ -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 + */ +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("")) +} diff --git a/site/src/xServices/users/usersXService.ts b/site/src/xServices/users/usersXService.ts index b08aad97ff..e1493bfaa1 100644 --- a/site/src/xServices/users/usersXService.ts +++ b/site/src/xServices/users/usersXService.ts @@ -4,28 +4,42 @@ import { ApiError, FieldErrors, isApiError, mapApiErrorToFieldErrors } from "../ import * as Types from "../../api/types" import * as TypesGen from "../../api/typesGenerated" import { displayError, displaySuccess } from "../../components/GlobalSnackbar/utils" +import { generateRandomString } from "../../util/random" export const Language = { createUserSuccess: "Successfully created 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 { + // Get users users?: TypesGen.User[] - userIdToSuspend?: TypesGen.User["id"] getUsersError?: Error | unknown createUserError?: Error | unknown createUserFormErrors?: FieldErrors + // Suspend user + userIdToSuspend?: TypesGen.User["id"] suspendUserError?: Error | unknown + // Reset user password + userIdToResetPassword?: TypesGen.User["id"] + resetUserPasswordError?: Error | unknown + newUserPassword?: string } export type UsersEvent = | { type: "GET_USERS" } | { type: "CREATE"; user: Types.CreateUserRequest } + // Suspend events | { type: "SUSPEND_USER"; userId: TypesGen.User["id"] } | { type: "CONFIRM_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( { @@ -43,6 +57,9 @@ export const usersMachine = createMachine( suspendUser: { data: TypesGen.User } + updateUserPassword: { + data: undefined + } }, }, id: "usersState", @@ -59,6 +76,10 @@ export const usersMachine = createMachine( target: "confirmUserSuspension", actions: ["assignUserIdToSuspend"], }, + RESET_USER_PASSWORD: { + target: "confirmUserPasswordReset", + actions: ["assignUserIdToResetPassword", "generateRandomPassword"], + }, }, }, 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: { on: { GET_USERS: "gettingUsers", @@ -145,6 +187,17 @@ export const usersMachine = createMachine( 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: { isFormError: (_, event) => isApiError(event.data), @@ -159,6 +212,9 @@ export const usersMachine = createMachine( assignUserIdToSuspend: assign({ userIdToSuspend: (_, event) => event.userId, }), + assignUserIdToResetPassword: assign({ + userIdToResetPassword: (_, event) => event.userId, + }), clearGetUsersError: assign((context: UsersContext) => ({ ...context, getUsersError: undefined, @@ -173,6 +229,9 @@ export const usersMachine = createMachine( assignSuspendUserError: assign({ suspendUserError: (_, event) => event.data, }), + assignResetUserPasswordError: assign({ + resetUserPasswordError: (_, event) => event.data, + }), clearCreateUserError: assign((context: UsersContext) => ({ ...context, createUserError: undefined, @@ -180,6 +239,9 @@ export const usersMachine = createMachine( clearSuspendUserError: assign({ suspendUserError: (_) => undefined, }), + clearResetUserPasswordError: assign({ + resetUserPasswordError: (_) => undefined, + }), displayCreateUserSuccess: () => { displaySuccess(Language.createUserSuccess) }, @@ -189,6 +251,15 @@ export const usersMachine = createMachine( displaySuspendedErrorMessage: () => { displayError(Language.suspendUserError) }, + displayResetPasswordSuccess: () => { + displaySuccess(Language.resetUserPasswordSuccess) + }, + displayResetPasswordErrorMessage: () => { + displayError(Language.resetUserPasswordError) + }, + generateRandomPassword: assign({ + newUserPassword: (_) => generateRandomString(12), + }), }, }, )