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