paperclip/ui/src/pages/InviteLanding.tsx
Dotta b9a80dcf22
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>
2026-04-17 09:44:19 -05:00

827 lines
32 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
import type { AgentAdapterType, JoinRequest } from "@paperclipai/shared";
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";
type AuthMode = "sign_in" | "sign_up";
type AuthFeedback = { tone: "error" | "info"; message: string };
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;
for (const segment of path) {
if (!current || typeof current !== "object") return null;
current = (current as Record<string, unknown>)[segment];
}
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 [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,
queryFn: () => healthApi.get(),
retry: false,
});
const sessionQuery = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
retry: false,
});
const inviteQuery = useQuery({
queryKey: queryKeys.access.invite(token),
queryFn: () => accessApi.getInvite(token),
enabled: token.length > 0,
retry: false,
});
const companiesQuery = useQuery({
queryKey: queryKeys.companies.all,
queryFn: () => companiesApi.list(),
enabled: !!sessionQuery.data && !!inviteQuery.data?.companyId,
retry: false,
});
useEffect(() => {
if (token) rememberPendingInviteToken(token);
}, [token]);
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 &&
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 (isCheckingExistingMembership) {
throw new Error("Checking your company access. Try again in a moment.");
}
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, {
requestType: "agent",
agentName: agentName.trim(),
adapterType,
capabilities: capabilities.trim() || null,
});
},
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 });
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>;
}
if (inviteQuery.isLoading || healthQuery.isLoading || sessionQuery.isLoading) {
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="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.
</p>
</div>
</div>
);
}
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="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">
{inviteJoinRequestStatus === "rejected"
? "This join request was not approved."
: "This invite has already been used."}
</p>
</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>
);
}
if (result?.kind === "join") {
const payload = result.payload as JoinRequest & {
claimSecret?: string;
claimApiKeyPath?: string;
onboarding?: Record<string, unknown>;
};
const claimSecret = typeof payload.claimSecret === "string" ? payload.claimSecret : null;
const claimApiKeyPath = typeof payload.claimApiKeyPath === "string" ? payload.claimApiKeyPath : null;
const onboardingTextUrl = readNestedString(payload.onboarding, ["textInstructions", "url"]);
const joinedNow = !showsAgentForm && payload.status === "approved";
return (
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>
</div>
) : (
<AwaitingJoinApprovalPanel
companyDisplayName={companyDisplayName}
companyLogoUrl={companyLogoUrl}
companyBrandColor={companyBrandColor}
invitedByUserName={invitedByUserName}
claimSecret={claimSecret}
claimApiKeyPath={claimApiKeyPath}
onboardingTextUrl={onboardingTextUrl}
/>
)
);
}
return (
<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"
/>
<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 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>
{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>
);
}