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