fix(access): make profile and member updates atomic

This commit is contained in:
Dotta committed 2026-04-15 22:48:48 -05:00
1 parent 8a8bcbbc02
commit 802252f377
10 files changed
+237 -4

No files matched your search

+23
View File
@@ -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(
{
+75
View File
@@ -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),
+1 -1
View File
@@ -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))
+59
View File
@@ -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,