diff --git a/site/src/components/SignInForm/SignInForm.stories.tsx b/site/src/components/SignInForm/SignInForm.stories.tsx index 7e6b24f79ed2a..1937dfd066a5b 100644 --- a/site/src/components/SignInForm/SignInForm.stories.tsx +++ b/site/src/components/SignInForm/SignInForm.stories.tsx @@ -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 = (args: SignInFormProps) => { 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({}) diff --git a/site/src/components/SignInForm/SignInForm.tsx b/site/src/components/SignInForm/SignInForm.tsx index bfa28ca8bd3cb..a4d75dad63dd7 100644 --- a/site/src/components/SignInForm/SignInForm.tsx +++ b/site/src/components/SignInForm/SignInForm.tsx @@ -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> authMethods?: AuthMethods onSubmit: ({ email, password }: { email: string; password: string }) => Promise // initialTouched is only used for testing the error state of the form. @@ -80,8 +88,7 @@ export const SignInForm: FC = ({ authMethods, redirectTo, isLoading, - authError, - methodsError, + loginErrors, onSubmit, initialTouched, }) => { @@ -101,18 +108,24 @@ export const SignInForm: FC = ({ onSubmit, initialTouched, }) - const getFieldHelpers = getFormHelpersWithError(form, authError) + const getFieldHelpers = getFormHelpersWithError( + form, + loginErrors.authError, + ) return ( <>
- {authError && ( - - )} - {methodsError && ( - + {Object.keys(loginErrors).map((errorKey: string) => + loginErrors[errorKey as LoginErrors] ? ( + + ) : null, )} { 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") }) diff --git a/site/src/pages/LoginPage/LoginPage.tsx b/site/src/pages/LoginPage/LoginPage.tsx index 9f6e7a0a93247..305012425130d 100644 --- a/site/src/pages/LoginPage/LoginPage.tsx +++ b/site/src/pages/LoginPage/LoginPage.tsx @@ -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 } 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} /> diff --git a/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPage.test.tsx b/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPage.test.tsx index 01c1ac82dada7..0fbde8225a9e0 100644 --- a/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPage.test.tsx +++ b/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPage.test.tsx @@ -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) diff --git a/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPage.tsx b/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPage.tsx index 14beb82fe6379..fcb008a6c0655 100644 --- a/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPage.tsx +++ b/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPage.tsx @@ -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 ( <>
- {!sshKey && ( - - - - )} - - {sshKey && ( - - -
- -
-
- )} +
= (args: SSHKeysPageViewProps) => ( + +) + +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", + }), +} diff --git a/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPageView.tsx b/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPageView.tsx new file mode 100644 index 0000000000000..9aa135bcf6956 --- /dev/null +++ b/site/src/pages/UserSettingsPage/SSHKeysPage/SSHKeysPageView.tsx @@ -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 = ({ + isLoading, + hasLoaded, + getSSHKeyError, + regenerateSSHKeyError, + sshKey, + onRegenerateClick, +}) => { + if (isLoading) { + return ( + + + + ) + } + + return ( + + {/* Regenerating the key is not an option if getSSHKey fails. + Only one of the error messages will exist at a single time */} + {getSSHKeyError && } + {regenerateSSHKeyError && ( + + )} + {hasLoaded && sshKey && ( + <> + +
+ +
+ + )} +
+ ) +} diff --git a/site/src/xServices/auth/authXService.ts b/site/src/xServices/auth/authXService.ts index 11d2c9c68d7b3..cf0a9432ea33a 100644 --- a/site/src/xServices/auth/authXService.ts +++ b/site/src/xServices/auth/authXService.ts @@ -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) - }, }, }, )