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**  **Valid invite**  - [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:
co-authored by
Jacob Shandling
parent
11319fdea7
commit
55fd95d760
@@ -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,
|
||||
});
|
||||
+28
-16
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user