Skip to content

Commit 3cd7b00

Browse files
committed
feat: add user password change page
1 parent ff542af commit 3cd7b00

File tree

8 files changed

+269
-16
lines changed

8 files changed

+269
-16
lines changed

site/src/api/api.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,8 +223,8 @@ export const suspendUser = async (userId: TypesGen.User["id"]): Promise<TypesGen
223223
return response.data
224224
}
225225

226-
export const updateUserPassword = async (password: string, userId: TypesGen.User["id"]): Promise<undefined> =>
227-
axios.put(`/api/v2/users/${userId}/password`, { password })
226+
export const updateUserPassword = async (userId: TypesGen.User["id"], updatePassword: TypesGen.UpdateUserPasswordRequest): Promise<undefined> =>
227+
axios.put(`/api/v2/users/${userId}/password`, updatePassword)
228228

229229
export const getSiteRoles = async (): Promise<Array<TypesGen.Role>> => {
230230
const response = await axios.get<Array<TypesGen.Role>>(`/api/v2/users/roles`)
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import FormHelperText from "@material-ui/core/FormHelperText"
2+
import TextField from "@material-ui/core/TextField"
3+
import { FormikContextType, FormikErrors, useFormik } from "formik"
4+
import React from "react"
5+
import * as Yup from "yup"
6+
import { getFormHelpers, nameValidator, onChangeTrimmed } from "../../util/formUtils"
7+
import { LoadingButton } from "../LoadingButton/LoadingButton"
8+
import { Stack } from "../Stack/Stack"
9+
10+
interface SecurityFormValues {
11+
old_password: string
12+
password: string
13+
confirm_password: string
14+
}
15+
16+
export const Language = {
17+
oldPasswordLabel: "Old Password",
18+
newPasswordLabel: "New Password",
19+
confirmPasswordLabel: "Confirm Password",
20+
oldPasswordRequired: "Old password is required",
21+
newPasswordRequired: "New password is required",
22+
confirmPasswordRequired: "Password confirmation is required",
23+
passwordMinLength: "Password must be at least 8 characters",
24+
passwordMaxLength: "Password must be no more than 64 characters",
25+
confirmPasswordMatch: "Password and confirmation must match",
26+
updatePassword: "Update password",
27+
}
28+
29+
const validationSchema = Yup.object({
30+
old_password: Yup.string().trim().required(Language.oldPasswordRequired),
31+
password: Yup.string().trim().min(8, Language.passwordMinLength).max(64, Language.passwordMaxLength).required(Language.newPasswordRequired),
32+
confirm_password: Yup.string().trim().test("passwords-match", Language.confirmPasswordMatch, function (value) {
33+
return (this.parent as SecurityFormValues).password === value
34+
})
35+
})
36+
37+
export type SecurityFormErrors = FormikErrors<SecurityFormValues>
38+
export interface SecurityFormProps {
39+
isLoading: boolean
40+
initialValues: SecurityFormValues
41+
onSubmit: (values: SecurityFormValues) => void
42+
formErrors?: SecurityFormErrors
43+
error?: string
44+
}
45+
46+
export const SecurityForm: React.FC<SecurityFormProps> = ({
47+
isLoading,
48+
onSubmit,
49+
initialValues,
50+
formErrors = {},
51+
error,
52+
}) => {
53+
const form: FormikContextType<SecurityFormValues> = useFormik<SecurityFormValues>({
54+
initialValues,
55+
validationSchema,
56+
onSubmit,
57+
})
58+
const getFieldHelpers = getFormHelpers<SecurityFormValues>(form, formErrors)
59+
60+
return (
61+
<>
62+
<form onSubmit={form.handleSubmit}>
63+
<Stack>
64+
<TextField
65+
{...getFieldHelpers("old_password")}
66+
onChange={onChangeTrimmed(form)}
67+
autoComplete="old_password"
68+
fullWidth
69+
label={Language.oldPasswordLabel}
70+
variant="outlined"
71+
/>
72+
<TextField
73+
{...getFieldHelpers("password")}
74+
onChange={onChangeTrimmed(form)}
75+
autoComplete="password"
76+
fullWidth
77+
label={Language.newPasswordLabel}
78+
variant="outlined"
79+
/>
80+
<TextField
81+
{...getFieldHelpers("confirm_password")}
82+
onChange={onChangeTrimmed(form)}
83+
autoComplete="confirm_password"
84+
fullWidth
85+
label={Language.confirmPasswordLabel}
86+
variant="outlined"
87+
/>
88+
89+
{error && <FormHelperText error>{error}</FormHelperText>}
90+
91+
<div>
92+
<LoadingButton loading={isLoading} type="submit" variant="contained">
93+
{isLoading ? "" : Language.updatePassword}
94+
</LoadingButton>
95+
</div>
96+
</Stack>
97+
</form>
98+
</>
99+
)
100+
}

site/src/pages/SettingsPages/AccountPage/LinkedAccountsPage.tsx

Lines changed: 0 additions & 12 deletions
This file was deleted.
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { fireEvent, screen, waitFor } from "@testing-library/react"
2+
import React from "react"
3+
import * as API from "../../../api/api"
4+
import { GlobalSnackbar } from "../../../components/GlobalSnackbar/GlobalSnackbar"
5+
import * as AccountForm from "../../../components/SettingsAccountForm/SettingsAccountForm"
6+
import { renderWithAuth } from "../../../testHelpers/renderHelpers"
7+
import * as AuthXService from "../../../xServices/auth/authXService"
8+
import { SecurityPage, Language } from "./SecurityPage"
9+
10+
const renderPage = () => {
11+
return renderWithAuth(
12+
<>
13+
<AccountPage />
14+
<GlobalSnackbar />
15+
</>,
16+
)
17+
}
18+
19+
const newData = {
20+
email: "user@coder.com",
21+
username: "user",
22+
}
23+
24+
const fillAndSubmitForm = async () => {
25+
await waitFor(() => screen.findByLabelText("Email"))
26+
fireEvent.change(screen.getByLabelText("Email"), { target: { value: newData.email } })
27+
fireEvent.change(screen.getByLabelText("Username"), { target: { value: newData.username } })
28+
fireEvent.click(screen.getByText(AccountForm.Language.updateSettings))
29+
}
30+
31+
describe("AccountPage", () => {
32+
describe("when it is a success", () => {
33+
it("shows the success message", async () => {
34+
jest.spyOn(API, "updateProfile").mockImplementationOnce((userId, data) =>
35+
Promise.resolve({
36+
id: userId,
37+
created_at: new Date().toString(),
38+
status: "active",
39+
organization_ids: ["123"],
40+
roles: [],
41+
...data,
42+
}),
43+
)
44+
const { user } = renderPage()
45+
await fillAndSubmitForm()
46+
47+
const successMessage = await screen.findByText(AuthXService.Language.successProfileUpdate)
48+
expect(successMessage).toBeDefined()
49+
expect(API.updateProfile).toBeCalledTimes(1)
50+
expect(API.updateProfile).toBeCalledWith(user.id, newData)
51+
})
52+
})
53+
54+
describe("when the email is already taken", () => {
55+
it("shows an error", async () => {
56+
jest.spyOn(API, "updateProfile").mockRejectedValueOnce({
57+
isAxiosError: true,
58+
response: {
59+
data: { message: "Invalid profile", errors: [{ detail: "Email is already in use", field: "email" }] },
60+
},
61+
})
62+
63+
const { user } = renderPage()
64+
await fillAndSubmitForm()
65+
66+
const errorMessage = await screen.findByText("Email is already in use")
67+
expect(errorMessage).toBeDefined()
68+
expect(API.updateProfile).toBeCalledTimes(1)
69+
expect(API.updateProfile).toBeCalledWith(user.id, newData)
70+
})
71+
})
72+
73+
describe("when the username is already taken", () => {
74+
it("shows an error", async () => {
75+
jest.spyOn(API, "updateProfile").mockRejectedValueOnce({
76+
isAxiosError: true,
77+
response: {
78+
data: { message: "Invalid profile", errors: [{ detail: "Username is already in use", field: "username" }] },
79+
},
80+
})
81+
82+
const { user } = renderPage()
83+
await fillAndSubmitForm()
84+
85+
const errorMessage = await screen.findByText("Username is already in use")
86+
expect(errorMessage).toBeDefined()
87+
expect(API.updateProfile).toBeCalledTimes(1)
88+
expect(API.updateProfile).toBeCalledWith(user.id, newData)
89+
})
90+
})
91+
92+
describe("when it is an unknown error", () => {
93+
it("shows a generic error message", async () => {
94+
jest.spyOn(API, "updateProfile").mockRejectedValueOnce({
95+
data: "unknown error",
96+
})
97+
98+
const { user } = renderPage()
99+
await fillAndSubmitForm()
100+
101+
const errorMessage = await screen.findByText(Language.unknownError)
102+
expect(errorMessage).toBeDefined()
103+
expect(API.updateProfile).toBeCalledTimes(1)
104+
expect(API.updateProfile).toBeCalledWith(user.id, newData)
105+
})
106+
})
107+
})
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { useActor } from "@xstate/react"
2+
import React, { useContext } from "react"
3+
import { isApiError, mapApiErrorToFieldErrors } from "../../../api/errors"
4+
import { Section } from "../../../components/Section/Section"
5+
import { SecurityForm } from "../../../components/SettingsSecurityForm/SettingsSecurityForm"
6+
import { XServiceContext } from "../../../xServices/StateContext"
7+
8+
export const Language = {
9+
title: "Security",
10+
unknownError: "Oops, an unknown error occurred.",
11+
}
12+
13+
export const AccountPage: React.FC = () => {
14+
const xServices = useContext(XServiceContext)
15+
const [authState, authSend] = useActor(xServices.authXService)
16+
const { me, updateProfileError } = authState.context
17+
const hasError = !!updateProfileError
18+
const formErrors =
19+
hasError && isApiError(updateProfileError) ? mapApiErrorToFieldErrors(updateProfileError.response.data) : undefined
20+
const hasUnknownError = hasError && !isApiError(updateProfileError)
21+
22+
if (!me) {
23+
throw new Error("No current user found")
24+
}
25+
26+
return (
27+
<Section title={Language.title}>
28+
<SecurityForm
29+
error={hasUnknownError ? Language.unknownError : undefined}
30+
formErrors={formErrors}
31+
isLoading={authState.matches("signedIn.profile.updatingProfile")}
32+
initialValues={{ old_password: "", password: "", confirm_password: "" }}
33+
onSubmit={(data) => {
34+
authSend({
35+
type: "UPDATE_PASSWORD",
36+
data,
37+
})
38+
}}
39+
/>
40+
</Section>
41+
)
42+
}

site/src/pages/UsersPage/UsersPage.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ describe("Users Page", () => {
198198

199199
// Check if the API was called correctly
200200
expect(API.updateUserPassword).toBeCalledTimes(1)
201-
expect(API.updateUserPassword).toBeCalledWith(expect.any(String), MockUser.id)
201+
expect(API.updateUserPassword).toBeCalledWith(MockUser.id, {password: expect.any(String), old_password: ""})
202202
})
203203
})
204204

site/src/xServices/auth/authXService.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export interface AuthContext {
5050
getMethodsError?: Error | unknown
5151
authError?: Error | unknown
5252
updateProfileError?: Error | unknown
53+
updateSecurityError?: Error | unknown
5354
me?: TypesGen.User
5455
methods?: TypesGen.AuthMethods
5556
permissions?: Permissions
@@ -64,6 +65,7 @@ export type AuthEvent =
6465
| { type: "SIGN_OUT" }
6566
| { type: "SIGN_IN"; email: string; password: string }
6667
| { type: "UPDATE_PROFILE"; data: TypesGen.UpdateUserProfileRequest }
68+
| { type: "UPDATE_SECURITY"; data: TypesGen.UpdateUserPasswordRequest }
6769
| { type: "GET_SSH_KEY" }
6870
| { type: "REGENERATE_SSH_KEY" }
6971
| { type: "CONFIRM_REGENERATE_SSH_KEY" }
@@ -145,6 +147,7 @@ export const authMachine =
145147
getUserError: undefined,
146148
authError: undefined,
147149
updateProfileError: undefined,
150+
updateSecurityError: undefined,
148151
methods: undefined,
149152
getMethodsError: undefined,
150153
},
@@ -165,6 +168,9 @@ export const authMachine =
165168
updateProfile: {
166169
data: TypesGen.User
167170
}
171+
updateSecurity: {
172+
data: TypesGen.UpdateUserPasswordRequest
173+
}
168174
checkPermissions: {
169175
data: TypesGen.UserAuthorizationResponse
170176
}
@@ -279,6 +285,9 @@ export const authMachine =
279285
UPDATE_PROFILE: {
280286
target: "updatingProfile",
281287
},
288+
UPDATE_SECURITY: {
289+
target: "updatingSecurity",
290+
},
282291
},
283292
},
284293
updatingProfile: {
@@ -345,6 +354,13 @@ export const authMachine =
345354

346355
return API.updateProfile(context.me.id, event.data)
347356
},
357+
updateSecurity: async (context, event) => {
358+
if (!context.me) {
359+
throw new Error("No current user found")
360+
}
361+
362+
return API.updateUserPassword(context.me.id, event.data)
363+
},
348364
checkPermissions: async (context) => {
349365
if (!context.me) {
350366
throw new Error("No current user found")

site/src/xServices/users/usersXService.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ export const usersMachine = createMachine(
221221
throw new Error("newUserPassword not generated")
222222
}
223223

224-
return API.updateUserPassword(context.newUserPassword, context.userIdToResetPassword)
224+
return API.updateUserPassword(context.userIdToResetPassword, {password: context.newUserPassword, old_password: ""})
225225
},
226226
updateUserRoles: (context, event) => {
227227
if (!context.userIdToUpdateRoles) {

0 commit comments

Comments
 (0)