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);