UI – Updates to confirm invite flow (#25583)

## For #24486 

- Check invite validity before rendering form, error if invalid
- Use data returned from validity check to pre-populate form
- Remove dependence of flow on URL params other than token
- Remove other URL params from link generated in invite confirmation
email
- Refactor form from JS to TS
- Refactor form from class to functional components
- Cleanup unused logic
- Improve error handling

**Invalid invite**

![invalid](https://github.com/user-attachments/assets/c42c47ca-6a0d-4112-89ea-68b77e748d12)


**Valid invite**

![valid-login-flow](https://github.com/user-attachments/assets/f2b97306-a1bd-47be-9725-968a3c4ad8a8)



- [x] Changes file added for user-visible changes in `changes/`
- [x] Updated tests
- [ ] A detailed QA plan exists on the associated ticket (if it isn't
there, work with the product group's QA engineer to add it)
- [x] Manual QA for all new/changed functionality

---------

Co-authored-by: Jacob Shandling <jacob@fleetdm.com>
This commit is contained in:
jacobshandling
2025-01-24 10:55:39 -08:00
committed by GitHub
co-authored by Jacob Shandling
parent 11319fdea7
commit 55fd95d760
11 changed files with 315 additions and 128 deletions
+1
View File
@@ -0,0 +1 @@
- Check the server for validity of any Fleet invites
@@ -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 (
<form className={className} autoComplete="off">
{baseError && <div className="form__base-error">{baseError}</div>}
<InputFieldWithIcon
{...fields.name}
autofocus
role="textbox"
label="Full name"
placeholder="Full name"
inputOptions={{
maxLength: "80",
}}
/>
<InputFieldWithIcon
{...fields.password}
label="Password"
placeholder="Password"
type="password"
helpText="Must include 12 characters, at least 1 number (e.g. 0 - 9), and at least 1 symbol (e.g. &*#)"
/>
<InputFieldWithIcon
{...fields.password_confirmation}
label="Confirm password"
placeholder="Confirm password"
type="password"
/>
<Button
onClick={handleSubmit}
className="confirm-invite-button"
type="Submit"
variant="brand"
>
Submit
</Button>
</form>
);
}
}
export default Form(ConfirmInviteForm, {
fields: formFields,
validate,
});
@@ -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(
<ConfirmInviteForm formData={formData} handleSubmit={handleSubmitSpy} />
<ConfirmInviteForm
defaultFormData={defaultFormData}
handleSubmit={handleSubmitSpy}
/>
);
expect(
screen.getByRole("textbox", { name: "Full name" })
@@ -26,7 +28,7 @@ describe("ConfirmInviteForm - component", () => {
const baseError = "Unable to authenticate the current user";
render(
<ConfirmInviteForm
serverErrors={{ base: baseError }}
ancestorError={baseError}
handleSubmit={handleSubmitSpy}
/>
);
@@ -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(
<ConfirmInviteForm formData={formData} handleSubmit={handleSubmitSpy} />
<ConfirmInviteForm
defaultFormData={defaultFormData}
handleSubmit={handleSubmitSpy}
/>
);
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(
<ConfirmInviteForm formData={formData} handleSubmit={handleSubmitSpy} />
<ConfirmInviteForm
defaultFormData={{ ...defaultFormData, ...{ name: "" } }}
handleSubmit={handleSubmitSpy}
/>
);
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(
<ConfirmInviteForm formData={formData} handleSubmit={handleSubmitSpy} />
<ConfirmInviteForm
defaultFormData={defaultFormData}
handleSubmit={handleSubmitSpy}
/>
);
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(
<ConfirmInviteForm formData={formData} handleSubmit={handleSubmitSpy} />
<ConfirmInviteForm
defaultFormData={defaultFormData}
handleSubmit={handleSubmitSpy}
/>
);
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(
<ConfirmInviteForm formData={formData} handleSubmit={handleSubmitSpy} />
<ConfirmInviteForm
defaultFormData={defaultFormData}
handleSubmit={handleSubmitSpy}
/>
);
await user.click(screen.getByRole("button", { name: "Submit" }));
@@ -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<IConfirmInviteFormData>;
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<IConfirmInviteFormData>({
name: defaultFormData?.name || "",
password: defaultFormData?.password || "",
password_confirmation: defaultFormData?.password || "",
});
const [formErrors, setFormErrors] = useState<IConfirmInviteFormErrors>({});
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<string, string> = {};
Object.keys(formErrors).forEach((k) => {
// @ts-ignore
if (newErrs[k]) {
// @ts-ignore
errsToSet[k] = newErrs[k];
}
});
setFormErrors(errsToSet);
};
const onSubmit = useCallback(
(evt: React.FormEvent<HTMLFormElement>) => {
evt.preventDefault();
const errs = validate(formData);
if (Object.keys(errs).length > 0) {
setFormErrors(errs);
return;
}
handleSubmit(formData);
},
[formData, handleSubmit]
);
return (
<form onSubmit={onSubmit} className={baseClass} autoComplete="off">
{ancestorError && <div className="form__base-error">{ancestorError}</div>}
<InputField
label="Full name"
autofocus
onChange={onInputChange}
name="name"
value={name}
error={formErrors.name}
parseTarget
maxLength={80}
/>
<InputField
label="Password"
type="password"
placeholder="Password"
helpText="Must include 12 characters, at least 1 number (e.g. 0 - 9), and at least 1 symbol (e.g. &*#)"
onChange={onInputChange}
name="password"
value={password}
error={formErrors.password}
parseTarget
/>
<InputField
label="Confirm password"
type="password"
placeholder="Confirm password"
onChange={onInputChange}
name="password_confirmation"
value={password_confirmation}
error={formErrors.password_confirmation}
parseTarget
/>
<Button
type="submit"
disabled={Object.keys(formErrors).length > 0}
className="confirm-invite-button"
variant="brand"
>
Submit
</Button>
</form>
);
};
export default ConfirmInviteForm;
@@ -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 };
+11
View File
@@ -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<HTMLFormElement>` 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
@@ -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<any>({});
useEffect(() => {
const { DASHBOARD } = paths;
const { invite_token } = params;
if (currentUser) {
return router.push(DASHBOARD);
const {
data: validInvite,
error: validateInviteError,
isLoading: isVerifyingInvite,
} = useQuery<IValidateInviteResp, AxiosError, IInvite>(
"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."
const renderContent = () => {
if (isVerifyingInvite) {
return <Spinner />;
}
// error is how API communicates an invalid invite
if (validateInviteError) {
return (
<StackedWhiteBoxes className={baseClass}>
<>
<p>
<b>That invite is invalid.</b>
</p>
<p>Please confirm your invite link.</p>
</>
</StackedWhiteBoxes>
);
} catch (error) {
console.error(error);
const errorsObject = formatErrorResponse(error);
setUserErrors(errorsObject);
}
};
return (
<AuthenticationFormWrapper>
// valid - return form pre-filled with data from api response
return (
<div className={`${baseClass}`}>
<div className={`${baseClass}__lead-wrapper`}>
<p className={`${baseClass}__lead-text`}>Welcome to Fleet</p>
@@ -73,13 +106,18 @@ const ConfirmInvitePage = ({
</p>
</div>
<ConfirmInviteForm
className={`${baseClass}__form`}
formData={inviteFormData}
defaultFormData={{
// at this point we will have a valid invite per error check above
name: validInvite?.name,
}}
handleSubmit={onSubmit}
serverErrors={userErrors}
/>
</div>
</AuthenticationFormWrapper>
);
};
return (
<AuthenticationFormWrapper>{renderContent()}</AuthenticationFormWrapper>
);
};
+7
View File
@@ -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<IValidateInviteResp> => {
return sendRequest("GET", endpoints.INVITE_VERIFY(token));
},
loadAll: ({ globalFilter = "" }: IInviteSearchOptions) => {
const queryParams = {
query: globalFilter,
+1
View File
@@ -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}`,
+2 -2
View File
@@ -124,7 +124,7 @@
{{if .SSOEnabled}}
<a
href="{{.BaseURL}}/login/ssoinvites/{{.Token}}?name={{.Name}}&email={{.Email}}"
href="{{.BaseURL}}/login/ssoinvites/{{.Token}}"
target="_blank"
style="
font-weight: 700;
@@ -144,7 +144,7 @@
</a>
{{else}}
<a
href="{{.BaseURL}}/login/invites/{{.Token}}?name={{.Name}}&email={{.Email}}"
href="{{.BaseURL}}/login/invites/{{.Token}}"
target="_blank"
style="
font-weight: 700;