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 (
-
- );
- }
-}
-
-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 (
+
+ );
+};
+
+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}}