mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 19:20:39 +09:00
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:
parent
e93e418cbf
commit
b9a80dcf22
150 changed files with 26872 additions and 1289 deletions
927
ui/src/pages/InviteUxLab.tsx
Normal file
927
ui/src/pages/InviteUxLab.tsx
Normal 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'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'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. 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue