forked from farhoodlabs/paperclip
ece8a51e22
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - This branch accumulated multiple already-tested control-plane, adapter runtime, invite, workspace, plugin, and UI quality fixes on the primary Paperclip checkout. > - `origin/master` advanced while those commits were still local, so the branch needed to be preserved and reconciled before review. > - Splitting the branch commit-by-commit against the new base produced overlapping conflicts with recently merged upstream PRs. > - This pull request keeps the remaining branch as one standalone PR because the final diff is 38 files after removing screenshot artifacts, under Greptile's 100-file cap, and can be merged independently after review. > - The benefit is that none of the local work is lost, the branch is now based on current `origin/master`, and reviewers can evaluate the reconciled changes in one place. ## What Changed - Merged the local accumulated branch with current `origin/master` and resolved the invite-flow overlaps from the newer upstream companies query helper. - Preserved the local fixes for invite existing-member behavior, invite link copy fallback, reusable workspace selection, worktree auth, static SPA fallback, markdown wrapping, plugin slot registration, cloud upstream UX/server polish, project sorting, and related tests. - Removed screenshot artifacts from the PR per review request. - Kept the PR under the requested file limit: 38 files changed, with no `pnpm-lock.yaml` or `.github/workflows/*` changes. ## Verification - `NODE_ENV=test pnpm exec vitest run ui/src/pages/CompanyInvites.test.tsx ui/src/pages/InviteLanding.test.tsx ui/src/pages/Projects.test.tsx ui/src/plugins/slots.test.ts ui/src/components/MarkdownBody.test.tsx server/src/__tests__/invite-accept-existing-member.test.ts server/src/__tests__/static-index-html.test.ts server/src/__tests__/execution-workspaces-service.test.ts server/src/__tests__/better-auth.test.ts server/src/__tests__/worktree-config.test.ts` - `NODE_ENV=test pnpm --filter @paperclipai/ui typecheck` - `NODE_ENV=test pnpm --filter @paperclipai/server typecheck` - Confirmed `git diff --name-only origin/master...HEAD | wc -l` is `38`. - Confirmed no PR diff entries match `pnpm-lock.yaml`, `.github/workflows/*`, or `screenshots/*`. ## Risks - Medium review risk because this is a bundled rescue PR rather than several narrow feature PRs. - Invite flow and company cache behavior overlapped with newer upstream changes; the merge resolution intentionally keeps the shared `companiesListQueryOptions` helper while preserving local existing-member invite behavior. - Visual review evidence is no longer attached in-repo because screenshots were removed from this PR per review request. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5-based coding agent, with repository tool access, terminal execution, and git/GitHub CLI operations. ## 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 checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] UI screenshots were intentionally removed from this PR per review request - [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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: CodexCoder <codexcoder@paperclip.local>
928 lines
36 KiB
TypeScript
928 lines
36 KiB
TypeScript
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.",
|
|
gets: "View-only company membership.",
|
|
},
|
|
{
|
|
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 management.",
|
|
gets: "Everything in Admin, plus managing members.",
|
|
},
|
|
] 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'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
|
|
? "Granting your access to Acme Robotics."
|
|
: isCurrentMember
|
|
? "This account already belongs to Acme Robotics."
|
|
: "This will grant or complete your access to 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/members">
|
|
Company Settings → Members
|
|
</a>
|
|
</div>
|
|
<p className="text-xs text-zinc-500">
|
|
Refresh this page after you've been approved — you'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. Human invitees get the selected role immediately after sign-in; agent invites still create a join request for 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 → Members 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>
|
|
);
|
|
}
|