mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 00:30:19 +02:00
Add archived company member removal
This commit is contained in:
1 parent
b9a80dcf22
commit
63b4dc7c5e
13 files changed
+715
-23
No files matched your search
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -59,6 +59,11 @@ export interface CompanyMembersResponse {
|
||||
};
|
||||
}
|
||||
|
||||
export interface ArchiveCompanyMemberResponse {
|
||||
member: CompanyMemberRecord;
|
||||
reassignedIssueCount: number;
|
||||
}
|
||||
|
||||
export interface Invite {
|
||||
id: string;
|
||||
companyId: string | null;
|
||||
|
||||
@@ -176,6 +176,7 @@ export type {
|
||||
AccessUserProfile,
|
||||
CompanyMemberRecord,
|
||||
CompanyMembersResponse,
|
||||
ArchiveCompanyMemberResponse,
|
||||
CompanyMembership,
|
||||
CompanyInviteListResponse,
|
||||
CompanyInviteRecord,
|
||||
|
||||
@@ -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<typeof updateMemberPermissionsSchema>;
|
||||
|
||||
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<typeof updateCompanyMemberSchema>;
|
||||
|
||||
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<typeof updateCompanyMemberWithPermissionsSchema>;
|
||||
|
||||
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<typeof archiveCompanyMemberSchema>;
|
||||
|
||||
export const updateUserCompanyAccessSchema = z.object({
|
||||
companyIds: z.array(z.string().uuid()).default([]),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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<string, unknown> | null;
|
||||
};
|
||||
|
||||
type MemberArchiveInput = {
|
||||
reassignment?: {
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export function accessService(db: Db) {
|
||||
async function isInstanceAdmin(userId: string | null | undefined): Promise<boolean> {
|
||||
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<Db, "select">,
|
||||
) {
|
||||
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<Db, "select">,
|
||||
) {
|
||||
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,
|
||||
|
||||
+18
-2
@@ -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<CompanyMember>(`/companies/${companyId}/members/${memberId}/role-and-grants`, input),
|
||||
|
||||
archiveMember: (
|
||||
companyId: string,
|
||||
memberId: string,
|
||||
input: {
|
||||
reassignment?: {
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
} | null;
|
||||
} = {},
|
||||
) => api.post<ArchiveCompanyMemberResponse>(`/companies/${companyId}/members/${memberId}/archive`, input),
|
||||
|
||||
approveJoinRequest: (companyId: string, requestId: string) =>
|
||||
api.post<JoinRequest>(`/companies/${companyId}/join-requests/${requestId}/approve`, {}),
|
||||
|
||||
|
||||
@@ -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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CompanyAccess />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -269,7 +351,7 @@ export function CompanyAccess() {
|
||||
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"
|
||||
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>
|
||||
@@ -286,10 +368,14 @@ export function CompanyAccess() {
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="min-w-0 text-sm text-muted-foreground">{formatGrantSummary(member)}</div>
|
||||
<div className="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)}>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@@ -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)
|
||||
}
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
@@ -423,10 +509,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,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
Reference in new issue
Block a user