mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 03:10:38 +09:00
[codex] Add access cleanup and user profile page (#4088)
## Thinking Path > - Paperclip is moving from a solo local operator model toward teams supervising AI-agent companies. > - Human access management and human-visible profile surfaces are part of that multiple-user path. > - The branch included related access cleanup, archived-member removal, permission protection, and a user profile page. > - These changes share company membership, user attribution, and access-service behavior. > - This pull request groups those human access/profile changes into one standalone branch. > - The benefit is safer member removal behavior and a first profile surface for user work, activity, and cost attribution. ## What Changed - Added archived company member removal support across shared contracts, server routes/services, and UI. - Protected company member removal with stricter permission checks and tests. - Added company user profile API, shared types, route wiring, client API, route, and UI page. - Simplified the user profile page visual design to a neutral typography-led layout. ## Verification - `pnpm install --frozen-lockfile` - `pnpm exec vitest run server/src/__tests__/access-service.test.ts server/src/__tests__/user-profile-routes.test.ts ui/src/pages/CompanyAccess.test.tsx --hookTimeout=30000` - `pnpm exec vitest run server/src/__tests__/user-profile-routes.test.ts --testTimeout=30000 --hookTimeout=30000` after an initial local embedded-Postgres hook timeout in the combined run. - Split integration check: merged after runtime/governance and dev-infra/backups with no merge conflicts. - Confirmed this branch does not include `pnpm-lock.yaml`. ## Risks - Medium risk: changes member removal permissions and adds a new user profile route with cross-table stats. - The profile page is a new UI surface and may need visual follow-up in browser QA. - No database migrations are included. > 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.4 tool-enabled coding model, agentic code-editing/runtime with local shell and GitHub CLI access; exact context window and reasoning mode are not exposed by the Paperclip harness. ## 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] 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
e89d3f7e11
commit
d8b63a18e7
23 changed files with 2156 additions and 51 deletions
|
|
@ -1,9 +1,16 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS, PERMISSION_KEYS, type PermissionKey } from "@paperclipai/shared";
|
||||
import { ShieldCheck, Users } from "lucide-react";
|
||||
import {
|
||||
HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS,
|
||||
PERMISSION_KEYS,
|
||||
type Agent,
|
||||
type PermissionKey,
|
||||
} from "@paperclipai/shared";
|
||||
import { ShieldCheck, Trash2, Users } from "lucide-react";
|
||||
import { accessApi, type CompanyMember } from "@/api/access";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
|
|
@ -41,6 +48,9 @@ const implicitRoleGrantMap: Record<NonNullable<CompanyMember["membershipRole"]>,
|
|||
viewer: [],
|
||||
};
|
||||
|
||||
const reassignmentIssueStatuses = "backlog,todo,in_progress,in_review,blocked,failed,timed_out";
|
||||
type EditableMemberStatus = "pending" | "active" | "suspended";
|
||||
|
||||
function getImplicitGrantKeys(role: CompanyMember["membershipRole"]) {
|
||||
return role ? implicitRoleGrantMap[role] : [];
|
||||
}
|
||||
|
|
@ -51,8 +61,10 @@ export function CompanyAccess() {
|
|||
const { pushToast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [editingMemberId, setEditingMemberId] = useState<string | null>(null);
|
||||
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null);
|
||||
const [reassignmentTarget, setReassignmentTarget] = useState<string>("__unassigned");
|
||||
const [draftRole, setDraftRole] = useState<CompanyMember["membershipRole"]>(null);
|
||||
const [draftStatus, setDraftStatus] = useState<CompanyMember["status"]>("active");
|
||||
const [draftStatus, setDraftStatus] = useState<EditableMemberStatus>("active");
|
||||
const [draftGrants, setDraftGrants] = useState<Set<PermissionKey>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -69,6 +81,12 @@ export function CompanyAccess() {
|
|||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId ?? ""),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const joinRequestsQuery = useQuery({
|
||||
queryKey: queryKeys.access.joinRequests(selectedCompanyId ?? "", "pending_approval"),
|
||||
queryFn: () => accessApi.listJoinRequests(selectedCompanyId!, "pending_approval"),
|
||||
|
|
@ -83,7 +101,7 @@ export function CompanyAccess() {
|
|||
};
|
||||
|
||||
const updateMemberMutation = useMutation({
|
||||
mutationFn: async (input: { memberId: string; membershipRole: CompanyMember["membershipRole"]; status: CompanyMember["status"]; grants: PermissionKey[] }) => {
|
||||
mutationFn: async (input: { memberId: string; membershipRole: CompanyMember["membershipRole"]; status: EditableMemberStatus; grants: PermissionKey[] }) => {
|
||||
return accessApi.updateMemberAccess(selectedCompanyId!, input.memberId, {
|
||||
membershipRole: input.membershipRole,
|
||||
status: input.status,
|
||||
|
|
@ -147,14 +165,70 @@ export function CompanyAccess() {
|
|||
() => membersQuery.data?.members.find((member) => member.id === editingMemberId) ?? null,
|
||||
[editingMemberId, membersQuery.data?.members],
|
||||
);
|
||||
const removingMember = useMemo(
|
||||
() => membersQuery.data?.members.find((member) => member.id === removingMemberId) ?? null,
|
||||
[removingMemberId, membersQuery.data?.members],
|
||||
);
|
||||
|
||||
const assignedIssuesQuery = useQuery({
|
||||
queryKey: ["access", "member-assigned-issues", selectedCompanyId ?? "", removingMember?.principalId ?? ""],
|
||||
queryFn: () =>
|
||||
issuesApi.list(selectedCompanyId!, {
|
||||
assigneeUserId: removingMember!.principalId,
|
||||
status: reassignmentIssueStatuses,
|
||||
}),
|
||||
enabled: !!selectedCompanyId && !!removingMember,
|
||||
});
|
||||
|
||||
const archiveMemberMutation = useMutation({
|
||||
mutationFn: async (input: { memberId: string; target: string }) => {
|
||||
const reassignment =
|
||||
input.target.startsWith("agent:")
|
||||
? { assigneeAgentId: input.target.slice("agent:".length), assigneeUserId: null }
|
||||
: input.target.startsWith("user:")
|
||||
? { assigneeAgentId: null, assigneeUserId: input.target.slice("user:".length) }
|
||||
: null;
|
||||
return accessApi.archiveMember(selectedCompanyId!, input.memberId, { reassignment });
|
||||
},
|
||||
onSuccess: async (result) => {
|
||||
setRemovingMemberId(null);
|
||||
setReassignmentTarget("__unassigned");
|
||||
await refreshAccessData();
|
||||
if (selectedCompanyId) {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.issues.listAssignedToMe(selectedCompanyId) });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) });
|
||||
}
|
||||
pushToast({
|
||||
title: "Member removed",
|
||||
body:
|
||||
result.reassignedIssueCount > 0
|
||||
? `${result.reassignedIssueCount} assigned issue${result.reassignedIssueCount === 1 ? "" : "s"} cleaned up.`
|
||||
: undefined,
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
title: "Failed to remove member",
|
||||
body: error instanceof Error ? error.message : "Unknown error",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingMember) return;
|
||||
setDraftRole(editingMember.membershipRole);
|
||||
setDraftStatus(editingMember.status);
|
||||
setDraftStatus(isEditableMemberStatus(editingMember.status) ? editingMember.status : "suspended");
|
||||
setDraftGrants(new Set(editingMember.grants.map((grant) => grant.permissionKey)));
|
||||
}, [editingMember]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!removingMember) return;
|
||||
setReassignmentTarget("__unassigned");
|
||||
}, [removingMember]);
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <div className="text-sm text-muted-foreground">Select a company to manage access.</div>;
|
||||
}
|
||||
|
|
@ -181,6 +255,14 @@ export function CompanyAccess() {
|
|||
approveJoinRequestMutation.isPending || rejectJoinRequestMutation.isPending;
|
||||
const implicitGrantKeys = getImplicitGrantKeys(draftRole);
|
||||
const implicitGrantSet = new Set(implicitGrantKeys);
|
||||
const activeReassignmentUsers = members.filter(
|
||||
(member) =>
|
||||
member.status === "active" &&
|
||||
member.principalType === "user" &&
|
||||
member.id !== removingMemberId,
|
||||
);
|
||||
const activeReassignmentAgents = (agentsQuery.data ?? []).filter(isAssignableAgent);
|
||||
const assignedIssues = assignedIssuesQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl space-y-8">
|
||||
|
|
@ -256,7 +338,7 @@ export function CompanyAccess() {
|
|||
) : null}
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
<div className="grid grid-cols-[minmax(0,1.5fr)_120px_120px_minmax(0,1.2fr)_120px] gap-3 border-b border-border px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<div className="grid grid-cols-[minmax(0,1.5fr)_120px_120px_minmax(0,1.2fr)_180px] gap-3 border-b border-border px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<div>User account</div>
|
||||
<div>Role</div>
|
||||
<div>Status</div>
|
||||
|
|
@ -266,33 +348,52 @@ export function CompanyAccess() {
|
|||
{members.length === 0 ? (
|
||||
<div className="px-4 py-8 text-sm text-muted-foreground">No user memberships found for this company yet.</div>
|
||||
) : (
|
||||
members.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="grid grid-cols-[minmax(0,1.5fr)_120px_120px_minmax(0,1.2fr)_120px] gap-3 border-b border-border px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{member.user?.name?.trim() || member.user?.email || member.principalId}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{member.user?.email || member.principalId}</div>
|
||||
members.map((member) => {
|
||||
const removalReason = member.removal?.reason ?? null;
|
||||
const canArchive = member.removal?.canArchive ?? true;
|
||||
return (
|
||||
<div
|
||||
key={member.id}
|
||||
className="grid grid-cols-[minmax(0,1.5fr)_120px_120px_minmax(0,1.2fr)_180px] gap-3 border-b border-border px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{member.user?.name?.trim() || member.user?.email || member.principalId}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{member.user?.email || member.principalId}</div>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{member.membershipRole
|
||||
? HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS[member.membershipRole]
|
||||
: "Unset"}
|
||||
</div>
|
||||
<div>
|
||||
<Badge variant={member.status === "active" ? "secondary" : member.status === "suspended" ? "destructive" : "outline"}>
|
||||
{member.status.replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="min-w-0 text-sm text-muted-foreground">{formatGrantSummary(member)}</div>
|
||||
<div className="space-y-1 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setEditingMemberId(member.id)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setRemovingMemberId(member.id)}
|
||||
disabled={!canArchive}
|
||||
title={removalReason ?? undefined}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
{removalReason ? (
|
||||
<div className="text-xs text-muted-foreground">{removalReason}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{member.membershipRole
|
||||
? HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS[member.membershipRole]
|
||||
: "Unset"}
|
||||
</div>
|
||||
<div>
|
||||
<Badge variant={member.status === "active" ? "secondary" : member.status === "suspended" ? "destructive" : "outline"}>
|
||||
{member.status.replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="min-w-0 text-sm text-muted-foreground">{formatGrantSummary(member)}</div>
|
||||
<div className="text-right">
|
||||
<Button size="sm" variant="outline" onClick={() => setEditingMemberId(member.id)}>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -331,7 +432,7 @@ export function CompanyAccess() {
|
|||
className="w-full rounded-md border border-border bg-background px-3 py-2"
|
||||
value={draftStatus}
|
||||
onChange={(event) =>
|
||||
setDraftStatus(event.target.value as CompanyMember["status"])
|
||||
setDraftStatus(event.target.value as EditableMemberStatus)
|
||||
}
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
|
|
@ -423,10 +524,109 @@ export function CompanyAccess() {
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!removingMember} onOpenChange={(open) => !open && setRemovingMemberId(null)}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove member</DialogTitle>
|
||||
<DialogDescription>
|
||||
Archive {memberDisplayName(removingMember)} and move active assignments before hiding this user from assignment fields.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{removingMember && (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-lg border border-border px-3 py-3">
|
||||
<div className="text-sm font-medium">{memberDisplayName(removingMember)}</div>
|
||||
<div className="text-sm text-muted-foreground">{removingMember.user?.email || removingMember.principalId}</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
{assignedIssuesQuery.isLoading
|
||||
? "Checking assigned issues..."
|
||||
: `${assignedIssues.length} open assigned issue${assignedIssues.length === 1 ? "" : "s"}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{assignedIssues.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Issue reassignment</div>
|
||||
<select
|
||||
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
|
||||
value={reassignmentTarget}
|
||||
onChange={(event) => setReassignmentTarget(event.target.value)}
|
||||
>
|
||||
<option value="__unassigned">Leave unassigned</option>
|
||||
{activeReassignmentUsers.length > 0 ? (
|
||||
<optgroup label="Humans">
|
||||
{activeReassignmentUsers.map((member) => (
|
||||
<option key={member.id} value={`user:${member.principalId}`}>
|
||||
{memberDisplayName(member)}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
) : null}
|
||||
{activeReassignmentAgents.length > 0 ? (
|
||||
<optgroup label="Agents">
|
||||
{activeReassignmentAgents.map((agent) => (
|
||||
<option key={agent.id} value={`agent:${agent.id}`}>
|
||||
{agent.name} ({agent.role})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
) : null}
|
||||
</select>
|
||||
<div className="max-h-36 overflow-auto rounded-lg border border-border">
|
||||
{assignedIssues.slice(0, 6).map((issue) => (
|
||||
<div key={issue.id} className="border-b border-border px-3 py-2 text-sm last:border-b-0">
|
||||
<div className="font-medium">{issue.identifier ?? issue.id.slice(0, 8)}</div>
|
||||
<div className="truncate text-muted-foreground">{issue.title}</div>
|
||||
</div>
|
||||
))}
|
||||
{assignedIssues.length > 6 ? (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
{assignedIssues.length - 6} more issue{assignedIssues.length - 6 === 1 ? "" : "s"}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRemovingMemberId(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (!removingMember) return;
|
||||
archiveMemberMutation.mutate({
|
||||
memberId: removingMember.id,
|
||||
target: reassignmentTarget,
|
||||
});
|
||||
}}
|
||||
disabled={archiveMemberMutation.isPending || assignedIssuesQuery.isLoading}
|
||||
>
|
||||
{archiveMemberMutation.isPending ? "Removing..." : "Remove member"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function memberDisplayName(member: CompanyMember | null) {
|
||||
if (!member) return "this member";
|
||||
return member.user?.name?.trim() || member.user?.email || member.principalId;
|
||||
}
|
||||
|
||||
function isAssignableAgent(agent: Agent) {
|
||||
return agent.status !== "terminated" && agent.status !== "pending_approval";
|
||||
}
|
||||
|
||||
function isEditableMemberStatus(status: CompanyMember["status"]): status is EditableMemberStatus {
|
||||
return status === "pending" || status === "active" || status === "suspended";
|
||||
}
|
||||
|
||||
function PendingJoinRequestCard({
|
||||
title,
|
||||
subtitle,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue