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.
+
+ ) : (
+
+
+
+
+ | State |
+ Role |
+ Invited by |
+ Created |
+ Join request |
+ Action |
+
+
+
+ {invitesQuery.data!.map((invite) => (
+
+ |
+
+ {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);
+}