mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +00:00
Add reset user password action (#1320)
This commit is contained in:
@ -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({
|
||||
|
@ -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 })
|
||||
|
@ -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}
|
||||
|
@ -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),
|
||||
}
|
@ -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%",
|
||||
},
|
||||
}))
|
@ -11,6 +11,7 @@ export const Language = {
|
||||
emptyMessage: "No users found",
|
||||
usernameLabel: "User",
|
||||
suspendMenuItem: "Suspend",
|
||||
resetPasswordMenuItem: "Reset password",
|
||||
}
|
||||
|
||||
const emptyState = <EmptyState message={Language.emptyMessage} />
|
||||
@ -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}
|
||||
@ -45,6 +47,10 @@ export const UsersTable: React.FC<UsersTableProps> = ({ users, onSuspendUser })
|
||||
label: Language.suspendMenuItem,
|
||||
onClick: onSuspendUser,
|
||||
},
|
||||
{
|
||||
label: Language.resetPasswordMenuItem,
|
||||
onClick: onResetUserPassword,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
@ -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(<UsersPage />)
|
||||
@ -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(
|
||||
<>
|
||||
<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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -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 = () => {
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<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")
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -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<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>
|
||||
)
|
||||
|
19
site/src/util/random.ts
Normal file
19
site/src/util/random.ts
Normal 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(""))
|
||||
}
|
@ -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),
|
||||
}),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
Reference in New Issue
Block a user