From 387ce15e434b8cf1d876b850bdc6996f53bd2eb3 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 23 May 2026 21:11:54 +0000 Subject: [PATCH] Fix invite landing company cache shape Co-Authored-By: Paperclip --- ui/src/pages/InviteLanding.test.tsx | 50 +++++++++++++++++++++++++++++ ui/src/pages/InviteLanding.tsx | 26 ++++++++++----- 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/ui/src/pages/InviteLanding.test.tsx b/ui/src/pages/InviteLanding.test.tsx index a800bd6f036..acdb8727474 100644 --- a/ui/src/pages/InviteLanding.test.tsx +++ b/ui/src/pages/InviteLanding.test.tsx @@ -6,6 +6,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { InviteLandingPage } from "./InviteLanding"; +import { queryKeys } from "../lib/queryKeys"; const getInviteMock = vi.hoisted(() => vi.fn()); const acceptInviteMock = vi.hoisted(() => vi.fn()); @@ -594,6 +595,55 @@ describe("InviteLandingPage", () => { }); }); + it("normalizes the shared company cache envelope before checking membership", async () => { + acceptInviteMock.mockResolvedValue({ + id: "join-1", + companyId: "company-1", + requestType: "human", + status: "pending_approval", + }); + getSessionMock.mockResolvedValue({ + session: { id: "session-1", userId: "user-1" }, + user: { + id: "user-1", + name: "Jane Example", + email: "jane@example.com", + image: null, + }, + }); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.setQueryData(queryKeys.companies.all, { + companies: [], + unauthorized: false, + }); + + await act(async () => { + root.render( + + + + } /> + + + , + ); + }); + await flushReact(); + await flushReact(); + await flushReact(); + + expect(acceptInviteMock).toHaveBeenCalledWith("pcp_invite_test", { requestType: "human" }); + expect(container.textContent).toContain("Request to join Acme Robotics"); + + await act(async () => { + root.unmount(); + }); + }); + it("waits for the membership check before showing invite acceptance to signed-in users", async () => { let resolveCompanies: ((value: Array<{ id: string; name: string }>) => void) | null = null; acceptInviteMock.mockResolvedValue({ diff --git a/ui/src/pages/InviteLanding.tsx b/ui/src/pages/InviteLanding.tsx index cb394505900..5586124a502 100644 --- a/ui/src/pages/InviteLanding.tsx +++ b/ui/src/pages/InviteLanding.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared"; -import type { AgentAdapterType, JoinRequest } from "@paperclipai/shared"; +import type { AgentAdapterType, Company, JoinRequest } from "@paperclipai/shared"; import { Button } from "@/components/ui/button"; import { CompanyPatternIcon } from "@/components/CompanyPatternIcon"; import { useCompany } from "@/context/CompanyContext"; @@ -17,6 +17,7 @@ import { formatDate } from "../lib/utils"; type AuthMode = "sign_in" | "sign_up"; type AuthFeedback = { tone: "error" | "info"; message: string }; +type CompanyListQueryData = Company[] | { companies?: Company[]; unauthorized?: boolean }; const joinAdapterOptions: AgentAdapterType[] = [...AGENT_ADAPTER_TYPES]; const ENABLED_INVITE_ADAPTERS = new Set([ @@ -27,6 +28,14 @@ const ENABLED_INVITE_ADAPTERS = new Set([ "pi_local", "cursor", ]); +const EMPTY_COMPANIES: Company[] = []; + +function normalizeCompanyList(value: unknown): Company[] { + if (Array.isArray(value)) return value as Company[]; + if (!value || typeof value !== "object") return EMPTY_COMPANIES; + const companies = (value as { companies?: unknown }).companies; + return Array.isArray(companies) ? (companies as Company[]) : EMPTY_COMPANIES; +} function readNestedString(value: unknown, path: string[]): string | null { let current: unknown = value; @@ -247,12 +256,14 @@ export function InviteLandingPage() { retry: false, }); - const companiesQuery = useQuery({ + const companiesQuery = useQuery({ queryKey: queryKeys.companies.all, queryFn: () => companiesApi.list(), enabled: !!sessionQuery.data && !!inviteQuery.data?.companyId, retry: false, + select: normalizeCompanyList, }); + const accessibleCompanies = companiesQuery.data ?? EMPTY_COMPANIES; useEffect(() => { if (token) rememberPendingInviteToken(token); @@ -264,14 +275,14 @@ export function InviteLandingPage() { useEffect(() => { if (!companiesQuery.data || !inviteQuery.data?.companyId) return; - const isMember = companiesQuery.data.some( + const isMember = accessibleCompanies.some( (c) => c.id === inviteQuery.data!.companyId ); if (isMember) { clearPendingInviteToken(token); navigate("/", { replace: true }); } - }, [companiesQuery.data, inviteQuery.data, token, navigate]); + }, [accessibleCompanies, companiesQuery.data, inviteQuery.data, token, navigate]); const invite = inviteQuery.data; const isCheckingExistingMembership = @@ -280,9 +291,7 @@ export function InviteLandingPage() { companiesQuery.isLoading; const isCurrentMember = Boolean(invite?.companyId) && - Boolean( - companiesQuery.data?.some((company) => company.id === invite?.companyId), - ); + accessibleCompanies.some((company) => company.id === invite?.companyId); const companyName = invite?.companyName?.trim() || null; const companyDisplayName = companyName || "this Paperclip company"; const companyLogoUrl = invite?.companyLogoUrl?.trim() || null; @@ -375,11 +384,12 @@ export function InviteLandingPage() { setAuthFeedback(null); rememberPendingInviteToken(token); await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session }); - const companies = await queryClient.fetchQuery({ + const companiesResult = await queryClient.fetchQuery({ queryKey: queryKeys.companies.all, queryFn: () => companiesApi.list(), retry: false, }); + const companies = normalizeCompanyList(companiesResult); if (invite?.companyId && companies.some((company) => company.id === invite.companyId)) { clearPendingInviteToken(token);