feat: add private project access

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-01 22:54:01 +00:00
1 parent df05b1a968
commit c761282d76
29 files changed
+1197 -29

No files matched your search

+12
View File
@@ -1,5 +1,6 @@
import type {
Project,
ProjectAccessMember,
ProjectWorkspace,
WorkspaceOperation,
WorkspaceRuntimeControlTarget,
@@ -29,6 +30,17 @@ export const projectsApi = {
api.post<Project>(`/companies/${companyId}/projects`, data),
update: (id: string, data: Record<string, unknown>, companyId?: string) =>
api.patch<Project>(projectPath(id, companyId), data),
listAccessMembers: (id: string, companyId?: string) =>
api.get<ProjectAccessMember[]>(projectPath(id, companyId, "/access-members")),
addAccessMember: (
id: string,
data: { subjectType: "user" | "agent"; subjectId: string },
companyId?: string,
) => api.post<ProjectAccessMember>(projectPath(id, companyId, "/access-members"), data),
removeAccessMember: (id: string, memberId: string, companyId?: string) =>
api.delete<ProjectAccessMember>(
projectPath(id, companyId, `/access-members/${encodeURIComponent(memberId)}`),
),
listWorkspaces: (projectId: string, companyId?: string) =>
api.get<ProjectWorkspace[]>(projectPath(projectId, companyId, "/workspaces")),
createWorkspace: (projectId: string, data: Record<string, unknown>, companyId?: string) =>
+44 -2
View File
@@ -230,8 +230,12 @@ vi.mock("@/components/ui/button", () => ({
}));
vi.mock("@/components/ui/toggle-switch", () => ({
ToggleSwitch: ({ checked, onCheckedChange }: { checked: boolean; onCheckedChange: () => void }) => (
<button type="button" aria-pressed={checked} onClick={onCheckedChange}>toggle</button>
ToggleSwitch: ({ checked, onCheckedChange, ...props }: {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
"aria-label"?: string;
}) => (
<button type="button" aria-pressed={checked} onClick={() => onCheckedChange(!checked)} {...props}>toggle</button>
),
}));
@@ -476,6 +480,44 @@ describe("NewIssueDialog", () => {
act(() => root.unmount());
});
it("defaults a private task to the current user's personal private project", async () => {
mockProjectsApi.list.mockResolvedValue([
{
id: "personal-project",
companyId: "company-1",
name: "My private tasks",
visibility: "private",
personalOwnerUserId: "user-1",
archivedAt: null,
},
]);
dialogState.newIssueDefaults = { title: "Private scratch task" };
const { root } = renderDialog(container);
await flush();
await flush();
const privateToggle = container.querySelector<HTMLButtonElement>('button[aria-label="Private task"]');
expect(privateToggle).not.toBeNull();
await act(async () => privateToggle!.click());
await flush();
const submitButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Create Task"));
await waitForAssertion(() => expect(submitButton?.hasAttribute("disabled")).toBe(false));
await act(async () => submitButton!.click());
await flush();
expect(mockIssuesApi.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
title: "Private scratch task",
visibility: "private",
projectId: "personal-project",
}),
);
act(() => root.unmount());
});
it("does not show user-secret warnings when the draft will not run an env binding that needs them", async () => {
const { root } = renderDialog(container);
await flush();
+14 -1
View File
@@ -1226,6 +1226,7 @@ export function NewIssueDialog() {
if (nextProjectId) trackRecentProject(nextProjectId);
setProjectId(nextProjectId);
const nextProject = orderedProjects.find((project) => project.id === nextProjectId);
if (nextProject?.visibility === "private") setIsPrivate(true);
executionWorkspaceDefaultProjectId.current = nextProjectId || null;
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(nextProject));
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(nextProject));
@@ -1957,7 +1958,19 @@ export function NewIssueDialog() {
</div>
<ToggleSwitch
checked={isPrivate}
onCheckedChange={setIsPrivate}
onCheckedChange={(checked) => {
setIsPrivate(checked);
if (!checked) {
const selectedProject = orderedProjects.find((project) => project.id === projectId);
if (selectedProject?.visibility === "private") handleProjectChange("");
return;
}
if (projectId || !currentUserId) return;
const personalProject = orderedProjects.find(
(project) => project.personalOwnerUserId === currentUserId,
);
if (personalProject) handleProjectChange(personalProject.id);
}}
aria-label="Private task"
/>
</div>
+144
View File
@@ -0,0 +1,144 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Lock, Trash2, UserPlus } from "lucide-react";
import type { Project } from "@paperclipai/shared";
import { accessApi } from "@/api/access";
import { agentsApi } from "@/api/agents";
import { projectsApi } from "@/api/projects";
import { useToastActions } from "@/context/ToastContext";
import { queryKeys } from "@/lib/queryKeys";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { SearchableSelect, type SearchableSelectGroup } from "@/components/SearchableSelect";
export function ProjectAccessMembers({ project }: { project: Project }) {
const [open, setOpen] = useState(false);
const [selection, setSelection] = useState("");
const queryClient = useQueryClient();
const { pushToast } = useToastActions();
const membersKey = queryKeys.projects.accessMembers(project.id);
const membersQuery = useQuery({
queryKey: membersKey,
queryFn: () => projectsApi.listAccessMembers(project.id, project.companyId),
enabled: open,
});
const directoryQuery = useQuery({
queryKey: queryKeys.access.companyUserDirectory(project.companyId),
queryFn: () => accessApi.listUserDirectory(project.companyId),
enabled: open,
});
const agentsQuery = useQuery({
queryKey: queryKeys.agents.list(project.companyId),
queryFn: () => agentsApi.list(project.companyId),
enabled: open,
});
const activeKeys = useMemo(
() => new Set((membersQuery.data ?? []).map((member) => `${member.subjectType}:${member.subjectId}`)),
[membersQuery.data],
);
const groups = useMemo<SearchableSelectGroup[]>(() => {
const people = (directoryQuery.data?.users ?? [])
.filter((entry) => entry.user && !activeKeys.has(`user:${entry.user.id}`))
.map((entry) => ({
key: `user:${entry.user!.id}`,
value: `user:${entry.user!.id}`,
label: entry.user!.name ?? entry.user!.email ?? "Unknown user",
searchText: entry.user!.email ?? "",
}));
const agentOptions = (agentsQuery.data ?? [])
.filter((agent) => !activeKeys.has(`agent:${agent.id}`))
.map((agent) => ({ key: `agent:${agent.id}`, value: `agent:${agent.id}`, label: agent.name }));
return [
...(people.length ? [{ id: "people", label: "People", options: people }] : []),
...(agentOptions.length ? [{ id: "agents", label: "Agents", options: agentOptions }] : []),
];
}, [activeKeys, agentsQuery.data, directoryQuery.data]);
const addMember = useMutation({
mutationFn: async () => {
const [subjectType, subjectId] = selection.split(/:(.+)/) as ["user" | "agent", string];
return projectsApi.addAccessMember(project.id, { subjectType, subjectId }, project.companyId);
},
onSuccess: () => {
setSelection("");
queryClient.invalidateQueries({ queryKey: membersKey });
pushToast({ title: "Project access added", tone: "success" });
},
onError: (error) => pushToast({ title: "Couldn't add project access", body: (error as Error).message, tone: "error" }),
});
const removeMember = useMutation({
mutationFn: (memberId: string) => projectsApi.removeAccessMember(project.id, memberId, project.companyId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: membersKey });
pushToast({ title: "Project access removed", tone: "success" });
},
onError: (error) => pushToast({ title: "Couldn't remove project access", body: (error as Error).message, tone: "error" }),
});
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<Lock className="h-3.5 w-3.5" aria-hidden="true" /> Manage access
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Private project access</DialogTitle>
<DialogDescription>
Members can discover this project and read every task in it. A task can still be shared directly.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{directoryQuery.isError || agentsQuery.isError ? (
<p className="text-sm text-destructive">Couldn&apos;t load the company access directory.</p>
) : null}
<div className="flex items-end gap-2">
<SearchableSelect
value={selection}
groups={groups}
onValueChange={(value) => setSelection(value)}
placeholder="Add a person or agent"
searchPlaceholder="Search people and agents"
emptyMessage="No more company members to add"
loading={directoryQuery.isLoading || agentsQuery.isLoading}
className="min-w-0 flex-1"
/>
<Button size="sm" disabled={!selection || addMember.isPending} onClick={() => addMember.mutate()}>
<UserPlus className="h-3.5 w-3.5" aria-hidden="true" /> Add
</Button>
</div>
<div className="divide-y divide-border/60">
{(membersQuery.data ?? []).map((member) => {
const isPersonalOwner = project.personalOwnerUserId === member.subjectId && member.subjectType === "user";
return (
<div key={member.id} className="flex items-center gap-3 py-2">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{member.subjectDisplayName ?? "Unknown member"}</div>
<div className="text-xs text-muted-foreground">
{isPersonalOwner ? "Owner" : member.subjectType === "agent" ? "Agent" : "Person"}
</div>
</div>
{!isPersonalOwner ? (
<Button
variant="ghost"
size="icon-sm"
aria-label={`Remove ${member.subjectDisplayName ?? "member"}`}
disabled={removeMember.isPending}
onClick={() => removeMember.mutate(member.id)}
>
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
) : null}
</div>
);
})}
{membersQuery.isLoading ? <p className="py-3 text-sm text-muted-foreground">Loading access…</p> : null}
{membersQuery.isError ? (
<p className="py-3 text-sm text-destructive">Couldn&apos;t load project access members.</p>
) : null}
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}
+21
View File
@@ -23,6 +23,7 @@ import { DraftInput } from "./agent-config-primitives";
import { InlineEditor } from "./InlineEditor";
import { EnvironmentVariablesEditor } from "./environment-variables-editor";
import { Badge } from "@/components/ui/badge";
import { ProjectAccessMembers } from "./ProjectAccessMembers";
const PROJECT_STATUSES = [
{ value: "backlog", label: "Backlog" },
@@ -46,6 +47,7 @@ export type ProjectConfigFieldKey =
| "name"
| "description"
| "status"
| "visibility"
| "goals"
| "env"
| "execution_workspace_enabled"
@@ -559,6 +561,25 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
<StatusBadge status={project.status} />
)}
</PropertyRow>
<PropertyRow label={<FieldLabel label="Visibility" state={fieldState("visibility")} />} alignStart>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm">
<ToggleSwitch
checked={project.visibility === "private"}
onCheckedChange={(checked) => {
if (!checked && !window.confirm("Make this project open to everyone in the company?")) return;
commitField("visibility", { visibility: checked ? "private" : "open" });
}}
disabled={!onUpdate && !onFieldUpdate}
/>
<span>{project.visibility === "private" ? "Private" : "Open to company"}</span>
</label>
<p className="text-(length:--text-micro) text-muted-foreground">
Only access members can discover this project. Tasks shared directly remain readable on their own.
</p>
{project.visibility === "private" ? <ProjectAccessMembers project={project} /> : null}
</div>
</PropertyRow>
{project.leadAgentId && (
<PropertyRow label="Lead">
<span className="text-sm font-mono">{project.leadAgentId.slice(0, 8)}</span>
+1
View File
@@ -285,6 +285,7 @@ export const queryKeys = {
list: (companyId: string, opts: { includeArchived?: boolean } = {}) =>
["projects", companyId, { includeArchived: opts.includeArchived === true }] as const,
detail: (id: string) => ["projects", "detail", id] as const,
accessMembers: (id: string) => ["projects", "access-members", id] as const,
},
cases: {
list: (companyId: string) => ["cases", companyId] as const,