Skip to content

Commit 2fe3963

Browse files
f0sselkylecarbs
authored andcommitted
feat: add user password change page (#1866)
1 parent dc8deea commit 2fe3963

File tree

10 files changed

+324
-17
lines changed

10 files changed

+324
-17
lines changed

site/src/AppRouter.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { CliAuthenticationPage } from "./pages/CliAuthPage/CliAuthPage"
99
import { HealthzPage } from "./pages/HealthzPage/HealthzPage"
1010
import { LoginPage } from "./pages/LoginPage/LoginPage"
1111
import { AccountPage } from "./pages/SettingsPages/AccountPage/AccountPage"
12+
import { SecurityPage } from "./pages/SettingsPages/SecurityPage/SecurityPage"
1213
import { SSHKeysPage } from "./pages/SettingsPages/SSHKeysPage/SSHKeysPage"
1314
import { TemplatePage } from "./pages/TemplatePage/TemplatePage"
1415
import TemplatesPage from "./pages/TemplatesPage/TemplatesPage"
@@ -126,6 +127,7 @@ export const AppRouter: React.FC = () => (
126127

127128
<Route path="settings" element={<SettingsLayout />}>
128129
<Route path="account" element={<AccountPage />} />
130+
<Route path="security" element={<SecurityPage />} />
129131
<Route path="ssh-keys" element={<SSHKeysPage />} />
130132
</Route>
131133

site/src/api/api.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -223,8 +223,10 @@ 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 (
227+
userId: TypesGen.User["id"],
228+
updatePassword: TypesGen.UpdateUserPasswordRequest,
229+
): Promise<undefined> => axios.put(`/api/v2/users/${userId}/password`, updatePassword)
228230

229231
export const getSiteRoles = async (): Promise<Array<TypesGen.Role>> => {
230232
const response = await axios.get<Array<TypesGen.Role>>(`/api/v2/users/roles`)

site/src/components/SettingsLayout/SettingsLayout.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@ import { TabPanel } from "../TabPanel/TabPanel"
77

88
export const Language = {
99
accountLabel: "Account",
10+
securityLabel: "Security",
1011
sshKeysLabel: "SSH keys",
1112
settingsLabel: "Settings",
1213
}
1314

1415
const menuItems = [
1516
{ label: Language.accountLabel, path: "/settings/account" },
17+
{ label: Language.securityLabel, path: "/settings/security" },
1618
{ label: Language.sshKeysLabel, path: "/settings/ssh-keys" },
1719
]
1820

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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, 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()
32+
.trim()
33+
.min(8, Language.passwordMinLength)
34+
.max(64, Language.passwordMaxLength)
35+
.required(Language.newPasswordRequired),
36+
confirm_password: Yup.string()
37+
.trim()
38+
.test("passwords-match", Language.confirmPasswordMatch, function (value) {
39+
return (this.parent as SecurityFormValues).password === value
40+
}),
41+
})
42+
43+
export type SecurityFormErrors = FormikErrors<SecurityFormValues>
44+
export interface SecurityFormProps {
45+
isLoading: boolean
46+
initialValues: SecurityFormValues
47+
onSubmit: (values: SecurityFormValues) => void
48+
formErrors?: SecurityFormErrors
49+
error?: string
50+
}
51+
52+
export const SecurityForm: React.FC<SecurityFormProps> = ({
53+
isLoading,
54+
onSubmit,
55+
initialValues,
56+
formErrors = {},
57+
error,
58+
}) => {
59+
const form: FormikContextType<SecurityFormValues> = useFormik<SecurityFormValues>({
60+
initialValues,
61+
validationSchema,
62+
onSubmit,
63+
})
64+
const getFieldHelpers = getFormHelpers<SecurityFormValues>(form, formErrors)
65+
66+
return (
67+
<>
68+
<form onSubmit={form.handleSubmit}>
69+
<Stack>
70+
<TextField
71+
{...getFieldHelpers("old_password")}
72+
onChange={onChangeTrimmed(form)}
73+
autoComplete="old_password"
74+
fullWidth
75+
label={Language.oldPasswordLabel}
76+
variant="outlined"
77+
type="password"
78+
/>
79+
<TextField
80+
{...getFieldHelpers("password")}
81+
onChange={onChangeTrimmed(form)}
82+
autoComplete="password"
83+
fullWidth
84+
label={Language.newPasswordLabel}
85+
variant="outlined"
86+
type="password"
87+
/>
88+
<TextField
89+
{...getFieldHelpers("confirm_password")}
90+
onChange={onChangeTrimmed(form)}
91+
autoComplete="confirm_password"
92+
fullWidth
93+
label={Language.confirmPasswordLabel}
94+
variant="outlined"
95+
type="password"
96+
/>
97+
98+
{error && <FormHelperText error>{error}</FormHelperText>}
99+
100+
<div>
101+
<LoadingButton loading={isLoading} type="submit" variant="contained">
102+
{isLoading ? "" : Language.updatePassword}
103+
</LoadingButton>
104+
</div>
105+
</Stack>
106+
</form>
107+
</>
108+
)
109+
}

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

-12
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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 SecurityForm from "../../../components/SettingsSecurityForm/SettingsSecurityForm"
6+
import { renderWithAuth } from "../../../testHelpers/renderHelpers"
7+
import * as AuthXService from "../../../xServices/auth/authXService"
8+
import { Language, SecurityPage } from "./SecurityPage"
9+
10+
const renderPage = () => {
11+
return renderWithAuth(
12+
<>
13+
<SecurityPage />
14+
<GlobalSnackbar />
15+
</>,
16+
)
17+
}
18+
19+
const newData = {
20+
old_password: "password1",
21+
password: "password2",
22+
confirm_password: "password2",
23+
}
24+
25+
const fillAndSubmitForm = async () => {
26+
await waitFor(() => screen.findByLabelText("Old Password"))
27+
fireEvent.change(screen.getByLabelText("Old Password"), { target: { value: newData.old_password } })
28+
fireEvent.change(screen.getByLabelText("New Password"), { target: { value: newData.password } })
29+
fireEvent.change(screen.getByLabelText("Confirm Password"), { target: { value: newData.confirm_password } })
30+
fireEvent.click(screen.getByText(SecurityForm.Language.updatePassword))
31+
}
32+
33+
describe("SecurityPage", () => {
34+
describe("when it is a success", () => {
35+
it("shows the success message", async () => {
36+
jest.spyOn(API, "updateUserPassword").mockImplementationOnce((_userId, _data) => Promise.resolve(undefined))
37+
const { user } = renderPage()
38+
await fillAndSubmitForm()
39+
40+
const successMessage = await screen.findByText(AuthXService.Language.successSecurityUpdate)
41+
expect(successMessage).toBeDefined()
42+
expect(API.updateUserPassword).toBeCalledTimes(1)
43+
expect(API.updateUserPassword).toBeCalledWith(user.id, newData)
44+
})
45+
})
46+
47+
describe("when the old_password is incorrect", () => {
48+
it("shows an error", async () => {
49+
jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce({
50+
isAxiosError: true,
51+
response: {
52+
data: { message: "Incorrect password.", errors: [{ detail: "Incorrect password.", field: "old_password" }] },
53+
},
54+
})
55+
56+
const { user } = renderPage()
57+
await fillAndSubmitForm()
58+
59+
const errorMessage = await screen.findByText("Incorrect password.")
60+
expect(errorMessage).toBeDefined()
61+
expect(API.updateUserPassword).toBeCalledTimes(1)
62+
expect(API.updateUserPassword).toBeCalledWith(user.id, newData)
63+
})
64+
})
65+
66+
describe("when the password is invalid", () => {
67+
it("shows an error", async () => {
68+
jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce({
69+
isAxiosError: true,
70+
response: {
71+
data: { message: "Invalid password.", errors: [{ detail: "Invalid password.", field: "password" }] },
72+
},
73+
})
74+
75+
const { user } = renderPage()
76+
await fillAndSubmitForm()
77+
78+
const errorMessage = await screen.findByText("Invalid password.")
79+
expect(errorMessage).toBeDefined()
80+
expect(API.updateUserPassword).toBeCalledTimes(1)
81+
expect(API.updateUserPassword).toBeCalledWith(user.id, newData)
82+
})
83+
})
84+
85+
describe("when it is an unknown error", () => {
86+
it("shows a generic error message", async () => {
87+
jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce({
88+
data: "unknown error",
89+
})
90+
91+
const { user } = renderPage()
92+
await fillAndSubmitForm()
93+
94+
const errorMessage = await screen.findByText(Language.unknownError)
95+
expect(errorMessage).toBeDefined()
96+
expect(API.updateUserPassword).toBeCalledTimes(1)
97+
expect(API.updateUserPassword).toBeCalledWith(user.id, newData)
98+
})
99+
})
100+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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 SecurityPage: React.FC = () => {
14+
const xServices = useContext(XServiceContext)
15+
const [authState, authSend] = useActor(xServices.authXService)
16+
const { me, updateSecurityError } = authState.context
17+
const hasError = !!updateSecurityError
18+
const formErrors =
19+
hasError && isApiError(updateSecurityError)
20+
? mapApiErrorToFieldErrors(updateSecurityError.response.data)
21+
: undefined
22+
const hasUnknownError = hasError && !isApiError(updateSecurityError)
23+
24+
if (!me) {
25+
throw new Error("No current user found")
26+
}
27+
28+
return (
29+
<Section title={Language.title}>
30+
<SecurityForm
31+
error={hasUnknownError ? Language.unknownError : undefined}
32+
formErrors={formErrors}
33+
isLoading={authState.matches("signedIn.security.updatingSecurity")}
34+
initialValues={{ old_password: "", password: "", confirm_password: "" }}
35+
onSubmit={(data) => {
36+
authSend({
37+
type: "UPDATE_SECURITY",
38+
data,
39+
})
40+
}}
41+
/>
42+
</Section>
43+
)
44+
}

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

+2-2
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

@@ -220,7 +220,7 @@ describe("Users Page", () => {
220220

221221
// Check if the API was called correctly
222222
expect(API.updateUserPassword).toBeCalledTimes(1)
223-
expect(API.updateUserPassword).toBeCalledWith(expect.any(String), MockUser.id)
223+
expect(API.updateUserPassword).toBeCalledWith(MockUser.id, { password: expect.any(String), old_password: "" })
224224
})
225225
})
226226
})

0 commit comments

Comments
 (0)