mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add user password change page (#1866)
This commit is contained in:
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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: "" })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user