mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
fix(access): make profile and member updates atomic
This commit is contained in:
1 parent
8a8bcbbc02
commit
802252f377
10 files changed
+237
-4
No files matched your search
@@ -110,6 +110,29 @@ describe("auth routes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the existing avatar when updating only the profile name", async () => {
|
||||
const app = await createApp(
|
||||
{
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "local_implicit",
|
||||
},
|
||||
baseUser,
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/auth/profile")
|
||||
.send({ name: "Board Operator" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
id: "user-1",
|
||||
name: "Board Operator",
|
||||
email: "jane@example.com",
|
||||
image: "https://example.com/jane.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts Paperclip asset paths for avatars", async () => {
|
||||
const app = await createApp(
|
||||
{
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
listJoinRequestsQuerySchema,
|
||||
resolveCliAuthChallengeSchema,
|
||||
searchAdminUsersQuerySchema,
|
||||
updateCompanyMemberWithPermissionsSchema,
|
||||
updateCompanyMemberSchema,
|
||||
updateMemberPermissionsSchema,
|
||||
updateUserCompanyAccessSchema,
|
||||
@@ -3673,6 +3674,80 @@ export function accessRoutes(
|
||||
}
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/companies/:companyId/members/:memberId/role-and-grants",
|
||||
validate(updateCompanyMemberWithPermissionsSchema),
|
||||
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 existing = await access.getMemberById(companyId, memberId);
|
||||
if (!existing) throw notFound("Member not found");
|
||||
|
||||
const nextMembershipRole =
|
||||
req.body.membershipRole !== undefined
|
||||
? req.body.membershipRole
|
||||
: existing.membershipRole;
|
||||
const nextStatus = req.body.status ?? existing.status;
|
||||
|
||||
if (
|
||||
existing.principalType === "user" &&
|
||||
existing.status === "active" &&
|
||||
existing.membershipRole === "owner" &&
|
||||
(nextStatus !== "active" || nextMembershipRole !== "owner")
|
||||
) {
|
||||
const activeOwnerCount = await db
|
||||
.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");
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await access.updateMemberAndPermissions(
|
||||
companyId,
|
||||
memberId,
|
||||
{
|
||||
membershipRole: req.body.membershipRole,
|
||||
status: req.body.status,
|
||||
grants: req.body.grants ?? [],
|
||||
},
|
||||
req.actor.userId ?? null,
|
||||
);
|
||||
if (!updated) throw notFound("Member not found");
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "company_member.access_updated",
|
||||
entityType: "company_membership",
|
||||
entityId: memberId,
|
||||
details: {
|
||||
membershipRole: updated.membershipRole,
|
||||
status: updated.status,
|
||||
grantCount: req.body.grants?.length ?? 0,
|
||||
},
|
||||
});
|
||||
|
||||
const member = (await loadCompanyMemberRecords(db, companyId)).find(
|
||||
(entry) => entry.id === memberId,
|
||||
);
|
||||
if (!member) throw notFound("Member not found");
|
||||
res.json(member);
|
||||
}
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/companies/:companyId/members/:memberId/permissions",
|
||||
validate(updateMemberPermissionsSchema),
|
||||
|
||||
@@ -72,7 +72,7 @@ export function authRoutes(db: Db) {
|
||||
.update(authUsers)
|
||||
.set({
|
||||
name: patch.name,
|
||||
image: patch.image ?? null,
|
||||
...(patch.image !== undefined ? { image: patch.image } : {}),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(authUsers.id, req.actor.userId))
|
||||
|
||||
@@ -143,6 +143,64 @@ export function accessService(db: Db) {
|
||||
return member;
|
||||
}
|
||||
|
||||
async function updateMemberAndPermissions(
|
||||
companyId: string,
|
||||
memberId: string,
|
||||
data: {
|
||||
membershipRole?: string | null;
|
||||
status?: "pending" | "active" | "suspended";
|
||||
grants: GrantInput[];
|
||||
},
|
||||
grantedByUserId: string | null,
|
||||
) {
|
||||
const existing = await getMemberById(companyId, memberId);
|
||||
if (!existing) return null;
|
||||
|
||||
const nextMembershipRole =
|
||||
data.membershipRole !== undefined ? data.membershipRole : existing.membershipRole;
|
||||
const nextStatus = data.status ?? existing.status;
|
||||
const now = new Date();
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const updated = await tx
|
||||
.update(companyMemberships)
|
||||
.set({
|
||||
membershipRole: nextMembershipRole,
|
||||
status: nextStatus,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(companyMemberships.id, existing.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? existing);
|
||||
|
||||
await tx
|
||||
.delete(principalPermissionGrants)
|
||||
.where(
|
||||
and(
|
||||
eq(principalPermissionGrants.companyId, companyId),
|
||||
eq(principalPermissionGrants.principalType, existing.principalType),
|
||||
eq(principalPermissionGrants.principalId, existing.principalId),
|
||||
),
|
||||
);
|
||||
if (data.grants.length > 0) {
|
||||
await tx.insert(principalPermissionGrants).values(
|
||||
data.grants.map((grant) => ({
|
||||
companyId,
|
||||
principalType: existing.principalType,
|
||||
principalId: existing.principalId,
|
||||
permissionKey: grant.permissionKey,
|
||||
scope: grant.scope ?? null,
|
||||
grantedByUserId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
async function promoteInstanceAdmin(userId: string) {
|
||||
const existing = await db
|
||||
.select()
|
||||
@@ -399,6 +457,7 @@ export function accessService(db: Db) {
|
||||
listActiveUserMemberships,
|
||||
copyActiveUserMemberships,
|
||||
setMemberPermissions,
|
||||
updateMemberAndPermissions,
|
||||
promoteInstanceAdmin,
|
||||
demoteInstanceAdmin,
|
||||
listUserCompanyAccess,
|
||||
|
||||
Reference in new issue
Block a user