From 63b4dc7c5e0c2d22afc554a47f7e5c8b249eb0fe Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 18 Apr 2026 08:02:38 -0500 Subject: [PATCH] Add archived company member removal --- packages/shared/src/constants.ts | 2 +- packages/shared/src/index.ts | 2 + packages/shared/src/types/access.ts | 5 + packages/shared/src/types/index.ts | 1 + packages/shared/src/validators/access.ts | 27 ++- packages/shared/src/validators/index.ts | 2 + server/src/__tests__/access-service.test.ts | 78 +++++++ server/src/routes/access.ts | 48 +++- server/src/services/access.ts | 238 +++++++++++++++++++- ui/src/api/access.ts | 20 +- ui/src/pages/CompanyAccess.test.tsx | 106 +++++++++ ui/src/pages/CompanyAccess.tsx | 203 ++++++++++++++++- ui/src/pages/InstanceAccess.tsx | 6 +- 13 files changed, 715 insertions(+), 23 deletions(-) diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index a9f5de3d430..392d606a830 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -359,7 +359,7 @@ export type LiveEventType = (typeof LIVE_EVENT_TYPES)[number]; export const PRINCIPAL_TYPES = ["user", "agent"] as const; export type PrincipalType = (typeof PRINCIPAL_TYPES)[number]; -export const MEMBERSHIP_STATUSES = ["pending", "active", "suspended"] as const; +export const MEMBERSHIP_STATUSES = ["pending", "active", "suspended", "archived"] as const; export type MembershipStatus = (typeof MEMBERSHIP_STATUSES)[number]; export const COMPANY_MEMBERSHIP_ROLES = [ diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d1569b67ab8..8817916dfbc 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -592,6 +592,7 @@ export { updateCurrentUserProfileSchema, updateCompanyMemberSchema, updateCompanyMemberWithPermissionsSchema, + archiveCompanyMemberSchema, updateMemberPermissionsSchema, searchAdminUsersQuerySchema, updateUserCompanyAccessSchema, @@ -613,6 +614,7 @@ export { type UpdateCurrentUserProfile, type UpdateCompanyMember, type UpdateCompanyMemberWithPermissions, + type ArchiveCompanyMember, type UpdateMemberPermissions, type SearchAdminUsersQuery, type UpdateUserCompanyAccess, diff --git a/packages/shared/src/types/access.ts b/packages/shared/src/types/access.ts index 25f605ba515..0102dd529fe 100644 --- a/packages/shared/src/types/access.ts +++ b/packages/shared/src/types/access.ts @@ -59,6 +59,11 @@ export interface CompanyMembersResponse { }; } +export interface ArchiveCompanyMemberResponse { + member: CompanyMemberRecord; + reassignedIssueCount: number; +} + export interface Invite { id: string; companyId: string | null; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 0089c92d015..20d59f9de67 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -176,6 +176,7 @@ export type { AccessUserProfile, CompanyMemberRecord, CompanyMembersResponse, + ArchiveCompanyMemberResponse, CompanyMembership, CompanyInviteListResponse, CompanyInviteRecord, diff --git a/packages/shared/src/validators/access.ts b/packages/shared/src/validators/access.ts index 8cd8f5c3a06..dbba3a8599e 100644 --- a/packages/shared/src/validators/access.ts +++ b/packages/shared/src/validators/access.ts @@ -5,7 +5,6 @@ import { INVITE_JOIN_TYPES, JOIN_REQUEST_STATUSES, JOIN_REQUEST_TYPES, - MEMBERSHIP_STATUSES, PERMISSION_KEYS, } from "../constants.js"; import { optionalAgentAdapterTypeSchema } from "../adapter-type.js"; @@ -97,9 +96,11 @@ export const updateMemberPermissionsSchema = z.object({ export type UpdateMemberPermissions = z.infer; +const editableMembershipStatuses = ["pending", "active", "suspended"] as const; + export const updateCompanyMemberSchema = z.object({ membershipRole: z.enum(HUMAN_COMPANY_MEMBERSHIP_ROLES).optional().nullable(), - status: z.enum(MEMBERSHIP_STATUSES).optional(), + status: z.enum(editableMembershipStatuses).optional(), }).refine((value) => value.membershipRole !== undefined || value.status !== undefined, { message: "membershipRole or status is required", }); @@ -108,7 +109,7 @@ export type UpdateCompanyMember = z.infer; export const updateCompanyMemberWithPermissionsSchema = z.object({ membershipRole: z.enum(HUMAN_COMPANY_MEMBERSHIP_ROLES).optional().nullable(), - status: z.enum(MEMBERSHIP_STATUSES).optional(), + status: z.enum(editableMembershipStatuses).optional(), grants: updateMemberPermissionsSchema.shape.grants.default([]), }).refine((value) => value.membershipRole !== undefined || value.status !== undefined, { message: "membershipRole or status is required", @@ -116,6 +117,26 @@ export const updateCompanyMemberWithPermissionsSchema = z.object({ export type UpdateCompanyMemberWithPermissions = z.infer; +export const archiveCompanyMemberSchema = z.object({ + reassignment: z + .object({ + assigneeAgentId: z.string().uuid().optional().nullable(), + assigneeUserId: z.string().optional().nullable(), + }) + .optional() + .nullable(), +}).superRefine((value, ctx) => { + if (value.reassignment?.assigneeAgentId && value.reassignment.assigneeUserId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Choose either an agent or user reassignment target", + path: ["reassignment"], + }); + } +}); + +export type ArchiveCompanyMember = z.infer; + export const updateUserCompanyAccessSchema = z.object({ companyIds: z.array(z.string().uuid()).default([]), }); diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 8e1662f4280..709c1e81403 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -264,6 +264,7 @@ export { updateCurrentUserProfileSchema, updateCompanyMemberSchema, updateCompanyMemberWithPermissionsSchema, + archiveCompanyMemberSchema, updateMemberPermissionsSchema, searchAdminUsersQuerySchema, updateUserCompanyAccessSchema, @@ -281,6 +282,7 @@ export { type UpdateCurrentUserProfile, type UpdateCompanyMember, type UpdateCompanyMemberWithPermissions, + type ArchiveCompanyMember, type UpdateMemberPermissions, type SearchAdminUsersQuery, type UpdateUserCompanyAccess, diff --git a/server/src/__tests__/access-service.test.ts b/server/src/__tests__/access-service.test.ts index de839370cb4..7ca629a1bad 100644 --- a/server/src/__tests__/access-service.test.ts +++ b/server/src/__tests__/access-service.test.ts @@ -5,6 +5,7 @@ import { companies, companyMemberships, createDb, + issues, principalPermissionGrants, } from "@paperclipai/db"; import { @@ -51,6 +52,7 @@ describeEmbeddedPostgres("access service", () => { }, 20_000); afterEach(async () => { + await db.delete(issues); await db.delete(principalPermissionGrants); await db.delete(companyMemberships); await db.delete(companies); @@ -96,4 +98,80 @@ describeEmbeddedPostgres("access service", () => { .then((rows) => rows[0]!); expect(unchanged.status).toBe("active"); }); + + it("archives members, clears grants, and reassigns open issues without deleting history", async () => { + const { company, owner } = await createCompanyWithOwner(db); + const member = await db + .insert(companyMemberships) + .values({ + companyId: company.id, + principalType: "user", + principalId: `member-${randomUUID()}`, + status: "active", + membershipRole: "operator", + }) + .returning() + .then((rows) => rows[0]!); + await db.insert(principalPermissionGrants).values({ + companyId: company.id, + principalType: "user", + principalId: member.principalId, + permissionKey: "tasks:assign", + grantedByUserId: owner.principalId, + }); + const openIssue = await db + .insert(issues) + .values({ + companyId: company.id, + title: "Open assigned issue", + status: "in_progress", + assigneeUserId: member.principalId, + }) + .returning() + .then((rows) => rows[0]!); + const doneIssue = await db + .insert(issues) + .values({ + companyId: company.id, + title: "Historical assigned issue", + status: "done", + assigneeUserId: member.principalId, + }) + .returning() + .then((rows) => rows[0]!); + + const access = accessService(db); + const result = await access.archiveMember(company.id, member.id, { + reassignment: { assigneeUserId: owner.principalId }, + }); + + expect(result?.reassignedIssueCount).toBe(1); + const archived = await db + .select() + .from(companyMemberships) + .where(eq(companyMemberships.id, member.id)) + .then((rows) => rows[0]!); + expect(archived.status).toBe("archived"); + + const remainingGrants = await db + .select() + .from(principalPermissionGrants) + .where(eq(principalPermissionGrants.principalId, member.principalId)); + expect(remainingGrants).toHaveLength(0); + + const reassignedIssue = await db + .select() + .from(issues) + .where(eq(issues.id, openIssue.id)) + .then((rows) => rows[0]!); + expect(reassignedIssue.assigneeUserId).toBe(owner.principalId); + expect(reassignedIssue.status).toBe("todo"); + + const historicalIssue = await db + .select() + .from(issues) + .where(eq(issues.id, doneIssue.id)) + .then((rows) => rows[0]!); + expect(historicalIssue.assigneeUserId).toBe(member.principalId); + }); }); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index c86637caf04..daf08d166d7 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -9,7 +9,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { Router } from "express"; import type { Request } from "express"; -import { and, desc, eq, gt, inArray, isNotNull, isNull, lte, sql } from "drizzle-orm"; +import { and, desc, eq, gt, inArray, isNotNull, isNull, lte, ne, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { assets, @@ -34,6 +34,7 @@ import { searchAdminUsersQuerySchema, updateCompanyMemberWithPermissionsSchema, updateCompanyMemberSchema, + archiveCompanyMemberSchema, updateMemberPermissionsSchema, updateUserCompanyAccessSchema, PERMISSION_KEYS @@ -994,7 +995,11 @@ async function loadCompanyAccessSummary( }; } -async function loadCompanyMemberRecords(db: Db, companyId: string) { +async function loadCompanyMemberRecords( + db: Db, + companyId: string, + options: { includeArchived?: boolean } = {}, +) { const members = await db .select() .from(companyMemberships) @@ -1002,6 +1007,7 @@ async function loadCompanyMemberRecords(db: Db, companyId: string) { and( eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalType, "user"), + options.includeArchived ? undefined : ne(companyMemberships.status, "archived"), ), ) .orderBy(desc(companyMemberships.updatedAt)); @@ -3834,6 +3840,44 @@ export function accessRoutes( } ); + router.post( + "/companies/:companyId/members/:memberId/archive", + validate(archiveCompanyMemberSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const memberId = req.params.memberId as string; + await assertCompanyPermission(req, companyId, "users:manage_permissions"); + + const result = await access.archiveMember(companyId, memberId, { + reassignment: req.body.reassignment ?? null, + }); + if (!result) throw notFound("Member not found"); + + await logActivity(db, { + companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "company_member.archived", + entityType: "company_membership", + entityId: memberId, + details: { + principalId: result.member.principalId, + reassignedIssueCount: result.reassignedIssueCount, + reassignment: req.body.reassignment ?? null, + }, + }); + + const member = (await loadCompanyMemberRecords(db, companyId, { includeArchived: true })).find( + (entry) => entry.id === memberId, + ); + if (!member) throw notFound("Member not found"); + res.json({ + member, + reassignedIssueCount: result.reassignedIssueCount, + }); + } + ); + router.patch( "/companies/:companyId/members/:memberId/permissions", validate(updateMemberPermissionsSchema), diff --git a/server/src/services/access.ts b/server/src/services/access.ts index 792c28f4606..44d7155367c 100644 --- a/server/src/services/access.ts +++ b/server/src/services/access.ts @@ -1,8 +1,10 @@ -import { and, eq, inArray, sql } from "drizzle-orm"; +import { and, eq, inArray, ne, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { + agents, companyMemberships, instanceUserRoles, + issues, principalPermissionGrants, } from "@paperclipai/db"; import type { PermissionKey, PrincipalType } from "@paperclipai/shared"; @@ -14,6 +16,13 @@ type GrantInput = { scope?: Record | null; }; +type MemberArchiveInput = { + reassignment?: { + assigneeAgentId?: string | null; + assigneeUserId?: string | null; + } | null; +}; + export function accessService(db: Db) { async function isInstanceAdmin(userId: string | null | undefined): Promise { if (!userId) return false; @@ -239,6 +248,176 @@ export function accessService(db: Db) { }); } + async function assertCanRemoveActiveOwner( + companyId: string, + principalType: PrincipalType, + status: string, + membershipRole: string | null, + tx: Pick, + ) { + if ( + principalType !== "user" || + status !== "active" || + membershipRole !== "owner" + ) { + return; + } + + const activeOwnerCount = await tx + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + eq(companyMemberships.membershipRole, "owner"), + ), + ) + .then((rows) => rows.length); + if (activeOwnerCount <= 1) { + throw conflict("Cannot remove the last active owner"); + } + } + + async function assertAssignableArchiveTarget( + companyId: string, + input: MemberArchiveInput["reassignment"], + tx: Pick, + ) { + if (!input?.assigneeAgentId && !input?.assigneeUserId) return; + if (input.assigneeAgentId && input.assigneeUserId) { + throw conflict("Choose either an agent or user reassignment target"); + } + if (input.assigneeUserId) { + const membership = await tx + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, input.assigneeUserId), + eq(companyMemberships.status, "active"), + ), + ) + .then((rows) => rows[0] ?? null); + if (!membership) { + throw conflict("Replacement user must be an active company member"); + } + return; + } + + const agent = await tx + .select({ + id: agents.id, + companyId: agents.companyId, + status: agents.status, + }) + .from(agents) + .where(eq(agents.id, input.assigneeAgentId!)) + .then((rows) => rows[0] ?? null); + if (!agent || agent.companyId !== companyId) { + throw conflict("Replacement agent must belong to the same company"); + } + if (agent.status === "pending_approval" || agent.status === "terminated") { + throw conflict("Replacement agent must be assignable"); + } + } + + async function archiveMember(companyId: string, memberId: string, input: MemberArchiveInput = {}) { + return db.transaction(async (tx) => { + await tx.execute(sql` + select ${companyMemberships.id} + from ${companyMemberships} + where ${companyMemberships.companyId} = ${companyId} + and ${companyMemberships.principalType} = 'user' + and ${companyMemberships.status} = 'active' + and ${companyMemberships.membershipRole} = 'owner' + for update + `); + + const existing = await tx + .select() + .from(companyMemberships) + .where(and(eq(companyMemberships.companyId, companyId), eq(companyMemberships.id, memberId))) + .then((rows) => rows[0] ?? null); + if (!existing) return null; + if (existing.principalType !== "user") { + throw conflict("Only human company members can be archived"); + } + if (existing.status === "archived") { + return { member: existing, reassignedIssueCount: 0 }; + } + if (input.reassignment?.assigneeUserId === existing.principalId) { + throw conflict("Replacement user cannot be the archived member"); + } + + await assertCanRemoveActiveOwner( + companyId, + existing.principalType, + existing.status, + existing.membershipRole, + tx, + ); + await assertAssignableArchiveTarget(companyId, input.reassignment, tx); + + const now = new Date(); + const assignmentPatch = { + assigneeAgentId: input.reassignment?.assigneeAgentId ?? null, + assigneeUserId: input.reassignment?.assigneeUserId ?? null, + updatedAt: now, + }; + const assignedOpenIssueWhere = and( + eq(issues.companyId, companyId), + eq(issues.assigneeUserId, existing.principalId), + sql`${issues.status} not in ('done', 'cancelled')`, + ); + const resetInProgress = await tx + .update(issues) + .set({ + ...assignmentPatch, + status: "todo", + startedAt: null, + checkoutRunId: null, + executionRunId: null, + executionLockedAt: null, + }) + .where(and(assignedOpenIssueWhere, eq(issues.status, "in_progress"))) + .returning({ id: issues.id }); + const reassigned = await tx + .update(issues) + .set(assignmentPatch) + .where(and(assignedOpenIssueWhere, ne(issues.status, "in_progress"))) + .returning({ id: issues.id }); + + await tx + .delete(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, companyId), + eq(principalPermissionGrants.principalType, existing.principalType), + eq(principalPermissionGrants.principalId, existing.principalId), + ), + ); + + const archived = await tx + .update(companyMemberships) + .set({ + status: "archived", + updatedAt: now, + }) + .where(eq(companyMemberships.id, existing.id)) + .returning() + .then((rows) => rows[0] ?? existing); + + return { + member: archived, + reassignedIssueCount: resetInProgress.length + reassigned.length, + }; + }); + } + async function promoteInstanceAdmin(userId: string) { const existing = await db .select() @@ -278,13 +457,61 @@ export function accessService(db: Db) { const target = new Set(companyIds); await db.transaction(async (tx) => { - const toDelete = existing.filter((row) => !target.has(row.companyId)).map((row) => row.id); - if (toDelete.length > 0) { - await tx.delete(companyMemberships).where(inArray(companyMemberships.id, toDelete)); + const toArchive = existing.filter((row) => !target.has(row.companyId) && row.status !== "archived"); + const activeOwnerArchives = toArchive.filter( + (row) => row.status === "active" && row.membershipRole === "owner", + ); + if (activeOwnerArchives.length > 0) { + const activeOwnerRows = await tx + .select({ companyId: companyMemberships.companyId, id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.status, "active"), + eq(companyMemberships.membershipRole, "owner"), + inArray(companyMemberships.companyId, activeOwnerArchives.map((row) => row.companyId)), + ), + ); + for (const row of activeOwnerArchives) { + const remainingOwners = + activeOwnerRows.filter((owner) => owner.companyId === row.companyId).length - 1; + if (remainingOwners <= 0) { + throw conflict("Cannot remove the last active owner"); + } + } + } + if (toArchive.length > 0) { + await tx + .update(companyMemberships) + .set({ status: "archived", updatedAt: new Date() }) + .where(inArray(companyMemberships.id, toArchive.map((row) => row.id))); + await tx + .delete(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.principalType, "user"), + eq(principalPermissionGrants.principalId, userId), + inArray(principalPermissionGrants.companyId, toArchive.map((row) => row.companyId)), + ), + ); } for (const companyId of target) { - if (existingByCompany.has(companyId)) continue; + const existingMembership = existingByCompany.get(companyId); + if (existingMembership) { + if (existingMembership.status !== "active") { + await tx + .update(companyMemberships) + .set({ + status: "active", + membershipRole: existingMembership.membershipRole ?? "operator", + updatedAt: new Date(), + }) + .where(eq(companyMemberships.id, existingMembership.id)); + } + continue; + } await tx.insert(companyMemberships).values({ companyId, principalType: "user", @@ -535,6 +762,7 @@ export function accessService(db: Db) { listMembers, listActiveUserMemberships, copyActiveUserMemberships, + archiveMember, setMemberPermissions, updateMemberAndPermissions, promoteInstanceAdmin, diff --git a/ui/src/api/access.ts b/ui/src/api/access.ts index 917a049fa0f..c4c1d5cde4a 100644 --- a/ui/src/api/access.ts +++ b/ui/src/api/access.ts @@ -120,7 +120,7 @@ export type CompanyMember = { companyId: string; principalType: "user"; principalId: string; - status: "pending" | "active" | "suspended"; + status: "pending" | "active" | "suspended" | "archived"; membershipRole: HumanCompanyRole | null; createdAt: string; updatedAt: string; @@ -128,6 +128,11 @@ export type CompanyMember = { grants: CompanyMemberGrant[]; }; +export type ArchiveCompanyMemberResponse = { + member: CompanyMember; + reassignedIssueCount: number; +}; + export type CompanyMembersResponse = { members: CompanyMember[]; access: { @@ -205,7 +210,7 @@ export type UserCompanyAccessEntry = { companyId: string; principalType: "user"; principalId: string; - status: "pending" | "active" | "suspended"; + status: "pending" | "active" | "suspended" | "archived"; membershipRole: HumanCompanyRole | "member" | null; createdAt: string; updatedAt: string; @@ -341,6 +346,17 @@ export const accessApi = { }, ) => api.patch(`/companies/${companyId}/members/${memberId}/role-and-grants`, input), + archiveMember: ( + companyId: string, + memberId: string, + input: { + reassignment?: { + assigneeAgentId?: string | null; + assigneeUserId?: string | null; + } | null; + } = {}, + ) => api.post(`/companies/${companyId}/members/${memberId}/archive`, input), + approveJoinRequest: (companyId: string, requestId: string) => api.post(`/companies/${companyId}/join-requests/${requestId}/approve`, {}), diff --git a/ui/src/pages/CompanyAccess.test.tsx b/ui/src/pages/CompanyAccess.test.tsx index 4d6af17cf0a..2ba0a45c512 100644 --- a/ui/src/pages/CompanyAccess.test.tsx +++ b/ui/src/pages/CompanyAccess.test.tsx @@ -9,6 +9,9 @@ import { CompanyAccess } from "./CompanyAccess"; const listMembersMock = vi.hoisted(() => vi.fn()); const listJoinRequestsMock = vi.hoisted(() => vi.fn()); const updateMemberAccessMock = vi.hoisted(() => vi.fn()); +const archiveMemberMock = vi.hoisted(() => vi.fn()); +const listAgentsMock = vi.hoisted(() => vi.fn()); +const listIssuesMock = vi.hoisted(() => vi.fn()); vi.mock("@/api/access", () => ({ accessApi: { @@ -18,11 +21,25 @@ vi.mock("@/api/access", () => ({ updateMemberPermissions: vi.fn(), updateMemberAccess: (companyId: string, memberId: string, input: unknown) => updateMemberAccessMock(companyId, memberId, input), + archiveMember: (companyId: string, memberId: string, input: unknown) => + archiveMemberMock(companyId, memberId, input), approveJoinRequest: vi.fn(), rejectJoinRequest: vi.fn(), }, })); +vi.mock("@/api/agents", () => ({ + agentsApi: { + list: (companyId: string) => listAgentsMock(companyId), + }, +})); + +vi.mock("@/api/issues", () => ({ + issuesApi: { + list: (companyId: string, filters: unknown) => listIssuesMock(companyId, filters), + }, +})); + vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-1", @@ -73,6 +90,23 @@ describe("CompanyAccess", () => { }, grants: [], }, + { + id: "member-2", + companyId: "company-1", + principalType: "user", + principalId: "user-2", + status: "active", + membershipRole: "operator", + createdAt: "2026-04-10T00:00:00.000Z", + updatedAt: "2026-04-10T00:00:00.000Z", + user: { + id: "user-2", + email: "board@paperclip.local", + name: "Board User", + image: null, + }, + grants: [], + }, ], access: { currentUserRole: "owner", @@ -113,6 +147,23 @@ describe("CompanyAccess", () => { }, ]); updateMemberAccessMock.mockResolvedValue({}); + archiveMemberMock.mockResolvedValue({ reassignedIssueCount: 1 }); + listAgentsMock.mockResolvedValue([ + { + id: "agent-1", + name: "Codex Worker", + role: "engineer", + status: "active", + }, + ]); + listIssuesMock.mockResolvedValue([ + { + id: "issue-1", + identifier: "PAP-1", + title: "Assigned to removed user", + status: "todo", + }, + ]); }); afterEach(() => { @@ -216,4 +267,59 @@ describe("CompanyAccess", () => { root.unmount(); }); }); + + it("removes a member with an issue reassignment target", async () => { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + + const removeButtons = Array.from(container.querySelectorAll("button")).filter( + (button) => button.textContent?.includes("Remove"), + ); + expect(removeButtons.length).toBeGreaterThan(0); + + await act(async () => { + removeButtons[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(document.body.textContent).toContain("Remove member"); + expect(document.body.textContent).toContain("Assigned to removed user"); + + const reassignmentSelect = document.body.querySelector("select"); + expect(reassignmentSelect).toBeTruthy(); + await act(async () => { + reassignmentSelect!.value = "user:user-2"; + reassignmentSelect!.dispatchEvent(new Event("change", { bubbles: true })); + }); + + const confirmButton = Array.from(document.body.querySelectorAll("button")).find( + (button) => button.textContent === "Remove member", + ); + expect(confirmButton).toBeTruthy(); + + await act(async () => { + confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(archiveMemberMock).toHaveBeenCalledWith("company-1", "member-1", { + reassignment: { assigneeAgentId: null, assigneeUserId: "user-2" }, + }); + + await act(async () => { + root.unmount(); + }); + }); }); diff --git a/ui/src/pages/CompanyAccess.tsx b/ui/src/pages/CompanyAccess.tsx index 9c65e45e161..1a9f9be5575 100644 --- a/ui/src/pages/CompanyAccess.tsx +++ b/ui/src/pages/CompanyAccess.tsx @@ -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, 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(null); + const [removingMemberId, setRemovingMemberId] = useState(null); + const [reassignmentTarget, setReassignmentTarget] = useState("__unassigned"); const [draftRole, setDraftRole] = useState(null); - const [draftStatus, setDraftStatus] = useState("active"); + const [draftStatus, setDraftStatus] = useState("active"); const [draftGrants, setDraftGrants] = useState>(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
Select a company to manage access.
; } @@ -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 (
@@ -256,7 +338,7 @@ export function CompanyAccess() { ) : null}
-
+
User account
Role
Status
@@ -269,7 +351,7 @@ export function CompanyAccess() { members.map((member) => (
{member.user?.name?.trim() || member.user?.email || member.principalId}
@@ -286,10 +368,14 @@ export function CompanyAccess() {
{formatGrantSummary(member)}
-
+
+
)) @@ -331,7 +417,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) } > @@ -423,10 +509,109 @@ export function CompanyAccess() { + + !open && setRemovingMemberId(null)}> + + + Remove member + + Archive {memberDisplayName(removingMember)} and move active assignments before hiding this user from assignment fields. + + + {removingMember && ( +
+
+
{memberDisplayName(removingMember)}
+
{removingMember.user?.email || removingMember.principalId}
+
+ {assignedIssuesQuery.isLoading + ? "Checking assigned issues..." + : `${assignedIssues.length} open assigned issue${assignedIssues.length === 1 ? "" : "s"}`} +
+
+ + {assignedIssues.length > 0 ? ( +
+
Issue reassignment
+ +
+ {assignedIssues.slice(0, 6).map((issue) => ( +
+
{issue.identifier ?? issue.id.slice(0, 8)}
+
{issue.title}
+
+ ))} + {assignedIssues.length > 6 ? ( +
+ {assignedIssues.length - 6} more issue{assignedIssues.length - 6 === 1 ? "" : "s"} +
+ ) : null} +
+
+ ) : null} +
+ )} + + + + +
+
); } +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, diff --git a/ui/src/pages/InstanceAccess.tsx b/ui/src/pages/InstanceAccess.tsx index d40ea814e9b..7ca2f6734df 100644 --- a/ui/src/pages/InstanceAccess.tsx +++ b/ui/src/pages/InstanceAccess.tsx @@ -51,7 +51,11 @@ export function InstanceAccess() { useEffect(() => { if (!userAccessQuery.data) return; setSelectedCompanyIds( - new Set(userAccessQuery.data.companyAccess.map((membership) => membership.companyId)), + new Set( + userAccessQuery.data.companyAccess + .filter((membership) => membership.status === "active") + .map((membership) => membership.companyId), + ), ); }, [userAccessQuery.data]);