mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix: manage backend authXService errors (#3190)
This commit is contained in:
@@ -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({})
|
||||||
|
|||||||
@@ -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 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user