Simplify company invites page

This commit is contained in:
Dotta committed 2026-04-10 16:39:38 -05:00
1 parent 162eef9f81
commit 9842754a4a
2 files changed
+440

No files matched your search

+210
View File
@@ -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(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<CompanyInvites />
</QueryClientProvider>
</MemoryRouter>,
);
});
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();
});
});
});
+230
View File
@@ -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 <div className="text-sm text-muted-foreground">Select a company to manage invites.</div>;
}
if (invitesQuery.isLoading) {
return <div className="text-sm text-muted-foreground">Loading invites…</div>;
}
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 <div className="text-sm text-destructive">{message}</div>;
}
return (
<div className="max-w-5xl space-y-8">
<div className="space-y-3">
<div className="flex items-center gap-2">
<MailPlus className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Company Invites</h1>
</div>
<p className="max-w-3xl text-sm text-muted-foreground">
Create human invite links for company access. New invite links are copied to your clipboard when they are generated.
</p>
</div>
<section className="space-y-4 rounded-xl border border-border p-5">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Create invite</h2>
<p className="text-sm text-muted-foreground">
Generate a human invite link and choose the default role it should request.
</p>
</div>
<label className="block max-w-sm space-y-2 text-sm">
<span className="font-medium">Default human role</span>
<select
className="w-full rounded-md border border-border bg-background px-3 py-2"
value={humanRole}
onChange={(event) =>
setHumanRole(event.target.value as "owner" | "admin" | "operator" | "viewer")
}
>
<option value="owner">Owner</option>
<option value="admin">Admin</option>
<option value="operator">Operator</option>
<option value="viewer">Viewer</option>
</select>
</label>
<div className="flex flex-wrap items-center gap-3">
<Button onClick={() => createInviteMutation.mutate()} disabled={createInviteMutation.isPending}>
{createInviteMutation.isPending ? "Creating…" : "Create invite"}
</Button>
<span className="text-sm text-muted-foreground">Invite history below keeps the audit trail.</span>
</div>
</section>
<section className="rounded-xl border border-border">
<div className="flex flex-wrap items-center justify-between gap-4 px-5 py-4">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Invite history</h2>
<p className="text-sm text-muted-foreground">
Review invite status, role, inviter, and any linked join request.
</p>
</div>
<Link to="/inbox/requests" className="text-sm underline underline-offset-4">
Open join request queue
</Link>
</div>
{(invitesQuery.data ?? []).length === 0 ? (
<div className="border-t border-border px-5 py-8 text-sm text-muted-foreground">
No invites have been created for this company yet.
</div>
) : (
<div className="overflow-x-auto border-t border-border">
<table className="min-w-full text-left text-sm">
<thead>
<tr className="border-b border-border">
<th className="px-5 py-3 font-medium text-muted-foreground">State</th>
<th className="px-5 py-3 font-medium text-muted-foreground">Role</th>
<th className="px-5 py-3 font-medium text-muted-foreground">Invited by</th>
<th className="px-5 py-3 font-medium text-muted-foreground">Created</th>
<th className="px-5 py-3 font-medium text-muted-foreground">Join request</th>
<th className="px-5 py-3 text-right font-medium text-muted-foreground">Action</th>
</tr>
</thead>
<tbody>
{invitesQuery.data!.map((invite) => (
<tr key={invite.id} className="border-b border-border last:border-b-0">
<td className="px-5 py-3 align-top">
<span className="inline-flex rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground">
{formatInviteState(invite.state)}
</span>
</td>
<td className="px-5 py-3 align-top">{invite.humanRole ?? "—"}</td>
<td className="px-5 py-3 align-top">
<div>{invite.invitedByUser?.name || invite.invitedByUser?.email || "Unknown inviter"}</div>
{invite.invitedByUser?.email && invite.invitedByUser.name ? (
<div className="text-xs text-muted-foreground">{invite.invitedByUser.email}</div>
) : null}
</td>
<td className="px-5 py-3 align-top text-muted-foreground">
{new Date(invite.createdAt).toLocaleString()}
</td>
<td className="px-5 py-3 align-top">
{invite.relatedJoinRequestId ? (
<Link to="/inbox/requests" className="underline underline-offset-4">
Review request
</Link>
) : (
<span className="text-muted-foreground">—</span>
)}
</td>
<td className="px-5 py-3 text-right align-top">
{invite.state === "active" ? (
<Button
size="sm"
variant="outline"
onClick={() => revokeMutation.mutate(invite.id)}
disabled={revokeMutation.isPending}
>
Revoke
</Button>
) : (
<span className="text-xs text-muted-foreground">Inactive</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}
function formatInviteState(state: "active" | "accepted" | "expired" | "revoked") {
return state.charAt(0).toUpperCase() + state.slice(1);
}