diff --git a/changes/24486-error-for-invalid-invites b/changes/24486-error-for-invalid-invites new file mode 100644 index 000000000000..c3517863a45c --- /dev/null +++ b/changes/24486-error-for-invalid-invites @@ -0,0 +1 @@ +- Check the server for validity of any Fleet invites diff --git a/frontend/components/forms/ConfirmInviteForm/ConfirmInviteForm.jsx b/frontend/components/forms/ConfirmInviteForm/ConfirmInviteForm.jsx deleted file mode 100644 index a47ce2b53685..000000000000 --- a/frontend/components/forms/ConfirmInviteForm/ConfirmInviteForm.jsx +++ /dev/null @@ -1,70 +0,0 @@ -import React, { Component } from "react"; -import PropTypes from "prop-types"; - -import Form from "components/forms/Form"; -import formFieldInterface from "interfaces/form_field"; -import Button from "components/buttons/Button"; -import InputFieldWithIcon from "components/forms/fields/InputFieldWithIcon"; -import helpers from "./helpers"; - -const formFields = ["name", "password", "password_confirmation"]; -const { validate } = helpers; - -class ConfirmInviteForm extends Component { - static propTypes = { - baseError: PropTypes.string, - className: PropTypes.string, - fields: PropTypes.shape({ - name: formFieldInterface.isRequired, - password: formFieldInterface.isRequired, - password_confirmation: formFieldInterface.isRequired, - }).isRequired, - handleSubmit: PropTypes.func.isRequired, - }; - - render() { - const { baseError, className, fields, handleSubmit } = this.props; - - return ( -
- {baseError &&
{baseError}
} - - - - - - ); - } -} - -export default Form(ConfirmInviteForm, { - fields: formFields, - validate, -}); diff --git a/frontend/components/forms/ConfirmInviteForm/ConfirmInviteForm.tests.jsx b/frontend/components/forms/ConfirmInviteForm/ConfirmInviteForm.tests.tsx similarity index 76% rename from frontend/components/forms/ConfirmInviteForm/ConfirmInviteForm.tests.jsx rename to frontend/components/forms/ConfirmInviteForm/ConfirmInviteForm.tests.tsx index 31de1e9f8d67..292be23f1161 100644 --- a/frontend/components/forms/ConfirmInviteForm/ConfirmInviteForm.tests.jsx +++ b/frontend/components/forms/ConfirmInviteForm/ConfirmInviteForm.tests.tsx @@ -7,12 +7,14 @@ import ConfirmInviteForm from "components/forms/ConfirmInviteForm"; describe("ConfirmInviteForm - component", () => { const handleSubmitSpy = jest.fn(); - const inviteToken = "abc123"; - const formData = { invite_token: inviteToken }; + const defaultFormData = { name: "Test User" }; it("renders", () => { render( - + ); expect( screen.getByRole("textbox", { name: "Full name" }) @@ -26,7 +28,7 @@ describe("ConfirmInviteForm - component", () => { const baseError = "Unable to authenticate the current user"; render( ); @@ -34,22 +36,20 @@ describe("ConfirmInviteForm - component", () => { expect(screen.getByText(baseError)).toBeInTheDocument(); }); - it("calls the handleSubmit prop with the invite_token when valid", async () => { + it("calls the handleSubmit prop when valid", async () => { const { user } = renderWithSetup( - + ); - await user.type( - screen.getByRole("textbox", { name: "Full name" }), - "Gnar Dog" - ); await user.type(screen.getByLabelText("Password"), "p@ssw0rd"); await user.type(screen.getByLabelText("Confirm password"), "p@ssw0rd"); await user.click(screen.getByRole("button", { name: "Submit" })); expect(handleSubmitSpy).toHaveBeenCalledWith({ - ...formData, - name: "Gnar Dog", + ...defaultFormData, password: "p@ssw0rd", password_confirmation: "p@ssw0rd", }); @@ -58,7 +58,10 @@ describe("ConfirmInviteForm - component", () => { describe("name input", () => { it("validates the field must be present", async () => { const { user } = renderWithSetup( - + ); await user.click(screen.getByRole("button", { name: "Submit" })); @@ -72,7 +75,10 @@ describe("ConfirmInviteForm - component", () => { describe("password input", () => { it("validates the field must be present", async () => { const { user } = renderWithSetup( - + ); await user.click(screen.getByRole("button", { name: "Submit" })); @@ -86,7 +92,10 @@ describe("ConfirmInviteForm - component", () => { describe("password_confirmation input", () => { it("validates the password_confirmation matches the password", async () => { const { user } = renderWithSetup( - + ); await user.type(screen.getByLabelText("Password"), "p@ssw0rd"); @@ -104,7 +113,10 @@ describe("ConfirmInviteForm - component", () => { it("validates the field must be present", async () => { const { user } = renderWithSetup( - + ); await user.click(screen.getByRole("button", { name: "Submit" })); diff --git a/frontend/components/forms/ConfirmInviteForm/ConfirmInviteForm.tsx b/frontend/components/forms/ConfirmInviteForm/ConfirmInviteForm.tsx new file mode 100644 index 000000000000..d318a7f69a60 --- /dev/null +++ b/frontend/components/forms/ConfirmInviteForm/ConfirmInviteForm.tsx @@ -0,0 +1,149 @@ +import React, { useCallback, useState } from "react"; + +import validateEquality from "components/forms/validators/validate_equality"; + +import Button from "components/buttons/Button"; +// @ts-ignore +import InputField from "components/forms/fields/InputField"; +import { IFormField } from "interfaces/form_field"; + +const baseClass = "confirm-invite-page__form"; +export interface IConfirmInviteFormData { + name: string; + password: string; + password_confirmation: string; +} +interface IConfirmInviteFormProps { + defaultFormData?: Partial; + handleSubmit: (data: IConfirmInviteFormData) => void; + ancestorError?: string; +} +interface IConfirmInviteFormErrors { + name?: string | null; + password?: string | null; + password_confirmation?: string | null; +} + +const validate = (formData: IConfirmInviteFormData) => { + const errors: IConfirmInviteFormErrors = {}; + const { + name, + password, + password_confirmation: passwordConfirmation, + } = formData; + + if (!name) { + errors.name = "Full name must be present"; + } + + if ( + password && + passwordConfirmation && + !validateEquality(password, passwordConfirmation) + ) { + errors.password_confirmation = + "Password confirmation does not match password"; + } + + if (!password) { + errors.password = "Password must be present"; + } + + if (!passwordConfirmation) { + errors.password_confirmation = "Password confirmation must be present"; + } + + return errors; +}; +const ConfirmInviteForm = ({ + defaultFormData, + handleSubmit, + ancestorError, +}: IConfirmInviteFormProps) => { + const [formData, setFormData] = useState({ + name: defaultFormData?.name || "", + password: defaultFormData?.password || "", + password_confirmation: defaultFormData?.password || "", + }); + const [formErrors, setFormErrors] = useState({}); + + const { name, password, password_confirmation } = formData; + + const onInputChange = ({ name: n, value }: IFormField) => { + const newFormData = { ...formData, [n]: value }; + setFormData(newFormData); + const newErrs = validate(newFormData); + // only set errors that are updates of existing errors + // new errors are only set on submit + const errsToSet: Record = {}; + Object.keys(formErrors).forEach((k) => { + // @ts-ignore + if (newErrs[k]) { + // @ts-ignore + errsToSet[k] = newErrs[k]; + } + }); + setFormErrors(errsToSet); + }; + + const onSubmit = useCallback( + (evt: React.FormEvent) => { + evt.preventDefault(); + + const errs = validate(formData); + if (Object.keys(errs).length > 0) { + setFormErrors(errs); + return; + } + handleSubmit(formData); + }, + [formData, handleSubmit] + ); + + return ( +
+ {ancestorError &&
{ancestorError}
} + + + + + + ); +}; + +export default ConfirmInviteForm; diff --git a/frontend/components/forms/ConfirmInviteForm/helpers.jsx b/frontend/components/forms/ConfirmInviteForm/helpers.jsx new file mode 100644 index 000000000000..8ae978b6408e --- /dev/null +++ b/frontend/components/forms/ConfirmInviteForm/helpers.jsx @@ -0,0 +1,38 @@ +import { size } from "lodash"; +import validateEquality from "components/forms/validators/validate_equality"; + +const validate = (formData) => { + const errors = {}; + const { + name, + password, + password_confirmation: passwordConfirmation, + } = formData; + + if (!name) { + errors.name = "Full name must be present"; + } + + if ( + password && + passwordConfirmation && + !validateEquality(password, passwordConfirmation) + ) { + errors.password_confirmation = + "Password confirmation does not match password"; + } + + if (!password) { + errors.password = "Password must be present"; + } + + if (!passwordConfirmation) { + errors.password_confirmation = "Password confirmation must be present"; + } + + const valid = !size(errors); + + return { valid, errors }; +}; + +export default { validate }; diff --git a/frontend/components/forms/ConfirmInviteForm/index.js b/frontend/components/forms/ConfirmInviteForm/index.tsx similarity index 100% rename from frontend/components/forms/ConfirmInviteForm/index.js rename to frontend/components/forms/ConfirmInviteForm/index.tsx diff --git a/frontend/docs/patterns.md b/frontend/docs/patterns.md index d34cb5a0d2ca..f417de7482c8 100644 --- a/frontend/docs/patterns.md +++ b/frontend/docs/patterns.md @@ -202,6 +202,17 @@ export default PackComposerPage; ## Forms +### Form submission + +When building a React-controlled form: +- Use the native HTML `form` element to wrap the form. +- Use a `Button` component with `type="submit"` for its submit button. +- Write a submit handler, e.g. `handleSubmit`, that accepts an `evt: +React.FormEvent` argument and, critically, calls `evt.preventDefault()` in its +body. This prevents the HTML `form`'s default submit behavior from interfering with our custom +handler's logic. +- Assign that handler to the `form`'s `onSubmit` property (*not* the submit button's `onClick`) + ### Data validation #### How to validate diff --git a/frontend/pages/ConfirmInvitePage/ConfirmInvitePage.tsx b/frontend/pages/ConfirmInvitePage/ConfirmInvitePage.tsx index 1aa1c153f65d..cc204c6a5904 100644 --- a/frontend/pages/ConfirmInvitePage/ConfirmInvitePage.tsx +++ b/frontend/pages/ConfirmInvitePage/ConfirmInvitePage.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useState, useEffect } from "react"; +import React, { useCallback, useContext } from "react"; import { InjectedRouter } from "react-router"; import { Params } from "react-router/lib/Router"; @@ -7,63 +7,96 @@ import { NotificationContext } from "context/notification"; import { ICreateUserWithInvitationFormData } from "interfaces/user"; import paths from "router/paths"; import usersAPI from "services/entities/users"; -import formatErrorResponse from "utilities/format_error_response"; +import inviteAPI, { IValidateInviteResp } from "services/entities/invites"; import AuthenticationFormWrapper from "components/AuthenticationFormWrapper"; -// @ts-ignore +import Spinner from "components/Spinner"; +import { useQuery } from "react-query"; +import { IInvite } from "interfaces/invite"; +import StackedWhiteBoxes from "components/StackedWhiteBoxes"; import ConfirmInviteForm from "components/forms/ConfirmInviteForm"; +import { IConfirmInviteFormData } from "components/forms/ConfirmInviteForm/ConfirmInviteForm"; +import { getErrorReason } from "interfaces/errors"; +import { AxiosError } from "axios"; interface IConfirmInvitePageProps { router: InjectedRouter; // v3 - location: any; // no type in react-router v3 params: Params; } const baseClass = "confirm-invite-page"; -const ConfirmInvitePage = ({ - router, - location, - params, -}: IConfirmInvitePageProps) => { - const { email, name } = location.query; - const { invite_token } = params; - const inviteFormData = { email, invite_token, name }; +const ConfirmInvitePage = ({ router, params }: IConfirmInvitePageProps) => { const { currentUser } = useContext(AppContext); const { renderFlash } = useContext(NotificationContext); - const [userErrors, setUserErrors] = useState({}); - useEffect(() => { - const { DASHBOARD } = paths; + const { invite_token } = params; - if (currentUser) { - return router.push(DASHBOARD); + const { + data: validInvite, + error: validateInviteError, + isLoading: isVerifyingInvite, + } = useQuery( + "invite", + () => inviteAPI.verify(invite_token), + { + select: (resp: IValidateInviteResp) => resp.invite, + retry: (failureCount, error) => failureCount < 4 && error.status !== 404, } - }, [currentUser]); + ); - const onSubmit = async (formData: ICreateUserWithInvitationFormData) => { - const { create } = usersAPI; - const { LOGIN } = paths; + const onSubmit = useCallback( + async (formData: IConfirmInviteFormData) => { + const dataForAPI: ICreateUserWithInvitationFormData = { + email: validInvite?.email || "", + invite_token, + name: formData.name, + password: formData.password, + password_confirmation: formData.password_confirmation, + }; - setUserErrors({}); + try { + await usersAPI.create(dataForAPI); + router.push(paths.LOGIN); + renderFlash( + "success", + "Registration successful! For security purposes, please log in." + ); + } catch (error) { + const reason = getErrorReason(error); + console.error(reason); + renderFlash("error", reason); + } + }, + [invite_token, renderFlash, router, validInvite?.email] + ); - try { - await create(formData); + if (currentUser) { + router.push(paths.DASHBOARD); + // return for router typechecking + return <>; + } - router.push(LOGIN); - renderFlash( - "success", - "Registration successful! For security purposes, please log in." - ); - } catch (error) { - console.error(error); - const errorsObject = formatErrorResponse(error); - setUserErrors(errorsObject); + const renderContent = () => { + if (isVerifyingInvite) { + return ; } - }; - return ( - + // error is how API communicates an invalid invite + if (validateInviteError) { + return ( + + <> +

+ That invite is invalid. +

+

Please confirm your invite link.

+ +
+ ); + } + // valid - return form pre-filled with data from api response + return (

Welcome to Fleet

@@ -73,13 +106,18 @@ const ConfirmInvitePage = ({

-
+ ); + }; + + return ( + {renderContent()} ); }; diff --git a/frontend/services/entities/invites.ts b/frontend/services/entities/invites.ts index 2f0c013fc28f..80c37a2848a4 100644 --- a/frontend/services/entities/invites.ts +++ b/frontend/services/entities/invites.ts @@ -22,6 +22,10 @@ interface IInviteSearchOptions { sortBy?: ISortOption[]; } +export interface IValidateInviteResp { + invite: IInvite; +} + export default { create: (formData: ICreateInviteFormData) => { const { INVITES } = endpoints; @@ -42,6 +46,9 @@ export default { return sendRequest("DELETE", path); }, + verify: (token: string): Promise => { + return sendRequest("GET", endpoints.INVITE_VERIFY(token)); + }, loadAll: ({ globalFilter = "" }: IInviteSearchOptions) => { const queryParams = { query: globalFilter, diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index 4fa098ef1b49..04403bd4a09a 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -63,6 +63,7 @@ export default { `/${API_VERSION}/fleet/hosts/${hostId}/software/${softwareId}/uninstall`, INVITES: `/${API_VERSION}/fleet/invites`, + INVITE_VERIFY: (token: string) => `/${API_VERSION}/fleet/invites/${token}`, // labels LABEL: (id: number) => `/${API_VERSION}/fleet/labels/${id}`, diff --git a/server/mail/templates/invite_token.html b/server/mail/templates/invite_token.html index e105c082a93d..76f0a18126b6 100644 --- a/server/mail/templates/invite_token.html +++ b/server/mail/templates/invite_token.html @@ -124,7 +124,7 @@

You have been invited to Fleet!

{{if .SSOEnabled}}