feat: add user password change page (#1866)

This commit is contained in:
Garrett Delfosse
2022-05-27 18:08:28 -04:00
committed by GitHub
parent 8cd7d4fa9c
commit 8222bdc3bc
10 changed files with 324 additions and 17 deletions
+2
View File
@@ -9,6 +9,7 @@ import { CliAuthenticationPage } from "./pages/CliAuthPage/CliAuthPage"
import { HealthzPage } from "./pages/HealthzPage/HealthzPage"
import { LoginPage } from "./pages/LoginPage/LoginPage"
import { AccountPage } from "./pages/SettingsPages/AccountPage/AccountPage"
import { SecurityPage } from "./pages/SettingsPages/SecurityPage/SecurityPage"
import { SSHKeysPage } from "./pages/SettingsPages/SSHKeysPage/SSHKeysPage"
import { TemplatePage } from "./pages/TemplatePage/TemplatePage"
import TemplatesPage from "./pages/TemplatesPage/TemplatesPage"
@@ -126,6 +127,7 @@ export const AppRouter: React.FC = () => (
<Route path="settings" element={<SettingsLayout />}>
<Route path="account" element={<AccountPage />} />
<Route path="security" element={<SecurityPage />} />
<Route path="ssh-keys" element={<SSHKeysPage />} />
</Route>
+4 -2
View File
@@ -223,8 +223,10 @@ export const suspendUser = async (userId: TypesGen.User["id"]): Promise<TypesGen
return response.data
}
export const updateUserPassword = async (password: string, userId: TypesGen.User["id"]): Promise<undefined> =>
axios.put(`/api/v2/users/${userId}/password`, { password })
export const updateUserPassword = async (
userId: TypesGen.User["id"],
updatePassword: TypesGen.UpdateUserPasswordRequest,
): Promise<undefined> => axios.put(`/api/v2/users/${userId}/password`, updatePassword)
export const getSiteRoles = async (): Promise<Array<TypesGen.Role>> => {
const response = await axios.get<Array<TypesGen.Role>>(`/api/v2/users/roles`)
@@ -7,12 +7,14 @@ import { TabPanel } from "../TabPanel/TabPanel"
export const Language = {
accountLabel: "Account",
securityLabel: "Security",
sshKeysLabel: "SSH keys",
settingsLabel: "Settings",
}
const menuItems = [
{ label: Language.accountLabel, path: "/settings/account" },
{ label: Language.securityLabel, path: "/settings/security" },
{ label: Language.sshKeysLabel, path: "/settings/ssh-keys" },
]
@@ -0,0 +1,109 @@
import FormHelperText from "@material-ui/core/FormHelperText"
import TextField from "@material-ui/core/TextField"
import { FormikContextType, FormikErrors, useFormik } from "formik"
import React from "react"
import * as Yup from "yup"
import { getFormHelpers, onChangeTrimmed } from "../../util/formUtils"
import { LoadingButton } from "../LoadingButton/LoadingButton"
import { Stack } from "../Stack/Stack"
interface SecurityFormValues {
old_password: string
password: string
confirm_password: string
}
export const Language = {
oldPasswordLabel: "Old Password",
newPasswordLabel: "New Password",
confirmPasswordLabel: "Confirm Password",
oldPasswordRequired: "Old password is required",
newPasswordRequired: "New password is required",
confirmPasswordRequired: "Password confirmation is required",
passwordMinLength: "Password must be at least 8 characters",
passwordMaxLength: "Password must be no more than 64 characters",
confirmPasswordMatch: "Password and confirmation must match",
updatePassword: "Update password",
}
const validationSchema = Yup.object({
old_password: Yup.string().trim().required(Language.oldPasswordRequired),
password: Yup.string()
.trim()
.min(8, Language.passwordMinLength)
.max(64, Language.passwordMaxLength)
.required(Language.newPasswordRequired),
confirm_password: Yup.string()
.trim()
.test("passwords-match", Language.confirmPasswordMatch, function (value) {
return (this.parent as SecurityFormValues).password === value
}),
})
export type SecurityFormErrors = FormikErrors<SecurityFormValues>
export interface SecurityFormProps {
isLoading: boolean
initialValues: SecurityFormValues
onSubmit: (values: SecurityFormValues) => void
formErrors?: SecurityFormErrors
error?: string
}
export const SecurityForm: React.FC<SecurityFormProps> = ({
isLoading,
onSubmit,
initialValues,
formErrors = {},
error,
}) => {
const form: FormikContextType<SecurityFormValues> = useFormik<SecurityFormValues>({
initialValues,
validationSchema,
onSubmit,
})
const getFieldHelpers = getFormHelpers<SecurityFormValues>(form, formErrors)
return (
<>
<form onSubmit={form.handleSubmit}>
<Stack>
<TextField
{...getFieldHelpers("old_password")}
onChange={onChangeTrimmed(form)}
autoComplete="old_password"
fullWidth
label={Language.oldPasswordLabel}
variant="outlined"
type="password"
/>
<TextField
{...getFieldHelpers("password")}
onChange={onChangeTrimmed(form)}
autoComplete="password"
fullWidth
label={Language.newPasswordLabel}
variant="outlined"
type="password"
/>
<TextField
{...getFieldHelpers("confirm_password")}
onChange={onChangeTrimmed(form)}
autoComplete="confirm_password"
fullWidth
label={Language.confirmPasswordLabel}
variant="outlined"
type="password"
/>
{error && <FormHelperText error>{error}</FormHelperText>}
<div>
<LoadingButton loading={isLoading} type="submit" variant="contained">
{isLoading ? "" : Language.updatePassword}
</LoadingButton>
</div>
</Stack>
</form>
</>
)
}
@@ -1,12 +0,0 @@
import React from "react"
import { Section } from "../../../components/Section/Section"
const Language = {
title: "Linked Accounts",
description:
"Linking your Coder account will add your workspace SSH key, allowing you to perform Git actions on all your workspaces.",
}
export const LinkedAccountsPage: React.FC = () => {
return <Section title={Language.title} description={Language.description} />
}
@@ -0,0 +1,100 @@
import { fireEvent, screen, waitFor } from "@testing-library/react"
import React from "react"
import * as API from "../../../api/api"
import { GlobalSnackbar } from "../../../components/GlobalSnackbar/GlobalSnackbar"
import * as SecurityForm from "../../../components/SettingsSecurityForm/SettingsSecurityForm"
import { renderWithAuth } from "../../../testHelpers/renderHelpers"
import * as AuthXService from "../../../xServices/auth/authXService"
import { Language, SecurityPage } from "./SecurityPage"
const renderPage = () => {
return renderWithAuth(
<>
<SecurityPage />
<GlobalSnackbar />
</>,
)
}
const newData = {
old_password: "password1",
password: "password2",
confirm_password: "password2",
}
const fillAndSubmitForm = async () => {
await waitFor(() => screen.findByLabelText("Old Password"))
fireEvent.change(screen.getByLabelText("Old Password"), { target: { value: newData.old_password } })
fireEvent.change(screen.getByLabelText("New Password"), { target: { value: newData.password } })
fireEvent.change(screen.getByLabelText("Confirm Password"), { target: { value: newData.confirm_password } })
fireEvent.click(screen.getByText(SecurityForm.Language.updatePassword))
}
describe("SecurityPage", () => {
describe("when it is a success", () => {
it("shows the success message", async () => {
jest.spyOn(API, "updateUserPassword").mockImplementationOnce((_userId, _data) => Promise.resolve(undefined))
const { user } = renderPage()
await fillAndSubmitForm()
const successMessage = await screen.findByText(AuthXService.Language.successSecurityUpdate)
expect(successMessage).toBeDefined()
expect(API.updateUserPassword).toBeCalledTimes(1)
expect(API.updateUserPassword).toBeCalledWith(user.id, newData)
})
})
describe("when the old_password is incorrect", () => {
it("shows an error", async () => {
jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce({
isAxiosError: true,
response: {
data: { message: "Incorrect password.", errors: [{ detail: "Incorrect password.", field: "old_password" }] },
},
})
const { user } = renderPage()
await fillAndSubmitForm()
const errorMessage = await screen.findByText("Incorrect password.")
expect(errorMessage).toBeDefined()
expect(API.updateUserPassword).toBeCalledTimes(1)
expect(API.updateUserPassword).toBeCalledWith(user.id, newData)
})
})
describe("when the password is invalid", () => {
it("shows an error", async () => {
jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce({
isAxiosError: true,
response: {
data: { message: "Invalid password.", errors: [{ detail: "Invalid password.", field: "password" }] },
},
})
const { user } = renderPage()
await fillAndSubmitForm()
const errorMessage = await screen.findByText("Invalid password.")
expect(errorMessage).toBeDefined()
expect(API.updateUserPassword).toBeCalledTimes(1)
expect(API.updateUserPassword).toBeCalledWith(user.id, newData)
})
})
describe("when it is an unknown error", () => {
it("shows a generic error message", async () => {
jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce({
data: "unknown error",
})
const { user } = renderPage()
await fillAndSubmitForm()
const errorMessage = await screen.findByText(Language.unknownError)
expect(errorMessage).toBeDefined()
expect(API.updateUserPassword).toBeCalledTimes(1)
expect(API.updateUserPassword).toBeCalledWith(user.id, newData)
})
})
})
@@ -0,0 +1,44 @@
import { useActor } from "@xstate/react"
import React, { useContext } from "react"
import { isApiError, mapApiErrorToFieldErrors } from "../../../api/errors"
import { Section } from "../../../components/Section/Section"
import { SecurityForm } from "../../../components/SettingsSecurityForm/SettingsSecurityForm"
import { XServiceContext } from "../../../xServices/StateContext"
export const Language = {
title: "Security",
unknownError: "Oops, an unknown error occurred.",
}
export const SecurityPage: React.FC = () => {
const xServices = useContext(XServiceContext)
const [authState, authSend] = useActor(xServices.authXService)
const { me, updateSecurityError } = authState.context
const hasError = !!updateSecurityError
const formErrors =
hasError && isApiError(updateSecurityError)
? mapApiErrorToFieldErrors(updateSecurityError.response.data)
: undefined
const hasUnknownError = hasError && !isApiError(updateSecurityError)
if (!me) {
throw new Error("No current user found")
}
return (
<Section title={Language.title}>
<SecurityForm
error={hasUnknownError ? Language.unknownError : undefined}
formErrors={formErrors}
isLoading={authState.matches("signedIn.security.updatingSecurity")}
initialValues={{ old_password: "", password: "", confirm_password: "" }}
onSubmit={(data) => {
authSend({
type: "UPDATE_SECURITY",
data,
})
}}
/>
</Section>
)
}
+2 -2
View File
@@ -198,7 +198,7 @@ describe("Users Page", () => {
// Check if the API was called correctly
expect(API.updateUserPassword).toBeCalledTimes(1)
expect(API.updateUserPassword).toBeCalledWith(expect.any(String), MockUser.id)
expect(API.updateUserPassword).toBeCalledWith(MockUser.id, { password: expect.any(String), old_password: "" })
})
})
@@ -220,7 +220,7 @@ describe("Users Page", () => {
// Check if the API was called correctly
expect(API.updateUserPassword).toBeCalledTimes(1)
expect(API.updateUserPassword).toBeCalledWith(expect.any(String), MockUser.id)
expect(API.updateUserPassword).toBeCalledWith(MockUser.id, { password: expect.any(String), old_password: "" })
})
})
})
+57
View File
@@ -5,6 +5,7 @@ import { displayError, displaySuccess } from "../../components/GlobalSnackbar/ut
export const Language = {
successProfileUpdate: "Updated settings.",
successSecurityUpdate: "Updated password.",
successRegenerateSSHKey: "SSH Key regenerated successfully",
errorRegenerateSSHKey: "Error on regenerate the SSH Key",
}
@@ -50,6 +51,7 @@ export interface AuthContext {
getMethodsError?: Error | unknown
authError?: Error | unknown
updateProfileError?: Error | unknown
updateSecurityError?: Error | unknown
me?: TypesGen.User
methods?: TypesGen.AuthMethods
permissions?: Permissions
@@ -64,6 +66,7 @@ export type AuthEvent =
| { type: "SIGN_OUT" }
| { type: "SIGN_IN"; email: string; password: string }
| { type: "UPDATE_PROFILE"; data: TypesGen.UpdateUserProfileRequest }
| { type: "UPDATE_SECURITY"; data: TypesGen.UpdateUserPasswordRequest }
| { type: "GET_SSH_KEY" }
| { type: "REGENERATE_SSH_KEY" }
| { type: "CONFIRM_REGENERATE_SSH_KEY" }
@@ -165,6 +168,9 @@ export const authMachine =
updateProfile: {
data: TypesGen.User
}
updateSecurity: {
data: undefined
}
checkPermissions: {
data: TypesGen.UserAuthorizationResponse
}
@@ -302,6 +308,41 @@ export const authMachine =
},
},
ssh: sshState,
security: {
initial: "idle",
states: {
idle: {
initial: "noError",
states: {
noError: {},
error: {},
},
on: {
UPDATE_SECURITY: {
target: "updatingSecurity",
},
},
},
updatingSecurity: {
entry: "clearUpdateSecurityError",
invoke: {
src: "updateSecurity",
onDone: [
{
actions: ["notifySuccessSecurityUpdate"],
target: "#authState.signedIn.security.idle.noError",
},
],
onError: [
{
actions: "assignUpdateSecurityError",
target: "#authState.signedIn.security.idle.error",
},
],
},
},
},
},
},
on: {
SIGN_OUT: {
@@ -345,6 +386,13 @@ export const authMachine =
return API.updateProfile(context.me.id, event.data)
},
updateSecurity: async (context, event) => {
if (!context.me) {
throw new Error("No current user found")
}
return API.updateUserPassword(context.me.id, event.data)
},
checkPermissions: async (context) => {
if (!context.me) {
throw new Error("No current user found")
@@ -399,6 +447,15 @@ export const authMachine =
clearUpdateProfileError: assign({
updateProfileError: (_) => undefined,
}),
clearUpdateSecurityError: assign({
updateSecurityError: (_) => undefined,
}),
notifySuccessSecurityUpdate: () => {
displaySuccess(Language.successSecurityUpdate)
},
assignUpdateSecurityError: assign({
updateSecurityError: (_, event) => event.data,
}),
assignPermissions: assign({
// Setting event.data as Permissions to be more stricted. So we know
// what permissions we asked for.
+4 -1
View File
@@ -221,7 +221,10 @@ export const usersMachine = createMachine(
throw new Error("newUserPassword not generated")
}
return API.updateUserPassword(context.newUserPassword, context.userIdToResetPassword)
return API.updateUserPassword(context.userIdToResetPassword, {
password: context.newUserPassword,
old_password: "",
})
},
updateUserRoles: (context, event) => {
if (!context.userIdToUpdateRoles) {