mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
Auto-accept invite after inline auth
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
089d73e2f4
commit
54b1667c3d
2 files changed
+188
-4
No files matched your search
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user