feat: implement multi-user access and invite flows (#3784)

## Thinking Path

> - Paperclip is the control plane for autonomous AI companies.
> - V1 needs to stay local-first while also supporting shared,
authenticated deployments.
> - Human operators need real identities, company membership, invite
flows, profile surfaces, and company-scoped access controls.
> - Agents and operators also need the existing issue, inbox, workspace,
approval, and plugin flows to keep working under those authenticated
boundaries.
> - This branch accumulated the multi-user implementation, follow-up QA
fixes, workspace/runtime refinements, invite UX improvements,
release-branch conflict resolution, and review hardening.
> - This pull request consolidates that branch onto the current `master`
branch as a single reviewable PR.
> - The benefit is a complete multi-user implementation path with tests
and docs carried forward without dropping existing branch work.

## What Changed

- Added authenticated human-user access surfaces: auth/session routes,
company user directory, profile settings, company access/member
management, join requests, and invite management.
- Added invite creation, invite landing, onboarding, logo/branding,
invite grants, deduped join requests, and authenticated multi-user E2E
coverage.
- Tightened company-scoped and instance-admin authorization across
board, plugin, adapter, access, issue, and workspace routes.
- Added profile-image URL validation hardening, avatar preservation on
name-only profile updates, and join-request uniqueness migration cleanup
for pending human requests.
- Added an atomic member role/status/grants update path so Company
Access saves no longer leave partially updated permissions.
- Improved issue chat, inbox, assignee identity rendering,
sidebar/account/company navigation, workspace routing, and execution
workspace reuse behavior for multi-user operation.
- Added and updated server/UI tests covering auth, invites, membership,
issue workspace inheritance, plugin authz, inbox/chat behavior, and
multi-user flows.
- Merged current `public-gh/master` into this branch, resolved all
conflicts, and verified no `pnpm-lock.yaml` change is included in this
PR diff.

## Verification

- `pnpm exec vitest run server/src/__tests__/issues-service.test.ts
ui/src/components/IssueChatThread.test.tsx ui/src/pages/Inbox.test.tsx`
- `pnpm run preflight:workspace-links && pnpm exec vitest run
server/src/__tests__/plugin-routes-authz.test.ts`
- `pnpm exec vitest run server/src/__tests__/plugin-routes-authz.test.ts
server/src/__tests__/workspace-runtime-service-authz.test.ts
server/src/__tests__/access-validators.test.ts`
- `pnpm exec vitest run
server/src/__tests__/authz-company-access.test.ts
server/src/__tests__/routines-routes.test.ts
server/src/__tests__/sidebar-preferences-routes.test.ts
server/src/__tests__/approval-routes-idempotency.test.ts
server/src/__tests__/openclaw-invite-prompt-route.test.ts
server/src/__tests__/agent-cross-tenant-authz-routes.test.ts
server/src/__tests__/routines-e2e.test.ts`
- `pnpm exec vitest run server/src/__tests__/auth-routes.test.ts
ui/src/pages/CompanyAccess.test.tsx`
- `pnpm --filter @paperclipai/shared typecheck && pnpm --filter
@paperclipai/db typecheck && pnpm --filter @paperclipai/server
typecheck`
- `pnpm --filter @paperclipai/shared typecheck && pnpm --filter
@paperclipai/server typecheck`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm db:generate`
- `npx playwright test --config tests/e2e/playwright.config.ts --list`
- Confirmed branch has no uncommitted changes and is `0` commits behind
`public-gh/master` before PR creation.
- Confirmed no `pnpm-lock.yaml` change is staged or present in the PR
diff.

## Risks

- High review surface area: this PR contains the accumulated multi-user
branch plus follow-up fixes, so reviewers should focus especially on
company-boundary enforcement and authenticated-vs-local deployment
behavior.
- UI behavior changed across invites, inbox, issue chat, access
settings, and sidebar navigation; no browser screenshots are included in
this branch-consolidation PR.
- Plugin install, upgrade, and lifecycle/config mutations now require
instance-admin access, which is intentional but may change expectations
for non-admin board users.
- A join-request dedupe migration rejects duplicate pending human
requests before creating unique indexes; deployments with unusual
historical duplicates should review the migration behavior.
- Company member role/status/grant saves now use a new combined
endpoint; older separate endpoints remain for compatibility.
- Full production build was not run locally in this heartbeat; CI should
cover the full matrix.

## Model Used

- OpenAI Codex coding agent, GPT-5-based model, CLI/tool-use
environment. Exact deployed model identifier and context window were not
exposed by the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

Note on screenshots: this is a branch-consolidation PR for an
already-developed multi-user branch, and no browser screenshots were
captured during this heartbeat.

---------

Co-authored-by: dotta <dotta@example.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dotta 2026-04-17 09:44:19 -05:00 committed by GitHub
parent e93e418cbf
commit b9a80dcf22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
150 changed files with 26872 additions and 1289 deletions

View file

@ -1,10 +1,12 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { activityApi } from "../api/activity";
import { accessApi } from "../api/access";
import { agentsApi } from "../api/agents";
import { issuesApi } from "../api/issues";
import { projectsApi } from "../api/projects";
import { goalsApi } from "../api/goals";
import { buildCompanyUserProfileMap } from "../lib/company-members";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
@ -60,6 +62,17 @@ export function Activity() {
enabled: !!selectedCompanyId,
});
const { data: companyMembers } = useQuery({
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const userProfileMap = useMemo(
() => buildCompanyUserProfileMap(companyMembers?.users),
[companyMembers?.users],
);
const agentMap = useMemo(() => {
const map = new Map<string, Agent>();
for (const a of agents ?? []) map.set(a.id, a);
@ -129,6 +142,7 @@ export function Activity() {
key={event.id}
event={event}
agentMap={agentMap}
userProfileMap={userProfileMap}
entityNameMap={entityNameMap}
entityTitleMap={entityTitleMap}
/>

View file

@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useSearchParams } from "@/lib/router";
import { authApi } from "../api/auth";
import { queryKeys } from "../lib/queryKeys";
import { getRememberedInvitePath } from "../lib/invite-memory";
import { Button } from "@/components/ui/button";
import { AsciiArtAnimation } from "@/components/AsciiArtAnimation";
import { Sparkles } from "lucide-react";
@ -19,7 +20,10 @@ export function AuthPage() {
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const nextPath = useMemo(() => searchParams.get("next") || "/", [searchParams]);
const nextPath = useMemo(
() => searchParams.get("next") || getRememberedInvitePath() || "/",
[searchParams],
);
const { data: session, isLoading: isSessionLoading } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),

View file

@ -0,0 +1,219 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CompanyAccess } from "./CompanyAccess";
const listMembersMock = vi.hoisted(() => vi.fn());
const listJoinRequestsMock = vi.hoisted(() => vi.fn());
const updateMemberAccessMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/access", () => ({
accessApi: {
listMembers: (companyId: string) => listMembersMock(companyId),
listJoinRequests: (companyId: string, status: string) => listJoinRequestsMock(companyId, status),
updateMember: vi.fn(),
updateMemberPermissions: vi.fn(),
updateMemberAccess: (companyId: string, memberId: string, input: unknown) =>
updateMemberAccessMock(companyId, memberId, input),
approveJoinRequest: vi.fn(),
rejectJoinRequest: vi.fn(),
},
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
selectedCompany: { id: "company-1", name: "Paperclip" },
}),
}));
vi.mock("@/context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
}));
vi.mock("@/context/ToastContext", () => ({
useToast: () => ({ pushToast: vi.fn() }),
}));
// 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("CompanyAccess", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
listMembersMock.mockResolvedValue({
members: [
{
id: "member-1",
companyId: "company-1",
principalType: "user",
principalId: "user-1",
status: "active",
membershipRole: "owner",
createdAt: "2026-04-10T00:00:00.000Z",
updatedAt: "2026-04-10T00:00:00.000Z",
user: {
id: "user-1",
email: "codexcoder@paperclip.local",
name: "Codex Coder",
image: null,
},
grants: [],
},
],
access: {
currentUserRole: "owner",
canManageMembers: true,
canInviteUsers: true,
canApproveJoinRequests: true,
},
});
listJoinRequestsMock.mockResolvedValue([
{
id: "join-1",
requestType: "human",
createdAt: "2026-04-10T00:00:00.000Z",
requesterUser: {
id: "user-2",
email: "board@paperclip.local",
name: "Board User",
image: null,
},
requestEmailSnapshot: "board@paperclip.local",
requestingUserId: "user-2",
invite: {
allowedJoinTypes: "human",
humanRole: "operator",
},
},
{
id: "join-2",
requestType: "agent",
createdAt: "2026-04-10T00:00:00.000Z",
agentName: "Codex Worker",
adapterType: "codex_local",
capabilities: "Implements code changes",
invite: {
allowedJoinTypes: "agent",
humanRole: null,
},
},
]);
updateMemberAccessMock.mockResolvedValue({});
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("keeps the page human-focused and explains implicit versus explicit grants", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CompanyAccess />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("Manage company user memberships");
expect(container.textContent).toContain("Humans");
expect(container.textContent).toContain("Pending human joins");
expect(container.textContent).toContain("User account");
expect(container.textContent).not.toContain("Agents");
expect(container.textContent).not.toContain("Pending agent joins");
expect(container.textContent).not.toContain("Open join request queue");
expect(container.textContent).not.toContain("Manage invites");
expect(container.textContent).not.toContain("Active user accounts");
expect(container.textContent).not.toContain("Suspended user accounts");
expect(container.textContent).not.toContain("Pending user joins");
const editButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Edit",
);
expect(editButton).toBeTruthy();
await act(async () => {
editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(document.body.textContent).toContain("Implicit grants from role");
expect(document.body.textContent).toContain("Owner currently includes these permissions automatically.");
expect(document.body.textContent).toContain(
"Included implicitly by the Owner role. Add an explicit grant only if it should stay after the role changes.",
);
await act(async () => {
root.unmount();
});
});
it("saves member role, status, and grants in one request", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CompanyAccess />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const editButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Edit",
);
expect(editButton).toBeTruthy();
await act(async () => {
editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const saveButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent === "Save access",
);
expect(saveButton).toBeTruthy();
await act(async () => {
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(updateMemberAccessMock).toHaveBeenCalledWith("company-1", "member-1", {
membershipRole: "owner",
status: "active",
grants: [],
});
await act(async () => {
root.unmount();
});
});
});

View file

@ -0,0 +1,476 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS, PERMISSION_KEYS, type PermissionKey } from "@paperclipai/shared";
import { ShieldCheck, Users } from "lucide-react";
import { accessApi, type CompanyMember } from "@/api/access";
import { ApiError } from "@/api/client";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useCompany } from "@/context/CompanyContext";
import { useToast } from "@/context/ToastContext";
import { queryKeys } from "@/lib/queryKeys";
const permissionLabels: Record<PermissionKey, string> = {
"agents:create": "Create agents",
"users:invite": "Invite humans and agents",
"users:manage_permissions": "Manage members and grants",
"tasks:assign": "Assign tasks",
"tasks:assign_scope": "Assign scoped tasks",
"joins:approve": "Approve join requests",
};
function formatGrantSummary(member: CompanyMember) {
if (member.grants.length === 0) return "No explicit grants";
return member.grants.map((grant) => permissionLabels[grant.permissionKey]).join(", ");
}
const implicitRoleGrantMap: Record<NonNullable<CompanyMember["membershipRole"]>, PermissionKey[]> = {
owner: ["agents:create", "users:invite", "users:manage_permissions", "tasks:assign", "joins:approve"],
admin: ["agents:create", "users:invite", "tasks:assign", "joins:approve"],
operator: ["tasks:assign"],
viewer: [],
};
function getImplicitGrantKeys(role: CompanyMember["membershipRole"]) {
return role ? implicitRoleGrantMap[role] : [];
}
export function CompanyAccess() {
const { selectedCompany, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToast();
const queryClient = useQueryClient();
const [editingMemberId, setEditingMemberId] = useState<string | null>(null);
const [draftRole, setDraftRole] = useState<CompanyMember["membershipRole"]>(null);
const [draftStatus, setDraftStatus] = useState<CompanyMember["status"]>("active");
const [draftGrants, setDraftGrants] = useState<Set<PermissionKey>>(new Set());
useEffect(() => {
setBreadcrumbs([
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
{ label: "Settings", href: "/company/settings" },
{ label: "Access" },
]);
}, [selectedCompany?.name, setBreadcrumbs]);
const membersQuery = useQuery({
queryKey: queryKeys.access.companyMembers(selectedCompanyId ?? ""),
queryFn: () => accessApi.listMembers(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const joinRequestsQuery = useQuery({
queryKey: queryKeys.access.joinRequests(selectedCompanyId ?? "", "pending_approval"),
queryFn: () => accessApi.listJoinRequests(selectedCompanyId!, "pending_approval"),
enabled: !!selectedCompanyId && !!membersQuery.data?.access.canApproveJoinRequests,
});
const refreshAccessData = async () => {
if (!selectedCompanyId) return;
await queryClient.invalidateQueries({ queryKey: queryKeys.access.companyMembers(selectedCompanyId) });
await queryClient.invalidateQueries({ queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId) });
await queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(selectedCompanyId, "pending_approval") });
};
const updateMemberMutation = useMutation({
mutationFn: async (input: { memberId: string; membershipRole: CompanyMember["membershipRole"]; status: CompanyMember["status"]; grants: PermissionKey[] }) => {
return accessApi.updateMemberAccess(selectedCompanyId!, input.memberId, {
membershipRole: input.membershipRole,
status: input.status,
grants: input.grants.map((permissionKey) => ({ permissionKey })),
});
},
onSuccess: async () => {
setEditingMemberId(null);
await refreshAccessData();
pushToast({
title: "Member updated",
tone: "success",
});
},
onError: (error) => {
pushToast({
title: "Failed to update member",
body: error instanceof Error ? error.message : "Unknown error",
tone: "error",
});
},
});
const approveJoinRequestMutation = useMutation({
mutationFn: (requestId: string) => accessApi.approveJoinRequest(selectedCompanyId!, requestId),
onSuccess: async () => {
await refreshAccessData();
pushToast({
title: "Join request approved",
tone: "success",
});
},
onError: (error) => {
pushToast({
title: "Failed to approve join request",
body: error instanceof Error ? error.message : "Unknown error",
tone: "error",
});
},
});
const rejectJoinRequestMutation = useMutation({
mutationFn: (requestId: string) => accessApi.rejectJoinRequest(selectedCompanyId!, requestId),
onSuccess: async () => {
await refreshAccessData();
pushToast({
title: "Join request rejected",
tone: "success",
});
},
onError: (error) => {
pushToast({
title: "Failed to reject join request",
body: error instanceof Error ? error.message : "Unknown error",
tone: "error",
});
},
});
const editingMember = useMemo(
() => membersQuery.data?.members.find((member) => member.id === editingMemberId) ?? null,
[editingMemberId, membersQuery.data?.members],
);
useEffect(() => {
if (!editingMember) return;
setDraftRole(editingMember.membershipRole);
setDraftStatus(editingMember.status);
setDraftGrants(new Set(editingMember.grants.map((grant) => grant.permissionKey)));
}, [editingMember]);
if (!selectedCompanyId) {
return <div className="text-sm text-muted-foreground">Select a company to manage access.</div>;
}
if (membersQuery.isLoading) {
return <div className="text-sm text-muted-foreground">Loading company access</div>;
}
if (membersQuery.error) {
const message =
membersQuery.error instanceof ApiError && membersQuery.error.status === 403
? "You do not have permission to manage company members."
: membersQuery.error instanceof Error
? membersQuery.error.message
: "Failed to load company members.";
return <div className="text-sm text-destructive">{message}</div>;
}
const members = membersQuery.data?.members ?? [];
const access = membersQuery.data?.access;
const pendingHumanJoinRequests =
joinRequestsQuery.data?.filter((request) => request.requestType === "human") ?? [];
const joinRequestActionPending =
approveJoinRequestMutation.isPending || rejectJoinRequestMutation.isPending;
const implicitGrantKeys = getImplicitGrantKeys(draftRole);
const implicitGrantSet = new Set(implicitGrantKeys);
return (
<div className="max-w-6xl space-y-8">
<div className="space-y-3">
<div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Company Access</h1>
</div>
<p className="max-w-3xl text-sm text-muted-foreground">
Manage company user memberships, membership status, and explicit permission grants for {selectedCompany?.name}.
</p>
</div>
{access && !access.currentUserRole && (
<div className="rounded-xl border border-amber-500/40 px-4 py-3 text-sm text-amber-200">
This account can manage access here through instance-admin privileges, but it does not currently hold an active company membership.
</div>
)}
<section className="space-y-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" />
<h2 className="text-base font-semibold">Humans</h2>
</div>
<p className="max-w-3xl text-sm text-muted-foreground">
Manage human company memberships, status, and grants here.
</p>
</div>
{access?.canApproveJoinRequests && pendingHumanJoinRequests.length > 0 ? (
<div className="space-y-3 rounded-xl border border-border px-4 py-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-sm font-semibold">Pending human joins</h3>
<p className="text-sm text-muted-foreground">
Review human join requests before they become active company members.
</p>
</div>
<Badge variant="outline">{pendingHumanJoinRequests.length} pending</Badge>
</div>
<div className="space-y-3">
{pendingHumanJoinRequests.map((request) => (
<PendingJoinRequestCard
key={request.id}
title={
request.requesterUser?.name ||
request.requestEmailSnapshot ||
request.requestingUserId ||
"Unknown human requester"
}
subtitle={
request.requesterUser?.email ||
request.requestEmailSnapshot ||
request.requestingUserId ||
"No email available"
}
context={
request.invite
? `${request.invite.allowedJoinTypes} join invite${request.invite.humanRole ? ` • default role ${request.invite.humanRole}` : ""}`
: "Invite metadata unavailable"
}
detail={`Submitted ${new Date(request.createdAt).toLocaleString()}`}
approveLabel="Approve human"
rejectLabel="Reject human"
disabled={joinRequestActionPending}
onApprove={() => approveJoinRequestMutation.mutate(request.id)}
onReject={() => rejectJoinRequestMutation.mutate(request.id)}
/>
))}
</div>
</div>
) : null}
<div className="overflow-hidden rounded-xl border border-border">
<div className="grid grid-cols-[minmax(0,1.5fr)_120px_120px_minmax(0,1.2fr)_120px] gap-3 border-b border-border px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<div>User account</div>
<div>Role</div>
<div>Status</div>
<div>Grants</div>
<div className="text-right">Action</div>
</div>
{members.length === 0 ? (
<div className="px-4 py-8 text-sm text-muted-foreground">No user memberships found for this company yet.</div>
) : (
members.map((member) => (
<div
key={member.id}
className="grid grid-cols-[minmax(0,1.5fr)_120px_120px_minmax(0,1.2fr)_120px] gap-3 border-b border-border px-4 py-3 last:border-b-0"
>
<div className="min-w-0">
<div className="truncate font-medium">{member.user?.name?.trim() || member.user?.email || member.principalId}</div>
<div className="truncate text-xs text-muted-foreground">{member.user?.email || member.principalId}</div>
</div>
<div className="text-sm">
{member.membershipRole
? HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS[member.membershipRole]
: "Unset"}
</div>
<div>
<Badge variant={member.status === "active" ? "secondary" : member.status === "suspended" ? "destructive" : "outline"}>
{member.status.replace("_", " ")}
</Badge>
</div>
<div className="min-w-0 text-sm text-muted-foreground">{formatGrantSummary(member)}</div>
<div className="text-right">
<Button size="sm" variant="outline" onClick={() => setEditingMemberId(member.id)}>
Edit
</Button>
</div>
</div>
))
)}
</div>
</section>
<Dialog open={!!editingMember} onOpenChange={(open) => !open && setEditingMemberId(null)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Edit member</DialogTitle>
<DialogDescription>
Update company role, membership status, and explicit grants for {editingMember?.user?.name || editingMember?.user?.email || editingMember?.principalId}.
</DialogDescription>
</DialogHeader>
{editingMember && (
<div className="space-y-5">
<div className="grid gap-4 md:grid-cols-2">
<label className="space-y-2 text-sm">
<span className="font-medium">Company role</span>
<select
className="w-full rounded-md border border-border bg-background px-3 py-2"
value={draftRole ?? ""}
onChange={(event) =>
setDraftRole((event.target.value || null) as CompanyMember["membershipRole"])
}
>
<option value="">Unset</option>
{Object.entries(HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
<label className="space-y-2 text-sm">
<span className="font-medium">Membership status</span>
<select
className="w-full rounded-md border border-border bg-background px-3 py-2"
value={draftStatus}
onChange={(event) =>
setDraftStatus(event.target.value as CompanyMember["status"])
}
>
<option value="active">Active</option>
<option value="pending">Pending</option>
<option value="suspended">Suspended</option>
</select>
</label>
</div>
<div className="space-y-3">
<div>
<h3 className="text-sm font-medium">Grants</h3>
<p className="text-sm text-muted-foreground">
Roles provide implicit grants automatically. Explicit grants below are only for overrides and extra access that should persist even if the role changes.
</p>
</div>
<div className="rounded-lg border border-border px-3 py-3">
<div className="text-sm font-medium">Implicit grants from role</div>
<p className="mt-1 text-sm text-muted-foreground">
{draftRole
? `${HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS[draftRole]} currently includes these permissions automatically.`
: "No role is selected, so this member has no implicit grants right now."}
</p>
{implicitGrantKeys.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{implicitGrantKeys.map((permissionKey) => (
<Badge key={permissionKey} variant="outline">
{permissionLabels[permissionKey]}
</Badge>
))}
</div>
) : null}
</div>
<div className="grid gap-3 md:grid-cols-2">
{PERMISSION_KEYS.map((permissionKey) => (
<label
key={permissionKey}
className="flex items-start gap-3 rounded-lg border border-border px-3 py-2"
>
<Checkbox
checked={draftGrants.has(permissionKey)}
onCheckedChange={(checked) => {
setDraftGrants((current) => {
const next = new Set(current);
if (checked) next.add(permissionKey);
else next.delete(permissionKey);
return next;
});
}}
/>
<span className="space-y-1">
<span className="block text-sm font-medium">{permissionLabels[permissionKey]}</span>
<span className="block text-xs text-muted-foreground">{permissionKey}</span>
{implicitGrantSet.has(permissionKey) ? (
<span className="block text-xs text-muted-foreground">
Included implicitly by the {draftRole ? HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS[draftRole] : "selected"} role. Add an explicit grant only if it should stay after the role changes.
</span>
) : null}
{draftGrants.has(permissionKey) ? (
<span className="block text-xs text-muted-foreground">
Stored explicitly for this member.
</span>
) : null}
</span>
</label>
))}
</div>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditingMemberId(null)}>
Cancel
</Button>
<Button
onClick={() => {
if (!editingMember) return;
updateMemberMutation.mutate({
memberId: editingMember.id,
membershipRole: draftRole,
status: draftStatus,
grants: [...draftGrants],
});
}}
disabled={updateMemberMutation.isPending}
>
{updateMemberMutation.isPending ? "Saving…" : "Save access"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function PendingJoinRequestCard({
title,
subtitle,
context,
detail,
detailSecondary,
approveLabel,
rejectLabel,
disabled,
onApprove,
onReject,
}: {
title: string;
subtitle: string;
context: string;
detail: string;
detailSecondary?: string;
approveLabel: string;
rejectLabel: string;
disabled: boolean;
onApprove: () => void;
onReject: () => void;
}) {
return (
<div className="rounded-xl border border-border px-4 py-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div>
<div className="font-medium">{title}</div>
<div className="text-sm text-muted-foreground">{subtitle}</div>
</div>
<div className="text-sm text-muted-foreground">{context}</div>
<div className="text-sm text-muted-foreground">{detail}</div>
{detailSecondary ? <div className="text-sm text-muted-foreground">{detailSecondary}</div> : null}
</div>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={onReject} disabled={disabled}>
{rejectLabel}
</Button>
<Button type="button" onClick={onApprove} disabled={disabled}>
{approveLabel}
</Button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,267 @@
// @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";
import { queryKeys } from "@/lib/queryKeys";
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, options?: unknown) => listInvitesMock(companyId, options),
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;
const inviteHistory = Array.from({ length: 25 }, (_, index) => {
const inviteNumber = 25 - index;
const isActive = inviteNumber === 25;
return {
id: `invite-${inviteNumber}`,
companyId: "company-1",
inviteType: "company_join",
tokenHash: `hash-${inviteNumber}`,
allowedJoinTypes: "human",
defaultsPayload: null,
expiresAt: "2026-04-20T00:00:00.000Z",
invitedByUserId: "user-1",
revokedAt: null,
acceptedAt: isActive ? null : "2026-04-11T00:00:00.000Z",
createdAt: `2026-04-${String(inviteNumber).padStart(2, "0")}T00:00:00.000Z`,
updatedAt: `2026-04-${String(inviteNumber).padStart(2, "0")}T00:00:00.000Z`,
companyName: "Paperclip",
humanRole: isActive ? "operator" : "viewer",
inviteMessage: null,
state: isActive ? "active" : "accepted",
invitedByUser: {
id: "user-1",
name: `Board User ${inviteNumber}`,
email: `board${inviteNumber}@paperclip.local`,
image: null,
},
relatedJoinRequestId: isActive ? "join-1" : null,
};
});
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
listInvitesMock.mockImplementation((_companyId: string, options?: { limit?: number; offset?: number }) => {
const limit = options?.limit ?? 20;
const offset = options?.offset ?? 0;
const invites = inviteHistory.slice(offset, offset + limit);
const nextOffset = offset + invites.length < inviteHistory.length ? offset + invites.length : null;
return Promise.resolve({ invites, nextOffset });
});
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 25");
expect(container.textContent).toContain("Board User 21");
expect(container.textContent).not.toContain("Board User 20");
expect(container.textContent).toContain("Review request");
expect(container.textContent).toContain("View more");
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");
expect(container.textContent).toContain("Choose a role");
expect(container.textContent).toContain("Each invite link is single-use.");
expect(container.textContent).toContain("Can create agents, invite users, assign tasks, and approve join requests.");
expect(container.textContent).toContain("Everything in Admin, plus managing members and permission grants.");
expect(listInvitesMock).toHaveBeenCalledWith("company-1", { limit: 5, offset: 0 });
const viewMoreButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "View more",
);
await act(async () => {
viewMoreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await flushReact();
expect(listInvitesMock).toHaveBeenCalledWith("company-1", { limit: 5, offset: 5 });
expect(container.textContent).toContain("Board User 20");
expect(container.textContent).toContain("Board User 16");
expect(container.textContent).toContain("View more");
await act(async () => {
const viewerRadio = container.querySelector('input[type="radio"][value="viewer"]') as HTMLInputElement | null;
viewerRadio?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
viewerRadio?.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(container.textContent).toContain("Latest invite link");
expect(container.textContent).toContain("This URL includes the current Paperclip domain returned by the server.");
expect(container.textContent).toContain("https://paperclip.local/invite/new-token");
expect(container.textContent).toContain("Open invite");
expect(pushToastMock).toHaveBeenCalledWith({
title: "Invite created",
body: "Invite ready below and copied to clipboard.",
tone: "success",
});
const inviteFieldButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("https://paperclip.local/invite/new-token"),
);
await act(async () => {
inviteFieldButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(clipboardWriteTextMock).toHaveBeenCalledTimes(2);
expect(container.textContent).toContain("Copied");
await act(async () => {
revokeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(revokeInviteMock).toHaveBeenCalledWith("invite-25");
await act(async () => {
root.unmount();
});
});
it("ignores legacy cached invite arrays and refetches paginated history", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
queryClient.setQueryData(["access", "invites", "company-1", "all"], inviteHistory.slice(0, 2));
await act(async () => {
root.render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<CompanyInvites />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("Board User 25");
expect(container.textContent).not.toContain("Board User 20");
expect(listInvitesMock).toHaveBeenCalledWith("company-1", { limit: 5, offset: 0 });
expect(queryClient.getQueryData(queryKeys.access.invites("company-1", "all", 5))).toMatchObject({
pages: [
{
invites: expect.any(Array),
nextOffset: 5,
},
],
});
await act(async () => {
root.unmount();
});
});
});

View file

@ -0,0 +1,374 @@
import { useEffect, useMemo, useState } from "react";
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink, 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";
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 INVITE_HISTORY_PAGE_SIZE = 5;
function isInviteHistoryRow(value: unknown): value is Awaited<ReturnType<typeof accessApi.listInvites>>["invites"][number] {
if (!value || typeof value !== "object") return false;
return "id" in value && "state" in value && "createdAt" in value;
}
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");
const [latestInviteUrl, setLatestInviteUrl] = useState<string | null>(null);
const [latestInviteCopied, setLatestInviteCopied] = useState(false);
useEffect(() => {
if (!latestInviteCopied) return;
const timeout = window.setTimeout(() => {
setLatestInviteCopied(false);
}, 1600);
return () => window.clearTimeout(timeout);
}, [latestInviteCopied]);
async function copyInviteUrl(url: string) {
try {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(url);
return true;
}
} catch {
// Fall through to the unavailable message below.
}
pushToast({
title: "Clipboard unavailable",
body: "Copy the invite URL manually from the field below.",
tone: "warn",
});
return false;
}
useEffect(() => {
setBreadcrumbs([
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
{ label: "Settings", href: "/company/settings" },
{ label: "Invites" },
]);
}, [selectedCompany?.name, setBreadcrumbs]);
const inviteHistoryQueryKey = queryKeys.access.invites(selectedCompanyId ?? "", "all", INVITE_HISTORY_PAGE_SIZE);
const invitesQuery = useInfiniteQuery({
queryKey: inviteHistoryQueryKey,
queryFn: ({ pageParam }) =>
accessApi.listInvites(selectedCompanyId!, {
limit: INVITE_HISTORY_PAGE_SIZE,
offset: pageParam,
}),
enabled: !!selectedCompanyId,
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextOffset ?? undefined,
});
const inviteHistory = useMemo(
() =>
invitesQuery.data?.pages.flatMap((page) =>
Array.isArray(page?.invites) ? page.invites.filter(isInviteHistoryRow) : [],
) ?? [],
[invitesQuery.data?.pages],
);
const createInviteMutation = useMutation({
mutationFn: () =>
accessApi.createCompanyInvite(selectedCompanyId!, {
allowedJoinTypes: "human",
humanRole,
agentMessage: null,
}),
onSuccess: async (invite) => {
setLatestInviteUrl(invite.inviteUrl);
setLatestInviteCopied(false);
const copied = await copyInviteUrl(invite.inviteUrl);
await queryClient.invalidateQueries({ queryKey: inviteHistoryQueryKey });
pushToast({
title: "Invite created",
body: copied ? "Invite ready below and copied to clipboard." : "Invite ready below.",
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: inviteHistoryQueryKey });
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 access it should request.
</p>
</div>
<fieldset className="space-y-3">
<legend className="text-sm font-medium">Choose a role</legend>
<div className="rounded-xl border border-border">
{inviteRoleOptions.map((option, index) => {
const checked = humanRole === option.value;
return (
<label
key={option.value}
className={`flex cursor-pointer gap-3 px-4 py-4 ${index > 0 ? "border-t border-border" : ""}`}
>
<input
type="radio"
name="invite-role"
value={option.value}
checked={checked}
onChange={() => setHumanRole(option.value)}
className="mt-1 h-4 w-4 border-border text-foreground"
/>
<span className="min-w-0 space-y-1">
<span className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{option.label}</span>
{option.value === "operator" ? (
<span className="rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground">
Default
</span>
) : null}
</span>
<span className="block max-w-2xl text-sm text-muted-foreground">{option.description}</span>
<span className="block text-sm text-foreground">{option.gets}</span>
</span>
</label>
);
})}
</div>
</fieldset>
<div className="rounded-lg border border-border px-4 py-3 text-sm text-muted-foreground">
Each invite link is single-use. The first successful use consumes the link and creates or reuses the matching join request before approval.
</div>
<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>
{latestInviteUrl ? (
<div className="space-y-3 rounded-lg border border-border px-4 py-4">
<div className="space-y-1">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium">Latest invite link</div>
{latestInviteCopied ? (
<div className="inline-flex items-center gap-1 text-xs font-medium text-foreground">
<Check className="h-3.5 w-3.5" />
Copied
</div>
) : null}
</div>
<div className="text-sm text-muted-foreground">
This URL includes the current Paperclip domain returned by the server.
</div>
</div>
<button
type="button"
onClick={async () => {
const copied = await copyInviteUrl(latestInviteUrl);
setLatestInviteCopied(copied);
}}
className="w-full rounded-md border border-border bg-muted/60 px-3 py-2 text-left text-sm break-all transition-colors hover:bg-background"
>
{latestInviteUrl}
</button>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" asChild>
<a href={latestInviteUrl} target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4" />
Open invite
</a>
</Button>
</div>
</div>
) : null}
</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>
{inviteHistory.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="border-t border-border">
<div className="overflow-x-auto">
<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>
{inviteHistory.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>
{invitesQuery.hasNextPage ? (
<div className="flex justify-center border-t border-border px-5 py-4">
<Button
type="button"
variant="outline"
onClick={() => invitesQuery.fetchNextPage()}
disabled={invitesQuery.isFetchingNextPage}
>
{invitesQuery.isFetchingNextPage ? "Loading more…" : "View more"}
</Button>
</div>
) : null}
</div>
)}
</section>
</div>
);
}
function formatInviteState(state: "active" | "accepted" | "expired" | "revoked") {
return state.charAt(0).toUpperCase() + state.slice(1);
}

View file

@ -3,10 +3,12 @@ import { Link } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { dashboardApi } from "../api/dashboard";
import { activityApi } from "../api/activity";
import { accessApi } from "../api/access";
import { issuesApi } from "../api/issues";
import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects";
import { heartbeatsApi } from "../api/heartbeats";
import { buildCompanyUserProfileMap } from "../lib/company-members";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
@ -82,6 +84,17 @@ export function Dashboard() {
enabled: !!selectedCompanyId,
});
const { data: companyMembers } = useQuery({
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const userProfileMap = useMemo(
() => buildCompanyUserProfileMap(companyMembers?.users),
[companyMembers?.users],
);
const recentIssues = issues ? getRecentIssues(issues) : [];
const recentActivity = useMemo(() => (activity ?? []).slice(0, 10), [activity]);
@ -320,6 +333,7 @@ export function Dashboard() {
key={event.id}
event={event}
agentMap={agentMap}
userProfileMap={userProfileMap}
entityNameMap={entityNameMap}
entityTitleMap={entityTitleMap}
className={animatedActivityIds.has(event.id) ? "activity-row-enter" : undefined}

View file

@ -5,7 +5,14 @@ import type { ComponentProps } from "react";
import { createRoot } from "react-dom/client";
import type { Issue } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { FailedRunInboxRow, InboxGroupHeader, InboxIssueMetaLeading, InboxIssueTrailingColumns } from "./Inbox";
import type { CompanyJoinRequest } from "../api/access";
import {
FailedRunInboxRow,
InboxGroupHeader,
InboxIssueMetaLeading,
InboxIssueTrailingColumns,
formatJoinRequestInboxLabel,
} from "./Inbox";
vi.mock("@/lib/router", () => ({
Link: ({ children, className, ...props }: ComponentProps<"a">) => (
@ -62,6 +69,44 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
};
}
function createJoinRequest(
overrides: Partial<CompanyJoinRequest> = {},
): CompanyJoinRequest {
return {
id: "join-1",
inviteId: "invite-1",
companyId: "company-1",
requestType: "human",
status: "pending_approval",
requestIp: "127.0.0.1",
requestingUserId: "user-1",
requestEmailSnapshot: "joiner@example.com",
agentName: null,
adapterType: null,
capabilities: null,
agentDefaultsPayload: null,
claimSecretExpiresAt: null,
claimSecretConsumedAt: null,
createdAgentId: null,
approvedByUserId: null,
approvedAt: null,
rejectedByUserId: null,
rejectedAt: null,
createdAt: new Date("2026-03-11T00:00:00.000Z"),
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
requesterUser: {
id: "user-1",
name: "Jordan Example",
email: "joiner@example.com",
image: null,
},
approvedByUser: null,
rejectedByUser: null,
invite: null,
...overrides,
};
}
describe("FailedRunInboxRow", () => {
let container: HTMLDivElement;
@ -246,6 +291,26 @@ describe("InboxIssueTrailingColumns", () => {
});
});
describe("formatJoinRequestInboxLabel", () => {
it("shows the human requester's name and email when available", () => {
expect(formatJoinRequestInboxLabel(createJoinRequest())).toBe(
"Jordan Example (joiner@example.com)",
);
});
it("falls back to the email snapshot when the requester profile is missing", () => {
expect(
formatJoinRequestInboxLabel(
createJoinRequest({
requesterUser: null,
requestEmailSnapshot: "snapshot@example.com",
requestingUserId: null,
}),
),
).toBe("snapshot@example.com");
});
});
describe("InboxGroupHeader", () => {
let container: HTMLDivElement;

View file

@ -24,6 +24,7 @@ import {
type IssueFilterState,
} from "../lib/issue-filters";
import { formatAssigneeUserLabel } from "../lib/assignees";
import { buildCompanyUserLabelMap, buildCompanyUserProfileMap } from "../lib/company-members";
import {
armIssueDetailInboxQuickArchive,
createIssueDetailLocationState,
@ -134,6 +135,7 @@ import {
type InboxIssueColumn,
type InboxKeyboardNavEntry,
saveLastInboxTab,
shouldShowCompanyAlerts,
shouldShowInboxSection,
type InboxGroupedSection,
type InboxTab,
@ -184,6 +186,39 @@ function readIssueIdFromRun(run: HeartbeatRun): string | null {
return null;
}
function nonEmptyLabel(value: string | null | undefined): string | null {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
}
export function formatJoinRequestInboxLabel(
joinRequest: Pick<
JoinRequest,
"requestType" | "agentName" | "requestEmailSnapshot" | "requestingUserId"
> & {
requesterUser?: {
name: string | null;
email: string | null;
} | null;
},
) {
if (joinRequest.requestType !== "human") {
return `Agent join request${joinRequest.agentName ? `: ${joinRequest.agentName}` : ""}`;
}
const requesterName = nonEmptyLabel(joinRequest.requesterUser?.name);
const requesterEmail =
nonEmptyLabel(joinRequest.requesterUser?.email) ??
nonEmptyLabel(joinRequest.requestEmailSnapshot);
const requesterId = nonEmptyLabel(joinRequest.requestingUserId);
if (requesterName && requesterEmail) return `${requesterName} (${requesterEmail})`;
if (requesterEmail) return requesterEmail;
if (requesterName) return requesterName;
if (requesterId) return requesterId;
return "Human join request";
}
type NonIssueUnreadState = "visible" | "fading" | "hidden" | null;
@ -510,10 +545,7 @@ function JoinRequestInboxRow({
selected?: boolean;
className?: string;
}) {
const label =
joinRequest.requestType === "human"
? "Human join request"
: `Agent join request${joinRequest.agentName ? `: ${joinRequest.agentName}` : ""}`;
const label = formatJoinRequestInboxLabel(joinRequest);
const showUnreadSlot = unreadState !== null;
const showUnreadDot = unreadState === "visible" || unreadState === "fading";
@ -787,8 +819,22 @@ export function Inbox() {
enabled: !!selectedCompanyId,
refetchInterval: 5000,
});
const { data: companyMembers } = useQuery({
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const currentUserId = session?.user.id ?? session?.session.userId ?? null;
const companyUserLabelMap = useMemo(
() => buildCompanyUserLabelMap(companyMembers?.users),
[companyMembers?.users],
);
const companyUserProfileMap = useMemo(
() => buildCompanyUserProfileMap(companyMembers?.users),
[companyMembers?.users],
);
const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]);
const touchedIssues = useMemo(() => getRecentTouchedIssues(touchedIssuesRaw), [touchedIssuesRaw]);
const visibleMineIssues = useMemo(
@ -960,14 +1006,14 @@ export function Inbox() {
}, [liveRuns]);
const approvalsToRender = useMemo(() => {
let filtered = getApprovalsForTab(approvals ?? [], tab, allApprovalFilter);
let filtered = getApprovalsForTab(approvals ?? [], tab, allApprovalFilter, currentUserId);
if (tab === "mine") {
filtered = filtered.filter(
(a) => !isInboxEntityDismissed(dismissedAtByKey, `approval:${a.id}`, a.updatedAt),
);
}
return filtered;
}, [approvals, tab, allApprovalFilter, dismissedAtByKey]);
}, [approvals, tab, allApprovalFilter, currentUserId, dismissedAtByKey]);
const showJoinRequestsCategory =
allCategoryFilter === "everything" || allCategoryFilter === "join_requests";
const showTouchedCategory =
@ -1745,12 +1791,15 @@ export function Inbox() {
}
const hasRunFailures = failedRuns.length > 0;
const showCompanyAlerts = shouldShowCompanyAlerts(tab) && showAlertsCategory;
const showAggregateAgentError =
showCompanyAlerts &&
!!dashboard &&
dashboard.agents.error > 0 &&
!hasRunFailures &&
!dismissedAlerts.has("alert:agent-errors");
const showBudgetAlert =
showCompanyAlerts &&
!!dashboard &&
dashboard.costs.monthBudgetCents > 0 &&
dashboard.costs.monthUtilizationPercent >= 80 &&
@ -1760,10 +1809,10 @@ export function Inbox() {
const showAlertsSection = shouldShowInboxSection({
tab,
hasItems: hasAlerts,
showOnMine: hasAlerts,
showOnRecent: hasAlerts,
showOnUnread: hasAlerts,
showOnAll: showAlertsCategory && hasAlerts,
showOnMine: false,
showOnRecent: false,
showOnUnread: false,
showOnAll: hasAlerts,
});
const visibleSections = [
@ -2064,6 +2113,9 @@ export function Inbox() {
const isFading = fadingOutIssues.has(issue.id);
const isArchiving = archivingIssueIds.has(issue.id);
const project = issue.projectId ? projectById.get(issue.projectId) ?? null : null;
const assigneeUserProfile = issue.assigneeUserId
? companyUserProfileMap.get(issue.assigneeUserId) ?? null
: null;
return (
<IssueRow
key={`issue:${issue.id}`}
@ -2140,6 +2192,12 @@ export function Inbox() {
defaultProjectWorkspaceIdByProjectId,
})}
assigneeName={agentName(issue.assigneeAgentId)}
assigneeUserName={
formatAssigneeUserLabel(issue.assigneeUserId, currentUserId, companyUserLabelMap)
?? assigneeUserProfile?.label
?? null
}
assigneeUserAvatarUrl={assigneeUserProfile?.image ?? null}
currentUserId={currentUserId}
parentIdentifier={issue.parentId ? (issueById.get(issue.parentId)?.identifier ?? null) : null}
parentTitle={issue.parentId ? (issueById.get(issue.parentId)?.title ?? null) : null}

View file

@ -0,0 +1,245 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Shield, ShieldCheck } from "lucide-react";
import { accessApi } from "@/api/access";
import { ApiError } from "@/api/client";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useCompany } from "@/context/CompanyContext";
import { useToast } from "@/context/ToastContext";
import { queryKeys } from "@/lib/queryKeys";
export function InstanceAccess() {
const { companies } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToast();
const queryClient = useQueryClient();
const [search, setSearch] = useState("");
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [selectedCompanyIds, setSelectedCompanyIds] = useState<Set<string>>(new Set());
useEffect(() => {
setBreadcrumbs([
{ label: "Instance Settings", href: "/instance/settings/general" },
{ label: "Access" },
]);
}, [setBreadcrumbs]);
const usersQuery = useQuery({
queryKey: queryKeys.access.adminUsers(search),
queryFn: () => accessApi.searchAdminUsers(search),
});
const selectedUser = useMemo(
() => usersQuery.data?.find((user) => user.id === selectedUserId) ?? null,
[selectedUserId, usersQuery.data],
);
const userAccessQuery = useQuery({
queryKey: queryKeys.access.userCompanyAccess(selectedUserId ?? ""),
queryFn: () => accessApi.getUserCompanyAccess(selectedUserId!),
enabled: !!selectedUserId,
});
useEffect(() => {
if (!selectedUserId && usersQuery.data?.[0]) {
setSelectedUserId(usersQuery.data[0].id);
}
}, [selectedUserId, usersQuery.data]);
useEffect(() => {
if (!userAccessQuery.data) return;
setSelectedCompanyIds(
new Set(userAccessQuery.data.companyAccess.map((membership) => membership.companyId)),
);
}, [userAccessQuery.data]);
const updateCompanyAccessMutation = useMutation({
mutationFn: () => accessApi.setUserCompanyAccess(selectedUserId!, [...selectedCompanyIds]),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.access.userCompanyAccess(selectedUserId!) });
await queryClient.invalidateQueries({ queryKey: queryKeys.access.adminUsers(search) });
pushToast({ title: "Company access updated", tone: "success" });
},
});
const setAdminMutation = useMutation({
mutationFn: async (makeAdmin: boolean) => {
if (!selectedUserId) throw new Error("No user selected");
if (makeAdmin) return accessApi.promoteInstanceAdmin(selectedUserId);
return accessApi.demoteInstanceAdmin(selectedUserId);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.access.adminUsers(search) });
if (selectedUserId) {
await queryClient.invalidateQueries({ queryKey: queryKeys.access.userCompanyAccess(selectedUserId) });
}
pushToast({ title: "Instance role updated", tone: "success" });
},
});
if (usersQuery.isLoading) {
return <div className="text-sm text-muted-foreground">Loading instance users</div>;
}
if (usersQuery.error) {
const message =
usersQuery.error instanceof ApiError && usersQuery.error.status === 403
? "Instance admin access is required to manage users."
: usersQuery.error instanceof Error
? usersQuery.error.message
: "Failed to load users.";
return <div className="text-sm text-destructive">{message}</div>;
}
return (
<div className="max-w-6xl space-y-6">
<div className="space-y-3">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Instance Access</h1>
</div>
<p className="max-w-3xl text-sm text-muted-foreground">
Search users, manage instance-admin status, and control which companies they can access.
</p>
</div>
<div className="grid gap-6 lg:grid-cols-[320px_minmax(0,1fr)]">
<section className="space-y-4 rounded-xl border border-border bg-card p-4">
<label className="block space-y-2 text-sm">
<span className="font-medium">Search users</span>
<input
className="w-full rounded-md border border-border bg-background px-3 py-2"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search by name or email"
/>
</label>
<div className="space-y-2">
{(usersQuery.data ?? []).map((user) => (
<button
key={user.id}
type="button"
onClick={() => setSelectedUserId(user.id)}
className={`w-full rounded-lg border px-3 py-3 text-left transition-colors ${
user.id === selectedUserId
? "border-foreground bg-accent"
: "border-border hover:bg-accent/40"
}`}
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="truncate font-medium">{user.name || user.email || user.id}</div>
<div className="truncate text-sm text-muted-foreground">{user.email || user.id}</div>
</div>
{user.isInstanceAdmin ? (
<ShieldCheck className="h-4 w-4 text-emerald-600" />
) : null}
</div>
<div className="mt-2 text-xs text-muted-foreground">
{user.activeCompanyMembershipCount} active company memberships
</div>
</button>
))}
</div>
</section>
<section className="space-y-4 rounded-xl border border-border bg-card p-5">
{!selectedUserId ? (
<div className="text-sm text-muted-foreground">Select a user to inspect instance access.</div>
) : userAccessQuery.isLoading ? (
<div className="text-sm text-muted-foreground">Loading user access</div>
) : userAccessQuery.error ? (
<div className="text-sm text-destructive">
{userAccessQuery.error instanceof Error ? userAccessQuery.error.message : "Failed to load user access."}
</div>
) : (
<>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="text-lg font-semibold">
{selectedUser?.name || selectedUser?.email || selectedUserId}
</div>
<div className="text-sm text-muted-foreground">
{selectedUser?.email || selectedUserId}
</div>
</div>
<Button
variant={selectedUser?.isInstanceAdmin ? "outline" : "default"}
onClick={() => setAdminMutation.mutate(!(selectedUser?.isInstanceAdmin ?? false))}
disabled={setAdminMutation.isPending}
>
{selectedUser?.isInstanceAdmin ? "Remove instance admin" : "Promote to instance admin"}
</Button>
</div>
<div className="space-y-3">
<div>
<h2 className="text-sm font-semibold">Company access</h2>
<p className="text-sm text-muted-foreground">
Toggle company membership for this user. New access defaults to an active operator membership.
</p>
</div>
<div className="grid gap-3 md:grid-cols-2">
{companies.map((company) => (
<label
key={company.id}
className="flex items-start gap-3 rounded-lg border border-border px-3 py-3"
>
<Checkbox
checked={selectedCompanyIds.has(company.id)}
onCheckedChange={(checked) => {
setSelectedCompanyIds((current) => {
const next = new Set(current);
if (checked) next.add(company.id);
else next.delete(company.id);
return next;
});
}}
/>
<span className="space-y-1">
<span className="block text-sm font-medium">{company.name}</span>
<span className="block text-xs text-muted-foreground">{company.issuePrefix}</span>
</span>
</label>
))}
</div>
<div className="flex justify-end">
<Button
onClick={() => updateCompanyAccessMutation.mutate()}
disabled={updateCompanyAccessMutation.isPending}
>
{updateCompanyAccessMutation.isPending ? "Saving…" : "Save company access"}
</Button>
</div>
</div>
<div className="space-y-2">
<h2 className="text-sm font-semibold">Current memberships</h2>
<div className="space-y-2">
{(userAccessQuery.data?.companyAccess ?? []).map((membership) => (
<div
key={membership.id}
className="flex items-center justify-between rounded-lg border border-border px-3 py-2 text-sm"
>
<div>
<div className="font-medium">{membership.companyName || membership.companyId}</div>
<div className="text-muted-foreground">
{membership.membershipRole || "unset"} {membership.status}
</div>
</div>
<div className="text-xs text-muted-foreground">
{new Date(membership.updatedAt).toLocaleDateString()}
</div>
</div>
))}
</div>
</div>
</>
)}
</section>
</div>
</div>
);
}

View file

@ -9,7 +9,9 @@ import {
} from "@paperclipai/shared";
import { LogOut, SlidersHorizontal } from "lucide-react";
import { authApi } from "@/api/auth";
import { healthApi } from "@/api/health";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { ModeBadge } from "@/components/access/ModeBadge";
import { Button } from "../components/ui/button";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
@ -44,6 +46,11 @@ export function InstanceGeneralSettings() {
queryKey: queryKeys.instance.generalSettings,
queryFn: () => instanceSettingsApi.getGeneral(),
});
const healthQuery = useQuery({
queryKey: queryKeys.health,
queryFn: () => healthApi.get(),
retry: false,
});
const updateGeneralMutation = useMutation({
mutationFn: instanceSettingsApi.updateGeneral,
@ -93,6 +100,39 @@ export function InstanceGeneralSettings() {
</div>
)}
<section className="rounded-xl border border-border bg-card p-5">
<div className="space-y-3">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold">Deployment and auth</h2>
<ModeBadge
deploymentMode={healthQuery.data?.deploymentMode}
deploymentExposure={healthQuery.data?.deploymentExposure}
/>
</div>
<div className="text-sm text-muted-foreground">
{healthQuery.data?.deploymentMode === "local_trusted"
? "Local trusted mode is optimized for a local operator. Browser requests run as local board context and no sign-in is required."
: healthQuery.data?.deploymentExposure === "public"
? "Authenticated public mode requires sign-in for board access and is intended for public URLs."
: "Authenticated private mode requires sign-in and is intended for LAN, VPN, or other private-network deployments."}
</div>
<div className="grid gap-3 md:grid-cols-3">
<StatusBox
label="Auth readiness"
value={healthQuery.data?.authReady ? "Ready" : "Not ready"}
/>
<StatusBox
label="Bootstrap status"
value={healthQuery.data?.bootstrapStatus === "bootstrap_pending" ? "Setup required" : "Ready"}
/>
<StatusBox
label="Bootstrap invite"
value={healthQuery.data?.bootstrapInviteActive ? "Active" : "None"}
/>
</div>
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
@ -330,3 +370,12 @@ export function InstanceGeneralSettings() {
</div>
);
}
function StatusBox({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-border bg-background px-3 py-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{label}</div>
<div className="mt-2 text-sm font-medium">{value}</div>
</div>
);
}

View file

@ -0,0 +1,657 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { InviteLandingPage } from "./InviteLanding";
const getInviteMock = vi.hoisted(() => vi.fn());
const acceptInviteMock = vi.hoisted(() => vi.fn());
const getSessionMock = vi.hoisted(() => vi.fn());
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: {
getInvite: (token: string) => getInviteMock(token),
acceptInvite: (token: string, input: unknown) => acceptInviteMock(token, input),
},
}));
vi.mock("../api/auth", () => ({
authApi: {
getSession: () => getSessionMock(),
signInEmail: (input: unknown) => signInEmailMock(input),
signUpEmail: (input: unknown) => signUpEmailMock(input),
},
}));
vi.mock("../api/health", () => ({
healthApi: {
get: () => healthGetMock(),
},
}));
vi.mock("../api/companies", () => ({
companiesApi: {
list: () => listCompaniesMock(),
},
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({
selectedCompany: null,
selectedCompanyId: null,
companies: [],
selectionSource: "manual",
loading: false,
error: null,
setSelectedCompanyId: setSelectedCompanyIdMock,
reloadCompanies: vi.fn(),
createCompany: vi.fn(),
}),
}));
// 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("InviteLandingPage", () => {
let container: HTMLDivElement;
beforeEach(() => {
localStorage.clear();
container = document.createElement("div");
document.body.appendChild(container);
Object.defineProperty(HTMLCanvasElement.prototype, "getContext", {
configurable: true,
value: vi.fn(() => ({
fillStyle: "",
fillRect: vi.fn(),
beginPath: vi.fn(),
arc: vi.fn(),
fill: vi.fn(),
})),
});
Object.defineProperty(HTMLCanvasElement.prototype, "toDataURL", {
configurable: true,
value: vi.fn(() => "data:image/png;base64,stub"),
});
getInviteMock.mockResolvedValue({
id: "invite-1",
companyId: "company-1",
companyName: "Acme Robotics",
companyLogoUrl: "/api/invites/pcp_invite_test/logo",
companyBrandColor: "#114488",
inviteType: "company_join",
allowedJoinTypes: "both",
humanRole: "operator",
expiresAt: "2027-03-07T00:10:00.000Z",
inviteMessage: "Welcome aboard.",
});
acceptInviteMock.mockReset();
healthGetMock.mockResolvedValue({
status: "ok",
deploymentMode: "authenticated",
});
listCompaniesMock.mockResolvedValue([]);
getSessionMock.mockResolvedValue(null);
signInEmailMock.mockResolvedValue(undefined);
signUpEmailMock.mockResolvedValue(undefined);
setSelectedCompanyIdMock.mockReset();
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("defaults invite auth to account creation and guides existing users back to sign in", async () => {
signUpEmailMock.mockRejectedValue(
Object.assign(new Error("User already exists. Use another email."), {
code: "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL",
status: 422,
}),
);
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();
expect(container.textContent).toContain("You've been invited to join Paperclip");
expect(container.textContent).toContain("Join Acme Robotics");
expect(container.textContent).toContain("Create account");
expect(container.textContent).toContain("I already have an account");
expect(container.textContent).toContain("Message from inviter");
expect(container.querySelector('[data-testid="invite-inline-auth"]')).not.toBeNull();
expect(localStorage.getItem("paperclip:pending-invite-token")).toBe("pcp_invite_test");
const inviteLogo = container.querySelector('img[alt="Acme Robotics logo"]');
expect(inviteLogo).not.toBeNull();
expect(inviteLogo?.className).toContain("object-contain");
expect(container.querySelector('input[name="name"]')).not.toBeNull();
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();
const inputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
expect(inputValueSetter).toBeTypeOf("function");
await act(async () => {
inputValueSetter!.call(nameInput, "Jane Example");
nameInput!.dispatchEvent(new Event("input", { bubbles: true }));
nameInput!.dispatchEvent(new Event("change", { bubbles: true }));
inputValueSetter!.call(emailInput, "jane@example.com");
emailInput!.dispatchEvent(new Event("input", { bubbles: true }));
emailInput!.dispatchEvent(new Event("change", { bubbles: true }));
inputValueSetter!.call(passwordInput, "supersecret");
passwordInput!.dispatchEvent(new Event("input", { bubbles: true }));
passwordInput!.dispatchEvent(new Event("change", { 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(container.textContent).toContain("An account already exists for jane@example.com. Sign in below to continue with this invite.");
expect(container.querySelector('input[name="name"]')).toBeNull();
expect(container.textContent).toContain("Sign in to continue");
expect(localStorage.getItem("paperclip:pending-invite-token")).toBe("pcp_invite_test");
await act(async () => {
root.unmount();
});
});
it("turns invalid sign-in responses into a clear invite-specific message", async () => {
signInEmailMock.mockRejectedValue(
Object.assign(new Error("Invalid email or password"), {
code: "INVALID_EMAIL_OR_PASSWORD",
status: 401,
}),
);
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 }));
emailInput!.dispatchEvent(new Event("change", { bubbles: true }));
inputValueSetter!.call(passwordInput, "wrongpass");
passwordInput!.dispatchEvent(new Event("input", { bubbles: true }));
passwordInput!.dispatchEvent(new Event("change", { 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: "wrongpass",
});
expect(container.textContent).toContain(
"That email and password did not match an existing Paperclip account. Check both fields, or create an account first if you are new here.",
);
await act(async () => {
root.unmount();
});
});
it("auto-accepts the invite after account creation and redirects into the company", async () => {
getSessionMock.mockResolvedValueOnce(null);
getSessionMock.mockResolvedValue({
session: { id: "session-1", userId: "user-1" },
user: {
id: "user-1",
name: "Jane Example",
email: "jane@example.com",
image: null,
},
});
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();
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("shows the pending approval page with the company icon and linked access instructions", async () => {
acceptInviteMock.mockResolvedValue({
id: "join-1",
companyId: "company-1",
requestType: "human",
status: "pending_approval",
});
getSessionMock.mockResolvedValue({
session: { id: "session-1", userId: "user-1" },
user: {
id: "user-1",
name: "Jane Example",
email: "jane@example.com",
image: null,
},
});
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();
await flushReact();
await flushReact();
expect(acceptInviteMock).toHaveBeenCalledWith("pcp_invite_test", { requestType: "human" });
expect(container.textContent).toContain("Request to join Acme Robotics");
expect(container.textContent).toContain("A company admin must approve your request to join.");
expect(container.textContent).toContain(
"Ask them to visit Company Settings → Access to approve your request.",
);
expect(container.querySelector('img[alt="Acme Robotics logo"]')).not.toBeNull();
expect(container.textContent).not.toContain("http://localhost/company/settings/access");
const approvalLinks = Array.from(container.querySelectorAll("a")).filter(
(link) => link.textContent === "Company Settings → Access",
);
expect(approvalLinks).toHaveLength(2);
const expectedApprovalUrl = `${window.location.origin}/company/settings/access`;
for (const link of approvalLinks) {
expect(link.getAttribute("href")).toBe(expectedApprovalUrl);
}
await act(async () => {
root.unmount();
});
});
it("keeps the waiting-for-approval state on refresh for an accepted invite", async () => {
getInviteMock.mockResolvedValue({
id: "invite-1",
companyId: "company-1",
companyName: "Acme Robotics",
companyLogoUrl: "/api/invites/pcp_invite_test/logo",
companyBrandColor: "#114488",
inviteType: "company_join",
allowedJoinTypes: "both",
humanRole: "operator",
expiresAt: "2027-03-07T00:10:00.000Z",
inviteMessage: "Welcome aboard.",
joinRequestStatus: "pending_approval",
joinRequestType: "human",
});
getSessionMock.mockResolvedValue({
session: { id: "session-1", userId: "user-1" },
user: {
id: "user-1",
name: "Jane Example",
email: "jane@example.com",
image: null,
},
});
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();
await flushReact();
expect(acceptInviteMock).not.toHaveBeenCalled();
expect(container.querySelector('[data-testid="invite-pending-approval"]')).not.toBeNull();
expect(container.textContent).toContain("Your request is still awaiting approval.");
expect(container.textContent).toContain(
"Ask them to visit Company Settings → Access to approve your request.",
);
await act(async () => {
root.unmount();
});
});
it("redirects straight to the company after sign-in when the user already has access", async () => {
getSessionMock.mockResolvedValueOnce(null);
getSessionMock.mockResolvedValue({
session: { id: "session-1", userId: "user-1" },
user: {
id: "user-1",
name: "Jane Example",
email: "jane@example.com",
image: null,
},
});
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({
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 logo = container.querySelector('img[alt="Acme Robotics logo"]') as HTMLImageElement | null;
expect(logo).not.toBeNull();
await act(async () => {
logo?.dispatchEvent(new Event("error"));
});
await flushReact();
expect(container.querySelector('img[alt="Acme Robotics logo"]')).toBeNull();
expect(container.querySelector('img[aria-hidden="true"]')).not.toBeNull();
await act(async () => {
root.unmount();
});
});
it("waits for the membership check before showing invite acceptance to signed-in users", async () => {
let resolveCompanies: ((value: Array<{ id: string; name: string }>) => void) | null = null;
acceptInviteMock.mockResolvedValue({
id: "join-1",
companyId: "company-1",
requestType: "human",
status: "pending_approval",
});
listCompaniesMock.mockImplementation(
() =>
new Promise<Array<{ id: string; name: string }>>((resolve) => {
resolveCompanies = resolve;
}),
);
getSessionMock.mockResolvedValue({
session: { id: "session-1", userId: "user-1" },
user: {
id: "user-1",
name: "Jane Example",
email: "jane@example.com",
image: null,
},
});
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();
expect(container.textContent).toContain("Checking your access...");
expect(container.textContent).not.toContain("Accept company invite");
expect(acceptInviteMock).not.toHaveBeenCalled();
await act(async () => {
resolveCompanies?.([]);
});
await flushReact();
await flushReact();
await flushReact();
expect(acceptInviteMock).toHaveBeenCalledWith("pcp_invite_test", { requestType: "human" });
expect(container.textContent).toContain("Request to join Acme Robotics");
await act(async () => {
root.unmount();
});
});
});

View file

@ -1,24 +1,32 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useParams } from "@/lib/router";
import { accessApi } from "../api/access";
import { authApi } from "../api/auth";
import { healthApi } from "../api/health";
import { queryKeys } from "../lib/queryKeys";
import { Button } from "@/components/ui/button";
import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
import type { AgentAdapterType, JoinRequest } from "@paperclipai/shared";
type JoinType = "human" | "agent";
const joinAdapterOptions: AgentAdapterType[] = [...AGENT_ADAPTER_TYPES];
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";
import { companiesApi } from "../api/companies";
import { healthApi } from "../api/health";
import { getAdapterLabel } from "../adapters/adapter-display-registry";
import { clearPendingInviteToken, rememberPendingInviteToken } from "../lib/invite-memory";
import { queryKeys } from "../lib/queryKeys";
import { formatDate } from "../lib/utils";
const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor"]);
type AuthMode = "sign_in" | "sign_up";
type AuthFeedback = { tone: "error" | "info"; message: string };
function dateTime(value: string) {
return new Date(value).toLocaleString();
}
const joinAdapterOptions: AgentAdapterType[] = [...AGENT_ADAPTER_TYPES];
const ENABLED_INVITE_ADAPTERS = new Set([
"claude_local",
"codex_local",
"gemini_local",
"opencode_local",
"pi_local",
"cursor",
]);
function readNestedString(value: unknown, path: string[]): string | null {
let current: unknown = value;
@ -29,16 +37,198 @@ function readNestedString(value: unknown, path: string[]): string | null {
return typeof current === "string" && current.trim().length > 0 ? current : null;
}
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";
const modeButtonBaseClassName =
"flex-1 border px-3 py-2 text-sm transition-colors";
function formatHumanRole(role: string | null | undefined) {
if (!role) return null;
return role.charAt(0).toUpperCase() + role.slice(1);
}
function getAuthErrorCode(error: unknown) {
if (!error || typeof error !== "object") return null;
const code = (error as { code?: unknown }).code;
return typeof code === "string" && code.trim().length > 0 ? code : null;
}
function getAuthErrorMessage(error: unknown) {
if (!(error instanceof Error)) return null;
const message = error.message.trim();
return message.length > 0 ? message : null;
}
function mapInviteAuthFeedback(
error: unknown,
authMode: AuthMode,
email: string,
): AuthFeedback {
const code = getAuthErrorCode(error);
const message = getAuthErrorMessage(error);
const emailLabel = email.trim().length > 0 ? email.trim() : "that email";
if (code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
return {
tone: "info",
message: `An account already exists for ${emailLabel}. Sign in below to continue with this invite.`,
};
}
if (code === "INVALID_EMAIL_OR_PASSWORD") {
return {
tone: "error",
message:
"That email and password did not match an existing Paperclip account. Check both fields, or create an account first if you are new here.",
};
}
if (authMode === "sign_in" && message === "Request failed: 401") {
return {
tone: "error",
message:
"That email and password did not match an existing Paperclip account. Check both fields, or create an account first if you are new here.",
};
}
if (authMode === "sign_up" && message === "Request failed: 422") {
return {
tone: "info",
message: `An account may already exist for ${emailLabel}. Try signing in instead.`,
};
}
return {
tone: "error",
message: message ?? "Authentication failed",
};
}
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";
}
type AwaitingJoinApprovalPanelProps = {
companyDisplayName: string;
companyLogoUrl: string | null;
companyBrandColor: string | null;
invitedByUserName: string | null;
claimSecret?: string | null;
claimApiKeyPath?: string | null;
onboardingTextUrl?: string | null;
};
function InviteCompanyLogo({
companyDisplayName,
companyLogoUrl,
companyBrandColor,
className,
}: {
companyDisplayName: string;
companyLogoUrl: string | null;
companyBrandColor: string | null;
className?: string;
}) {
return (
<CompanyPatternIcon
companyName={companyDisplayName}
logoUrl={companyLogoUrl}
brandColor={companyBrandColor}
logoFit="contain"
className={className}
/>
);
}
function AwaitingJoinApprovalPanel({
companyDisplayName,
companyLogoUrl,
companyBrandColor,
invitedByUserName,
claimSecret = null,
claimApiKeyPath = null,
onboardingTextUrl = null,
}: AwaitingJoinApprovalPanelProps) {
const approvalUrl = `${window.location.origin}/company/settings/access`;
const approverLabel = invitedByUserName ?? "A company admin";
return (
<div className="min-h-screen bg-zinc-950 px-6 py-12 text-zinc-100">
<div className="mx-auto max-w-md border border-zinc-800 bg-zinc-950 p-6" data-testid="invite-pending-approval">
<div className="flex items-center gap-3">
<InviteCompanyLogo
companyDisplayName={companyDisplayName}
companyLogoUrl={companyLogoUrl}
companyBrandColor={companyBrandColor}
className="h-12 w-12 border border-zinc-800 rounded-none"
/>
<h1 className="text-lg font-semibold">Request to join {companyDisplayName}</h1>
</div>
<div className="mt-4 space-y-3">
<p className="text-sm text-zinc-400">
Your request is still awaiting approval. {approverLabel} must approve your request to join.
</p>
<div className="border border-zinc-800 p-3">
<p className="text-xs text-zinc-500 mb-1">Approval page</p>
<a
href={approvalUrl}
className="text-sm text-zinc-200 underline underline-offset-2 hover:text-zinc-100"
>
Company Settings Access
</a>
</div>
<p className="text-sm text-zinc-400">
Ask them to visit <a href={approvalUrl} className="text-zinc-200 underline underline-offset-2 hover:text-zinc-100">Company Settings Access</a> to approve your request.
</p>
<p className="text-xs text-zinc-500">
Refresh this page after you've been approved — you'll be redirected automatically.
</p>
</div>
{claimSecret && claimApiKeyPath ? (
<div className="mt-4 space-y-1 border border-zinc-800 p-3 text-xs text-zinc-400">
<div className="text-zinc-200">Claim secret</div>
<div className="font-mono break-all">{claimSecret}</div>
<div className="font-mono break-all">POST {claimApiKeyPath}</div>
</div>
) : null}
{onboardingTextUrl ? (
<div className="mt-4 text-xs text-zinc-400">
Onboarding: <span className="font-mono break-all">{onboardingTextUrl}</span>
</div>
) : null}
</div>
</div>
);
}
export function InviteLandingPage() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { setSelectedCompanyId } = useCompany();
const params = useParams();
const token = (params.token ?? "").trim();
const [joinType, setJoinType] = useState<JoinType>("human");
const [authMode, setAuthMode] = useState<AuthMode>("sign_up");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [agentName, setAgentName] = useState("");
const [adapterType, setAdapterType] = useState<AgentAdapterType>("claude_local");
const [capabilities, setCapabilities] = useState("");
const [result, setResult] = useState<{ kind: "bootstrap" | "join"; payload: unknown } | null>(null);
const [error, setError] = useState<string | null>(null);
const [authFeedback, setAuthFeedback] = useState<AuthFeedback | null>(null);
const [autoAcceptStarted, setAutoAcceptStarted] = useState(false);
const healthQuery = useQuery({
queryKey: queryKeys.health,
@ -57,33 +247,85 @@ export function InviteLandingPage() {
retry: false,
});
const invite = inviteQuery.data;
const companyName = invite?.companyName?.trim() || null;
const allowedJoinTypes = invite?.allowedJoinTypes ?? "both";
const availableJoinTypes = useMemo(() => {
if (invite?.inviteType === "bootstrap_ceo") return ["human"] as JoinType[];
if (allowedJoinTypes === "both") return ["human", "agent"] as JoinType[];
return [allowedJoinTypes] as JoinType[];
}, [invite?.inviteType, allowedJoinTypes]);
const companiesQuery = useQuery({
queryKey: queryKeys.companies.all,
queryFn: () => companiesApi.list(),
enabled: !!sessionQuery.data && !!inviteQuery.data?.companyId,
retry: false,
});
useEffect(() => {
if (!availableJoinTypes.includes(joinType)) {
setJoinType(availableJoinTypes[0] ?? "human");
}
}, [availableJoinTypes, joinType]);
if (token) rememberPendingInviteToken(token);
}, [token]);
const requiresAuthForHuman =
joinType === "human" &&
useEffect(() => {
setAutoAcceptStarted(false);
}, [token]);
useEffect(() => {
if (!companiesQuery.data || !inviteQuery.data?.companyId) return;
const isMember = companiesQuery.data.some(
(c) => c.id === inviteQuery.data!.companyId
);
if (isMember) {
clearPendingInviteToken(token);
navigate("/", { replace: true });
}
}, [companiesQuery.data, inviteQuery.data, token, navigate]);
const invite = inviteQuery.data;
const isCheckingExistingMembership =
Boolean(sessionQuery.data) &&
Boolean(invite?.companyId) &&
companiesQuery.isLoading;
const isCurrentMember =
Boolean(invite?.companyId) &&
Boolean(
companiesQuery.data?.some((company) => company.id === invite?.companyId),
);
const companyName = invite?.companyName?.trim() || null;
const companyDisplayName = companyName || "this Paperclip company";
const companyLogoUrl = invite?.companyLogoUrl?.trim() || null;
const companyBrandColor = invite?.companyBrandColor?.trim() || null;
const invitedByUserName = invite?.invitedByUserName?.trim() || null;
const inviteMessage = invite?.inviteMessage?.trim() || null;
const requestedHumanRole = formatHumanRole(invite?.humanRole);
const inviteJoinRequestStatus = invite?.joinRequestStatus ?? null;
const inviteJoinRequestType = invite?.joinRequestType ?? null;
const requiresHumanAccount =
healthQuery.data?.deploymentMode === "authenticated" &&
!sessionQuery.data;
!sessionQuery.data &&
invite?.allowedJoinTypes !== "agent";
const showsAgentForm = invite?.inviteType !== "bootstrap_ceo" && invite?.allowedJoinTypes === "agent";
const shouldAutoAcceptHumanInvite =
Boolean(sessionQuery.data) &&
!showsAgentForm &&
invite?.inviteType !== "bootstrap_ceo" &&
!inviteJoinRequestStatus &&
!isCheckingExistingMembership &&
!isCurrentMember &&
!result &&
error === null;
const sessionLabel =
sessionQuery.data?.user.name?.trim() ||
sessionQuery.data?.user.email?.trim() ||
"this account";
const authCanSubmit =
email.trim().length > 0 &&
password.trim().length > 0 &&
(authMode === "sign_in" || (name.trim().length > 0 && password.trim().length >= 8));
const acceptMutation = useMutation({
mutationFn: async () => {
if (!invite) throw new Error("Invite not found");
if (invite.inviteType === "bootstrap_ceo") {
return accessApi.acceptInvite(token, { requestType: "human" });
if (isCheckingExistingMembership) {
throw new Error("Checking your company access. Try again in a moment.");
}
if (joinType === "human") {
if (isCurrentMember) {
throw new Error("This account already belongs to the company.");
}
if (invite.inviteType === "bootstrap_ceo" || invite.allowedJoinTypes !== "agent") {
return accessApi.acceptInvite(token, { requestType: "human" });
}
return accessApi.acceptInvite(token, {
@ -95,17 +337,87 @@ export function InviteLandingPage() {
},
onSuccess: async (payload) => {
setError(null);
clearPendingInviteToken(token);
const asBootstrap = isBootstrapAcceptancePayload(payload);
setResult({ kind: asBootstrap ? "bootstrap" : "join", payload });
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>);
setResult({ kind: asBootstrap ? "bootstrap" : "join", payload });
if (invite?.companyId && isApprovedHumanJoinPayload(payload, showsAgentForm)) {
setSelectedCompanyId(invite.companyId, { source: "manual" });
navigate("/", { replace: true });
}
},
onError: (err) => {
setError(err instanceof Error ? err.message : "Failed to accept invite");
},
});
useEffect(() => {
if (!shouldAutoAcceptHumanInvite || autoAcceptStarted || acceptMutation.isPending) return;
setAutoAcceptStarted(true);
setError(null);
acceptMutation.mutate();
}, [acceptMutation, autoAcceptStarted, shouldAutoAcceptHumanInvite]);
const authMutation = useMutation({
mutationFn: async () => {
if (authMode === "sign_in") {
await authApi.signInEmail({ email: email.trim(), password });
return;
}
await authApi.signUpEmail({
name: name.trim(),
email: email.trim(),
password,
});
},
onSuccess: async () => {
setAuthFeedback(null);
rememberPendingInviteToken(token);
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
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;
}
if (!invite || invite.inviteType !== "bootstrap_ceo") {
return;
}
try {
const payload = await acceptMutation.mutateAsync();
if (isBootstrapAcceptancePayload(payload)) {
navigate("/", { replace: true });
}
} catch {
return;
}
},
onError: (err) => {
const nextFeedback = mapInviteAuthFeedback(err, authMode, email);
if (getAuthErrorCode(err) === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
setAuthMode("sign_in");
setPassword("");
}
setAuthFeedback(nextFeedback);
},
});
const joinButtonLabel = useMemo(() => {
if (!invite) return "Continue";
if (invite.inviteType === "bootstrap_ceo") return "Accept invite";
if (showsAgentForm) return "Submit request";
return sessionQuery.data ? "Accept invite" : "Continue";
}, [invite, sessionQuery.data, showsAgentForm]);
if (!token) {
return <div className="mx-auto max-w-xl py-10 text-sm text-destructive">Invalid invite token.</div>;
}
@ -114,10 +426,14 @@ export function InviteLandingPage() {
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading invite...</div>;
}
if (isCheckingExistingMembership) {
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Checking your access...</div>;
}
if (inviteQuery.error || !invite) {
return (
<div className="mx-auto max-w-xl py-10">
<div className="rounded-lg border border-border bg-card p-6">
<div className="border border-border bg-card p-6" data-testid="invite-error">
<h1 className="text-lg font-semibold">Invite not available</h1>
<p className="mt-2 text-sm text-muted-foreground">
This invite may be expired, revoked, or already used.
@ -127,17 +443,50 @@ export function InviteLandingPage() {
);
}
if (result?.kind === "bootstrap") {
if (
inviteJoinRequestStatus === "approved" &&
inviteJoinRequestType === "human" &&
isCurrentMember
) {
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Opening company...</div>;
}
if (inviteJoinRequestStatus === "pending_approval") {
return (
<AwaitingJoinApprovalPanel
companyDisplayName={companyDisplayName}
companyLogoUrl={companyLogoUrl}
companyBrandColor={companyBrandColor}
invitedByUserName={invitedByUserName}
/>
);
}
if (inviteJoinRequestStatus) {
return (
<div className="mx-auto max-w-xl py-10">
<div className="rounded-lg border border-border bg-card p-6">
<h1 className="text-lg font-semibold">Bootstrap complete</h1>
<div className="border border-border bg-card p-6" data-testid="invite-error">
<h1 className="text-lg font-semibold">Invite not available</h1>
<p className="mt-2 text-sm text-muted-foreground">
The first instance admin is now configured. You can continue to the board.
{inviteJoinRequestStatus === "rejected"
? "This join request was not approved."
: "This invite has already been used."}
</p>
<Button asChild className="mt-4">
<Link to="/">Open board</Link>
</Button>
</div>
</div>
);
}
if (result?.kind === "bootstrap") {
return (
<div className="min-h-screen bg-zinc-950 px-6 py-12 text-zinc-100">
<div className="mx-auto max-w-md border border-zinc-800 bg-zinc-950 p-6">
<h1 className="text-lg font-semibold">Bootstrap complete</h1>
<div className="mt-4">
<Button asChild className="rounded-none">
<Link to="/">Open board</Link>
</Button>
</div>
</div>
</div>
);
@ -148,171 +497,330 @@ export function InviteLandingPage() {
claimSecret?: string;
claimApiKeyPath?: string;
onboarding?: Record<string, unknown>;
diagnostics?: Array<{
code: string;
level: "info" | "warn";
message: string;
hint?: string;
}>;
};
const claimSecret = typeof payload.claimSecret === "string" ? payload.claimSecret : null;
const claimApiKeyPath = typeof payload.claimApiKeyPath === "string" ? payload.claimApiKeyPath : null;
const onboardingSkillUrl = readNestedString(payload.onboarding, ["skill", "url"]);
const onboardingSkillPath = readNestedString(payload.onboarding, ["skill", "path"]);
const onboardingInstallPath = readNestedString(payload.onboarding, ["skill", "installPath"]);
const onboardingTextUrl = readNestedString(payload.onboarding, ["textInstructions", "url"]);
const onboardingTextPath = readNestedString(payload.onboarding, ["textInstructions", "path"]);
const diagnostics = Array.isArray(payload.diagnostics) ? payload.diagnostics : [];
const joinedNow = !showsAgentForm && payload.status === "approved";
return (
<div className="mx-auto max-w-xl py-10">
<div className="rounded-lg border border-border bg-card p-6">
<h1 className="text-lg font-semibold">Join request submitted</h1>
<p className="mt-2 text-sm text-muted-foreground">
Your request is pending admin approval. You will not have access until approved.
</p>
<div className="mt-4 rounded-md border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
Request ID: <span className="font-mono">{payload.id}</span>
joinedNow ? (
<div className="min-h-screen bg-zinc-950 px-6 py-12 text-zinc-100">
<div className="mx-auto max-w-md border border-zinc-800 bg-zinc-950 p-6">
<div className="flex items-center gap-3">
<InviteCompanyLogo
companyDisplayName={companyDisplayName}
companyLogoUrl={companyLogoUrl}
companyBrandColor={companyBrandColor}
className="h-12 w-12 border border-zinc-800 rounded-none"
/>
<h1 className="text-lg font-semibold">You joined the company</h1>
</div>
<div className="mt-4">
<Button asChild className="w-full rounded-none">
<Link to="/">Open board</Link>
</Button>
</div>
</div>
{claimSecret && claimApiKeyPath && (
<div className="mt-3 space-y-1 rounded-md border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
<p className="font-medium text-foreground">One-time claim secret (save now)</p>
<p className="font-mono break-all">{claimSecret}</p>
<p className="font-mono break-all">POST {claimApiKeyPath}</p>
</div>
)}
{(onboardingSkillUrl || onboardingSkillPath || onboardingInstallPath) && (
<div className="mt-3 space-y-1 rounded-md border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
<p className="font-medium text-foreground">Paperclip skill bootstrap</p>
{onboardingSkillUrl && <p className="font-mono break-all">GET {onboardingSkillUrl}</p>}
{!onboardingSkillUrl && onboardingSkillPath && <p className="font-mono break-all">GET {onboardingSkillPath}</p>}
{onboardingInstallPath && <p className="font-mono break-all">Install to {onboardingInstallPath}</p>}
</div>
)}
{(onboardingTextUrl || onboardingTextPath) && (
<div className="mt-3 space-y-1 rounded-md border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
<p className="font-medium text-foreground">Agent-readable onboarding text</p>
{onboardingTextUrl && <p className="font-mono break-all">GET {onboardingTextUrl}</p>}
{!onboardingTextUrl && onboardingTextPath && <p className="font-mono break-all">GET {onboardingTextPath}</p>}
</div>
)}
{diagnostics.length > 0 && (
<div className="mt-3 space-y-1 rounded-md border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
<p className="font-medium text-foreground">Connectivity diagnostics</p>
{diagnostics.map((diag, idx) => (
<div key={`${diag.code}:${idx}`} className="space-y-0.5">
<p className={diag.level === "warn" ? "text-amber-600 dark:text-amber-400" : undefined}>
[{diag.level}] {diag.message}
</p>
{diag.hint && <p className="font-mono break-all">{diag.hint}</p>}
</div>
))}
</div>
)}
</div>
</div>
) : (
<AwaitingJoinApprovalPanel
companyDisplayName={companyDisplayName}
companyLogoUrl={companyLogoUrl}
companyBrandColor={companyBrandColor}
invitedByUserName={invitedByUserName}
claimSecret={claimSecret}
claimApiKeyPath={claimApiKeyPath}
onboardingTextUrl={onboardingTextUrl}
/>
)
);
}
return (
<div className="mx-auto max-w-xl py-10">
<div className="rounded-lg border border-border bg-card p-6">
<h1 className="text-xl font-semibold">
{invite.inviteType === "bootstrap_ceo"
? "Bootstrap your Paperclip instance"
: companyName
? `Join ${companyName}`
: "Join this Paperclip company"}
</h1>
<p className="mt-2 text-sm text-muted-foreground">
{invite.inviteType !== "bootstrap_ceo" && companyName
? `You were invited to join ${companyName}. `
: null}
Invite expires {dateTime(invite.expiresAt)}.
</p>
{invite.inviteType !== "bootstrap_ceo" && (
<div className="mt-5 flex gap-2">
{availableJoinTypes.map((type) => (
<button
key={type}
type="button"
onClick={() => setJoinType(type)}
className={`rounded-md border px-3 py-1.5 text-sm ${
joinType === type
? "border-foreground bg-foreground text-background"
: "border-border bg-background text-foreground"
}`}
>
Join as {type}
</button>
))}
</div>
)}
{joinType === "agent" && invite.inviteType !== "bootstrap_ceo" && (
<div className="mt-4 space-y-3">
<label className="block text-sm">
<span className="mb-1 block text-muted-foreground">Agent name</span>
<input
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
value={agentName}
onChange={(event) => setAgentName(event.target.value)}
<div className="min-h-screen bg-zinc-950 px-6 py-12 text-zinc-100">
<div className="mx-auto max-w-5xl">
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.15fr)_minmax(360px,0.85fr)]">
<section className={`${panelClassName} space-y-6`}>
<div className="flex items-start gap-4">
<InviteCompanyLogo
companyDisplayName={companyDisplayName}
companyLogoUrl={companyLogoUrl}
companyBrandColor={companyBrandColor}
className="h-16 w-16 rounded-none border border-zinc-800"
/>
</label>
<label className="block text-sm">
<span className="mb-1 block text-muted-foreground">Adapter type</span>
<select
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
value={adapterType}
onChange={(event) => setAdapterType(event.target.value as AgentAdapterType)}
>
{joinAdapterOptions.map((type) => (
<option key={type} value={type} disabled={!ENABLED_INVITE_ADAPTERS.has(type)}>
{getAdapterLabel(type)}{!ENABLED_INVITE_ADAPTERS.has(type) ? " (Coming soon)" : ""}
</option>
))}
</select>
</label>
<label className="block text-sm">
<span className="mb-1 block text-muted-foreground">Capabilities (optional)</span>
<textarea
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
rows={4}
value={capabilities}
onChange={(event) => setCapabilities(event.target.value)}
/>
</label>
</div>
)}
{requiresAuthForHuman && (
<div className="mt-4 rounded-md border border-border bg-muted/30 p-3 text-sm">
Sign in or create an account before submitting a human join request.
<div className="mt-2">
<Button asChild size="sm" variant="outline">
<Link to={`/auth?next=${encodeURIComponent(`/invite/${token}`)}`}>Sign in / Create account</Link>
</Button>
<div className="min-w-0">
<p className="text-xs uppercase tracking-[0.24em] text-zinc-500">
You&apos;ve been invited to join Paperclip
</p>
<h1 className="mt-2 text-2xl font-semibold">
{invite.inviteType === "bootstrap_ceo" ? "Set up Paperclip" : `Join ${companyDisplayName}`}
</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-zinc-300">
{showsAgentForm
? "Review the invite details, then submit the agent information below to start the join request."
: requiresHumanAccount
? "Create your Paperclip account first. If you already have one, switch to sign in and continue the invite with the same email."
: "Your account is ready. Review the invite details, then accept it to continue."}
</p>
</div>
</div>
</div>
)}
{error && <p className="mt-3 text-sm text-destructive">{error}</p>}
<div className="grid gap-3 sm:grid-cols-2">
<div className="border border-zinc-800 p-3">
<div className="text-xs uppercase tracking-[0.2em] text-zinc-500">Company</div>
<div className="mt-1 text-sm text-zinc-100">{companyDisplayName}</div>
</div>
<div className="border border-zinc-800 p-3">
<div className="text-xs uppercase tracking-[0.2em] text-zinc-500">Invited by</div>
<div className="mt-1 text-sm text-zinc-100">{invitedByUserName ?? "Paperclip board"}</div>
</div>
<div className="border border-zinc-800 p-3">
<div className="text-xs uppercase tracking-[0.2em] text-zinc-500">Requested access</div>
<div className="mt-1 text-sm text-zinc-100">
{showsAgentForm ? "Agent join request" : requestedHumanRole ?? "Company access"}
</div>
</div>
<div className="border border-zinc-800 p-3">
<div className="text-xs uppercase tracking-[0.2em] text-zinc-500">Invite expires</div>
<div className="mt-1 text-sm text-zinc-100">{formatDate(invite.expiresAt)}</div>
</div>
</div>
<Button
className="mt-5"
disabled={
acceptMutation.isPending ||
(joinType === "agent" && invite.inviteType !== "bootstrap_ceo" && agentName.trim().length === 0) ||
requiresAuthForHuman
}
onClick={() => acceptMutation.mutate()}
>
{acceptMutation.isPending
? "Submitting…"
: invite.inviteType === "bootstrap_ceo"
? "Accept bootstrap invite"
: "Submit join request"}
</Button>
{inviteMessage ? (
<div className="border border-amber-500/40 bg-amber-500/10 p-4">
<div className="text-xs uppercase tracking-[0.2em] text-amber-200/80">Message from inviter</div>
<p className="mt-2 text-sm leading-6 text-amber-50">{inviteMessage}</p>
</div>
) : null}
{sessionQuery.data ? (
<div className="border border-emerald-500/40 bg-emerald-500/10 p-4 text-sm text-emerald-50">
Signed in as <span className="font-medium">{sessionLabel}</span>.
</div>
) : null}
</section>
<section className={`${panelClassName} h-fit`}>
{showsAgentForm ? (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold">Submit agent details</h2>
<p className="mt-1 text-sm text-zinc-400">
This invite will create an approval request for a new agent in {companyDisplayName}.
</p>
</div>
<label className="block text-sm">
<span className="mb-1 block text-zinc-400">Agent name</span>
<input
className={fieldClassName}
value={agentName}
onChange={(event) => setAgentName(event.target.value)}
/>
</label>
<label className="block text-sm">
<span className="mb-1 block text-zinc-400">Adapter type</span>
<select
className={fieldClassName}
value={adapterType}
onChange={(event) => setAdapterType(event.target.value as AgentAdapterType)}
>
{joinAdapterOptions.map((type) => (
<option key={type} value={type} disabled={!ENABLED_INVITE_ADAPTERS.has(type)}>
{getAdapterLabel(type)}{!ENABLED_INVITE_ADAPTERS.has(type) ? " (Coming soon)" : ""}
</option>
))}
</select>
</label>
<label className="block text-sm">
<span className="mb-1 block text-zinc-400">Capabilities</span>
<textarea
className={fieldClassName}
rows={4}
value={capabilities}
onChange={(event) => setCapabilities(event.target.value)}
/>
</label>
{error ? <p className="text-xs text-red-400">{error}</p> : null}
<Button
className="w-full rounded-none"
disabled={acceptMutation.isPending || agentName.trim().length === 0}
onClick={() => acceptMutation.mutate()}
>
{acceptMutation.isPending ? "Working..." : joinButtonLabel}
</Button>
</div>
) : requiresHumanAccount ? (
<div className="space-y-5">
<div>
<h2 className="text-lg font-semibold">
{authMode === "sign_up" ? "Create your account" : "Sign in to continue"}
</h2>
<p className="mt-1 text-sm text-zinc-400">
{authMode === "sign_up"
? `Start with a Paperclip account. After that, you'll come right back here to accept the invite for ${companyDisplayName}.`
: "Use the Paperclip account that already matches this invite. If you do not have one yet, switch back to create account."}
</p>
</div>
<div className="flex gap-2">
<button
type="button"
className={`${modeButtonBaseClassName} ${
authMode === "sign_up"
? "border-zinc-100 bg-zinc-100 text-zinc-950"
: "border-zinc-800 text-zinc-300 hover:border-zinc-600"
}`}
onClick={() => {
setAuthFeedback(null);
setAuthMode("sign_up");
}}
>
Create account
</button>
<button
type="button"
className={`${modeButtonBaseClassName} ${
authMode === "sign_in"
? "border-zinc-100 bg-zinc-100 text-zinc-950"
: "border-zinc-800 text-zinc-300 hover:border-zinc-600"
}`}
onClick={() => {
setAuthFeedback(null);
setAuthMode("sign_in");
}}
>
I already have an account
</button>
</div>
<form
className="space-y-4"
method="post"
action={authMode === "sign_up" ? "/api/auth/sign-up/email" : "/api/auth/sign-in/email"}
onSubmit={(event) => {
event.preventDefault();
if (authMutation.isPending) return;
if (!authCanSubmit) {
setAuthFeedback({ tone: "error", message: "Please fill in all required fields." });
return;
}
authMutation.mutate();
}}
data-testid="invite-inline-auth"
>
{authMode === "sign_up" ? (
<label className="block text-sm">
<span className="mb-1 block text-zinc-400">Name</span>
<input
name="name"
className={fieldClassName}
value={name}
onChange={(event) => {
setName(event.target.value);
setAuthFeedback(null);
}}
autoComplete="name"
autoFocus
/>
</label>
) : null}
<label className="block text-sm">
<span className="mb-1 block text-zinc-400">Email</span>
<input
name="email"
type="email"
className={fieldClassName}
value={email}
onChange={(event) => {
setEmail(event.target.value);
setAuthFeedback(null);
}}
autoComplete="email"
autoFocus={authMode === "sign_in"}
/>
</label>
<label className="block text-sm">
<span className="mb-1 block text-zinc-400">Password</span>
<input
name="password"
type="password"
className={fieldClassName}
value={password}
onChange={(event) => {
setPassword(event.target.value);
setAuthFeedback(null);
}}
autoComplete={authMode === "sign_in" ? "current-password" : "new-password"}
/>
</label>
{authFeedback ? (
<p
className={`text-xs ${
authFeedback.tone === "info" ? "text-amber-300" : "text-red-400"
}`}
>
{authFeedback.message}
</p>
) : null}
<Button
type="submit"
className="w-full rounded-none"
disabled={authMutation.isPending}
aria-disabled={!authCanSubmit || authMutation.isPending}
>
{authMutation.isPending
? "Working..."
: authMode === "sign_in"
? "Sign in and continue"
: "Create account and continue"}
</Button>
</form>
<p className="text-xs leading-5 text-zinc-500">
{authMode === "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."}
</p>
</div>
) : (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold">
{shouldAutoAcceptHumanInvite
? "Submitting join request"
: invite.inviteType === "bootstrap_ceo"
? "Accept bootstrap invite"
: "Accept company invite"}
</h2>
<p className="mt-1 text-sm text-zinc-400">
{shouldAutoAcceptHumanInvite
? `Submitting your join request for ${companyDisplayName}.`
: isCurrentMember
? `This account already belongs to ${companyDisplayName}.`
: `This will ${
invite.inviteType === "bootstrap_ceo" ? "finish setting up Paperclip" : `submit or complete your join request for ${companyDisplayName}`
}.`}
</p>
</div>
{error ? <p className="text-xs text-red-400">{error}</p> : null}
{shouldAutoAcceptHumanInvite ? (
<div className="text-sm text-zinc-400">
{acceptMutation.isPending ? "Submitting request..." : "Finishing sign-in..."}
</div>
) : (
<Button
className="w-full rounded-none"
disabled={acceptMutation.isPending || isCurrentMember}
onClick={() => acceptMutation.mutate()}
>
{acceptMutation.isPending ? "Working..." : joinButtonLabel}
</Button>
)}
</div>
)}
</section>
</div>
</div>
</div>
);

View file

@ -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 }) => (
<div aria-label={`${companyName} logo`}>{companyName}</div>
),
}));
// 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(<InviteUxLab />);
});
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();
});
});
});

View file

@ -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 (
<section
className={cn(
"rounded-[28px] border border-border/70 bg-background/80 p-4 shadow-[0_24px_60px_rgba(15,23,42,0.08)] sm:p-5",
accentClassName,
)}
>
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-muted-foreground">
{eyebrow}
</div>
<h2 className="mt-1 text-xl font-semibold tracking-tight">{title}</h2>
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">{description}</p>
</div>
</div>
{children}
</section>
);
}
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 (
<Card className={cn("rounded-[24px] shadow-none", toneClassName)}>
<CardHeader className="space-y-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-current/10 bg-background/70 text-muted-foreground">
{icon}
</div>
<div>
<CardTitle className="text-base">{title}</CardTitle>
<CardDescription className="mt-2 text-sm leading-6">{body}</CardDescription>
</div>
</CardHeader>
</Card>
);
}
function InviteLandingShell({
left,
right,
}: {
left: ReactNode;
right: ReactNode;
}) {
return (
<div className="overflow-hidden rounded-[28px] border border-zinc-800 bg-zinc-950 shadow-[0_30px_80px_rgba(2,6,23,0.55)]">
<div className="grid gap-px bg-zinc-800 lg:grid-cols-[minmax(0,1.15fr)_minmax(320px,0.85fr)]">
<section className={cn(panelClassName, "space-y-6 bg-zinc-950")}>{left}</section>
<section className={cn(panelClassName, "h-full bg-zinc-950")}>{right}</section>
</div>
</div>
);
}
function InviteSummaryPanel({
title,
description,
inviteMessage,
requestedAccess,
signedInLabel,
}: {
title: string;
description: string;
inviteMessage?: string;
requestedAccess: string;
signedInLabel?: string;
}) {
return (
<>
<div className="flex items-start gap-4">
<CompanyPatternIcon
companyName="Acme Robotics"
logoUrl="/api/invites/pcp_invite_test/logo"
brandColor="#114488"
className="h-16 w-16 rounded-none border border-zinc-800"
/>
<div className="min-w-0">
<p className="text-xs uppercase tracking-[0.24em] text-zinc-500">You&apos;ve been invited to join Paperclip</p>
<h3 className="mt-2 text-2xl font-semibold text-zinc-100">{title}</h3>
<p className="mt-2 max-w-2xl text-sm leading-6 text-zinc-300">{description}</p>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<MetaCard label="Company" value="Acme Robotics" />
<MetaCard label="Invited by" value="Board User" />
<MetaCard label="Requested access" value={requestedAccess} />
<MetaCard label="Invite expires" value="Mar 7, 2027" />
</div>
{inviteMessage ? (
<div className="border border-amber-500/40 bg-amber-500/10 p-4">
<div className="text-xs uppercase tracking-[0.2em] text-amber-200/80">Message from inviter</div>
<p className="mt-2 text-sm leading-6 text-amber-50">{inviteMessage}</p>
</div>
) : null}
{signedInLabel ? (
<div className="border border-emerald-500/40 bg-emerald-500/10 p-4 text-sm text-emerald-50">
Signed in as <span className="font-medium">{signedInLabel}</span>.
</div>
) : null}
</>
);
}
function MetaCard({ label, value }: { label: string; value: string }) {
return (
<div className="border border-zinc-800 p-3">
<div className="text-xs uppercase tracking-[0.2em] text-zinc-500">{label}</div>
<div className="mt-1 text-sm text-zinc-100">{value}</div>
</div>
);
}
function InlineAuthPreview({
mode,
feedback,
working,
}: {
mode: "sign_up" | "sign_in";
feedback?: { tone: "info" | "error"; text: string };
working?: boolean;
}) {
return (
<div className="space-y-5">
<div>
<h3 className="text-lg font-semibold text-zinc-100">
{mode === "sign_up" ? "Create your account" : "Sign in to continue"}
</h3>
<p className="mt-1 text-sm text-zinc-400">
{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."}
</p>
</div>
<div className="flex gap-2">
<button
type="button"
className={cn(
"flex-1 border px-3 py-2 text-sm transition-colors",
mode === "sign_up"
? "border-zinc-100 bg-zinc-100 text-zinc-950"
: "border-zinc-800 text-zinc-300 hover:border-zinc-600",
)}
>
Create account
</button>
<button
type="button"
className={cn(
"flex-1 border px-3 py-2 text-sm transition-colors",
mode === "sign_in"
? "border-zinc-100 bg-zinc-100 text-zinc-950"
: "border-zinc-800 text-zinc-300 hover:border-zinc-600",
)}
>
I already have an account
</button>
</div>
<form className="space-y-4">
{mode === "sign_up" ? (
<label className="block text-sm">
<span className="mb-1 block text-zinc-400">Name</span>
<input name="name" className={fieldClassName} defaultValue="Jane Example" readOnly />
</label>
) : null}
<label className="block text-sm">
<span className="mb-1 block text-zinc-400">Email</span>
<input name="email" type="email" className={fieldClassName} defaultValue="jane@example.com" readOnly />
</label>
<label className="block text-sm">
<span className="mb-1 block text-zinc-400">Password</span>
<input name="password" type="password" className={fieldClassName} defaultValue="supersecret" readOnly />
</label>
{feedback ? (
<p className={cn("text-xs", feedback.tone === "info" ? "text-amber-300" : "text-red-400")}>
{feedback.text}
</p>
) : null}
<Button type="button" className="w-full rounded-none" disabled={working}>
{working ? "Working..." : mode === "sign_in" ? "Sign in and continue" : "Create account and continue"}
</Button>
</form>
<p className="text-xs leading-5 text-zinc-500">
{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."}
</p>
</div>
);
}
function AgentRequestPreview() {
return (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold text-zinc-100">Submit agent details</h3>
<p className="mt-1 text-sm text-zinc-400">
This invite will create an approval request for a new agent in Acme Robotics.
</p>
</div>
<label className="block text-sm">
<span className="mb-1 block text-zinc-400">Agent name</span>
<input className={fieldClassName} defaultValue="Acme Ops Agent" readOnly />
</label>
<label className="block text-sm">
<span className="mb-1 block text-zinc-400">Adapter type</span>
<select className={fieldClassName} defaultValue="codex_local" disabled>
<option value="codex_local">Codex</option>
<option value="claude_local">Claude Code</option>
<option value="cursor">Cursor</option>
</select>
</label>
<label className="block text-sm">
<span className="mb-1 block text-zinc-400">Capabilities</span>
<textarea
className={fieldClassName}
rows={4}
defaultValue="Reviews invites, triages requests, and keeps the board queue moving."
readOnly
/>
</label>
<Button type="button" className="w-full rounded-none">
Submit request
</Button>
</div>
);
}
function AcceptInvitePreview({
autoAccept,
isCurrentMember,
error,
}: {
autoAccept?: boolean;
isCurrentMember?: boolean;
error?: string;
}) {
return (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold text-zinc-100">Accept company invite</h3>
<p className="mt-1 text-sm text-zinc-400">
{autoAccept
? "Submitting your join request for Acme Robotics."
: isCurrentMember
? "This account already belongs to Acme Robotics."
: "This will submit or complete your join request for Acme Robotics."}
</p>
</div>
{error ? <p className="text-xs text-red-400">{error}</p> : null}
{autoAccept ? (
<div className="text-sm text-zinc-400">Submitting request...</div>
) : (
<Button type="button" className="w-full rounded-none" disabled={isCurrentMember}>
Accept invite
</Button>
)}
</div>
);
}
function InviteResultPreview({
title,
description,
claimSecret,
onboardingTextUrl,
joinedNow = false,
}: {
title: string;
description: string;
claimSecret?: string;
onboardingTextUrl?: string;
joinedNow?: boolean;
}) {
return (
<div className="mx-auto max-w-md border border-zinc-800 bg-zinc-950 p-6 text-zinc-100">
<div className="flex items-center gap-3">
<CompanyPatternIcon
companyName="Acme Robotics"
logoUrl="/api/invites/pcp_invite_test/logo"
brandColor="#114488"
className="h-12 w-12 rounded-none border border-zinc-800"
/>
<h3 className="text-lg font-semibold">{title}</h3>
</div>
<div className="mt-4 space-y-3">
<p className="text-sm text-zinc-400">{description}</p>
{joinedNow ? (
<Button type="button" className="w-full rounded-none">
Open board
</Button>
) : (
<>
<div className="border border-zinc-800 p-3">
<p className="mb-1 text-xs text-zinc-500">Approval page</p>
<a className="text-sm text-zinc-200 underline underline-offset-2" href="/company/settings/access">
Company Settings Access
</a>
</div>
<p className="text-xs text-zinc-500">
Refresh this page after you&apos;ve been approved you&apos;ll be redirected automatically.
</p>
</>
)}
{claimSecret ? (
<div className="space-y-1 border border-zinc-800 p-3 text-xs text-zinc-400">
<div className="text-zinc-200">Claim secret</div>
<div className="font-mono break-all">{claimSecret}</div>
<div className="font-mono break-all">POST /api/agents/claim-api-key</div>
</div>
) : null}
{onboardingTextUrl ? (
<div className="text-xs text-zinc-400">
Onboarding: <span className="font-mono break-all">{onboardingTextUrl}</span>
</div>
) : null}
</div>
</div>
);
}
function AuthScreenPreview({ mode, error }: { mode: "sign_in" | "sign_up"; error?: string }) {
return (
<div className="overflow-hidden rounded-[28px] border border-border/70 bg-background shadow-[0_24px_60px_rgba(15,23,42,0.08)]">
<div className="grid gap-px bg-border/60 md:grid-cols-2">
<div className="flex min-h-[420px] flex-col justify-center bg-background px-8 py-10">
<div className="mx-auto w-full max-w-md">
<div className="mb-8 flex items-center gap-2">
<FlaskConical className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Paperclip</span>
</div>
<h3 className="text-xl font-semibold">
{mode === "sign_in" ? "Sign in to Paperclip" : "Create your Paperclip account"}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{mode === "sign_in"
? "Use your email and password to access this instance."
: "Create an account for this instance. Email confirmation is not required in v1."}
</p>
<div className="mt-6 space-y-4">
{mode === "sign_up" ? (
<label className="block">
<span className="mb-1 block text-xs text-muted-foreground">Name</span>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm"
defaultValue="Jane Example"
readOnly
/>
</label>
) : null}
<label className="block">
<span className="mb-1 block text-xs text-muted-foreground">Email</span>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm"
defaultValue="jane@example.com"
readOnly
/>
</label>
<label className="block">
<span className="mb-1 block text-xs text-muted-foreground">Password</span>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm"
defaultValue="supersecret"
readOnly
/>
</label>
{error ? <p className="text-xs text-destructive">{error}</p> : null}
<Button type="button" className="w-full">
{mode === "sign_in" ? "Sign In" : "Create Account"}
</Button>
</div>
<div className="mt-5 text-sm text-muted-foreground">
{mode === "sign_in" ? "Need an account?" : "Already have an account?"}{" "}
<span className="font-medium text-foreground underline underline-offset-2">
{mode === "sign_in" ? "Create one" : "Sign in"}
</span>
</div>
</div>
</div>
<div className="hidden min-h-[420px] items-center justify-center bg-[radial-gradient(circle_at_top,rgba(8,145,178,0.18),transparent_48%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,1))] px-8 py-10 md:flex">
<div className="max-w-sm space-y-4 text-zinc-200">
<div className="inline-flex items-center gap-2 rounded-full border border-cyan-400/30 bg-cyan-500/[0.08] px-3 py-1 text-[10px] uppercase tracking-[0.22em] text-cyan-200">
Auth preview
</div>
<div className="text-2xl font-semibold">Side-by-side signup styling review</div>
<p className="text-sm leading-6 text-zinc-400">
This frame mirrors the production auth surface so spacing, label density, button treatments, and desktop composition are easy to compare.
</p>
</div>
</div>
</div>
</div>
);
}
function CompanyInvitesPreview() {
return (
<div className="grid gap-5 xl:grid-cols-[minmax(0,0.92fr)_minmax(0,1.08fr)]">
<Card className="rounded-[28px] shadow-none">
<CardHeader className="space-y-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<MailPlus className="h-4 w-4" />
Company Invites
</div>
<div>
<CardTitle>Create invite</CardTitle>
<CardDescription className="mt-2">
Generate a human invite link and choose the default access it should request.
</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-4">
<fieldset className="space-y-3">
<legend className="text-sm font-medium">Choose a role</legend>
<div className="rounded-2xl border border-border">
{inviteRoleOptions.map((option, index) => (
<label
key={option.value}
className={cn("flex cursor-default gap-3 px-4 py-4", index > 0 && "border-t border-border")}
>
<input
type="radio"
readOnly
checked={option.value === "operator"}
className="mt-1 h-4 w-4 border-border text-foreground"
/>
<span className="min-w-0 space-y-1">
<span className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{option.label}</span>
{option.value === "operator" ? (
<span className="rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground">
Default
</span>
) : null}
</span>
<span className="block max-w-2xl text-sm text-muted-foreground">{option.description}</span>
<span className="block text-sm text-foreground">{option.gets}</span>
</span>
</label>
))}
</div>
</fieldset>
<div className="rounded-xl border border-border px-4 py-3 text-sm text-muted-foreground">
Each invite link is single-use. The first successful use consumes the link and creates or reuses the matching join request before approval.
</div>
<div className="flex flex-wrap items-center gap-3">
<Button type="button">Create invite</Button>
<span className="text-sm text-muted-foreground">Invite history below keeps the audit trail.</span>
</div>
<div className="space-y-3 rounded-2xl border border-border px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">Latest invite link</div>
<div className="text-sm text-muted-foreground">
This URL includes the current Paperclip domain returned by the server.
</div>
</div>
<div className="inline-flex items-center gap-1 text-xs font-medium text-foreground">
<Check className="h-3.5 w-3.5" />
Copied
</div>
</div>
<button
type="button"
className="w-full rounded-md border border-border bg-muted/60 px-3 py-2 text-left text-sm break-all"
>
https://paperclip.local/invite/new-token
</button>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" variant="outline">
<ExternalLink className="h-4 w-4" />
Open invite
</Button>
</div>
</div>
</CardContent>
</Card>
<Card className="rounded-[28px] shadow-none">
<CardHeader className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle>Invite history</CardTitle>
<CardDescription className="mt-2">
Review invite status, role, inviter, and any linked join request.
</CardDescription>
</div>
<a href="/inbox/requests" className="text-sm underline underline-offset-4">
Open join request queue
</a>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="overflow-x-auto rounded-2xl border 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>
{inviteHistory.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">
{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.invitedBy}</div>
<div className="text-xs text-muted-foreground">{invite.email}</div>
</td>
<td className="px-5 py-3 align-top text-muted-foreground">{invite.createdAt}</td>
<td className="px-5 py-3 align-top">
{invite.relatedLabel === "Review request" ? (
<a href="/inbox/requests" className="underline underline-offset-4">
{invite.relatedLabel}
</a>
) : (
<span className="text-muted-foreground">{invite.relatedLabel}</span>
)}
</td>
<td className="px-5 py-3 text-right align-top">
{invite.action === "Revoke" ? (
<Button type="button" size="sm" variant="outline">
Revoke
</Button>
) : (
<span className="text-xs text-muted-foreground">Inactive</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-2xl border border-border p-4">
<div className="text-sm font-medium">Empty history state</div>
<div className="mt-2 text-sm text-muted-foreground">
No invites have been created for this company yet.
</div>
</div>
<div className="rounded-2xl border border-rose-400/40 bg-rose-500/[0.07] p-4">
<div className="text-sm font-medium text-foreground">Permission error</div>
<div className="mt-2 text-sm text-muted-foreground">
You do not have permission to manage company invites.
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
export function InviteUxLab() {
return (
<div className="space-y-6">
<div className="overflow-hidden rounded-[32px] border border-border/70 bg-[linear-gradient(135deg,rgba(8,145,178,0.10),transparent_28%),linear-gradient(180deg,rgba(245,158,11,0.10),transparent_44%),var(--background)] shadow-[0_30px_80px_rgba(15,23,42,0.10)]">
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.2fr)_320px]">
<div className="p-6 sm:p-7">
<div className="inline-flex items-center gap-2 rounded-full border border-cyan-500/25 bg-cyan-500/[0.08] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.24em] text-cyan-700 dark:text-cyan-300">
<FlaskConical className="h-3.5 w-3.5" />
Invite UX Lab
</div>
<h1 className="mt-4 text-3xl font-semibold tracking-tight">Invite and signup UX review surface</h1>
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">
This page collects the current invite landing, signup, approval-result, and company invite-management states in one place so styling changes can be reviewed without recreating each backend condition by hand.
</p>
<div className="mt-5 flex flex-wrap items-center gap-2">
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
/tests/ux/invites
</Badge>
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
signup + invite states
</Badge>
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
fixture-backed preview
</Badge>
</div>
</div>
<aside className="border-t border-border/60 bg-background/70 p-6 lg:border-l lg:border-t-0">
<div className="mb-4 text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground">
Covered states
</div>
<div className="space-y-3">
{[
"Invite loading, access-check, missing-token, and unavailable states",
"Inline account creation and sign-in variants, including feedback/error copy",
"Human accept, agent request, and auto-accept transitions",
"Pending approval, joined-now, claim secret, and onboarding result screens",
"Company invite creation, copied-link, history, empty, and permission-error states",
].map((highlight) => (
<div
key={highlight}
className="rounded-2xl border border-border/70 bg-background/85 px-4 py-3 text-sm text-muted-foreground"
>
{highlight}
</div>
))}
</div>
</aside>
</div>
</div>
<LabSection
eyebrow="Top-level states"
title="Landing state coverage"
description="Small cards for the fast-return invite states that do not render the full split-screen layout."
accentClassName="bg-[linear-gradient(180deg,rgba(59,130,246,0.05),transparent_30%),var(--background)]"
>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<StatusCard
icon={<Loader2 className="h-4 w-4 animate-spin" />}
title="Loading invite"
body="Shown while invite summary, deployment mode, or auth session data is still loading."
/>
<StatusCard
icon={<Clock3 className="h-4 w-4" />}
title="Checking your access"
body="Shown after sign-in while the app verifies whether the current user already belongs to the invited company."
/>
<StatusCard
icon={<KeyRound className="h-4 w-4" />}
title="Invalid invite token"
body="The token is missing entirely, so the page short-circuits before any invite lookup."
tone="error"
/>
<StatusCard
icon={<Link2 className="h-4 w-4" />}
title="Invite not available"
body="Used for expired, revoked, already-consumed, or otherwise missing invites."
tone="warn"
/>
<StatusCard
icon={<ShieldCheck className="h-4 w-4" />}
title="Bootstrap complete"
body="Result screen for bootstrap CEO invites after setup has been accepted successfully."
tone="success"
/>
<StatusCard
icon={<ArrowRight className="h-4 w-4" />}
title="Auto-accept in progress"
body="Signed-in human users skip the extra button click and move straight into join submission."
/>
<StatusCard
icon={<Users className="h-4 w-4" />}
title="Already a member"
body="Acceptance stays disabled and the page redirects into the company once membership is confirmed."
/>
<StatusCard
icon={<UserPlus className="h-4 w-4" />}
title="Invite result surfaces"
body="Both pending-approval and joined-now confirmations are included below with claim and onboarding extras."
tone="success"
/>
</div>
</LabSection>
<LabSection
eyebrow="Invite landing"
title="Split-screen invite flows"
description="These frames mirror the production invite surface closely enough to review spacing, hierarchy, and control states while keeping data fixture-driven."
accentClassName="bg-[linear-gradient(180deg,rgba(234,179,8,0.06),transparent_28%),var(--background)]"
>
<div className="space-y-5">
<InviteLandingShell
left={
<InviteSummaryPanel
title="Join Acme Robotics"
description="Create your Paperclip account first. If you already have one, switch to sign in and continue the invite with the same email."
inviteMessage="Welcome aboard."
requestedAccess="Operator"
/>
}
right={<InlineAuthPreview mode="sign_up" />}
/>
<InviteLandingShell
left={
<InviteSummaryPanel
title="Join Acme Robotics"
description="Create your Paperclip account first. If you already have one, switch to sign in and continue the invite with the same email."
inviteMessage="Welcome aboard."
requestedAccess="Operator"
/>
}
right={
<InlineAuthPreview
mode="sign_in"
feedback={{
tone: "info",
text: "An account already exists for jane@example.com. Sign in below to continue with this invite.",
}}
/>
}
/>
<InviteLandingShell
left={
<InviteSummaryPanel
title="Join Acme Robotics"
description="Your account is ready. Review the invite details, then accept it to continue."
inviteMessage="Welcome aboard."
requestedAccess="Operator"
signedInLabel="Jane Example"
/>
}
right={<AcceptInvitePreview autoAccept />}
/>
<InviteLandingShell
left={
<InviteSummaryPanel
title="Join Acme Robotics"
description="Review the invite details, then submit the agent information below to start the join request."
requestedAccess="Agent join request"
/>
}
right={<AgentRequestPreview />}
/>
<InviteLandingShell
left={
<InviteSummaryPanel
title="Join Acme Robotics"
description="Your account is ready. Review the invite details, then accept it to continue."
requestedAccess="Operator"
signedInLabel="Jane Example"
/>
}
right={<AcceptInvitePreview error="This account already belongs to the company." isCurrentMember />}
/>
</div>
</LabSection>
<LabSection
eyebrow="Result states"
title="Approval and completion screens"
description="These are the post-submit states returned from invite acceptance, including optional claim and onboarding metadata."
accentClassName="bg-[linear-gradient(180deg,rgba(16,185,129,0.06),transparent_30%),var(--background)]"
>
<div className="grid gap-5 xl:grid-cols-3">
<InviteResultPreview
title="Request to join Acme Robotics"
description="Board User must approve your request to join."
claimSecret="pcp_claim_secret_demo"
onboardingTextUrl="/api/invites/pcp_invite_test/onboarding.txt"
/>
<InviteResultPreview
title="You joined the company"
description="Your account already matched the approved invite, so the board can be opened immediately."
joinedNow
/>
<InviteResultPreview
title="Request to join Acme Robotics"
description="Ask them to visit Company Settings → Access to approve your request."
/>
</div>
</LabSection>
<LabSection
eyebrow="Standalone auth"
title="Auth page states"
description="The general `/auth` page uses a different composition from invite landing. These previews keep both sign-in and sign-up variants visible."
accentClassName="bg-[linear-gradient(180deg,rgba(168,85,247,0.06),transparent_28%),var(--background)]"
>
<div className="space-y-5">
<AuthScreenPreview mode="sign_in" error="Invalid email or password" />
<AuthScreenPreview mode="sign_up" />
</div>
</LabSection>
<LabSection
eyebrow="Company settings"
title="Company invite management"
description="This section captures the board-side invite creation flow, copied-link state, audit table, and the edge states that are otherwise tedious to stage."
accentClassName="bg-[linear-gradient(180deg,rgba(244,114,182,0.06),transparent_28%),var(--background)]"
>
<CompanyInvitesPreview />
</LabSection>
</div>
);
}

View file

@ -7,6 +7,7 @@ import { approvalsApi } from "../api/approvals";
import { activityApi, type RunForIssue } from "../api/activity";
import { heartbeatsApi, type ActiveRunForIssue, type LiveRunForIssue } from "../api/heartbeats";
import { instanceSettingsApi } from "../api/instanceSettings";
import { accessApi } from "../api/access";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { projectsApi } from "../api/projects";
@ -17,6 +18,7 @@ import { useSidebar } from "../context/SidebarContext";
import { useToastActions } from "../context/ToastContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees";
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, buildCompanyUserProfileMap, buildMarkdownMentionOptions } from "../lib/company-members";
import { extractIssueTimelineEvents } from "../lib/issue-timeline-events";
import { queryKeys } from "../lib/queryKeys";
import { keepPreviousDataForSameQueryTail } from "../lib/query-placeholder-data";
@ -250,14 +252,17 @@ function mergeOptimisticFeedbackVote(
];
}
function ActorIdentity({ evt, agentMap }: { evt: ActivityEvent; agentMap: Map<string, Agent> }) {
function ActorIdentity({ evt, agentMap, userProfileMap }: { evt: ActivityEvent; agentMap: Map<string, Agent>; userProfileMap?: Map<string, import("../lib/company-members").CompanyUserProfile> }) {
const id = evt.actorId;
if (evt.actorType === "agent") {
const agent = agentMap.get(id);
return <Identity name={agent?.name ?? id.slice(0, 8)} size="sm" />;
}
if (evt.actorType === "system") return <Identity name="System" size="sm" />;
if (evt.actorType === "user") return <Identity name="Board" size="sm" />;
if (evt.actorType === "user") {
const profile = userProfileMap?.get(id);
return <Identity name={profile?.label ?? "Board"} avatarUrl={profile?.image} size="sm" />;
}
return <Identity name={id || "Unknown"} size="sm" />;
}
@ -502,6 +507,8 @@ type IssueDetailChatTabProps = {
feedbackTermsUrl: string | null;
agentMap: Map<string, Agent>;
currentUserId: string | null;
userLabelMap: ReadonlyMap<string, string> | null;
userProfileMap: ReadonlyMap<string, import("../lib/company-members").CompanyUserProfile> | null;
draftKey: string;
reassignOptions: Array<{ id: string; label: string; searchText?: string }>;
currentAssigneeValue: string;
@ -538,6 +545,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
feedbackTermsUrl,
agentMap,
currentUserId,
userLabelMap,
userProfileMap,
draftKey,
reassignOptions,
currentAssigneeValue,
@ -682,6 +691,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
issueStatus={issueStatus}
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
userProfileMap={userProfileMap}
draftKey={draftKey}
enableReassign
reassignOptions={reassignOptions}
@ -713,6 +724,7 @@ type IssueDetailActivityTabProps = {
issueId: string;
agentMap: Map<string, Agent>;
currentUserId: string | null;
userProfileMap: Map<string, import("../lib/company-members").CompanyUserProfile>;
pendingApprovalAction: { approvalId: string; action: "approve" | "reject" } | null;
onApprovalAction: (approvalId: string, action: "approve" | "reject") => void;
};
@ -721,6 +733,7 @@ function IssueDetailActivityTab({
issueId,
agentMap,
currentUserId,
userProfileMap,
pendingApprovalAction,
onApprovalAction,
}: IssueDetailActivityTabProps) {
@ -837,8 +850,8 @@ function IssueDetailActivityTab({
<div className="space-y-1.5">
{activity.slice(0, 20).map((evt) => (
<div key={evt.id} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<ActorIdentity evt={evt} agentMap={agentMap} />
<span>{formatIssueActivityAction(evt.action, evt.details, { agentMap, currentUserId })}</span>
<ActorIdentity evt={evt} agentMap={agentMap} userProfileMap={userProfileMap} />
<span>{formatIssueActivityAction(evt.action, evt.details, { agentMap, userProfileMap, currentUserId })}</span>
<span className="ml-auto shrink-0">{relativeTime(evt.createdAt)}</span>
</div>
))}
@ -976,6 +989,11 @@ export function IssueDetail() {
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: companyMembers } = useQuery({
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
@ -1027,31 +1045,21 @@ export function IssueDetail() {
for (const a of agents ?? []) map.set(a.id, a);
return map;
}, [agents]);
const userProfileMap = useMemo(
() => buildCompanyUserProfileMap(companyMembers?.users),
[companyMembers?.users],
);
const userLabelMap = useMemo(
() => buildCompanyUserLabelMap(companyMembers?.users),
[companyMembers?.users],
);
const mentionOptions = useMemo<MentionOption[]>(() => {
const options: MentionOption[] = [];
const activeAgents = [...(agents ?? [])]
.filter((agent) => agent.status !== "terminated")
.sort((a, b) => a.name.localeCompare(b.name));
for (const agent of activeAgents) {
options.push({
id: `agent:${agent.id}`,
name: agent.name,
kind: "agent",
agentId: agent.id,
agentIcon: agent.icon,
});
}
for (const project of orderedProjects) {
options.push({
id: `project:${project.id}`,
name: project.name,
kind: "project",
projectId: project.id,
projectColor: project.color,
});
}
return options;
}, [agents, orderedProjects]);
return buildMarkdownMentionOptions({
agents,
projects: orderedProjects,
members: companyMembers?.users,
});
}, [agents, companyMembers?.users, orderedProjects]);
const resolvedProject = useMemo(
() => (issue?.projectId ? orderedProjects.find((project) => project.id === issue.projectId) ?? issue.project ?? null : null),
@ -1085,6 +1093,7 @@ export function IssueDetail() {
const commentReassignOptions = useMemo(() => {
const options: Array<{ id: string; label: string; searchText?: string }> = [];
options.push(...buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId] }));
const activeAgents = [...(agents ?? [])]
.filter((agent) => agent.status !== "terminated")
.sort((a, b) => a.name.localeCompare(b.name));
@ -1095,7 +1104,7 @@ export function IssueDetail() {
options.push({ id: `user:${currentUserId}`, label: "Me" });
}
return options;
}, [agents, currentUserId]);
}, [agents, companyMembers?.users, currentUserId]);
const actualAssigneeValue = useMemo(
() => assigneeValueFromSelection(issue ?? {}),
@ -2628,6 +2637,8 @@ export function IssueDetail() {
feedbackTermsUrl={FEEDBACK_TERMS_URL}
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
userProfileMap={userProfileMap}
draftKey={`paperclip:issue-comment-draft:${issue.id}`}
reassignOptions={commentReassignOptions}
currentAssigneeValue={actualAssigneeValue}
@ -2652,6 +2663,7 @@ export function IssueDetail() {
issueId={issue.id}
agentMap={agentMap}
currentUserId={currentUserId}
userProfileMap={userProfileMap}
pendingApprovalAction={pendingApprovalAction}
onApprovalAction={(approvalId, action) => {
approvalDecision.mutate({ approvalId, action });

View file

@ -0,0 +1,194 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { UserPlus2 } from "lucide-react";
import { accessApi } from "@/api/access";
import { ApiError } from "@/api/client";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useCompany } from "@/context/CompanyContext";
import { useToast } from "@/context/ToastContext";
import { queryKeys } from "@/lib/queryKeys";
export function JoinRequestQueue() {
const { selectedCompany, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToast();
const queryClient = useQueryClient();
const [status, setStatus] = useState<"pending_approval" | "approved" | "rejected">("pending_approval");
const [requestType, setRequestType] = useState<"all" | "human" | "agent">("all");
useEffect(() => {
setBreadcrumbs([
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
{ label: "Inbox", href: "/inbox" },
{ label: "Join Requests" },
]);
}, [selectedCompany?.name, setBreadcrumbs]);
const requestsQuery = useQuery({
queryKey: queryKeys.access.joinRequests(selectedCompanyId ?? "", `${status}:${requestType}`),
queryFn: () =>
accessApi.listJoinRequests(
selectedCompanyId!,
status,
requestType === "all" ? undefined : requestType,
),
enabled: !!selectedCompanyId,
});
const approveMutation = useMutation({
mutationFn: (requestId: string) => accessApi.approveJoinRequest(selectedCompanyId!, requestId),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(selectedCompanyId!, `${status}:${requestType}`) });
await queryClient.invalidateQueries({ queryKey: queryKeys.access.companyMembers(selectedCompanyId!) });
await queryClient.invalidateQueries({ queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!) });
pushToast({ title: "Join request approved", tone: "success" });
},
});
const rejectMutation = useMutation({
mutationFn: (requestId: string) => accessApi.rejectJoinRequest(selectedCompanyId!, requestId),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(selectedCompanyId!, `${status}:${requestType}`) });
pushToast({ title: "Join request rejected", tone: "success" });
},
});
if (!selectedCompanyId) {
return <div className="text-sm text-muted-foreground">Select a company to review join requests.</div>;
}
if (requestsQuery.isLoading) {
return <div className="text-sm text-muted-foreground">Loading join requests</div>;
}
if (requestsQuery.error) {
const message =
requestsQuery.error instanceof ApiError && requestsQuery.error.status === 403
? "You do not have permission to review join requests for this company."
: requestsQuery.error instanceof Error
? requestsQuery.error.message
: "Failed to load join requests.";
return <div className="text-sm text-destructive">{message}</div>;
}
return (
<div className="max-w-6xl space-y-6">
<div className="space-y-3">
<div className="flex items-center gap-2">
<UserPlus2 className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Join Request Queue</h1>
</div>
<p className="max-w-3xl text-sm text-muted-foreground">
Review human and agent join requests outside the mixed inbox feed. This queue uses the same approval mutations as the inline inbox cards.
</p>
</div>
<div className="flex flex-wrap gap-3 rounded-xl border border-border bg-card p-4">
<label className="space-y-2 text-sm">
<span className="font-medium">Status</span>
<select
className="rounded-md border border-border bg-background px-3 py-2"
value={status}
onChange={(event) =>
setStatus(event.target.value as "pending_approval" | "approved" | "rejected")
}
>
<option value="pending_approval">Pending approval</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
</select>
</label>
<label className="space-y-2 text-sm">
<span className="font-medium">Request type</span>
<select
className="rounded-md border border-border bg-background px-3 py-2"
value={requestType}
onChange={(event) =>
setRequestType(event.target.value as "all" | "human" | "agent")
}
>
<option value="all">All</option>
<option value="human">Human</option>
<option value="agent">Agent</option>
</select>
</label>
</div>
<div className="space-y-4">
{(requestsQuery.data ?? []).length === 0 ? (
<div className="rounded-xl border border-dashed border-border px-4 py-8 text-sm text-muted-foreground">
No join requests match the current filters.
</div>
) : (
requestsQuery.data!.map((request) => (
<div key={request.id} className="rounded-xl border border-border bg-card p-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={request.status === "pending_approval" ? "secondary" : request.status === "approved" ? "outline" : "destructive"}>
{request.status.replace("_", " ")}
</Badge>
<Badge variant="outline">{request.requestType}</Badge>
{request.adapterType ? <Badge variant="outline">{request.adapterType}</Badge> : null}
</div>
<div>
<div className="text-base font-medium">
{request.requestType === "human"
? request.requesterUser?.name || request.requestEmailSnapshot || request.requestingUserId || "Unknown human requester"
: request.agentName || "Unknown agent requester"}
</div>
<div className="text-sm text-muted-foreground">
{request.requestType === "human"
? request.requesterUser?.email || request.requestEmailSnapshot || request.requestingUserId
: request.capabilities || request.requestIp}
</div>
</div>
</div>
{request.status === "pending_approval" ? (
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => rejectMutation.mutate(request.id)}
disabled={rejectMutation.isPending}
>
Reject
</Button>
<Button
onClick={() => approveMutation.mutate(request.id)}
disabled={approveMutation.isPending}
>
Approve
</Button>
</div>
) : null}
</div>
<div className="mt-4 grid gap-3 text-sm text-muted-foreground md:grid-cols-2">
<div className="rounded-lg border border-border bg-background px-3 py-2">
<div className="text-xs font-medium uppercase tracking-wide">Invite context</div>
<div className="mt-2">
{request.invite
? `${request.invite.allowedJoinTypes} join invite${request.invite.humanRole ? ` • default role ${request.invite.humanRole}` : ""}`
: "Invite metadata unavailable"}
</div>
{request.invite?.inviteMessage ? (
<div className="mt-2 text-foreground">{request.invite.inviteMessage}</div>
) : null}
</div>
<div className="rounded-lg border border-border bg-background px-3 py-2">
<div className="text-xs font-medium uppercase tracking-wide">Request details</div>
<div className="mt-2">Submitted {new Date(request.createdAt).toLocaleString()}</div>
<div>Source IP {request.requestIp}</div>
{request.requestType === "agent" && request.capabilities ? <div>{request.capabilities}</div> : null}
</div>
</div>
</div>
))
)}
</div>
</div>
);
}

View file

@ -0,0 +1,133 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ProfileSettings } from "./ProfileSettings";
const mockAuthApi = vi.hoisted(() => ({
getSession: vi.fn(),
signInEmail: vi.fn(),
signUpEmail: vi.fn(),
getProfile: vi.fn(),
updateProfile: vi.fn(),
signOut: vi.fn(),
}));
const mockAssetsApi = vi.hoisted(() => ({
uploadImage: vi.fn(),
uploadCompanyLogo: vi.fn(),
}));
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
vi.mock("@/api/auth", () => ({
authApi: mockAuthApi,
}));
vi.mock("@/api/assets", () => ({
assetsApi: mockAssetsApi,
}));
vi.mock("../context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({
setBreadcrumbs: mockSetBreadcrumbs,
}),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
selectedCompany: { id: "company-1", name: "Paperclip", issuePrefix: "PAP" },
}),
}));
// 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("ProfileSettings", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockAuthApi.getSession.mockResolvedValue({
session: { id: "session-1", userId: "user-1" },
user: {
id: "user-1",
name: "Jane Example",
email: "jane@example.com",
image: "https://example.com/jane.png",
},
});
mockAssetsApi.uploadImage.mockResolvedValue({
assetId: "asset-1",
contentPath: "/api/assets/asset-1/content",
});
mockAuthApi.updateProfile.mockImplementation(async (input: { name: string; image: string | null }) => ({
id: "user-1",
name: input.name,
email: "jane@example.com",
image: input.image,
}));
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("uploads a clicked avatar into Paperclip storage and persists the returned asset path", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ProfileSettings />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).not.toContain("Avatar image URL");
const avatarInput = container.querySelector('input[type="file"]') as HTMLInputElement | null;
expect(avatarInput).not.toBeNull();
const file = new File(["avatar"], "avatar.png", { type: "image/png" });
Object.defineProperty(avatarInput, "files", {
configurable: true,
value: [file],
});
await act(async () => {
avatarInput?.dispatchEvent(new Event("change", { bubbles: true }));
});
await flushReact();
await flushReact();
expect(mockAssetsApi.uploadImage).toHaveBeenCalledWith("company-1", file, "profiles/user-1");
expect(mockAuthApi.updateProfile).toHaveBeenCalledWith({
name: "Jane Example",
image: "/api/assets/asset-1/content",
});
await act(async () => {
root.unmount();
});
});
});

View file

@ -0,0 +1,273 @@
import { useEffect, useId, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Camera, LoaderCircle, Save, Trash2, UserRoundPen } from "lucide-react";
import type { AuthSession, CurrentUserProfile, UpdateCurrentUserProfile } from "@paperclipai/shared";
import { authApi } from "@/api/auth";
import { assetsApi } from "@/api/assets";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
function deriveInitials(name: string) {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length >= 2) return `${parts[0]?.[0] ?? ""}${parts[parts.length - 1]?.[0] ?? ""}`.toUpperCase();
return name.slice(0, 2).toUpperCase();
}
export function ProfileSettings() {
const { setBreadcrumbs } = useBreadcrumbs();
const { selectedCompanyId, selectedCompany } = useCompany();
const queryClient = useQueryClient();
const avatarInputId = useId();
const avatarInputRef = useRef<HTMLInputElement | null>(null);
const [name, setName] = useState("");
const [image, setImage] = useState("");
const [actionError, setActionError] = useState<string | null>(null);
const sessionQuery = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
retry: false,
});
useEffect(() => {
setBreadcrumbs([
{ label: "Instance Settings" },
{ label: "Profile" },
]);
}, [setBreadcrumbs]);
useEffect(() => {
const session = sessionQuery.data;
if (!session) return;
setName(session.user.name ?? "");
setImage(session.user.image ?? "");
}, [sessionQuery.data]);
function syncSessionProfile(profile: CurrentUserProfile) {
queryClient.setQueryData<AuthSession | null>(queryKeys.auth.session, (current) => {
if (!current) return current;
return {
...current,
user: {
...current.user,
...profile,
},
};
});
}
async function persistProfile(input: UpdateCurrentUserProfile) {
const profile = await authApi.updateProfile(input);
syncSessionProfile(profile);
return profile;
}
function resolveProfileName() {
return name.trim() || sessionQuery.data?.user.name || "Board";
}
const updateMutation = useMutation({
mutationFn: (input: UpdateCurrentUserProfile) => persistProfile(input),
onSuccess: (profile) => {
setActionError(null);
setName(profile.name ?? "");
setImage(profile.image ?? "");
},
onError: (error) => {
setActionError(error instanceof Error ? error.message : "Failed to update profile.");
},
});
const uploadAvatarMutation = useMutation({
mutationFn: async (file: File) => {
if (!selectedCompanyId) {
throw new Error("Select a company before uploading a profile avatar.");
}
const asset = await assetsApi.uploadImage(
selectedCompanyId,
file,
`profiles/${sessionQuery.data?.user.id ?? "board-user"}`,
);
return persistProfile({ name: resolveProfileName(), image: asset.contentPath });
},
onSuccess: (profile) => {
setActionError(null);
setName(profile.name ?? "");
setImage(profile.image ?? "");
},
onError: (error) => {
setActionError(error instanceof Error ? error.message : "Failed to upload avatar.");
},
});
const removeAvatarMutation = useMutation({
mutationFn: () => persistProfile({ name: resolveProfileName(), image: null }),
onSuccess: (profile) => {
setActionError(null);
setName(profile.name ?? "");
setImage(profile.image ?? "");
},
onError: (error) => {
setActionError(error instanceof Error ? error.message : "Failed to remove avatar.");
},
});
if (sessionQuery.isLoading) {
return <div className="text-sm text-muted-foreground">Loading profile...</div>;
}
if (sessionQuery.error || !sessionQuery.data) {
return (
<div className="text-sm text-destructive">
{sessionQuery.error instanceof Error ? sessionQuery.error.message : "Failed to load profile."}
</div>
);
}
const currentName = name.trim() || sessionQuery.data.user.name || "Board";
const currentImage = image.trim() || null;
const initials = deriveInitials(currentName);
const isSavingProfile = updateMutation.isPending || uploadAvatarMutation.isPending || removeAvatarMutation.isPending;
const uploadHint = selectedCompany
? `Stored in Paperclip file storage for ${selectedCompany.name}.`
: "Select a company to upload an avatar into Paperclip storage.";
return (
<div className="max-w-4xl space-y-6">
<div className="space-y-2">
<div className="flex items-center gap-2">
<UserRoundPen className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Profile</h1>
</div>
<p className="text-sm text-muted-foreground">
Control how your account appears in the sidebar and other board surfaces.
</p>
</div>
{actionError ? (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{actionError}
</div>
) : null}
<section className="space-y-8">
<div className="relative overflow-hidden rounded-[28px] border border-border/70 bg-card shadow-sm">
<div className="absolute inset-x-0 top-0 h-32 bg-[linear-gradient(135deg,hsl(var(--primary))_0%,hsl(var(--accent))_58%,color-mix(in_oklab,hsl(var(--background))_76%,white_24%)_100%)]" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.22),transparent_34%),radial-gradient(circle_at_bottom_left,rgba(255,255,255,0.08),transparent_36%)]" />
<div className="relative p-6 pt-10">
<div className="flex flex-wrap items-end gap-5 rounded-[24px] border border-border/70 bg-background/92 p-5 shadow-[0_18px_44px_-28px_rgba(0,0,0,0.45)] backdrop-blur-sm">
<div className="space-y-3">
<label
htmlFor={avatarInputId}
className="group relative block cursor-pointer rounded-full focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background"
>
<input
ref={avatarInputRef}
id={avatarInputId}
type="file"
accept="image/*"
className="sr-only"
disabled={!selectedCompanyId || isSavingProfile}
onChange={(event) => {
const file = event.target.files?.[0];
if (!file) return;
uploadAvatarMutation.mutate(file);
event.target.value = "";
}}
/>
<span className="absolute inset-0 z-10 rounded-full bg-black/0 transition-colors group-hover:bg-black/14 group-focus-within:bg-black/14" />
<span className="absolute bottom-1 right-1 z-20 flex size-9 items-center justify-center rounded-full border border-background bg-primary text-primary-foreground shadow-sm">
{uploadAvatarMutation.isPending ? <LoaderCircle className="size-4 animate-spin" /> : <Camera className="size-4" />}
</span>
<Avatar size="lg" className="data-[size=lg]:size-24 ring-4 ring-background shadow-xl">
{currentImage ? <AvatarImage src={currentImage} alt={currentName} /> : null}
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
</label>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="secondary"
onClick={() => avatarInputRef.current?.click()}
disabled={!selectedCompanyId || isSavingProfile}
>
{uploadAvatarMutation.isPending ? <LoaderCircle className="size-4 animate-spin" /> : <Camera className="size-4" />}
{currentImage ? "Change photo" : "Upload photo"}
</Button>
{currentImage ? (
<Button
type="button"
variant="outline"
onClick={() => removeAvatarMutation.mutate()}
disabled={isSavingProfile}
>
{removeAvatarMutation.isPending ? <LoaderCircle className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
Remove
</Button>
) : null}
</div>
</div>
<div className="min-w-0 flex-1 space-y-2 pb-1">
<div>
<h2 className="truncate text-2xl font-semibold text-foreground">{currentName}</h2>
<p className="truncate text-sm text-muted-foreground">{sessionQuery.data.user.email ?? "No email"}</p>
</div>
<p className="max-w-2xl text-sm leading-6 text-muted-foreground">
Click the avatar to upload a new image. {uploadHint}
</p>
</div>
</div>
</div>
</div>
<form
className="grid gap-6 md:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
updateMutation.mutate({ name: resolveProfileName(), image: image.trim() || null });
}}
>
<div className="space-y-2">
<Label htmlFor="profile-name">Display name</Label>
<Input
id="profile-name"
value={name}
onChange={(event) => setName(event.target.value)}
maxLength={120}
placeholder="Board"
/>
<p className="text-xs text-muted-foreground">
Shown in the sidebar account footer and comment author surfaces.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="profile-email">Email</Label>
<Input
id="profile-email"
value={sessionQuery.data.user.email ?? ""}
readOnly
disabled
/>
<p className="text-xs text-muted-foreground">
Email is managed by your auth session and is read-only here.
</p>
</div>
<div className="md:col-span-2 flex justify-end">
<Button type="submit" disabled={isSavingProfile || !name.trim()}>
{updateMutation.isPending ? <LoaderCircle className="size-4 animate-spin" /> : <Save className="size-4" />}
{updateMutation.isPending ? "Saving..." : "Save profile"}
</Button>
</div>
</form>
</section>
</div>
);
}

View file

@ -363,7 +363,6 @@ export function ProjectDetail() {
const experimentalSettingsQuery = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
retry: false,
});
const {
slots: pluginDetailSlots,

View file

@ -807,20 +807,11 @@ export function Routines() {
bordered={false}
contentClassName="min-h-[160px] text-sm text-muted-foreground"
onSubmit={() => {
if (!createRoutine.isPending && draft.title.trim()) {
if (!createRoutine.isPending && draft.title.trim() && draft.projectId && draft.assigneeAgentId) {
createRoutine.mutate();
}
}}
/>
<div className="mt-3 space-y-3">
<RoutineVariablesHint />
<RoutineVariablesEditor
title={draft.title}
description={draft.description}
value={draft.variables}
onChange={(variables) => setDraft((current) => ({ ...current, variables }))}
/>
</div>
</div>
<div className="border-t border-border/60 px-5 py-3">