diff --git a/ui/src/pages/InviteLanding.test.tsx b/ui/src/pages/InviteLanding.test.tsx index 47cfcabe4aa..8294879ac65 100644 --- a/ui/src/pages/InviteLanding.test.tsx +++ b/ui/src/pages/InviteLanding.test.tsx @@ -14,6 +14,7 @@ const signInEmailMock = vi.hoisted(() => vi.fn()); const signUpEmailMock = vi.hoisted(() => vi.fn()); const healthGetMock = vi.hoisted(() => vi.fn()); const listCompaniesMock = vi.hoisted(() => vi.fn()); +const setSelectedCompanyIdMock = vi.hoisted(() => vi.fn()); vi.mock("../api/access", () => ({ accessApi: { @@ -50,7 +51,7 @@ vi.mock("@/context/CompanyContext", () => ({ selectionSource: "manual", loading: false, error: null, - setSelectedCompanyId: vi.fn(), + setSelectedCompanyId: setSelectedCompanyIdMock, reloadCompanies: vi.fn(), createCompany: vi.fn(), }), @@ -109,6 +110,7 @@ describe("InviteLandingPage", () => { getSessionMock.mockResolvedValue(null); signInEmailMock.mockResolvedValue(undefined); signUpEmailMock.mockResolvedValue(undefined); + setSelectedCompanyIdMock.mockReset(); }); afterEach(() => { @@ -276,6 +278,145 @@ describe("InviteLandingPage", () => { }); }); + it("auto-accepts the invite after account creation and redirects into the company", async () => { + acceptInviteMock.mockResolvedValue({ + id: "join-1", + companyId: "company-1", + requestType: "human", + status: "approved", + }); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + + } /> + + + , + ); + }); + await flushReact(); + await flushReact(); + + const inputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + expect(inputValueSetter).toBeTypeOf("function"); + + const nameInput = container.querySelector('input[name="name"]') as HTMLInputElement | null; + const emailInput = container.querySelector('input[name="email"]') as HTMLInputElement | null; + const passwordInput = container.querySelector('input[name="password"]') as HTMLInputElement | null; + expect(nameInput).not.toBeNull(); + expect(emailInput).not.toBeNull(); + expect(passwordInput).not.toBeNull(); + + await act(async () => { + inputValueSetter!.call(nameInput, "Jane Example"); + nameInput!.dispatchEvent(new Event("input", { bubbles: true })); + inputValueSetter!.call(emailInput, "jane@example.com"); + emailInput!.dispatchEvent(new Event("input", { bubbles: true })); + inputValueSetter!.call(passwordInput, "supersecret"); + passwordInput!.dispatchEvent(new Event("input", { bubbles: true })); + }); + + const authForm = container.querySelector('[data-testid="invite-inline-auth"]') as HTMLFormElement | null; + expect(authForm).not.toBeNull(); + + await act(async () => { + authForm?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + }); + await flushReact(); + await flushReact(); + await flushReact(); + + expect(signUpEmailMock).toHaveBeenCalledWith({ + name: "Jane Example", + email: "jane@example.com", + password: "supersecret", + }); + expect(acceptInviteMock).toHaveBeenCalledWith("pcp_invite_test", { requestType: "human" }); + expect(setSelectedCompanyIdMock).toHaveBeenCalledWith("company-1", { source: "manual" }); + expect(localStorage.getItem("paperclip:pending-invite-token")).toBeNull(); + + await act(async () => { + root.unmount(); + }); + }); + + it("redirects straight to the company after sign-in when the user already has access", async () => { + listCompaniesMock.mockResolvedValue([{ id: "company-1", name: "Acme Robotics" }]); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + + } /> + + + , + ); + }); + await flushReact(); + await flushReact(); + + const inputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + expect(inputValueSetter).toBeTypeOf("function"); + + const existingAccountButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "I already have an account", + ); + expect(existingAccountButton).not.toBeNull(); + + await act(async () => { + existingAccountButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + const emailInput = container.querySelector('input[name="email"]') as HTMLInputElement | null; + const passwordInput = container.querySelector('input[name="password"]') as HTMLInputElement | null; + expect(emailInput).not.toBeNull(); + expect(passwordInput).not.toBeNull(); + + await act(async () => { + inputValueSetter!.call(emailInput, "jane@example.com"); + emailInput!.dispatchEvent(new Event("input", { bubbles: true })); + inputValueSetter!.call(passwordInput, "supersecret"); + passwordInput!.dispatchEvent(new Event("input", { bubbles: true })); + }); + + const authForm = container.querySelector('[data-testid="invite-inline-auth"]') as HTMLFormElement | null; + expect(authForm).not.toBeNull(); + + await act(async () => { + authForm?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + }); + await flushReact(); + await flushReact(); + + expect(signInEmailMock).toHaveBeenCalledWith({ + email: "jane@example.com", + password: "supersecret", + }); + expect(acceptInviteMock).not.toHaveBeenCalled(); + expect(setSelectedCompanyIdMock).toHaveBeenCalledWith("company-1", { source: "manual" }); + expect(localStorage.getItem("paperclip:pending-invite-token")).toBeNull(); + + await act(async () => { + root.unmount(); + }); + }); + it("falls back to the generated company icon when the invite logo fails to load", async () => { const root = createRoot(container); const queryClient = new QueryClient({ diff --git a/ui/src/pages/InviteLanding.tsx b/ui/src/pages/InviteLanding.tsx index f021172f32f..ecfec7d5adc 100644 --- a/ui/src/pages/InviteLanding.tsx +++ b/ui/src/pages/InviteLanding.tsx @@ -4,6 +4,7 @@ import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared"; import type { AgentAdapterType, JoinRequest } from "@paperclipai/shared"; import { Button } from "@/components/ui/button"; import { CompanyPatternIcon } from "@/components/CompanyPatternIcon"; +import { useCompany } from "@/context/CompanyContext"; import { Link, useNavigate, useParams } from "@/lib/router"; import { accessApi } from "../api/access"; import { authApi } from "../api/auth"; @@ -104,9 +105,24 @@ function mapInviteAuthFeedback( }; } +function isBootstrapAcceptancePayload(payload: unknown) { + return Boolean( + payload && + typeof payload === "object" && + "bootstrapAccepted" in (payload as Record), + ); +} + +function isApprovedHumanJoinPayload(payload: unknown, showsAgentForm: boolean) { + if (!payload || typeof payload !== "object" || showsAgentForm) return false; + const status = (payload as { status?: unknown }).status; + return status === "approved"; +} + export function InviteLandingPage() { const queryClient = useQueryClient(); const navigate = useNavigate(); + const { setSelectedCompanyId } = useCompany(); const params = useParams(); const token = (params.token ?? "").trim(); const [authMode, setAuthMode] = useState("sign_up"); @@ -215,8 +231,7 @@ export function InviteLandingPage() { clearPendingInviteToken(token); await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session }); await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); - const asBootstrap = - payload && typeof payload === "object" && "bootstrapAccepted" in (payload as Record); + const asBootstrap = isBootstrapAcceptancePayload(payload); setResult({ kind: asBootstrap ? "bootstrap" : "join", payload }); }, onError: (err) => { @@ -240,7 +255,35 @@ export function InviteLandingPage() { setAuthFeedback(null); rememberPendingInviteToken(token); await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session }); - await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); + const companies = await queryClient.fetchQuery({ + queryKey: queryKeys.companies.all, + queryFn: () => companiesApi.list(), + retry: false, + }); + + if (invite?.companyId && companies.some((company) => company.id === invite.companyId)) { + clearPendingInviteToken(token); + setSelectedCompanyId(invite.companyId, { source: "manual" }); + navigate("/", { replace: true }); + return; + } + + let payload: unknown; + try { + payload = await acceptMutation.mutateAsync(); + } catch { + return; + } + + if (invite?.companyId && isApprovedHumanJoinPayload(payload, showsAgentForm)) { + setSelectedCompanyId(invite.companyId, { source: "manual" }); + navigate("/", { replace: true }); + return; + } + + if (isBootstrapAcceptancePayload(payload)) { + navigate("/", { replace: true }); + } }, onError: (err) => { const nextFeedback = mapInviteAuthFeedback(err, authMode, email);