diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 90cf9c5472764..978ed067e9f4f 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -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 = () => ( }> } /> + } /> } /> diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 0697200681513..54ce7828f874d 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -223,8 +223,10 @@ export const suspendUser = async (userId: TypesGen.User["id"]): Promise => - axios.put(`/api/v2/users/${userId}/password`, { password }) +export const updateUserPassword = async ( + userId: TypesGen.User["id"], + updatePassword: TypesGen.UpdateUserPasswordRequest, +): Promise => axios.put(`/api/v2/users/${userId}/password`, updatePassword) export const getSiteRoles = async (): Promise> => { const response = await axios.get>(`/api/v2/users/roles`) diff --git a/site/src/components/SettingsLayout/SettingsLayout.tsx b/site/src/components/SettingsLayout/SettingsLayout.tsx index 0a1344b99c43d..786e7bc027418 100644 --- a/site/src/components/SettingsLayout/SettingsLayout.tsx +++ b/site/src/components/SettingsLayout/SettingsLayout.tsx @@ -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" }, ] diff --git a/site/src/components/SettingsSecurityForm/SettingsSecurityForm.tsx b/site/src/components/SettingsSecurityForm/SettingsSecurityForm.tsx new file mode 100644 index 0000000000000..d10726227f427 --- /dev/null +++ b/site/src/components/SettingsSecurityForm/SettingsSecurityForm.tsx @@ -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 +export interface SecurityFormProps { + isLoading: boolean + initialValues: SecurityFormValues + onSubmit: (values: SecurityFormValues) => void + formErrors?: SecurityFormErrors + error?: string +} + +export const SecurityForm: React.FC = ({ + isLoading, + onSubmit, + initialValues, + formErrors = {}, + error, +}) => { + const form: FormikContextType = useFormik({ + initialValues, + validationSchema, + onSubmit, + }) + const getFieldHelpers = getFormHelpers(form, formErrors) + + return ( + <> + + + + + + + {error && {error}} + + + + {isLoading ? "" : Language.updatePassword} + + + + + > + ) +} diff --git a/site/src/pages/SettingsPages/AccountPage/LinkedAccountsPage.tsx b/site/src/pages/SettingsPages/AccountPage/LinkedAccountsPage.tsx deleted file mode 100644 index 994492cc91f48..0000000000000 --- a/site/src/pages/SettingsPages/AccountPage/LinkedAccountsPage.tsx +++ /dev/null @@ -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 -} diff --git a/site/src/pages/SettingsPages/SecurityPage/SecurityPage.test.tsx b/site/src/pages/SettingsPages/SecurityPage/SecurityPage.test.tsx new file mode 100644 index 0000000000000..b6301fc0601c1 --- /dev/null +++ b/site/src/pages/SettingsPages/SecurityPage/SecurityPage.test.tsx @@ -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( + <> + + + >, + ) +} + +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) + }) + }) +}) diff --git a/site/src/pages/SettingsPages/SecurityPage/SecurityPage.tsx b/site/src/pages/SettingsPages/SecurityPage/SecurityPage.tsx new file mode 100644 index 0000000000000..49157467b51c6 --- /dev/null +++ b/site/src/pages/SettingsPages/SecurityPage/SecurityPage.tsx @@ -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 ( + + { + authSend({ + type: "UPDATE_SECURITY", + data, + }) + }} + /> + + ) +} diff --git a/site/src/pages/UsersPage/UsersPage.test.tsx b/site/src/pages/UsersPage/UsersPage.test.tsx index d0671b068e3c2..e50388f89b3b2 100644 --- a/site/src/pages/UsersPage/UsersPage.test.tsx +++ b/site/src/pages/UsersPage/UsersPage.test.tsx @@ -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: "" }) }) }) }) diff --git a/site/src/xServices/auth/authXService.ts b/site/src/xServices/auth/authXService.ts index 3e634fc8b58d8..cca8aae0a8f1c 100644 --- a/site/src/xServices/auth/authXService.ts +++ b/site/src/xServices/auth/authXService.ts @@ -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. diff --git a/site/src/xServices/users/usersXService.ts b/site/src/xServices/users/usersXService.ts index ba467c03df237..c21219ae6e1be 100644 --- a/site/src/xServices/users/usersXService.ts +++ b/site/src/xServices/users/usersXService.ts @@ -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) {