diff --git a/coderd/users.go b/coderd/users.go index 108ad0813cb49..7841198558f5b 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 89b3bde7688e9..52df1ac6ed8f8 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 d0e275009f315..5384d95304b63 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 e32468f705afc..a3dff970f499c 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 0000000000000..8a0c1f19a6c46 --- /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 0000000000000..472e233688f27 --- /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 952c86c5b3a0b..bf5fb2298dd10 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 f7efb64cea22b..2aeb7f21ba31f 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 28332235604b3..0ce09831728f7 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 855f15a1d7e18..4872e02d76085 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 0000000000000..e4d51da67b360 --- /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 b08aad97ff21a..e1493bfaa139c 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), + }), }, }, ) 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