Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

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 ourterms of service andprivacy statement. We’ll occasionally send you account related emails.

Already on GitHub?Sign in to your account

Merged
BrunoQuaresma merged 18 commits intomainfrombq/fe/update-user-password
May 6, 2022
Merged
Show file tree
Hide file tree
Changes fromall commits
Commits
Show all changes
18 commits
Select commitHold shift + click to select a range
346e5e4
Add UpdateUserHashedPassword query
BrunoQuaresmaMay 4, 2022
4bd7557
chore: Merge branch 'main' of github.com:coder/coder into bq/update-u…
BrunoQuaresmaMay 4, 2022
de39cf5
Add database functions
BrunoQuaresmaMay 5, 2022
2fe1716
Add update user password endpoint
BrunoQuaresmaMay 5, 2022
212020a
Add tests and fixes
BrunoQuaresmaMay 5, 2022
2699445
Remove confirmation and fix lint issues
BrunoQuaresmaMay 5, 2022
355f163
Return hash error as server error
BrunoQuaresmaMay 5, 2022
56b29fd
Update coderd/database/databasefake/databasefake.go
BrunoQuaresmaMay 5, 2022
30b8f15
Improve readbility
BrunoQuaresmaMay 5, 2022
f6be255
Add RBAC
BrunoQuaresmaMay 5, 2022
5df5763
Fix route
BrunoQuaresmaMay 5, 2022
b9dbd64
Merge branch 'bq/update-user-password' of github.com:coder/coder into…
BrunoQuaresmaMay 5, 2022
69af903
Add missing TS types
BrunoQuaresmaMay 5, 2022
d85092b
Update update password request params
BrunoQuaresmaMay 5, 2022
d35139c
Add FE for reset user password
BrunoQuaresmaMay 5, 2022
2a9b9be
Merge branch 'main' of github.com:coder/coder into bq/fe/update-user-…
BrunoQuaresmaMay 6, 2022
8957339
Add reset password dialog to storybook
BrunoQuaresmaMay 6, 2022
e388f8a
Add tests
BrunoQuaresmaMay 6, 2022
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 deletionscoderd/users.go
View file
Open in desktop
Original file line numberDiff line numberDiff 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 deletionssite/jest.setup.ts
View file
Open in desktop
Original file line numberDiff line numberDiff 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 deletionssite/src/api/index.ts
View file
Open in desktop
Original file line numberDiff line numberDiff 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 deletionssite/src/components/CodeBlock/CodeBlock.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff 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
View file
Open in desktop
Original file line numberDiff line numberDiff 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),
}
View file
Open in desktop
Original file line numberDiff line numberDiff 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 deletionsite/src/components/UsersTable/UsersTable.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff 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 deletionsite/src/pages/UsersPage/UsersPage.test.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff 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 DownExpand 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 DownExpand Up@@ -81,7 +109,7 @@ describe("Users Page", () => {
jest.spyOn(API, "suspendUser").mockRejectedValueOnce({})
})

// Check if thesuccess message is displayed
// Check if theerror 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 deletionsite/src/pages/UsersPage/UsersPage.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff 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.

BrunoQuaresma reacted with thumbs up emoji
Copy link
CollaboratorAuthor

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?

presleyp reacted with thumbs up emoji

/**
* 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 deletionsite/src/pages/UsersPage/UsersPageView.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff 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 deletionssite/src/util/random.ts
View file
Open in desktop
Original file line numberDiff line numberDiff 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

[8]ページ先頭

©2009-2025 Movatter.jp