diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 894b86b750b..f81b44592c9 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -39,6 +39,7 @@ import { PluginSettings } from "./pages/PluginSettings"; import { AdapterManager } from "./pages/AdapterManager"; import { PluginPage } from "./pages/PluginPage"; import { IssueChatUxLab } from "./pages/IssueChatUxLab"; +import { InviteUxLab } from "./pages/InviteUxLab"; import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab"; import { OrgChart } from "./pages/OrgChart"; import { NewAgent } from "./pages/NewAgent"; @@ -117,6 +118,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/pages/InviteUxLab.test.tsx b/ui/src/pages/InviteUxLab.test.tsx new file mode 100644 index 00000000000..0eb7e8d85ed --- /dev/null +++ b/ui/src/pages/InviteUxLab.test.tsx @@ -0,0 +1,52 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { InviteUxLab } from "./InviteUxLab"; + +vi.mock("@/components/CompanyPatternIcon", () => ({ + CompanyPatternIcon: ({ companyName }: { companyName: string }) => ( +
{companyName}
+ ), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe("InviteUxLab", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("renders the invite/signup review sections", async () => { + const root = createRoot(container); + + await act(async () => { + root.render(); + }); + + expect(container.textContent).toContain("Invite and signup UX review surface"); + expect(container.textContent).toContain("/tests/ux/invites"); + expect(container.textContent).toContain("Landing state coverage"); + expect(container.textContent).toContain("Split-screen invite flows"); + expect(container.textContent).toContain("Approval and completion screens"); + expect(container.textContent).toContain("Auth page states"); + expect(container.textContent).toContain("Company invite management"); + expect(container.textContent).toContain("Create your account"); + expect(container.textContent).toContain("Invite history"); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/pages/InviteUxLab.tsx b/ui/src/pages/InviteUxLab.tsx new file mode 100644 index 00000000000..941a4875512 --- /dev/null +++ b/ui/src/pages/InviteUxLab.tsx @@ -0,0 +1,927 @@ +import type { ReactNode } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { CompanyPatternIcon } from "@/components/CompanyPatternIcon"; +import { cn } from "@/lib/utils"; +import { + ArrowRight, + Check, + Clock3, + ExternalLink, + FlaskConical, + KeyRound, + Link2, + Loader2, + MailPlus, + ShieldCheck, + UserPlus, + Users, +} from "lucide-react"; + +const inviteRoleOptions = [ + { + value: "viewer", + label: "Viewer", + description: "Can view company work and follow along without operational permissions.", + gets: "No built-in grants.", + }, + { + value: "operator", + label: "Operator", + description: "Recommended for people who need to help run work without managing access.", + gets: "Can assign tasks.", + }, + { + value: "admin", + label: "Admin", + description: "Recommended for operators who need to invite people, create agents, and approve joins.", + gets: "Can create agents, invite users, assign tasks, and approve join requests.", + }, + { + value: "owner", + label: "Owner", + description: "Full company access, including membership and permission management.", + gets: "Everything in Admin, plus managing members and permission grants.", + }, +] as const; + +const inviteHistory = [ + { + id: "invite-active", + state: "Active", + humanRole: "operator", + invitedBy: "Board User 25", + email: "board25@paperclip.local", + createdAt: "Apr 25, 2026, 9:00 AM", + action: "Revoke", + relatedLabel: "Review request", + }, + { + id: "invite-accepted", + state: "Accepted", + humanRole: "viewer", + invitedBy: "Board User 24", + email: "board24@paperclip.local", + createdAt: "Apr 24, 2026, 8:15 AM", + action: "Inactive", + relatedLabel: "—", + }, + { + id: "invite-revoked", + state: "Revoked", + humanRole: "admin", + invitedBy: "Board User 20", + email: "board20@paperclip.local", + createdAt: "Apr 20, 2026, 2:45 PM", + action: "Inactive", + relatedLabel: "—", + }, + { + id: "invite-expired", + state: "Expired", + humanRole: "owner", + invitedBy: "Board User 19", + email: "board19@paperclip.local", + createdAt: "Apr 19, 2026, 7:10 PM", + action: "Inactive", + relatedLabel: "—", + }, +] as const; + +const fieldClassName = + "w-full border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-zinc-500"; +const panelClassName = "border border-zinc-800 bg-zinc-950/95 p-6"; + +function LabSection({ + eyebrow, + title, + description, + accentClassName, + children, +}: { + eyebrow: string; + title: string; + description: string; + accentClassName?: string; + children: ReactNode; +}) { + return ( +
+
+
+
+ {eyebrow} +
+

{title}

+

{description}

+
+
+ {children} +
+ ); +} + +function StatusCard({ + icon, + title, + body, + tone = "default", +}: { + icon: ReactNode; + title: string; + body: string; + tone?: "default" | "warn" | "success" | "error"; +}) { + const toneClassName = { + default: "border-border/70 bg-background/85", + warn: "border-amber-400/40 bg-amber-500/[0.08]", + success: "border-emerald-400/40 bg-emerald-500/[0.08]", + error: "border-rose-400/40 bg-rose-500/[0.08]", + }[tone]; + + return ( + + +
+ {icon} +
+
+ {title} + {body} +
+
+
+ ); +} + +function InviteLandingShell({ + left, + right, +}: { + left: ReactNode; + right: ReactNode; +}) { + return ( +
+
+
{left}
+
{right}
+
+
+ ); +} + +function InviteSummaryPanel({ + title, + description, + inviteMessage, + requestedAccess, + signedInLabel, +}: { + title: string; + description: string; + inviteMessage?: string; + requestedAccess: string; + signedInLabel?: string; +}) { + return ( + <> +
+ +
+

You've been invited to join Paperclip

+

{title}

+

{description}

+
+
+ +
+ + + + +
+ + {inviteMessage ? ( +
+
Message from inviter
+

{inviteMessage}

+
+ ) : null} + + {signedInLabel ? ( +
+ Signed in as {signedInLabel}. +
+ ) : null} + + ); +} + +function MetaCard({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function InlineAuthPreview({ + mode, + feedback, + working, +}: { + mode: "sign_up" | "sign_in"; + feedback?: { tone: "info" | "error"; text: string }; + working?: boolean; +}) { + return ( +
+
+

+ {mode === "sign_up" ? "Create your account" : "Sign in to continue"} +

+

+ {mode === "sign_up" + ? "Start with a Paperclip account. After that, you'll come right back here to accept the invite for Acme Robotics." + : "Use the Paperclip account that already matches this invite. If you do not have one yet, switch back to create account."} +

+
+ +
+ + +
+ +
+ {mode === "sign_up" ? ( + + ) : null} + + + {feedback ? ( +

+ {feedback.text} +

+ ) : null} + +
+ +

+ {mode === "sign_up" + ? "Already signed up before? Use the existing-account option instead so the invite lands on the right Paperclip user." + : "No account yet? Switch back to create account so you can accept the invite with a new login."} +

+
+ ); +} + +function AgentRequestPreview() { + return ( +
+
+

Submit agent details

+

+ This invite will create an approval request for a new agent in Acme Robotics. +

+
+ + +