fix: manage backend authXService errors (#3190)

This commit is contained in:
Abhineet Jain
2022-07-26 15:39:45 -04:00
committed by GitHub
parent b19cf701c5
commit 0128ca6bd1
15 changed files with 230 additions and 118 deletions
@@ -9,7 +9,7 @@ import { ApiError, getErrorDetail, getErrorMessage } from "api/errors"
import { Stack } from "components/Stack/Stack" import { Stack } from "components/Stack/Stack"
import { FC, useState } from "react" import { FC, useState } from "react"
const Language = { export const Language = {
retryMessage: "Retry", retryMessage: "Retry",
unknownErrorMessage: "An unknown error has occurred", unknownErrorMessage: "An unknown error has occurred",
moreDetails: "More", moreDetails: "More",
@@ -91,7 +91,6 @@ interface StyleProps {
const useStyles = makeStyles<Theme, StyleProps>((theme) => ({ const useStyles = makeStyles<Theme, StyleProps>((theme) => ({
root: { root: {
background: darken(theme.palette.error.main, 0.6), background: darken(theme.palette.error.main, 0.6),
margin: `${theme.spacing(2)}px`,
padding: `${theme.spacing(2)}px`, padding: `${theme.spacing(2)}px`,
borderRadius: theme.shape.borderRadius, borderRadius: theme.shape.borderRadius,
gap: 0, gap: 0,
@@ -0,0 +1,53 @@
import { Story } from "@storybook/react"
import { AccountForm, AccountFormProps } from "./SettingsAccountForm"
export default {
title: "components/SettingsAccountForm",
component: AccountForm,
argTypes: {
onSubmit: { action: "Submit" },
},
}
const Template: Story<AccountFormProps> = (args: AccountFormProps) => <AccountForm {...args} />
export const Example = Template.bind({})
Example.args = {
email: "test-user@org.com",
isLoading: false,
initialValues: {
username: "test-user",
},
updateProfileError: undefined,
onSubmit: () => {
return Promise.resolve()
},
}
export const Loading = Template.bind({})
Loading.args = {
...Example.args,
isLoading: true,
}
export const WithError = Template.bind({})
WithError.args = {
...Example.args,
updateProfileError: {
response: {
data: {
message: "Username is invalid",
validations: [
{
field: "username",
detail: "Username is too long.",
},
],
},
},
isAxiosError: true,
},
initialTouched: {
username: true,
},
}
@@ -1,9 +1,9 @@
import FormHelperText from "@material-ui/core/FormHelperText"
import TextField from "@material-ui/core/TextField" import TextField from "@material-ui/core/TextField"
import { FormikContextType, FormikErrors, useFormik } from "formik" import { ErrorSummary } from "components/ErrorSummary/ErrorSummary"
import { FormikContextType, FormikTouched, useFormik } from "formik"
import { FC } from "react" import { FC } from "react"
import * as Yup from "yup" import * as Yup from "yup"
import { getFormHelpers, nameValidator, onChangeTrimmed } from "../../util/formUtils" import { getFormHelpersWithError, nameValidator, onChangeTrimmed } from "../../util/formUtils"
import { LoadingButton } from "../LoadingButton/LoadingButton" import { LoadingButton } from "../LoadingButton/LoadingButton"
import { Stack } from "../Stack/Stack" import { Stack } from "../Stack/Stack"
@@ -21,15 +21,14 @@ const validationSchema = Yup.object({
username: nameValidator(Language.usernameLabel), username: nameValidator(Language.usernameLabel),
}) })
export type AccountFormErrors = FormikErrors<AccountFormValues>
export interface AccountFormProps { export interface AccountFormProps {
email: string email: string
isLoading: boolean isLoading: boolean
initialValues: AccountFormValues initialValues: AccountFormValues
onSubmit: (values: AccountFormValues) => void onSubmit: (values: AccountFormValues) => void
formErrors?: AccountFormErrors updateProfileError?: Error | unknown
error?: string // initialTouched is only used for testing the error state of the form.
initialTouched?: FormikTouched<AccountFormValues>
} }
export const AccountForm: FC<AccountFormProps> = ({ export const AccountForm: FC<AccountFormProps> = ({
@@ -37,20 +36,22 @@ export const AccountForm: FC<AccountFormProps> = ({
isLoading, isLoading,
onSubmit, onSubmit,
initialValues, initialValues,
formErrors = {}, updateProfileError,
error, initialTouched,
}) => { }) => {
const form: FormikContextType<AccountFormValues> = useFormik<AccountFormValues>({ const form: FormikContextType<AccountFormValues> = useFormik<AccountFormValues>({
initialValues, initialValues,
validationSchema, validationSchema,
onSubmit, onSubmit,
initialTouched,
}) })
const getFieldHelpers = getFormHelpers<AccountFormValues>(form, formErrors) const getFieldHelpers = getFormHelpersWithError<AccountFormValues>(form, updateProfileError)
return ( return (
<> <>
<form onSubmit={form.handleSubmit}> <form onSubmit={form.handleSubmit}>
<Stack> <Stack>
{updateProfileError && <ErrorSummary error={updateProfileError} />}
<TextField <TextField
disabled disabled
fullWidth fullWidth
@@ -67,8 +68,6 @@ export const AccountForm: FC<AccountFormProps> = ({
variant="outlined" variant="outlined"
/> />
{error && <FormHelperText error>{error}</FormHelperText>}
<div> <div>
<LoadingButton loading={isLoading} type="submit" variant="contained"> <LoadingButton loading={isLoading} type="submit" variant="contained">
{isLoading ? "" : Language.updateSettings} {isLoading ? "" : Language.updateSettings}
@@ -0,0 +1,54 @@
import { Story } from "@storybook/react"
import { SecurityForm, SecurityFormProps } from "./SettingsSecurityForm"
export default {
title: "components/SettingsSecurityForm",
component: SecurityForm,
argTypes: {
onSubmit: { action: "Submit" },
},
}
const Template: Story<SecurityFormProps> = (args: SecurityFormProps) => <SecurityForm {...args} />
export const Example = Template.bind({})
Example.args = {
isLoading: false,
initialValues: {
old_password: "",
password: "",
confirm_password: "",
},
updateSecurityError: undefined,
onSubmit: () => {
return Promise.resolve()
},
}
export const Loading = Template.bind({})
Loading.args = {
...Example.args,
isLoading: true,
}
export const WithError = Template.bind({})
WithError.args = {
...Example.args,
updateSecurityError: {
response: {
data: {
message: "Old password is incorrect",
validations: [
{
field: "old_password",
detail: "Old password is incorrect.",
},
],
},
},
isAxiosError: true,
},
initialTouched: {
old_password: true,
},
}
@@ -1,9 +1,9 @@
import FormHelperText from "@material-ui/core/FormHelperText"
import TextField from "@material-ui/core/TextField" import TextField from "@material-ui/core/TextField"
import { FormikContextType, FormikErrors, useFormik } from "formik" import { ErrorSummary } from "components/ErrorSummary/ErrorSummary"
import { FormikContextType, FormikTouched, useFormik } from "formik"
import React from "react" import React from "react"
import * as Yup from "yup" import * as Yup from "yup"
import { getFormHelpers, onChangeTrimmed } from "../../util/formUtils" import { getFormHelpersWithError, onChangeTrimmed } from "../../util/formUtils"
import { LoadingButton } from "../LoadingButton/LoadingButton" import { LoadingButton } from "../LoadingButton/LoadingButton"
import { Stack } from "../Stack/Stack" import { Stack } from "../Stack/Stack"
@@ -40,33 +40,35 @@ const validationSchema = Yup.object({
}), }),
}) })
export type SecurityFormErrors = FormikErrors<SecurityFormValues>
export interface SecurityFormProps { export interface SecurityFormProps {
isLoading: boolean isLoading: boolean
initialValues: SecurityFormValues initialValues: SecurityFormValues
onSubmit: (values: SecurityFormValues) => void onSubmit: (values: SecurityFormValues) => void
formErrors?: SecurityFormErrors updateSecurityError?: Error | unknown
error?: string // initialTouched is only used for testing the error state of the form.
initialTouched?: FormikTouched<SecurityFormValues>
} }
export const SecurityForm: React.FC<SecurityFormProps> = ({ export const SecurityForm: React.FC<SecurityFormProps> = ({
isLoading, isLoading,
onSubmit, onSubmit,
initialValues, initialValues,
formErrors = {}, updateSecurityError,
error, initialTouched,
}) => { }) => {
const form: FormikContextType<SecurityFormValues> = useFormik<SecurityFormValues>({ const form: FormikContextType<SecurityFormValues> = useFormik<SecurityFormValues>({
initialValues, initialValues,
validationSchema, validationSchema,
onSubmit, onSubmit,
initialTouched,
}) })
const getFieldHelpers = getFormHelpers<SecurityFormValues>(form, formErrors) const getFieldHelpers = getFormHelpersWithError<SecurityFormValues>(form, updateSecurityError)
return ( return (
<> <>
<form onSubmit={form.handleSubmit}> <form onSubmit={form.handleSubmit}>
<Stack> <Stack>
{updateSecurityError && <ErrorSummary error={updateSecurityError} />}
<TextField <TextField
{...getFieldHelpers("old_password")} {...getFieldHelpers("old_password")}
onChange={onChangeTrimmed(form)} onChange={onChangeTrimmed(form)}
@@ -95,8 +97,6 @@ export const SecurityForm: React.FC<SecurityFormProps> = ({
type="password" type="password"
/> />
{error && <FormHelperText error>{error}</FormHelperText>}
<div> <div>
<LoadingButton loading={isLoading} type="submit" variant="contained"> <LoadingButton loading={isLoading} type="submit" variant="contained">
{isLoading ? "" : Language.updatePassword} {isLoading ? "" : Language.updatePassword}
@@ -6,7 +6,6 @@ export default {
component: SignInForm, component: SignInForm,
argTypes: { argTypes: {
isLoading: "boolean", isLoading: "boolean",
authErrorMessage: "string",
onSubmit: { action: "Submit" }, onSubmit: { action: "Submit" },
}, },
} }
@@ -16,7 +15,7 @@ const Template: Story<SignInFormProps> = (args: SignInFormProps) => <SignInForm
export const SignedOut = Template.bind({}) export const SignedOut = Template.bind({})
SignedOut.args = { SignedOut.args = {
isLoading: false, isLoading: false,
authErrorMessage: undefined, authError: undefined,
onSubmit: () => { onSubmit: () => {
return Promise.resolve() return Promise.resolve()
}, },
@@ -33,12 +32,31 @@ Loading.args = {
} }
export const WithLoginError = Template.bind({}) export const WithLoginError = Template.bind({})
WithLoginError.args = { ...SignedOut.args, authErrorMessage: "Email or password was invalid" } WithLoginError.args = {
...SignedOut.args,
authError: {
response: {
data: {
message: "Email or password was invalid",
validations: [
{
field: "password",
detail: "Password is invalid.",
},
],
},
},
isAxiosError: true,
},
initialTouched: {
password: true,
},
}
export const WithAuthMethodsError = Template.bind({}) export const WithAuthMethodsError = Template.bind({})
WithAuthMethodsError.args = { WithAuthMethodsError.args = {
...SignedOut.args, ...SignedOut.args,
methodsErrorMessage: "Failed to fetch auth methods", methodsError: new Error("Failed to fetch auth methods"),
} }
export const WithGithub = Template.bind({}) export const WithGithub = Template.bind({})
+45 -49
View File
@@ -1,14 +1,15 @@
import Button from "@material-ui/core/Button" import Button from "@material-ui/core/Button"
import FormHelperText from "@material-ui/core/FormHelperText"
import Link from "@material-ui/core/Link" import Link from "@material-ui/core/Link"
import { makeStyles } from "@material-ui/core/styles" import { makeStyles } from "@material-ui/core/styles"
import TextField from "@material-ui/core/TextField" import TextField from "@material-ui/core/TextField"
import GitHubIcon from "@material-ui/icons/GitHub" import GitHubIcon from "@material-ui/icons/GitHub"
import { FormikContextType, useFormik } from "formik" import { ErrorSummary } from "components/ErrorSummary/ErrorSummary"
import { Stack } from "components/Stack/Stack"
import { FormikContextType, FormikTouched, useFormik } from "formik"
import { FC } from "react" import { FC } from "react"
import * as Yup from "yup" import * as Yup from "yup"
import { AuthMethods } from "../../api/typesGenerated" import { AuthMethods } from "../../api/typesGenerated"
import { getFormHelpers, onChangeTrimmed } from "../../util/formUtils" import { getFormHelpersWithError, onChangeTrimmed } from "../../util/formUtils"
import { Welcome } from "../Welcome/Welcome" import { Welcome } from "../Welcome/Welcome"
import { LoadingButton } from "./../LoadingButton/LoadingButton" import { LoadingButton } from "./../LoadingButton/LoadingButton"
@@ -39,17 +40,6 @@ const validationSchema = Yup.object({
}) })
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
loginBtnWrapper: {
marginTop: theme.spacing(6),
borderTop: `1px solid ${theme.palette.action.disabled}`,
paddingTop: theme.spacing(3),
},
loginTextField: {
marginTop: theme.spacing(2),
},
submitBtn: {
marginTop: theme.spacing(2),
},
buttonIcon: { buttonIcon: {
width: 14, width: 14,
height: 14, height: 14,
@@ -78,19 +68,22 @@ const useStyles = makeStyles((theme) => ({
export interface SignInFormProps { export interface SignInFormProps {
isLoading: boolean isLoading: boolean
redirectTo: string redirectTo: string
authErrorMessage?: string authError?: Error | unknown
methodsErrorMessage?: string methodsError?: Error | unknown
authMethods?: AuthMethods authMethods?: AuthMethods
onSubmit: ({ email, password }: { email: string; password: string }) => Promise<void> onSubmit: ({ email, password }: { email: string; password: string }) => Promise<void>
// initialTouched is only used for testing the error state of the form.
initialTouched?: FormikTouched<BuiltInAuthFormValues>
} }
export const SignInForm: FC<SignInFormProps> = ({ export const SignInForm: FC<SignInFormProps> = ({
authMethods, authMethods,
redirectTo, redirectTo,
isLoading, isLoading,
authErrorMessage, authError,
methodsErrorMessage, methodsError,
onSubmit, onSubmit,
initialTouched,
}) => { }) => {
const styles = useStyles() const styles = useStyles()
@@ -106,43 +99,46 @@ export const SignInForm: FC<SignInFormProps> = ({
// field), or after a submission attempt. // field), or after a submission attempt.
validateOnBlur: false, validateOnBlur: false,
onSubmit, onSubmit,
initialTouched,
}) })
const getFieldHelpers = getFormHelpers<BuiltInAuthFormValues>(form) const getFieldHelpers = getFormHelpersWithError<BuiltInAuthFormValues>(form, authError)
return ( return (
<> <>
<Welcome /> <Welcome />
<form onSubmit={form.handleSubmit}> <form onSubmit={form.handleSubmit}>
<TextField <Stack>
{...getFieldHelpers("email")} {authError && (
onChange={onChangeTrimmed(form)} <ErrorSummary error={authError} defaultMessage={Language.authErrorMessage} />
autoFocus )}
autoComplete="email" {methodsError && (
className={styles.loginTextField} <ErrorSummary error={methodsError} defaultMessage={Language.methodsErrorMessage} />
fullWidth )}
label={Language.emailLabel} <TextField
type="email" {...getFieldHelpers("email")}
variant="outlined" onChange={onChangeTrimmed(form)}
/> autoFocus
<TextField autoComplete="email"
{...getFieldHelpers("password")} fullWidth
autoComplete="current-password" label={Language.emailLabel}
className={styles.loginTextField} type="email"
fullWidth variant="outlined"
id="password" />
label={Language.passwordLabel} <TextField
type="password" {...getFieldHelpers("password")}
variant="outlined" autoComplete="current-password"
/> fullWidth
{authErrorMessage && <FormHelperText error>{authErrorMessage}</FormHelperText>} id="password"
{methodsErrorMessage && ( label={Language.passwordLabel}
<FormHelperText error>{Language.methodsErrorMessage}</FormHelperText> type="password"
)} variant="outlined"
<div className={styles.submitBtn}> />
<LoadingButton loading={isLoading} fullWidth type="submit" variant="contained"> <div>
{isLoading ? "" : Language.passwordSignIn} <LoadingButton loading={isLoading} fullWidth type="submit" variant="contained">
</LoadingButton> {isLoading ? "" : Language.passwordSignIn}
</div> </LoadingButton>
</div>
</Stack>
</form> </form>
{authMethods?.github && ( {authMethods?.github && (
<> <>
+3 -2
View File
@@ -52,10 +52,11 @@ describe("LoginPage", () => {
it("shows an error if fetching auth methods fails", async () => { it("shows an error if fetching auth methods fails", async () => {
// Given // Given
const apiErrorMessage = "Unable to fetch methods"
server.use( server.use(
// Make login fail // Make login fail
rest.get("/api/v2/users/authmethods", async (req, res, ctx) => { rest.get("/api/v2/users/authmethods", async (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ message: "nope" })) return res(ctx.status(500), ctx.json({ message: apiErrorMessage }))
}), }),
) )
@@ -63,7 +64,7 @@ describe("LoginPage", () => {
render(<LoginPage />) render(<LoginPage />)
// Then // Then
const errorMessage = await screen.findByText(Language.methodsErrorMessage) const errorMessage = await screen.findByText(apiErrorMessage)
expect(errorMessage).toBeDefined() expect(errorMessage).toBeDefined()
}) })
+2 -9
View File
@@ -3,7 +3,6 @@ import { useActor } from "@xstate/react"
import React, { useContext } from "react" import React, { useContext } from "react"
import { Helmet } from "react-helmet" import { Helmet } from "react-helmet"
import { Navigate, useLocation } from "react-router-dom" import { Navigate, useLocation } from "react-router-dom"
import { isApiError } from "../../api/errors"
import { Footer } from "../../components/Footer/Footer" import { Footer } from "../../components/Footer/Footer"
import { SignInForm } from "../../components/SignInForm/SignInForm" import { SignInForm } from "../../components/SignInForm/SignInForm"
import { pageTitle } from "../../util/page" import { pageTitle } from "../../util/page"
@@ -36,12 +35,6 @@ export const LoginPage: React.FC = () => {
const [authState, authSend] = useActor(xServices.authXService) const [authState, authSend] = useActor(xServices.authXService)
const isLoading = authState.hasTag("loading") const isLoading = authState.hasTag("loading")
const redirectTo = retrieveRedirect(location.search) const redirectTo = retrieveRedirect(location.search)
const authErrorMessage = isApiError(authState.context.authError)
? authState.context.authError.response.data.message
: undefined
const getMethodsError = authState.context.getMethodsError
? (authState.context.getMethodsError as Error).message
: undefined
const onSubmit = async ({ email, password }: { email: string; password: string }) => { const onSubmit = async ({ email, password }: { email: string; password: string }) => {
authSend({ type: "SIGN_IN", email, password }) authSend({ type: "SIGN_IN", email, password })
@@ -61,8 +54,8 @@ export const LoginPage: React.FC = () => {
authMethods={authState.context.methods} authMethods={authState.context.methods}
redirectTo={redirectTo} redirectTo={redirectTo}
isLoading={isLoading} isLoading={isLoading}
authErrorMessage={authErrorMessage} authError={authState.context.authError}
methodsErrorMessage={getMethodsError} methodsError={authState.context.getMethodsError as Error}
onSubmit={onSubmit} onSubmit={onSubmit}
/> />
</div> </div>
@@ -1,10 +1,11 @@
import { fireEvent, screen, waitFor } from "@testing-library/react" import { fireEvent, screen, waitFor } from "@testing-library/react"
import { Language as ErrorSummaryLanguage } from "components/ErrorSummary/ErrorSummary"
import * as API from "../../../api/api" import * as API from "../../../api/api"
import { GlobalSnackbar } from "../../../components/GlobalSnackbar/GlobalSnackbar" import { GlobalSnackbar } from "../../../components/GlobalSnackbar/GlobalSnackbar"
import * as AccountForm from "../../../components/SettingsAccountForm/SettingsAccountForm" import * as AccountForm from "../../../components/SettingsAccountForm/SettingsAccountForm"
import { renderWithAuth } from "../../../testHelpers/renderHelpers" import { renderWithAuth } from "../../../testHelpers/renderHelpers"
import * as AuthXService from "../../../xServices/auth/authXService" import * as AuthXService from "../../../xServices/auth/authXService"
import { AccountPage, Language } from "./AccountPage" import { AccountPage } from "./AccountPage"
const renderPage = () => { const renderPage = () => {
return renderWithAuth( return renderWithAuth(
@@ -80,7 +81,7 @@ describe("AccountPage", () => {
const { user } = renderPage() const { user } = renderPage()
await fillAndSubmitForm() await fillAndSubmitForm()
const errorMessage = await screen.findByText(Language.unknownError) const errorMessage = await screen.findByText(ErrorSummaryLanguage.unknownErrorMessage)
expect(errorMessage).toBeDefined() expect(errorMessage).toBeDefined()
expect(API.updateProfile).toBeCalledTimes(1) expect(API.updateProfile).toBeCalledTimes(1)
expect(API.updateProfile).toBeCalledWith(user.id, newData) expect(API.updateProfile).toBeCalledWith(user.id, newData)
@@ -1,25 +1,17 @@
import { useActor } from "@xstate/react" import { useActor } from "@xstate/react"
import React, { useContext } from "react" import React, { useContext } from "react"
import { isApiError, mapApiErrorToFieldErrors } from "../../../api/errors"
import { Section } from "../../../components/Section/Section" import { Section } from "../../../components/Section/Section"
import { AccountForm } from "../../../components/SettingsAccountForm/SettingsAccountForm" import { AccountForm } from "../../../components/SettingsAccountForm/SettingsAccountForm"
import { XServiceContext } from "../../../xServices/StateContext" import { XServiceContext } from "../../../xServices/StateContext"
export const Language = { export const Language = {
title: "Account", title: "Account",
unknownError: "Oops, an unknown error occurred.",
} }
export const AccountPage: React.FC = () => { export const AccountPage: React.FC = () => {
const xServices = useContext(XServiceContext) const xServices = useContext(XServiceContext)
const [authState, authSend] = useActor(xServices.authXService) const [authState, authSend] = useActor(xServices.authXService)
const { me, updateProfileError } = authState.context const { me, updateProfileError } = authState.context
const hasError = !!updateProfileError
const formErrors =
hasError && isApiError(updateProfileError)
? mapApiErrorToFieldErrors(updateProfileError.response.data)
: undefined
const hasUnknownError = hasError && !isApiError(updateProfileError)
if (!me) { if (!me) {
throw new Error("No current user found") throw new Error("No current user found")
@@ -29,8 +21,7 @@ export const AccountPage: React.FC = () => {
<Section title={Language.title}> <Section title={Language.title}>
<AccountForm <AccountForm
email={me.email} email={me.email}
error={hasUnknownError ? Language.unknownError : undefined} updateProfileError={updateProfileError}
formErrors={formErrors}
isLoading={authState.matches("signedIn.profile.updatingProfile")} isLoading={authState.matches("signedIn.profile.updatingProfile")}
initialValues={{ username: me.username }} initialValues={{ username: me.username }}
onSubmit={(data) => { onSubmit={(data) => {
@@ -1,11 +1,12 @@
import { fireEvent, screen, waitFor } from "@testing-library/react" import { fireEvent, screen, waitFor } from "@testing-library/react"
import { Language as ErrorSummaryLanguage } from "components/ErrorSummary/ErrorSummary"
import React from "react" import React from "react"
import * as API from "../../../api/api" import * as API from "../../../api/api"
import { GlobalSnackbar } from "../../../components/GlobalSnackbar/GlobalSnackbar" import { GlobalSnackbar } from "../../../components/GlobalSnackbar/GlobalSnackbar"
import * as SecurityForm from "../../../components/SettingsSecurityForm/SettingsSecurityForm" import * as SecurityForm from "../../../components/SettingsSecurityForm/SettingsSecurityForm"
import { renderWithAuth } from "../../../testHelpers/renderHelpers" import { renderWithAuth } from "../../../testHelpers/renderHelpers"
import * as AuthXService from "../../../xServices/auth/authXService" import * as AuthXService from "../../../xServices/auth/authXService"
import { Language, SecurityPage } from "./SecurityPage" import { SecurityPage } from "./SecurityPage"
const renderPage = () => { const renderPage = () => {
return renderWithAuth( return renderWithAuth(
@@ -65,8 +66,9 @@ describe("SecurityPage", () => {
const { user } = renderPage() const { user } = renderPage()
await fillAndSubmitForm() await fillAndSubmitForm()
const errorMessage = await screen.findByText("Incorrect password.") const errorMessage = await screen.findAllByText("Incorrect password.")
expect(errorMessage).toBeDefined() expect(errorMessage).toBeDefined()
expect(errorMessage).toHaveLength(2)
expect(API.updateUserPassword).toBeCalledTimes(1) expect(API.updateUserPassword).toBeCalledTimes(1)
expect(API.updateUserPassword).toBeCalledWith(user.id, newData) expect(API.updateUserPassword).toBeCalledWith(user.id, newData)
}) })
@@ -87,8 +89,9 @@ describe("SecurityPage", () => {
const { user } = renderPage() const { user } = renderPage()
await fillAndSubmitForm() await fillAndSubmitForm()
const errorMessage = await screen.findByText("Invalid password.") const errorMessage = await screen.findAllByText("Invalid password.")
expect(errorMessage).toBeDefined() expect(errorMessage).toBeDefined()
expect(errorMessage).toHaveLength(2)
expect(API.updateUserPassword).toBeCalledTimes(1) expect(API.updateUserPassword).toBeCalledTimes(1)
expect(API.updateUserPassword).toBeCalledWith(user.id, newData) expect(API.updateUserPassword).toBeCalledWith(user.id, newData)
}) })
@@ -103,7 +106,7 @@ describe("SecurityPage", () => {
const { user } = renderPage() const { user } = renderPage()
await fillAndSubmitForm() await fillAndSubmitForm()
const errorMessage = await screen.findByText(Language.unknownError) const errorMessage = await screen.findByText(ErrorSummaryLanguage.unknownErrorMessage)
expect(errorMessage).toBeDefined() expect(errorMessage).toBeDefined()
expect(API.updateUserPassword).toBeCalledTimes(1) expect(API.updateUserPassword).toBeCalledTimes(1)
expect(API.updateUserPassword).toBeCalledWith(user.id, newData) expect(API.updateUserPassword).toBeCalledWith(user.id, newData)
@@ -1,25 +1,17 @@
import { useActor } from "@xstate/react" import { useActor } from "@xstate/react"
import React, { useContext } from "react" import React, { useContext } from "react"
import { isApiError, mapApiErrorToFieldErrors } from "../../../api/errors"
import { Section } from "../../../components/Section/Section" import { Section } from "../../../components/Section/Section"
import { SecurityForm } from "../../../components/SettingsSecurityForm/SettingsSecurityForm" import { SecurityForm } from "../../../components/SettingsSecurityForm/SettingsSecurityForm"
import { XServiceContext } from "../../../xServices/StateContext" import { XServiceContext } from "../../../xServices/StateContext"
export const Language = { export const Language = {
title: "Security", title: "Security",
unknownError: "Oops, an unknown error occurred.",
} }
export const SecurityPage: React.FC = () => { export const SecurityPage: React.FC = () => {
const xServices = useContext(XServiceContext) const xServices = useContext(XServiceContext)
const [authState, authSend] = useActor(xServices.authXService) const [authState, authSend] = useActor(xServices.authXService)
const { me, updateSecurityError } = authState.context 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) { if (!me) {
throw new Error("No current user found") throw new Error("No current user found")
@@ -28,8 +20,7 @@ export const SecurityPage: React.FC = () => {
return ( return (
<Section title={Language.title}> <Section title={Language.title}>
<SecurityForm <SecurityForm
error={hasUnknownError ? Language.unknownError : undefined} updateSecurityError={updateSecurityError}
formErrors={formErrors}
isLoading={authState.matches("signedIn.security.updatingSecurity")} isLoading={authState.matches("signedIn.security.updatingSecurity")}
initialValues={{ old_password: "", password: "", confirm_password: "" }} initialValues={{ old_password: "", password: "", confirm_password: "" }}
onSubmit={(data) => { onSubmit={(data) => {
+12
View File
@@ -1,3 +1,4 @@
import { hasApiFieldErrors, isApiError, mapApiErrorToFieldErrors } from "api/errors"
import { FormikContextType, FormikErrors, getIn } from "formik" import { FormikContextType, FormikErrors, getIn } from "formik"
import { ChangeEvent, ChangeEventHandler, FocusEventHandler, ReactNode } from "react" import { ChangeEvent, ChangeEventHandler, FocusEventHandler, ReactNode } from "react"
import * as Yup from "yup" import * as Yup from "yup"
@@ -45,6 +46,17 @@ export const getFormHelpers =
} }
} }
export const getFormHelpersWithError = <T>(
form: FormikContextType<T>,
error?: Error | unknown,
): ((name: keyof T, HelperText?: ReactNode) => FormHelpers) => {
const apiValidationErrors =
isApiError(error) && hasApiFieldErrors(error)
? (mapApiErrorToFieldErrors(error.response.data) as FormikErrors<T>)
: undefined
return getFormHelpers(form, apiValidationErrors)
}
export const onChangeTrimmed = export const onChangeTrimmed =
<T>(form: FormikContextType<T>) => <T>(form: FormikContextType<T>) =>
(event: ChangeEvent<HTMLInputElement>): void => { (event: ChangeEvent<HTMLInputElement>): void => {
+4 -3
View File
@@ -1,4 +1,3 @@
import { AxiosError } from "axios"
import { assign, createMachine } from "xstate" import { assign, createMachine } from "xstate"
import * as API from "../../api/api" import * as API from "../../api/api"
import * as TypesGen from "../../api/typesGenerated" import * as TypesGen from "../../api/typesGenerated"
@@ -49,8 +48,10 @@ type Permissions = Record<keyof typeof permissionsToCheck, boolean>
export interface AuthContext { export interface AuthContext {
getUserError?: Error | unknown getUserError?: Error | unknown
// The getMethods API call does not return an ApiError.
// It can only error out in a generic fashion.
getMethodsError?: Error | unknown getMethodsError?: Error | unknown
authError?: Error | AxiosError | unknown authError?: Error | unknown
updateProfileError?: Error | unknown updateProfileError?: Error | unknown
updateSecurityError?: Error | unknown updateSecurityError?: Error | unknown
me?: TypesGen.User me?: TypesGen.User
@@ -194,12 +195,12 @@ export const authMachine =
}, },
}, },
signingIn: { signingIn: {
entry: "clearAuthError",
invoke: { invoke: {
src: "signIn", src: "signIn",
id: "signIn", id: "signIn",
onDone: [ onDone: [
{ {
actions: "clearAuthError",
target: "gettingUser", target: "gettingUser",
}, },
], ],