From 9842754a4a31db627bdeda59742d7f555bc7cf71 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 10 Apr 2026 16:39:38 -0500 Subject: [PATCH] Simplify company invites page --- ui/src/pages/CompanyInvites.test.tsx | 210 ++++++++++++++++++++++++ ui/src/pages/CompanyInvites.tsx | 230 +++++++++++++++++++++++++++ 2 files changed, 440 insertions(+) create mode 100644 ui/src/pages/CompanyInvites.test.tsx create mode 100644 ui/src/pages/CompanyInvites.tsx diff --git a/ui/src/pages/CompanyInvites.test.tsx b/ui/src/pages/CompanyInvites.test.tsx new file mode 100644 index 00000000000..cc2686e3c21 --- /dev/null +++ b/ui/src/pages/CompanyInvites.test.tsx @@ -0,0 +1,210 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CompanyInvites } from "./CompanyInvites"; + +const listInvitesMock = vi.hoisted(() => vi.fn()); +const createCompanyInviteMock = vi.hoisted(() => vi.fn()); +const revokeInviteMock = vi.hoisted(() => vi.fn()); +const pushToastMock = vi.hoisted(() => vi.fn()); +const setBreadcrumbsMock = vi.hoisted(() => vi.fn()); +const clipboardWriteTextMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/api/access", () => ({ + accessApi: { + listInvites: (companyId: string) => listInvitesMock(companyId), + createCompanyInvite: (companyId: string, input: unknown) => + createCompanyInviteMock(companyId, input), + revokeInvite: (inviteId: string) => revokeInviteMock(inviteId), + }, +})); + +vi.mock("@/context/CompanyContext", () => ({ + useCompany: () => ({ + selectedCompanyId: "company-1", + selectedCompany: { id: "company-1", name: "Paperclip", issuePrefix: "PAP" }, + }), +})); + +vi.mock("@/context/BreadcrumbContext", () => ({ + useBreadcrumbs: () => ({ setBreadcrumbs: setBreadcrumbsMock }), +})); + +vi.mock("@/context/ToastContext", () => ({ + useToast: () => ({ pushToast: pushToastMock }), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +describe("CompanyInvites", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + + listInvitesMock.mockResolvedValue([ + { + id: "invite-1", + companyId: "company-1", + inviteType: "company_join", + tokenHash: "hash-1", + allowedJoinTypes: "human", + defaultsPayload: null, + expiresAt: "2026-04-20T00:00:00.000Z", + invitedByUserId: "user-1", + revokedAt: null, + acceptedAt: null, + createdAt: "2026-04-10T00:00:00.000Z", + updatedAt: "2026-04-10T00:00:00.000Z", + companyName: "Paperclip", + humanRole: "operator", + inviteMessage: null, + state: "active", + invitedByUser: { + id: "user-1", + name: "Board User", + email: "board@paperclip.local", + image: null, + }, + relatedJoinRequestId: "join-1", + }, + { + id: "invite-2", + companyId: "company-1", + inviteType: "company_join", + tokenHash: "hash-2", + allowedJoinTypes: "human", + defaultsPayload: null, + expiresAt: "2026-04-20T00:00:00.000Z", + invitedByUserId: "user-1", + revokedAt: null, + acceptedAt: "2026-04-11T00:00:00.000Z", + createdAt: "2026-04-09T00:00:00.000Z", + updatedAt: "2026-04-11T00:00:00.000Z", + companyName: "Paperclip", + humanRole: "viewer", + inviteMessage: null, + state: "accepted", + invitedByUser: { + id: "user-1", + name: "Board User", + email: "board@paperclip.local", + image: null, + }, + relatedJoinRequestId: null, + }, + ]); + + createCompanyInviteMock.mockResolvedValue({ + inviteUrl: "https://paperclip.local/invite/new-token", + onboardingTextUrl: null, + onboardingTextPath: null, + humanRole: "viewer", + allowedJoinTypes: "human", + }); + + revokeInviteMock.mockResolvedValue(undefined); + + Object.defineProperty(globalThis.navigator, "clipboard", { + configurable: true, + value: { writeText: clipboardWriteTextMock }, + }); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("renders a human-only invite flow and keeps invite history in a table", async () => { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + + + , + ); + }); + await flushReact(); + await flushReact(); + + expect(container.textContent).toContain("Company Invites"); + expect(container.textContent).toContain("Create invite"); + expect(container.textContent).toContain("Invite history"); + expect(container.textContent).toContain("Board User"); + expect(container.textContent).toContain("Review request"); + expect(container.textContent).not.toContain("Human or agent"); + expect(container.textContent).not.toContain("Invite message"); + expect(container.textContent).not.toContain("Latest generated invite"); + expect(container.textContent).not.toContain("Active invites"); + expect(container.textContent).not.toContain("Consumed invites"); + expect(container.textContent).not.toContain("Expired invites"); + expect(container.textContent).not.toContain("OpenClaw shortcut"); + + const roleSelect = container.querySelector("select") as HTMLSelectElement | null; + expect(roleSelect).not.toBeNull(); + + await act(async () => { + if (roleSelect) { + roleSelect.value = "viewer"; + roleSelect.dispatchEvent(new Event("change", { bubbles: true })); + } + }); + + const buttons = Array.from(container.querySelectorAll("button")); + const createButton = buttons.find((button) => button.textContent === "Create invite"); + const revokeButton = buttons.find((button) => button.textContent === "Revoke"); + + expect(createButton).toBeTruthy(); + expect(revokeButton).toBeTruthy(); + + await act(async () => { + createButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + await flushReact(); + + expect(createCompanyInviteMock).toHaveBeenCalledWith("company-1", { + allowedJoinTypes: "human", + humanRole: "viewer", + agentMessage: null, + }); + expect(clipboardWriteTextMock).toHaveBeenCalledWith("https://paperclip.local/invite/new-token"); + expect(pushToastMock).toHaveBeenCalledWith({ + title: "Invite created", + body: "Invite link copied to clipboard.", + tone: "success", + }); + + await act(async () => { + revokeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(revokeInviteMock).toHaveBeenCalledWith("invite-1"); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/pages/CompanyInvites.tsx b/ui/src/pages/CompanyInvites.tsx new file mode 100644 index 00000000000..356bc2443ad --- /dev/null +++ b/ui/src/pages/CompanyInvites.tsx @@ -0,0 +1,230 @@ +import { useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { MailPlus } from "lucide-react"; +import { accessApi } from "@/api/access"; +import { ApiError } from "@/api/client"; +import { Button } from "@/components/ui/button"; +import { useBreadcrumbs } from "@/context/BreadcrumbContext"; +import { useCompany } from "@/context/CompanyContext"; +import { useToast } from "@/context/ToastContext"; +import { Link } from "@/lib/router"; +import { queryKeys } from "@/lib/queryKeys"; + +export function CompanyInvites() { + const { selectedCompany, selectedCompanyId } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + const { pushToast } = useToast(); + const queryClient = useQueryClient(); + const [humanRole, setHumanRole] = useState<"owner" | "admin" | "operator" | "viewer">("operator"); + + useEffect(() => { + setBreadcrumbs([ + { label: selectedCompany?.name ?? "Company", href: "/dashboard" }, + { label: "Settings", href: "/company/settings" }, + { label: "Invites" }, + ]); + }, [selectedCompany?.name, setBreadcrumbs]); + + const invitesQuery = useQuery({ + queryKey: queryKeys.access.invites(selectedCompanyId ?? "", "all"), + queryFn: () => accessApi.listInvites(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + + const createInviteMutation = useMutation({ + mutationFn: () => + accessApi.createCompanyInvite(selectedCompanyId!, { + allowedJoinTypes: "human", + humanRole, + agentMessage: null, + }), + onSuccess: async (invite) => { + let copied = false; + + try { + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(invite.inviteUrl); + copied = true; + } + } catch { + copied = false; + } + + await queryClient.invalidateQueries({ queryKey: queryKeys.access.invites(selectedCompanyId!, "all") }); + pushToast({ + title: "Invite created", + body: copied ? "Invite link copied to clipboard." : "Invite link created, but clipboard copy was unavailable.", + tone: "success", + }); + }, + onError: (error) => { + pushToast({ + title: "Failed to create invite", + body: error instanceof Error ? error.message : "Unknown error", + tone: "error", + }); + }, + }); + + const revokeMutation = useMutation({ + mutationFn: (inviteId: string) => accessApi.revokeInvite(inviteId), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: queryKeys.access.invites(selectedCompanyId!, "all") }); + pushToast({ title: "Invite revoked", tone: "success" }); + }, + onError: (error) => { + pushToast({ + title: "Failed to revoke invite", + body: error instanceof Error ? error.message : "Unknown error", + tone: "error", + }); + }, + }); + + if (!selectedCompanyId) { + return
Select a company to manage invites.
; + } + + if (invitesQuery.isLoading) { + return
Loading invites…
; + } + + if (invitesQuery.error) { + const message = + invitesQuery.error instanceof ApiError && invitesQuery.error.status === 403 + ? "You do not have permission to manage company invites." + : invitesQuery.error instanceof Error + ? invitesQuery.error.message + : "Failed to load invites."; + return
{message}
; + } + + return ( +
+
+
+ +

Company Invites

+
+

+ Create human invite links for company access. New invite links are copied to your clipboard when they are generated. +

+
+ +
+
+

Create invite

+

+ Generate a human invite link and choose the default role it should request. +

+
+ + + +
+ + Invite history below keeps the audit trail. +
+
+ +
+
+
+

Invite history

+

+ Review invite status, role, inviter, and any linked join request. +

+
+ + Open join request queue + +
+ + {(invitesQuery.data ?? []).length === 0 ? ( +
+ No invites have been created for this company yet. +
+ ) : ( +
+ + + + + + + + + + + + + {invitesQuery.data!.map((invite) => ( + + + + + + + + + ))} + +
StateRoleInvited byCreatedJoin requestAction
+ + {formatInviteState(invite.state)} + + {invite.humanRole ?? "—"} +
{invite.invitedByUser?.name || invite.invitedByUser?.email || "Unknown inviter"}
+ {invite.invitedByUser?.email && invite.invitedByUser.name ? ( +
{invite.invitedByUser.email}
+ ) : null} +
+ {new Date(invite.createdAt).toLocaleString()} + + {invite.relatedJoinRequestId ? ( + + Review request + + ) : ( + — + )} + + {invite.state === "active" ? ( + + ) : ( + Inactive + )} +
+
+ )} +
+
+ ); +} + +function formatInviteState(state: "active" | "accepted" | "expired" | "revoked") { + return state.charAt(0).toUpperCase() + state.slice(1); +}