Auto-accept invite after inline auth

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-13 07:57:04 -05:00
1 parent 089d73e2f4
commit 54b1667c3d
2 files changed
+188 -4

No files matched your search

+142 -1
View File
@@ -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(
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="/invite/:token" element={<InviteLandingPage />} />
</Routes>
</QueryClientProvider>
</MemoryRouter>,
);
});
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(
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="/invite/:token" element={<InviteLandingPage />} />
</Routes>
</QueryClientProvider>
</MemoryRouter>,
);
});
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({
+46 -3
View File
@@ -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<string, unknown>),
);
}
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<AuthMode>("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<string, unknown>);
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);