Fix SSO invite acceptance error when accepting invitation (#34103)

Resolves #34103 

The SSO invite acceptance page submitted a create-user request without
the invitee's email, because it read `email` from the URL query string
which the invite email link never populates. To resolve this the email
was loaded by calling GET /api/_version_/fleet/invites/{token} endpoint.

As part of this fix, the ConfirmSSOInvite components were refactored
from 'classical' components to functional components.
This commit is contained in:
Juan Fernandez
2026-05-07 12:25:24 -04:00
committed by GitHub
parent 3bbb2d206c
commit 2c7bb2f6eb
9 changed files with 326 additions and 104 deletions
+1
View File
@@ -0,0 +1 @@
- Fixed SSO invite acceptance flow by resolving the email from the invite token.
@@ -1,53 +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 ConfirmSSOInviteForm 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
placeholder="Full name"
inputOptions={{
maxLength: "80",
}}
ignore1password
/>
<div className="button-wrap">
<Button onClick={handleSubmit} type="Submit">
Submit
</Button>
</div>
</form>
);
}
}
export default Form(ConfirmSSOInviteForm, {
fields: formFields,
validate,
});
@@ -0,0 +1,85 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import { renderWithSetup } from "test/test-utils";
import ConfirmSSOInviteForm from "components/forms/ConfirmSSOInviteForm";
describe("ConfirmSSOInviteForm - component", () => {
const handleSubmitSpy = jest.fn();
const defaultName = "Test User";
const email = "test@example.com";
beforeEach(() => {
handleSubmitSpy.mockReset();
});
it("renders the email field as disabled and prefilled, with a name field", () => {
render(
<ConfirmSSOInviteForm
defaultName={defaultName}
email={email}
handleSubmit={handleSubmitSpy}
/>
);
const emailInput = screen.getByLabelText("Email") as HTMLInputElement;
expect(emailInput).toBeInTheDocument();
expect(emailInput).toBeDisabled();
expect(emailInput.value).toBe(email);
const nameInput = screen.getByRole("textbox", {
name: "Full name",
}) as HTMLInputElement;
expect(nameInput).toBeInTheDocument();
expect(nameInput.value).toBe(defaultName);
expect(screen.getByRole("button", { name: "Submit" })).toBeInTheDocument();
});
it("calls handleSubmit with the name when valid", async () => {
const { user } = renderWithSetup(
<ConfirmSSOInviteForm
defaultName={defaultName}
email={email}
handleSubmit={handleSubmitSpy}
/>
);
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(handleSubmitSpy).toHaveBeenCalledWith(defaultName);
});
it("validates that the name field must be present", async () => {
const { user } = renderWithSetup(
<ConfirmSSOInviteForm email={email} handleSubmit={handleSubmitSpy} />
);
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(
await screen.findByText("Full name must be present")
).toBeInTheDocument();
expect(handleSubmitSpy).not.toHaveBeenCalled();
});
it("rejects a whitespace-only name and submits a trimmed value otherwise", async () => {
const { user } = renderWithSetup(
<ConfirmSSOInviteForm email={email} handleSubmit={handleSubmitSpy} />
);
const nameInput = screen.getByRole("textbox", { name: "Full name" });
await user.type(nameInput, " ");
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(
await screen.findByText("Full name must be present")
).toBeInTheDocument();
expect(handleSubmitSpy).not.toHaveBeenCalled();
await user.type(nameInput, "Padded Name ");
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(handleSubmitSpy).toHaveBeenCalledWith("Padded Name");
});
});
@@ -0,0 +1,58 @@
import React, { useState } from "react";
import Button from "components/buttons/Button";
import InputField from "components/forms/fields/InputField";
const baseClass = "confirm-invite-page__form";
interface IConfirmSSOInviteFormProps {
defaultName?: string;
email?: string;
handleSubmit: (name: string) => void;
}
const ConfirmSSOInviteForm = ({
defaultName = "",
email = "",
handleSubmit,
}: IConfirmSSOInviteFormProps) => {
const [name, setName] = useState(defaultName);
const [nameError, setNameError] = useState<string | null>(null);
const onNameChange = (value: string) => {
setName(value);
if (nameError && value.trim()) setNameError(null);
};
const onSubmit = (evt: React.FormEvent<HTMLFormElement>) => {
evt.preventDefault();
const trimmedName = name.trim();
if (!trimmedName) {
setNameError("Full name must be present");
return;
}
handleSubmit(trimmedName);
};
return (
<form onSubmit={onSubmit} className={baseClass} autoComplete="off">
<InputField label="Email" name="email" value={email} disabled />
<InputField
label="Full name"
autofocus
onChange={onNameChange}
name="name"
value={name}
error={nameError}
inputOptions={{ maxLength: 80 }}
/>
<div className="button-wrap--center">
<Button type="submit" disabled={!!nameError} size="wide">
Submit
</Button>
</div>
</form>
);
};
export default ConfirmSSOInviteForm;
@@ -1,16 +0,0 @@
import { size } from "lodash";
const validate = (formData) => {
const errors = {};
const { name } = formData;
if (!name) {
errors.name = "Full name must be present";
}
const valid = !size(errors);
return { valid, errors };
};
export default { validate };
+3 -2
View File
@@ -126,6 +126,7 @@ export interface ICreateUserWithInvitationFormData {
email: string;
invite_token: string;
name: string;
password: string;
password_confirmation: string;
password?: string;
password_confirmation?: string;
sso_invite?: boolean;
}
@@ -0,0 +1,105 @@
import React from "react";
import { screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "react-query";
import { createMockRouter, renderWithSetup } from "test/test-utils";
import inviteAPI from "services/entities/invites";
import usersAPI from "services/entities/users";
import sessionsAPI from "services/entities/sessions";
import { IUser } from "interfaces/user";
import ConfirmSSOInvitePage from "./ConfirmSSOInvitePage";
jest.mock("services/entities/invites");
jest.mock("services/entities/users");
jest.mock("services/entities/sessions");
const mockInviteAPI = inviteAPI as jest.Mocked<typeof inviteAPI>;
const mockUsersAPI = usersAPI as jest.Mocked<typeof usersAPI>;
const mockSessionsAPI = sessionsAPI as jest.Mocked<typeof sessionsAPI>;
const renderPage = (token = "abc") => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, cacheTime: 0 } },
});
return renderWithSetup(
<QueryClientProvider client={client}>
<ConfirmSSOInvitePage
params={{ invite_token: token }}
router={createMockRouter()}
/>
</QueryClientProvider>
);
};
describe("ConfirmSSOInvitePage", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("calls usersAPI.create with the email resolved from the verified invite, then triggers SSO initialization", async () => {
// The page eventually does `window.location.href = url` after
// initializeSSO resolves, but JSDOM does not implement navigation.
// Resolve usersAPI.create successfully (its payload is what we assert
// on) and reject initializeSSO so the post-create code path
// short-circuits before touching window.location. We still assert that
// initializeSSO was called.
mockInviteAPI.verify.mockResolvedValue({
invite: {
created_at: "2026-05-07T00:00:00Z",
updated_at: "2026-05-07T00:00:00Z",
id: 1,
invited_by: 1,
email: "invitee@example.com",
name: "Invitee Name",
sso_enabled: true,
global_role: "observer",
teams: [],
},
});
mockUsersAPI.create.mockResolvedValue({} as IUser);
mockSessionsAPI.initializeSSO.mockRejectedValue(
new Error("redirect skipped")
);
const { user } = renderPage("token-xyz");
expect(
await screen.findByRole("textbox", { name: "Full name" })
).toBeInTheDocument();
const emailInput = screen.getByLabelText("Email") as HTMLInputElement;
expect(emailInput).toBeDisabled();
expect(emailInput.value).toBe("invitee@example.com");
await user.click(screen.getByRole("button", { name: "Submit" }));
await waitFor(() => {
expect(mockUsersAPI.create).toHaveBeenCalledWith({
email: "invitee@example.com",
invite_token: "token-xyz",
name: "Invitee Name",
sso_invite: true,
});
});
await waitFor(() => {
expect(mockSessionsAPI.initializeSSO).toHaveBeenCalled();
});
});
it("renders the invalid invite token message when verification fails", async () => {
// Reject with a 4xx-shaped error so DEFAULT_USE_QUERY_OPTIONS does not
// trigger retries.
mockInviteAPI.verify.mockRejectedValue({ status: 404, message: "invalid" });
renderPage("bad-token");
expect(
await screen.findByText(/this invite token is invalid/i)
).toBeInTheDocument();
expect(
screen.queryByRole("textbox", { name: "Full name" })
).not.toBeInTheDocument();
});
});
@@ -1,21 +1,24 @@
import React, { useState, useEffect, useContext } from "react";
import React, { useCallback, useContext, useEffect } from "react";
import { InjectedRouter } from "react-router";
import { Params } from "react-router/lib/Router";
import { useQuery } from "react-query";
import { AxiosError } from "axios";
import paths from "router/paths";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import usersAPI from "services/entities/users";
import sessionsAPI from "services/entities/sessions";
import formatErrorResponse from "utilities/format_error_response";
import inviteAPI, { IValidateInviteResp } from "services/entities/invites";
import { IInvite } from "interfaces/invite";
import { getErrorReason } from "interfaces/errors";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
import AuthenticationFormWrapper from "components/AuthenticationFormWrapper";
// @ts-ignore
import Spinner from "components/Spinner";
import ConfirmSSOInviteForm from "components/forms/ConfirmSSOInviteForm";
interface IConfirmSSOInvitePageProps {
location: {
query: { email?: string; name?: string };
};
params: Params;
router: InjectedRouter;
}
@@ -23,53 +26,91 @@ interface IConfirmSSOInvitePageProps {
const baseClass = "confirm-invite-page";
const ConfirmSSOInvitePage = ({
location,
params,
router,
}: IConfirmSSOInvitePageProps) => {
const { email, name } = location.query;
const { invite_token } = params;
const inviteFormData = { email, invite_token, name };
const { currentUser } = useContext(AppContext);
const [errors, setErrors] = useState<{ [key: string]: string }>({});
const { renderFlash } = useContext(NotificationContext);
useEffect(() => {
const { DASHBOARD } = paths;
if (currentUser) {
return router.push(DASHBOARD);
router.push(paths.DASHBOARD);
}
}, [currentUser]);
}, [currentUser, router]);
const onSubmit = async (formData: any) => {
const { DASHBOARD } = paths;
formData.sso_invite = true;
try {
await usersAPI.create(formData);
const { url } = await sessionsAPI.initializeSSO(DASHBOARD);
window.location.href = url;
} catch (response) {
const errorObject = formatErrorResponse(response);
setErrors(errorObject);
return false;
const {
data: validInvite,
error: validateInviteError,
isLoading: isVerifyingInvite,
} = useQuery<IValidateInviteResp, AxiosError, IInvite>(
["invite", invite_token],
() => inviteAPI.verify(invite_token),
{
...DEFAULT_USE_QUERY_OPTIONS,
select: (resp: IValidateInviteResp) => resp.invite,
}
};
);
return (
<AuthenticationFormWrapper className={baseClass} header="Welcome to Fleet">
const onSubmit = useCallback(
async (name: string) => {
// The form is only rendered once the invite has been verified, so
// validInvite is always defined here. The early return tightens
// types and guards against future drift.
if (!validInvite) return;
try {
await usersAPI.create({
email: validInvite.email,
invite_token,
name,
sso_invite: true,
});
const { url } = await sessionsAPI.initializeSSO(paths.DASHBOARD);
window.location.href = url;
} catch (error) {
renderFlash("error", getErrorReason(error));
}
},
[invite_token, renderFlash, validInvite]
);
const isInvalidInvite =
!isVerifyingInvite && (!!validateInviteError || !validInvite);
const renderContent = () => {
if (isVerifyingInvite) {
return <Spinner />;
}
if (isInvalidInvite) {
return (
<p className={`${baseClass}__description`}>
This invite token is invalid. Please confirm your invite link.
</p>
);
}
return (
<>
<p className={`${baseClass}__description`}>
Please provide your name to get started.
</p>
<ConfirmSSOInviteForm
className={`${baseClass}__form`}
formData={inviteFormData}
defaultName={validInvite?.name}
email={validInvite?.email}
handleSubmit={onSubmit}
serverErrors={errors}
/>
</>
);
};
return (
<AuthenticationFormWrapper
header={isInvalidInvite ? "Invalid invite token" : "Welcome to Fleet"}
className={baseClass}
>
{renderContent()}
</AuthenticationFormWrapper>
);
};