fix: handle more auth API errors (#3241)

This commit is contained in:
Abhineet Jain
2022-07-28 17:14:05 -04:00
committed by GitHub
parent 6b82fdd0c0
commit 74c87664c1
9 changed files with 203 additions and 70 deletions
@@ -1,5 +1,6 @@
import { Story } from "@storybook/react"
import { SignInForm, SignInFormProps } from "./SignInForm"
import { makeMockApiError } from "testHelpers/entities"
import { LoginErrors, SignInForm, SignInFormProps } from "./SignInForm"
export default {
title: "components/SignInForm",
@@ -15,7 +16,7 @@ const Template: Story<SignInFormProps> = (args: SignInFormProps) => <SignInForm
export const SignedOut = Template.bind({})
SignedOut.args = {
isLoading: false,
authError: undefined,
loginErrors: {},
onSubmit: () => {
return Promise.resolve()
},
@@ -34,29 +35,39 @@ Loading.args = {
export const WithLoginError = Template.bind({})
WithLoginError.args = {
...SignedOut.args,
authError: {
response: {
data: {
message: "Email or password was invalid",
validations: [
{
field: "password",
detail: "Password is invalid.",
},
],
},
},
isAxiosError: true,
loginErrors: {
[LoginErrors.AUTH_ERROR]: makeMockApiError({
message: "Email or password was invalid",
validations: [
{
field: "password",
detail: "Password is invalid.",
},
],
}),
},
initialTouched: {
password: true,
},
}
export const WithCheckPermissionsError = Template.bind({})
WithCheckPermissionsError.args = {
...SignedOut.args,
loginErrors: {
[LoginErrors.CHECK_PERMISSIONS_ERROR]: makeMockApiError({
message: "Unable to fetch user permissions",
detail: "Resource not found or you do not have access to this resource.",
}),
},
}
export const WithAuthMethodsError = Template.bind({})
WithAuthMethodsError.args = {
...SignedOut.args,
methodsError: new Error("Failed to fetch auth methods"),
loginErrors: {
[LoginErrors.GET_METHODS_ERROR]: new Error("Failed to fetch auth methods"),
},
}
export const WithGithub = Template.bind({})
+25 -12
View File
@@ -23,13 +23,22 @@ interface BuiltInAuthFormValues {
password: string
}
export enum LoginErrors {
AUTH_ERROR = "authError",
CHECK_PERMISSIONS_ERROR = "checkPermissionsError",
GET_METHODS_ERROR = "getMethodsError",
}
export const Language = {
emailLabel: "Email",
passwordLabel: "Password",
emailInvalid: "Please enter a valid email address.",
emailRequired: "Please enter an email address.",
authErrorMessage: "Incorrect email or password.",
methodsErrorMessage: "Unable to fetch auth methods.",
errorMessages: {
[LoginErrors.AUTH_ERROR]: "Incorrect email or password.",
[LoginErrors.CHECK_PERMISSIONS_ERROR]: "Unable to fetch user permissions.",
[LoginErrors.GET_METHODS_ERROR]: "Unable to fetch auth methods.",
},
passwordSignIn: "Sign In",
githubSignIn: "GitHub",
}
@@ -68,8 +77,7 @@ const useStyles = makeStyles((theme) => ({
export interface SignInFormProps {
isLoading: boolean
redirectTo: string
authError?: Error | unknown
methodsError?: Error | unknown
loginErrors: Partial<Record<LoginErrors, Error | unknown>>
authMethods?: AuthMethods
onSubmit: ({ email, password }: { email: string; password: string }) => Promise<void>
// initialTouched is only used for testing the error state of the form.
@@ -80,8 +88,7 @@ export const SignInForm: FC<SignInFormProps> = ({
authMethods,
redirectTo,
isLoading,
authError,
methodsError,
loginErrors,
onSubmit,
initialTouched,
}) => {
@@ -101,18 +108,24 @@ export const SignInForm: FC<SignInFormProps> = ({
onSubmit,
initialTouched,
})
const getFieldHelpers = getFormHelpersWithError<BuiltInAuthFormValues>(form, authError)
const getFieldHelpers = getFormHelpersWithError<BuiltInAuthFormValues>(
form,
loginErrors.authError,
)
return (
<>
<Welcome />
<form onSubmit={form.handleSubmit}>
<Stack>
{authError && (
<ErrorSummary error={authError} defaultMessage={Language.authErrorMessage} />
)}
{methodsError && (
<ErrorSummary error={methodsError} defaultMessage={Language.methodsErrorMessage} />
{Object.keys(loginErrors).map((errorKey: string) =>
loginErrors[errorKey as LoginErrors] ? (
<ErrorSummary
key={errorKey}
error={loginErrors[errorKey as LoginErrors]}
defaultMessage={Language.errorMessages[errorKey as LoginErrors]}
/>
) : null,
)}
<TextField
{...getFieldHelpers("email")}
+2 -2
View File
@@ -30,7 +30,7 @@ describe("LoginPage", () => {
server.use(
// Make login fail
rest.post("/api/v2/users/login", async (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ message: Language.authErrorMessage }))
return res(ctx.status(500), ctx.json({ message: Language.errorMessages.authError }))
}),
)
@@ -45,7 +45,7 @@ describe("LoginPage", () => {
act(() => signInButton.click())
// Then
const errorMessage = await screen.findByText(Language.authErrorMessage)
const errorMessage = await screen.findByText(Language.errorMessages.authError)
expect(errorMessage).toBeDefined()
expect(history.location.pathname).toEqual("/login")
})
+7 -2
View File
@@ -40,6 +40,8 @@ export const LoginPage: React.FC = () => {
authSend({ type: "SIGN_IN", email, password })
}
const { authError, checkPermissionsError, getMethodsError } = authState.context
if (authState.matches("signedIn")) {
return <Navigate to={redirectTo} replace />
} else {
@@ -54,8 +56,11 @@ export const LoginPage: React.FC = () => {
authMethods={authState.context.methods}
redirectTo={redirectTo}
isLoading={isLoading}
authError={authState.context.authError}
methodsError={authState.context.getMethodsError as Error}
loginErrors={{
authError,
checkPermissionsError,
getMethodsError,
}}
onSubmit={onSubmit}
/>
</div>
@@ -4,6 +4,7 @@ import { GlobalSnackbar } from "../../../components/GlobalSnackbar/GlobalSnackba
import { MockGitSSHKey, renderWithAuth } from "../../../testHelpers/renderHelpers"
import { Language as authXServiceLanguage } from "../../../xServices/auth/authXService"
import { Language as SSHKeysPageLanguage, SSHKeysPage } from "./SSHKeysPage"
import { Language as SSHKeysPageViewLanguage } from "./SSHKeysPageView"
describe("SSH keys Page", () => {
it("shows the SSH key", async () => {
@@ -26,7 +27,7 @@ describe("SSH keys Page", () => {
// Click on the "Regenerate" button to display the confirm dialog
const regenerateButton = screen.getByRole("button", {
name: SSHKeysPageLanguage.regenerateLabel,
name: SSHKeysPageViewLanguage.regenerateLabel,
})
fireEvent.click(regenerateButton)
const confirmDialog = screen.getByRole("dialog")
@@ -72,7 +73,7 @@ describe("SSH keys Page", () => {
// Click on the "Regenerate" button to display the confirm dialog
const regenerateButton = screen.getByRole("button", {
name: SSHKeysPageLanguage.regenerateLabel,
name: SSHKeysPageViewLanguage.regenerateLabel,
})
fireEvent.click(regenerateButton)
const confirmDialog = screen.getByRole("dialog")
@@ -85,7 +86,7 @@ describe("SSH keys Page", () => {
fireEvent.click(confirmButton)
// Check if the error message is displayed
await screen.findByText(authXServiceLanguage.errorRegenerateSSHKey)
await screen.findByText(SSHKeysPageViewLanguage.errorRegenerateSSHKey)
// Check if the API was called correctly
expect(API.regenerateUserSSHKey).toBeCalledTimes(1)
@@ -1,19 +1,14 @@
import Box from "@material-ui/core/Box"
import Button from "@material-ui/core/Button"
import CircularProgress from "@material-ui/core/CircularProgress"
import { useActor } from "@xstate/react"
import React, { useContext, useEffect } from "react"
import { CodeExample } from "../../../components/CodeExample/CodeExample"
import { ConfirmDialog } from "../../../components/ConfirmDialog/ConfirmDialog"
import { Section } from "../../../components/Section/Section"
import { Stack } from "../../../components/Stack/Stack"
import { XServiceContext } from "../../../xServices/StateContext"
import { SSHKeysPageView } from "./SSHKeysPageView"
export const Language = {
title: "SSH keys",
description:
"Coder automatically inserts a private key into every workspace; you can add the corresponding public key to any services (such as Git) that you need access to from your workspace.",
regenerateLabel: "Regenerate",
regenerateDialogTitle: "Regenerate SSH key?",
regenerateDialogMessage:
"You will need to replace the public SSH key on services you use it with, and you'll need to rebuild existing workspaces.",
@@ -24,36 +19,30 @@ export const Language = {
export const SSHKeysPage: React.FC = () => {
const xServices = useContext(XServiceContext)
const [authState, authSend] = useActor(xServices.authXService)
const { sshKey } = authState.context
const { sshKey, getSSHKeyError, regenerateSSHKeyError } = authState.context
useEffect(() => {
authSend({ type: "GET_SSH_KEY" })
}, [authSend])
const isLoading = authState.matches("signedIn.ssh.gettingSSHKey")
const hasLoaded = authState.matches("signedIn.ssh.loaded")
const onRegenerateClick = () => {
authSend({ type: "REGENERATE_SSH_KEY" })
}
return (
<>
<Section title={Language.title} description={Language.description}>
{!sshKey && (
<Box p={4}>
<CircularProgress size={26} />
</Box>
)}
{sshKey && (
<Stack>
<CodeExample code={sshKey.public_key.trim()} />
<div>
<Button
variant="outlined"
onClick={() => {
authSend({ type: "REGENERATE_SSH_KEY" })
}}
>
{Language.regenerateLabel}
</Button>
</div>
</Stack>
)}
<SSHKeysPageView
isLoading={isLoading}
hasLoaded={hasLoaded}
getSSHKeyError={getSSHKeyError}
regenerateSSHKeyError={regenerateSSHKeyError}
sshKey={sshKey}
onRegenerateClick={onRegenerateClick}
/>
</Section>
<ConfirmDialog
@@ -0,0 +1,53 @@
import { Story } from "@storybook/react"
import { makeMockApiError } from "testHelpers/entities"
import { SSHKeysPageView, SSHKeysPageViewProps } from "./SSHKeysPageView"
export default {
title: "components/SSHKeysPageView",
component: SSHKeysPageView,
argTypes: {
onRegenerateClick: { action: "Submit" },
},
}
const Template: Story<SSHKeysPageViewProps> = (args: SSHKeysPageViewProps) => (
<SSHKeysPageView {...args} />
)
export const Example = Template.bind({})
Example.args = {
isLoading: false,
hasLoaded: true,
sshKey: {
user_id: "test-user-id",
created_at: "2022-07-28T07:45:50.795918897Z",
updated_at: "2022-07-28T07:45:50.795919142Z",
public_key: "SSH-Key",
},
onRegenerateClick: () => {
return Promise.resolve()
},
}
export const Loading = Template.bind({})
Loading.args = {
...Example.args,
isLoading: true,
}
export const WithGetSSHKeyError = Template.bind({})
WithGetSSHKeyError.args = {
...Example.args,
hasLoaded: false,
getSSHKeyError: makeMockApiError({
message: "Failed to get SSH key",
}),
}
export const WithRegenerateSSHKeyError = Template.bind({})
WithRegenerateSSHKeyError.args = {
...Example.args,
regenerateSSHKeyError: makeMockApiError({
message: "Failed to regenerate SSH key",
}),
}
@@ -0,0 +1,64 @@
import Box from "@material-ui/core/Box"
import Button from "@material-ui/core/Button"
import CircularProgress from "@material-ui/core/CircularProgress"
import { GitSSHKey } from "api/typesGenerated"
import { CodeExample } from "components/CodeExample/CodeExample"
import { ErrorSummary } from "components/ErrorSummary/ErrorSummary"
import { Stack } from "components/Stack/Stack"
import { FC } from "react"
export const Language = {
errorRegenerateSSHKey: "Error on regenerating the SSH Key",
regenerateLabel: "Regenerate",
}
export interface SSHKeysPageViewProps {
isLoading: boolean
hasLoaded: boolean
getSSHKeyError?: Error | unknown
regenerateSSHKeyError?: Error | unknown
sshKey?: GitSSHKey
onRegenerateClick: () => void
}
export const SSHKeysPageView: FC<SSHKeysPageViewProps> = ({
isLoading,
hasLoaded,
getSSHKeyError,
regenerateSSHKeyError,
sshKey,
onRegenerateClick,
}) => {
if (isLoading) {
return (
<Box p={4}>
<CircularProgress size={26} />
</Box>
)
}
return (
<Stack>
{/* Regenerating the key is not an option if getSSHKey fails.
Only one of the error messages will exist at a single time */}
{getSSHKeyError && <ErrorSummary error={getSSHKeyError} />}
{regenerateSSHKeyError && (
<ErrorSummary
error={regenerateSSHKeyError}
defaultMessage={Language.errorRegenerateSSHKey}
dismissible
/>
)}
{hasLoaded && sshKey && (
<>
<CodeExample code={sshKey.public_key.trim()} />
<div>
<Button variant="outlined" onClick={onRegenerateClick}>
{Language.regenerateLabel}
</Button>
</div>
</>
)}
</Stack>
)
}
+4 -7
View File
@@ -1,13 +1,12 @@
import { assign, createMachine } from "xstate"
import * as API from "../../api/api"
import * as TypesGen from "../../api/typesGenerated"
import { displayError, displaySuccess } from "../../components/GlobalSnackbar/utils"
import { displaySuccess } from "../../components/GlobalSnackbar/utils"
export const Language = {
successProfileUpdate: "Updated settings.",
successSecurityUpdate: "Updated password.",
successRegenerateSSHKey: "SSH Key regenerated successfully",
errorRegenerateSSHKey: "Error on regenerate the SSH Key",
}
export const checks = {
@@ -130,7 +129,7 @@ const sshState = {
],
onError: [
{
actions: ["assignRegenerateSSHKeyError", "notifySSHKeyRegenerationError"],
actions: ["assignRegenerateSSHKeyError"],
target: "#authState.signedIn.ssh.loaded.idle",
},
],
@@ -214,12 +213,13 @@ export const authMachine =
tags: "loading",
},
gettingUser: {
entry: "clearGetUserError",
invoke: {
src: "getMe",
id: "getMe",
onDone: [
{
actions: ["assignMe", "clearGetUserError"],
actions: ["assignMe"],
target: "gettingPermissions",
},
],
@@ -488,9 +488,6 @@ export const authMachine =
notifySuccessSSHKeyRegenerated: () => {
displaySuccess(Language.successRegenerateSSHKey)
},
notifySSHKeyRegenerationError: () => {
displayError(Language.errorRegenerateSSHKey)
},
},
},
)