Skip to content

Commit d35139c

Browse files
committed
Add FE for reset user password
1 parent d85092b commit d35139c

File tree

8 files changed

+185
-7
lines changed

8 files changed

+185
-7
lines changed

site/src/api/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,6 @@ export const suspendUser = async (userId: TypesGen.User["id"]): Promise<TypesGen
140140
const response = await axios.put<TypesGen.User>(`/api/v2/users/${userId}/suspend`)
141141
return response.data
142142
}
143+
144+
export const updateUserPassword = async (password: string, userId: TypesGen.User["id"]): Promise<undefined> =>
145+
axios.put(`/api/v2/users/${userId}/password`, { password })

site/src/components/CodeBlock/CodeBlock.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import { makeStyles } from "@material-ui/core/styles"
22
import React from "react"
33
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
4+
import { combineClasses } from "../../util/combineClasses"
45

56
export interface CodeBlockProps {
67
lines: string[]
8+
className?: string
79
}
810

9-
export const CodeBlock: React.FC<CodeBlockProps> = ({ lines }) => {
11+
export const CodeBlock: React.FC<CodeBlockProps> = ({ lines, className = "" }) => {
1012
const styles = useStyles()
1113

1214
return (
13-
<div className={styles.root}>
15+
<div className={combineClasses([styles.root, className])}>
1416
{lines.map((line, idx) => (
1517
<div className={styles.line} key={idx}>
1618
{line}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import DialogActions from "@material-ui/core/DialogActions"
2+
import DialogContent from "@material-ui/core/DialogContent"
3+
import DialogContentText from "@material-ui/core/DialogContentText"
4+
import { makeStyles } from "@material-ui/core/styles"
5+
import React from "react"
6+
import * as TypesGen from "../../api/typesGenerated"
7+
import { CodeBlock } from "../CodeBlock/CodeBlock"
8+
import { Dialog, DialogActionButtons, DialogTitle } from "../Dialog/Dialog"
9+
10+
interface ResetPasswordDialogProps {
11+
open: boolean
12+
onClose: () => void
13+
onConfirm: () => void
14+
user?: TypesGen.User
15+
newPassword?: string
16+
}
17+
18+
export const ResetPasswordDialog: React.FC<ResetPasswordDialogProps> = ({
19+
open,
20+
onClose,
21+
onConfirm,
22+
user,
23+
newPassword,
24+
}) => {
25+
const styles = useStyles()
26+
27+
return (
28+
<Dialog open={open} onClose={onClose}>
29+
<DialogTitle title="Reset password" />
30+
31+
<DialogContent>
32+
<DialogContentText variant="subtitle2">
33+
You will need to send <strong>{user?.username}</strong> the following password:
34+
</DialogContentText>
35+
36+
<DialogContentText component="div">
37+
<CodeBlock lines={[newPassword ?? ""]} className={styles.codeBlock} />
38+
</DialogContentText>
39+
</DialogContent>
40+
41+
<DialogActions>
42+
<DialogActionButtons onCancel={onClose} confirmText="Reset password" onConfirm={onConfirm} />
43+
</DialogActions>
44+
</Dialog>
45+
)
46+
}
47+
48+
const useStyles = makeStyles(() => ({
49+
codeBlock: {
50+
minHeight: "auto",
51+
userSelect: "all",
52+
width: "100%",
53+
},
54+
}))

site/src/components/UsersTable/UsersTable.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const Language = {
1111
emptyMessage: "No users found",
1212
usernameLabel: "User",
1313
suspendMenuItem: "Suspend",
14+
resetPasswordMenuItem: "Reset password",
1415
}
1516

1617
const emptyState = <EmptyState message={Language.emptyMessage} />
@@ -28,9 +29,10 @@ const columns: Column<UserResponse>[] = [
2829
export interface UsersTableProps {
2930
users: UserResponse[]
3031
onSuspendUser: (user: UserResponse) => void
32+
onResetUserPassword: (user: UserResponse) => void
3133
}
3234

33-
export const UsersTable: React.FC<UsersTableProps> = ({ users, onSuspendUser }) => {
35+
export const UsersTable: React.FC<UsersTableProps> = ({ users, onSuspendUser, onResetUserPassword }) => {
3436
return (
3537
<Table
3638
columns={columns}
@@ -45,6 +47,10 @@ export const UsersTable: React.FC<UsersTableProps> = ({ users, onSuspendUser })
4547
label: Language.suspendMenuItem,
4648
onClick: onSuspendUser,
4749
},
50+
{
51+
label: Language.resetPasswordMenuItem,
52+
onClick: onResetUserPassword,
53+
},
4854
]}
4955
/>
5056
)}

site/src/pages/UsersPage/UsersPage.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import React, { useContext, useEffect } from "react"
33
import { useNavigate } from "react-router"
44
import { ConfirmDialog } from "../../components/ConfirmDialog/ConfirmDialog"
55
import { FullScreenLoader } from "../../components/Loader/FullScreenLoader"
6+
import { ResetPasswordDialog } from "../../components/ResetPasswordDialog/ResetPasswordDialog"
67
import { XServiceContext } from "../../xServices/StateContext"
78
import { UsersPageView } from "./UsersPageView"
89

@@ -15,9 +16,10 @@ export const Language = {
1516
export const UsersPage: React.FC = () => {
1617
const xServices = useContext(XServiceContext)
1718
const [usersState, usersSend] = useActor(xServices.usersXService)
18-
const { users, getUsersError, userIdToSuspend } = usersState.context
19+
const { users, getUsersError, userIdToSuspend, userIdToResetPassword, newUserPassword } = usersState.context
1920
const navigate = useNavigate()
2021
const userToBeSuspended = users?.find((u) => u.id === userIdToSuspend)
22+
const userToResetPassword = users?.find((u) => u.id === userIdToResetPassword)
2123

2224
/**
2325
* Fetch users on component mount
@@ -39,6 +41,9 @@ export const UsersPage: React.FC = () => {
3941
onSuspendUser={(user) => {
4042
usersSend({ type: "SUSPEND_USER", userId: user.id })
4143
}}
44+
onResetUserPassword={(user) => {
45+
usersSend({ type: "RESET_USER_PASSWORD", userId: user.id })
46+
}}
4247
error={getUsersError}
4348
/>
4449

@@ -61,6 +66,18 @@ export const UsersPage: React.FC = () => {
6166
</>
6267
}
6368
/>
69+
70+
<ResetPasswordDialog
71+
user={userToResetPassword}
72+
newPassword={newUserPassword}
73+
open={usersState.matches("confirmUserPasswordReset")}
74+
onClose={() => {
75+
usersSend("CANCEL_USER_PASSWORD_RESET")
76+
}}
77+
onConfirm={() => {
78+
usersSend("CONFIRM_USER_PASSWORD_RESET")
79+
}}
80+
/>
6481
</>
6582
)
6683
}

site/src/pages/UsersPage/UsersPageView.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,26 @@ export interface UsersPageViewProps {
1515
users: UserResponse[]
1616
openUserCreationDialog: () => void
1717
onSuspendUser: (user: UserResponse) => void
18+
onResetUserPassword: (user: UserResponse) => void
1819
error?: unknown
1920
}
2021

2122
export const UsersPageView: React.FC<UsersPageViewProps> = ({
2223
users,
2324
openUserCreationDialog,
2425
onSuspendUser,
26+
onResetUserPassword,
2527
error,
2628
}) => {
2729
return (
2830
<Stack spacing={4}>
2931
<Header title={Language.pageTitle} action={{ text: Language.newUserButton, onClick: openUserCreationDialog }} />
3032
<Margins>
31-
{error ? <ErrorSummary error={error} /> : <UsersTable users={users} onSuspendUser={onSuspendUser} />}
33+
{error ? (
34+
<ErrorSummary error={error} />
35+
) : (
36+
<UsersTable users={users} onSuspendUser={onSuspendUser} onResetUserPassword={onResetUserPassword} />
37+
)}
3238
</Margins>
3339
</Stack>
3440
)

site/src/util/random.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Generate a cryptographically secure random string using the specified number
3+
* of bytes then encode with base64.
4+
*
5+
* Base64 encodes 6 bits per character and pads with = so the length will not
6+
* equal the number of randomly generated bytes.
7+
* @see <https://developer.mozilla.org/en-US/docs/Glossary/Base64#encoded_size_increase>
8+
*/
9+
export const generateRandomString = (bytes: number): string => {
10+
const byteArr = window.crypto.getRandomValues(new Uint8Array(bytes))
11+
// The types for `map` don't seem to support mapping from one array type to
12+
// another and `String.fromCharCode.apply` wants `number[]` so loop like this
13+
// instead.
14+
const strArr: string[] = []
15+
for (const byte of byteArr) {
16+
strArr.push(String.fromCharCode(byte))
17+
}
18+
return btoa(strArr.join(""))
19+
}

site/src/xServices/users/usersXService.ts

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,42 @@ import { ApiError, FieldErrors, isApiError, mapApiErrorToFieldErrors } from "../
44
import * as Types from "../../api/types"
55
import * as TypesGen from "../../api/typesGenerated"
66
import { displayError, displaySuccess } from "../../components/GlobalSnackbar/utils"
7+
import { generateRandomString } from "../../util/random"
78

89
export const Language = {
910
createUserSuccess: "Successfully created user.",
1011
suspendUserSuccess: "Successfully suspended the user.",
11-
suspendUserError: "Error on suspend the user",
12+
suspendUserError: "Error on suspend the user.",
13+
resetUserPasswordSuccess: "Successfully updated the user password.",
14+
resetUserPasswordError: "Error on reset the user password.",
1215
}
1316

1417
export interface UsersContext {
18+
// Get users
1519
users?: TypesGen.User[]
16-
userIdToSuspend?: TypesGen.User["id"]
1720
getUsersError?: Error | unknown
1821
createUserError?: Error | unknown
1922
createUserFormErrors?: FieldErrors
23+
// Suspend user
24+
userIdToSuspend?: TypesGen.User["id"]
2025
suspendUserError?: Error | unknown
26+
// Reset user password
27+
userIdToResetPassword?: TypesGen.User["id"]
28+
resetUserPasswordError?: Error | unknown
29+
newUserPassword?: string
2130
}
2231

2332
export type UsersEvent =
2433
| { type: "GET_USERS" }
2534
| { type: "CREATE"; user: Types.CreateUserRequest }
35+
// Suspend events
2636
| { type: "SUSPEND_USER"; userId: TypesGen.User["id"] }
2737
| { type: "CONFIRM_USER_SUSPENSION" }
2838
| { type: "CANCEL_USER_SUSPENSION" }
39+
// Reset password events
40+
| { type: "RESET_USER_PASSWORD"; userId: TypesGen.User["id"] }
41+
| { type: "CONFIRM_USER_PASSWORD_RESET" }
42+
| { type: "CANCEL_USER_PASSWORD_RESET" }
2943

3044
export const usersMachine = createMachine(
3145
{
@@ -43,6 +57,9 @@ export const usersMachine = createMachine(
4357
suspendUser: {
4458
data: TypesGen.User
4559
}
60+
updateUserPassword: {
61+
data: undefined
62+
}
4663
},
4764
},
4865
id: "usersState",
@@ -59,6 +76,10 @@ export const usersMachine = createMachine(
5976
target: "confirmUserSuspension",
6077
actions: ["assignUserIdToSuspend"],
6178
},
79+
RESET_USER_PASSWORD: {
80+
target: "confirmUserPasswordReset",
81+
actions: ["assignUserIdToResetPassword", "generateRandomPassword"],
82+
},
6283
},
6384
},
6485
gettingUsers: {
@@ -124,6 +145,27 @@ export const usersMachine = createMachine(
124145
},
125146
},
126147
},
148+
confirmUserPasswordReset: {
149+
on: {
150+
CONFIRM_USER_PASSWORD_RESET: "resettingUserPassword",
151+
CANCEL_USER_PASSWORD_RESET: "idle",
152+
},
153+
},
154+
resettingUserPassword: {
155+
entry: "clearResetUserPasswordError",
156+
invoke: {
157+
src: "resetUserPassword",
158+
id: "resetUserPassword",
159+
onDone: {
160+
target: "idle",
161+
actions: ["displayResetPasswordSuccess"],
162+
},
163+
onError: {
164+
target: "idle",
165+
actions: ["assignResetUserPasswordError", "displayResetPasswordErrorMessage"],
166+
},
167+
},
168+
},
127169
error: {
128170
on: {
129171
GET_USERS: "gettingUsers",
@@ -145,6 +187,17 @@ export const usersMachine = createMachine(
145187

146188
return API.suspendUser(context.userIdToSuspend)
147189
},
190+
resetUserPassword: (context) => {
191+
if (!context.userIdToResetPassword) {
192+
throw new Error("userIdToResetPassword is undefined")
193+
}
194+
195+
if (!context.newUserPassword) {
196+
throw new Error("newUserPassword not generated")
197+
}
198+
199+
return API.updateUserPassword(context.newUserPassword, context.userIdToResetPassword)
200+
},
148201
},
149202
guards: {
150203
isFormError: (_, event) => isApiError(event.data),
@@ -159,6 +212,9 @@ export const usersMachine = createMachine(
159212
assignUserIdToSuspend: assign({
160213
userIdToSuspend: (_, event) => event.userId,
161214
}),
215+
assignUserIdToResetPassword: assign({
216+
userIdToResetPassword: (_, event) => event.userId,
217+
}),
162218
clearGetUsersError: assign((context: UsersContext) => ({
163219
...context,
164220
getUsersError: undefined,
@@ -173,13 +229,19 @@ export const usersMachine = createMachine(
173229
assignSuspendUserError: assign({
174230
suspendUserError: (_, event) => event.data,
175231
}),
232+
assignResetUserPasswordError: assign({
233+
resetUserPasswordError: (_, event) => event.data,
234+
}),
176235
clearCreateUserError: assign((context: UsersContext) => ({
177236
...context,
178237
createUserError: undefined,
179238
})),
180239
clearSuspendUserError: assign({
181240
suspendUserError: (_) => undefined,
182241
}),
242+
clearResetUserPasswordError: assign({
243+
resetUserPasswordError: (_) => undefined,
244+
}),
183245
displayCreateUserSuccess: () => {
184246
displaySuccess(Language.createUserSuccess)
185247
},
@@ -189,6 +251,15 @@ export const usersMachine = createMachine(
189251
displaySuspendedErrorMessage: () => {
190252
displayError(Language.suspendUserError)
191253
},
254+
displayResetPasswordSuccess: () => {
255+
displaySuccess(Language.resetUserPasswordSuccess)
256+
},
257+
displayResetPasswordErrorMessage: () => {
258+
displaySuccess(Language.resetUserPasswordError)
259+
},
260+
generateRandomPassword: assign({
261+
newUserPassword: (_) => generateRandomString(12),
262+
}),
192263
},
193264
},
194265
)

0 commit comments

Comments
 (0)