diff --git a/changes/34103-sso-invite-accept b/changes/34103-sso-invite-accept new file mode 100644 index 0000000000..62b7a382d1 --- /dev/null +++ b/changes/34103-sso-invite-accept @@ -0,0 +1 @@ +- Fixed SSO invite acceptance flow by resolving the email from the invite token. diff --git a/frontend/components/forms/ConfirmSSOInviteForm/ConfirmSSOInviteForm.jsx b/frontend/components/forms/ConfirmSSOInviteForm/ConfirmSSOInviteForm.jsx deleted file mode 100644 index 2e771ac827..0000000000 --- a/frontend/components/forms/ConfirmSSOInviteForm/ConfirmSSOInviteForm.jsx +++ /dev/null @@ -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 ( -
- {baseError &&
{baseError}
} - -
- -
- - ); - } -} - -export default Form(ConfirmSSOInviteForm, { - fields: formFields, - validate, -}); diff --git a/frontend/components/forms/ConfirmSSOInviteForm/ConfirmSSOInviteForm.tests.tsx b/frontend/components/forms/ConfirmSSOInviteForm/ConfirmSSOInviteForm.tests.tsx new file mode 100644 index 0000000000..d1c4763227 --- /dev/null +++ b/frontend/components/forms/ConfirmSSOInviteForm/ConfirmSSOInviteForm.tests.tsx @@ -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( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + 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"); + }); +}); diff --git a/frontend/components/forms/ConfirmSSOInviteForm/ConfirmSSOInviteForm.tsx b/frontend/components/forms/ConfirmSSOInviteForm/ConfirmSSOInviteForm.tsx new file mode 100644 index 0000000000..8196521542 --- /dev/null +++ b/frontend/components/forms/ConfirmSSOInviteForm/ConfirmSSOInviteForm.tsx @@ -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(null); + + const onNameChange = (value: string) => { + setName(value); + if (nameError && value.trim()) setNameError(null); + }; + + const onSubmit = (evt: React.FormEvent) => { + evt.preventDefault(); + const trimmedName = name.trim(); + if (!trimmedName) { + setNameError("Full name must be present"); + return; + } + handleSubmit(trimmedName); + }; + + return ( +
+ + +
+ +
+ + ); +}; + +export default ConfirmSSOInviteForm; diff --git a/frontend/components/forms/ConfirmSSOInviteForm/helpers.js b/frontend/components/forms/ConfirmSSOInviteForm/helpers.js deleted file mode 100644 index a058694548..0000000000 --- a/frontend/components/forms/ConfirmSSOInviteForm/helpers.js +++ /dev/null @@ -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 }; diff --git a/frontend/components/forms/ConfirmSSOInviteForm/index.js b/frontend/components/forms/ConfirmSSOInviteForm/index.ts similarity index 100% rename from frontend/components/forms/ConfirmSSOInviteForm/index.js rename to frontend/components/forms/ConfirmSSOInviteForm/index.ts diff --git a/frontend/interfaces/user.ts b/frontend/interfaces/user.ts index 1dea7f3f1d..75dfdfc8b8 100644 --- a/frontend/interfaces/user.ts +++ b/frontend/interfaces/user.ts @@ -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; } diff --git a/frontend/pages/ConfirmSSOInvitePage/ConfirmSSOInvitePage.tests.tsx b/frontend/pages/ConfirmSSOInvitePage/ConfirmSSOInvitePage.tests.tsx new file mode 100644 index 0000000000..5de5ed3ac8 --- /dev/null +++ b/frontend/pages/ConfirmSSOInvitePage/ConfirmSSOInvitePage.tests.tsx @@ -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; +const mockUsersAPI = usersAPI as jest.Mocked; +const mockSessionsAPI = sessionsAPI as jest.Mocked; + +const renderPage = (token = "abc") => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, cacheTime: 0 } }, + }); + return renderWithSetup( + + + + ); +}; + +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(); + }); +}); diff --git a/frontend/pages/ConfirmSSOInvitePage/ConfirmSSOInvitePage.tsx b/frontend/pages/ConfirmSSOInvitePage/ConfirmSSOInvitePage.tsx index e8a1f5c5c0..df553f6e82 100644 --- a/frontend/pages/ConfirmSSOInvitePage/ConfirmSSOInvitePage.tsx +++ b/frontend/pages/ConfirmSSOInvitePage/ConfirmSSOInvitePage.tsx @@ -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( + ["invite", invite_token], + () => inviteAPI.verify(invite_token), + { + ...DEFAULT_USE_QUERY_OPTIONS, + select: (resp: IValidateInviteResp) => resp.invite, } - }; + ); - return ( - + 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 ; + } + + if (isInvalidInvite) { + return ( +

+ This invite token is invalid. Please confirm your invite link. +

+ ); + } + + return ( <>

Please provide your name to get started.

+ ); + }; + + return ( + + {renderContent()} ); };