mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
feat: add private project access
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
df05b1a968
commit
c761282d76
29 files changed
+1197
-29
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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't load project access members.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user