mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30:39 +09:00
## 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>
463 lines
16 KiB
TypeScript
463 lines
16 KiB
TypeScript
import { useState, useRef, useEffect, useCallback } from "react";
|
|
import {
|
|
Tooltip,
|
|
TooltipTrigger,
|
|
TooltipContent,
|
|
} from "@/components/ui/tooltip";
|
|
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { HelpCircle, ChevronDown, ChevronRight } from "lucide-react";
|
|
import { cn } from "../lib/utils";
|
|
import { AGENT_ROLE_LABELS } from "@paperclipai/shared";
|
|
|
|
/* ---- Help text for (?) tooltips ---- */
|
|
export const help: Record<string, string> = {
|
|
name: "Display name for this agent.",
|
|
title: "Job title shown in the org chart.",
|
|
role: "Organizational role. Determines position and capabilities.",
|
|
reportsTo: "The agent this one reports to in the org hierarchy.",
|
|
capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.",
|
|
adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw Gateway, spawned process, or generic HTTP webhook.",
|
|
cwd: "Deprecated legacy working directory fallback for local adapters. Existing agents may still carry this value, but new configurations should use project workspaces instead.",
|
|
promptTemplate: "Sent on every heartbeat. Keep this small and dynamic. Use it for current-task framing, not large static instructions. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} and other template variables.",
|
|
model: "Override the default model used by the adapter.",
|
|
thinkingEffort: "Control model reasoning depth. Supported values vary by adapter/model.",
|
|
chrome: "Enable Claude's Chrome integration by passing --chrome.",
|
|
dangerouslySkipPermissions: "Run unattended by auto-approving adapter permission prompts when supported.",
|
|
dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.",
|
|
search: "Enable Codex web search capability during runs.",
|
|
fastMode: "Enable Codex Fast mode. This burns credits/tokens much faster and is currently supported on GPT-5.4 only.",
|
|
workspaceStrategy: "How Paperclip should realize an execution workspace for this agent. Keep project_primary for normal cwd execution, or use git_worktree for issue-scoped isolated checkouts.",
|
|
workspaceBaseRef: "Base git ref used when creating a worktree branch. Leave blank to use the resolved workspace ref or HEAD.",
|
|
workspaceBranchTemplate: "Template for naming derived branches. Supports {{issue.identifier}}, {{issue.title}}, {{agent.name}}, {{project.id}}, {{workspace.repoRef}}, and {{slug}}.",
|
|
worktreeParentDir: "Directory where derived worktrees should be created. Absolute, ~-prefixed, and repo-relative paths are supported.",
|
|
runtimeServicesJson: "Optional workspace runtime service definitions. Use this for shared app servers, workers, or other long-lived companion processes attached to the workspace.",
|
|
maxTurnsPerRun: "Maximum number of agentic turns (tool calls) per heartbeat run.",
|
|
command: "The command to execute (e.g. node, python).",
|
|
localCommand: "Override the path to the CLI command you want the adapter to call (e.g. /usr/local/bin/claude, codex, opencode).",
|
|
args: "Command-line arguments, comma-separated.",
|
|
extraArgs: "Extra CLI arguments for local adapters, comma-separated.",
|
|
envVars: "Environment variables injected into the adapter process. Use plain values or secret references.",
|
|
bootstrapPrompt: "Only sent when Paperclip starts a fresh session. Use this for stable setup guidance that should not be repeated on every heartbeat.",
|
|
payloadTemplateJson: "Optional JSON merged into remote adapter request payloads before Paperclip adds its standard wake and workspace fields.",
|
|
webhookUrl: "The URL that receives POST requests when the agent is invoked.",
|
|
heartbeatInterval: "Run this agent automatically on a timer. Useful for periodic tasks like checking for new work.",
|
|
intervalSec: "Seconds between automatic heartbeat invocations.",
|
|
timeoutSec: "Maximum seconds a run can take before being terminated. 0 means no timeout.",
|
|
graceSec: "Seconds to wait after sending interrupt before force-killing the process.",
|
|
wakeOnDemand: "Allow this agent to be woken by assignments, API calls, UI actions, or automated systems.",
|
|
cooldownSec: "Minimum seconds between consecutive heartbeat runs.",
|
|
maxConcurrentRuns: "Maximum number of heartbeat runs that can execute simultaneously for this agent.",
|
|
budgetMonthlyCents: "Monthly spending limit in cents. 0 means no limit.",
|
|
};
|
|
|
|
import { getAdapterLabels } from "../adapters/adapter-display-registry";
|
|
|
|
export const adapterLabels = getAdapterLabels();
|
|
|
|
export const roleLabels = AGENT_ROLE_LABELS as Record<string, string>;
|
|
|
|
/* ---- Primitive components ---- */
|
|
|
|
export function HintIcon({ text }: { text: string }) {
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button type="button" className="inline-flex text-muted-foreground/50 hover:text-muted-foreground transition-colors">
|
|
<HelpCircle className="h-3 w-3" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" className="max-w-xs">
|
|
{text}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
export function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
|
return (
|
|
<div>
|
|
<div className="flex items-center gap-1.5 mb-1">
|
|
<label className="text-xs text-muted-foreground">{label}</label>
|
|
{hint && <HintIcon text={hint} />}
|
|
</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ToggleField({
|
|
label,
|
|
hint,
|
|
checked,
|
|
onChange,
|
|
toggleTestId,
|
|
}: {
|
|
label: string;
|
|
hint?: string;
|
|
checked: boolean;
|
|
onChange: (v: boolean) => void;
|
|
toggleTestId?: string;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-xs text-muted-foreground">{label}</span>
|
|
{hint && <HintIcon text={hint} />}
|
|
</div>
|
|
<button
|
|
data-slot="toggle"
|
|
data-testid={toggleTestId}
|
|
type="button"
|
|
className={cn(
|
|
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
|
checked ? "bg-green-600" : "bg-muted"
|
|
)}
|
|
onClick={() => onChange(!checked)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
|
checked ? "translate-x-4.5" : "translate-x-0.5"
|
|
)}
|
|
/>
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ToggleWithNumber({
|
|
label,
|
|
hint,
|
|
checked,
|
|
onCheckedChange,
|
|
number,
|
|
onNumberChange,
|
|
numberLabel,
|
|
numberHint,
|
|
numberPrefix,
|
|
showNumber,
|
|
}: {
|
|
label: string;
|
|
hint?: string;
|
|
checked: boolean;
|
|
onCheckedChange: (v: boolean) => void;
|
|
number: number;
|
|
onNumberChange: (v: number) => void;
|
|
numberLabel: string;
|
|
numberHint?: string;
|
|
numberPrefix?: string;
|
|
showNumber: boolean;
|
|
}) {
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-xs text-muted-foreground">{label}</span>
|
|
{hint && <HintIcon text={hint} />}
|
|
</div>
|
|
<ToggleSwitch
|
|
checked={checked}
|
|
onCheckedChange={onCheckedChange}
|
|
/>
|
|
</div>
|
|
{showNumber && (
|
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
{numberPrefix && <span>{numberPrefix}</span>}
|
|
<input
|
|
type="number"
|
|
className="w-16 rounded-md border border-border px-2 py-0.5 bg-transparent outline-none text-xs font-mono text-center"
|
|
value={number}
|
|
onChange={(e) => onNumberChange(Number(e.target.value))}
|
|
/>
|
|
<span>{numberLabel}</span>
|
|
{numberHint && <HintIcon text={numberHint} />}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function CollapsibleSection({
|
|
title,
|
|
icon,
|
|
open,
|
|
onToggle,
|
|
bordered,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
icon?: React.ReactNode;
|
|
open: boolean;
|
|
onToggle: () => void;
|
|
bordered?: boolean;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className={cn(bordered && "border-t border-border")}>
|
|
<button
|
|
className="flex items-center gap-2 w-full px-4 py-2 text-xs font-medium text-muted-foreground hover:bg-accent/30 transition-colors"
|
|
onClick={onToggle}
|
|
>
|
|
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
|
{icon}
|
|
{title}
|
|
</button>
|
|
{open && <div className="px-4 pb-3">{children}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function AutoExpandTextarea({
|
|
value,
|
|
onChange,
|
|
onBlur,
|
|
placeholder,
|
|
minRows,
|
|
}: {
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
onBlur?: () => void;
|
|
placeholder?: string;
|
|
minRows?: number;
|
|
}) {
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const rows = minRows ?? 3;
|
|
const lineHeight = 20;
|
|
const minHeight = rows * lineHeight;
|
|
|
|
const adjustHeight = useCallback(() => {
|
|
const el = textareaRef.current;
|
|
if (!el) return;
|
|
el.style.height = "auto";
|
|
el.style.height = `${Math.max(minHeight, el.scrollHeight)}px`;
|
|
}, [minHeight]);
|
|
|
|
useEffect(() => { adjustHeight(); }, [value, adjustHeight]);
|
|
|
|
return (
|
|
<textarea
|
|
ref={textareaRef}
|
|
className="w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40 resize-none overflow-hidden"
|
|
placeholder={placeholder}
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
onBlur={onBlur}
|
|
style={{ minHeight }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Text input that manages internal draft state.
|
|
* Calls `onCommit` on blur (and optionally on every change if `immediate` is set).
|
|
*/
|
|
export function DraftInput({
|
|
value,
|
|
onCommit,
|
|
immediate,
|
|
className,
|
|
...props
|
|
}: {
|
|
value: string;
|
|
onCommit: (v: string) => void;
|
|
immediate?: boolean;
|
|
className?: string;
|
|
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "onChange" | "className">) {
|
|
const [draft, setDraft] = useState(value);
|
|
useEffect(() => setDraft(value), [value]);
|
|
|
|
return (
|
|
<input
|
|
className={className}
|
|
value={draft}
|
|
onChange={(e) => {
|
|
setDraft(e.target.value);
|
|
if (immediate) onCommit(e.target.value);
|
|
}}
|
|
onBlur={() => {
|
|
if (draft !== value) onCommit(draft);
|
|
}}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Auto-expanding textarea with draft state and blur-commit.
|
|
*/
|
|
export function DraftTextarea({
|
|
value,
|
|
onCommit,
|
|
immediate,
|
|
placeholder,
|
|
minRows,
|
|
}: {
|
|
value: string;
|
|
onCommit: (v: string) => void;
|
|
immediate?: boolean;
|
|
placeholder?: string;
|
|
minRows?: number;
|
|
}) {
|
|
const [draft, setDraft] = useState(value);
|
|
useEffect(() => setDraft(value), [value]);
|
|
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const rows = minRows ?? 3;
|
|
const lineHeight = 20;
|
|
const minHeight = rows * lineHeight;
|
|
|
|
const adjustHeight = useCallback(() => {
|
|
const el = textareaRef.current;
|
|
if (!el) return;
|
|
el.style.height = "auto";
|
|
el.style.height = `${Math.max(minHeight, el.scrollHeight)}px`;
|
|
}, [minHeight]);
|
|
|
|
useEffect(() => { adjustHeight(); }, [draft, adjustHeight]);
|
|
|
|
return (
|
|
<textarea
|
|
ref={textareaRef}
|
|
className="w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40 resize-none overflow-hidden"
|
|
placeholder={placeholder}
|
|
value={draft}
|
|
onChange={(e) => {
|
|
setDraft(e.target.value);
|
|
if (immediate) onCommit(e.target.value);
|
|
}}
|
|
onBlur={() => {
|
|
if (draft !== value) onCommit(draft);
|
|
}}
|
|
style={{ minHeight }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Number input with draft state and blur-commit.
|
|
*/
|
|
export function DraftNumberInput({
|
|
value,
|
|
onCommit,
|
|
immediate,
|
|
className,
|
|
...props
|
|
}: {
|
|
value: number;
|
|
onCommit: (v: number) => void;
|
|
immediate?: boolean;
|
|
className?: string;
|
|
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "onChange" | "className" | "type">) {
|
|
const [draft, setDraft] = useState(String(value));
|
|
useEffect(() => setDraft(String(value)), [value]);
|
|
|
|
return (
|
|
<input
|
|
type="number"
|
|
className={className}
|
|
value={draft}
|
|
onChange={(e) => {
|
|
setDraft(e.target.value);
|
|
if (immediate) onCommit(Number(e.target.value) || 0);
|
|
}}
|
|
onBlur={() => {
|
|
const num = Number(draft) || 0;
|
|
if (num !== value) onCommit(num);
|
|
}}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* "Choose" button that opens a dialog explaining the user must manually
|
|
* type the path due to browser security limitations.
|
|
*/
|
|
export function ChoosePathButton() {
|
|
const [open, setOpen] = useState(false);
|
|
return (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="inline-flex items-center rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent/50 transition-colors shrink-0"
|
|
onClick={() => setOpen(true)}
|
|
>
|
|
Choose
|
|
</button>
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Specify path manually</DialogTitle>
|
|
<DialogDescription>
|
|
Browser security blocks apps from reading full local paths via a file picker.
|
|
Copy the absolute path and paste it into the input.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4 text-sm">
|
|
<section className="space-y-1.5">
|
|
<p className="font-medium">macOS (Finder)</p>
|
|
<ol className="list-decimal space-y-1 pl-5 text-muted-foreground">
|
|
<li>Find the folder in Finder.</li>
|
|
<li>Hold <kbd>Option</kbd> and right-click the folder.</li>
|
|
<li>Click "Copy <folder name> as Pathname".</li>
|
|
<li>Paste the result into the path input.</li>
|
|
</ol>
|
|
<p className="rounded-md bg-muted px-2 py-1 font-mono text-xs">
|
|
/Users/yourname/Documents/project
|
|
</p>
|
|
</section>
|
|
<section className="space-y-1.5">
|
|
<p className="font-medium">Windows (File Explorer)</p>
|
|
<ol className="list-decimal space-y-1 pl-5 text-muted-foreground">
|
|
<li>Find the folder in File Explorer.</li>
|
|
<li>Hold <kbd>Shift</kbd> and right-click the folder.</li>
|
|
<li>Click "Copy as path".</li>
|
|
<li>Paste the result into the path input.</li>
|
|
</ol>
|
|
<p className="rounded-md bg-muted px-2 py-1 font-mono text-xs">
|
|
C:\Users\yourname\Documents\project
|
|
</p>
|
|
</section>
|
|
<section className="space-y-1.5">
|
|
<p className="font-medium">Terminal fallback (macOS/Linux)</p>
|
|
<ol className="list-decimal space-y-1 pl-5 text-muted-foreground">
|
|
<li>Run <code>cd /path/to/folder</code>.</li>
|
|
<li>Run <code>pwd</code>.</li>
|
|
<li>Copy the output and paste it into the path input.</li>
|
|
</ol>
|
|
</section>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
|
OK
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Label + input rendered on the same line (inline layout for compact fields).
|
|
*/
|
|
export function InlineField({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
<label className="text-xs text-muted-foreground">{label}</label>
|
|
{hint && <HintIcon text={hint} />}
|
|
</div>
|
|
<div className="w-24 ml-auto">{children}</div>
|
|
</div>
|
|
);
|
|
}
|