Skip to content

Commit 0d7d423

Browse files
feedback
1 parent e71de80 commit 0d7d423

File tree

4 files changed

+110
-73
lines changed

4 files changed

+110
-73
lines changed

docs/docs/configuration/auth/roles-and-permissions.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ To promote a member, click the action menu (three dots) next to their name in th
3535

3636
### Demoting an owner to member
3737

38-
To demote an owner, click the action menu next to their name and select **Demote to member**. Owners can also demote themselves to step down from the role. The last remaining owner of an organization cannot be demoted at least one owner must exist at all times.
38+
To demote an owner, click the action menu next to their name and select **Demote to member**. Owners can also demote themselves to step down from the role. The last remaining owner of an organization cannot be demoted - at least one owner must exist at all times.
3939

4040
<Frame>
4141
<img src="/images/demote_to_member.png" alt="Dropdown menu showing Demote to member option for an owner" />

packages/web/src/ee/features/userManagement/actions.ts

Lines changed: 51 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { ErrorCode } from "@/lib/errorCodes";
66
import { notFound, ServiceError } from "@/lib/serviceError";
77
import { prisma } from "@/prisma";
88
import { withAuthV2, withMinimumOrgRole } from "@/withAuthV2";
9-
import { OrgRole } from "@sourcebot/db";
9+
import { OrgRole, Prisma } from "@sourcebot/db";
1010
import { hasEntitlement } from "@sourcebot/shared";
1111
import { StatusCodes } from "http-status-codes";
1212

@@ -87,54 +87,62 @@ export const demoteToMember = async (memberId: string): Promise<{ success: boole
8787
return orgManagementNotAvailable();
8888
}
8989

90-
const targetMember = await prisma.userToOrg.findUnique({
91-
where: {
92-
orgId_userId: {
90+
const guardError = await prisma.$transaction(async (tx) => {
91+
const targetMember = await tx.userToOrg.findUnique({
92+
where: {
93+
orgId_userId: {
94+
orgId: org.id,
95+
userId: memberId,
96+
},
97+
},
98+
});
99+
100+
if (!targetMember) {
101+
return notFound("Member not found in this organization");
102+
}
103+
104+
if (targetMember.role !== OrgRole.OWNER) {
105+
return {
106+
statusCode: StatusCodes.BAD_REQUEST,
107+
errorCode: ErrorCode.INVALID_REQUEST_BODY,
108+
message: "This member is not an owner.",
109+
} satisfies ServiceError;
110+
}
111+
112+
const ownerCount = await tx.userToOrg.count({
113+
where: {
93114
orgId: org.id,
94-
userId: memberId,
115+
role: OrgRole.OWNER,
95116
},
96-
},
97-
});
98-
99-
if (!targetMember) {
100-
return notFound("Member not found in this organization");
101-
}
102-
103-
if (targetMember.role !== OrgRole.OWNER) {
104-
return {
105-
statusCode: StatusCodes.BAD_REQUEST,
106-
errorCode: ErrorCode.INVALID_REQUEST_BODY,
107-
message: "This member is not an owner.",
108-
} satisfies ServiceError;
109-
}
117+
});
118+
119+
if (ownerCount <= 1) {
120+
return {
121+
statusCode: StatusCodes.FORBIDDEN,
122+
errorCode: ErrorCode.LAST_OWNER_CANNOT_BE_DEMOTED,
123+
message: "Cannot demote the last owner. Promote another member to owner first.",
124+
} satisfies ServiceError;
125+
}
126+
127+
await tx.userToOrg.update({
128+
where: {
129+
orgId_userId: {
130+
orgId: org.id,
131+
userId: memberId,
132+
},
133+
},
134+
data: {
135+
role: "MEMBER",
136+
},
137+
});
110138

111-
const ownerCount = await prisma.userToOrg.count({
112-
where: {
113-
orgId: org.id,
114-
role: OrgRole.OWNER,
115-
},
116-
});
139+
return null;
140+
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
117141

118-
if (ownerCount <= 1) {
119-
return {
120-
statusCode: StatusCodes.FORBIDDEN,
121-
errorCode: ErrorCode.LAST_OWNER_CANNOT_BE_DEMOTED,
122-
message: "Cannot demote the last owner. Promote another member to owner first.",
123-
} satisfies ServiceError;
142+
if (guardError) {
143+
return guardError;
124144
}
125145

126-
await prisma.userToOrg.update({
127-
where: {
128-
orgId_userId: {
129-
orgId: org.id,
130-
userId: memberId,
131-
},
132-
},
133-
data: {
134-
role: "MEMBER",
135-
},
136-
});
137-
138146
await auditService.createAudit({
139147
action: "org.owner_demoted_to_member",
140148
actor: { id: user.id, type: "user" },

packages/web/src/features/userManagement/actions.ts

Lines changed: 57 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,45 @@ import { notFound, ServiceError } from "@/lib/serviceError";
66
import { isServiceError } from "@/lib/utils";
77
import { prisma } from "@/prisma";
88
import { withAuthV2, withMinimumOrgRole } from "@/withAuthV2";
9-
import { OrgRole } from "@sourcebot/db";
9+
import { OrgRole, Prisma } from "@sourcebot/db";
1010
import { IS_BILLING_ENABLED } from "@/ee/features/billing/stripe";
1111
import { decrementOrgSeatCount } from "@/ee/features/billing/serverUtils";
1212
import { StatusCodes } from "http-status-codes";
1313

1414
export const removeMemberFromOrg = async (memberId: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
1515
withAuthV2(async ({ org, role }) =>
1616
withMinimumOrgRole(role, OrgRole.OWNER, async () => {
17-
const targetMember = await prisma.userToOrg.findUnique({
18-
where: {
19-
orgId_userId: {
20-
orgId: org.id,
21-
userId: memberId,
17+
const guardError = await prisma.$transaction(async (tx) => {
18+
const targetMember = await tx.userToOrg.findUnique({
19+
where: {
20+
orgId_userId: {
21+
orgId: org.id,
22+
userId: memberId,
23+
}
2224
}
25+
});
26+
27+
if (!targetMember) {
28+
return notFound("Member not found in this organization");
2329
}
24-
});
2530

26-
if (!targetMember) {
27-
return notFound("Member not found in this organization");
28-
}
31+
if (targetMember.role === OrgRole.OWNER) {
32+
const ownerCount = await tx.userToOrg.count({
33+
where: {
34+
orgId: org.id,
35+
role: OrgRole.OWNER,
36+
},
37+
});
38+
39+
if (ownerCount <= 1) {
40+
return {
41+
statusCode: StatusCodes.FORBIDDEN,
42+
errorCode: ErrorCode.LAST_OWNER_CANNOT_BE_REMOVED,
43+
message: "Cannot remove the last owner of the organization.",
44+
} satisfies ServiceError;
45+
}
46+
}
2947

30-
await prisma.$transaction(async (tx) => {
3148
await tx.userToOrg.delete({
3249
where: {
3350
orgId_userId: {
@@ -43,32 +60,38 @@ export const removeMemberFromOrg = async (memberId: string): Promise<{ success:
4360
throw result;
4461
}
4562
}
46-
});
63+
64+
return null;
65+
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
66+
67+
if (guardError) {
68+
return guardError;
69+
}
4770

4871
return { success: true };
4972
}))
5073
);
5174

5275
export const leaveOrg = async (): Promise<{ success: boolean } | ServiceError> => sew(() =>
5376
withAuthV2(async ({ user, org, role }) => {
54-
if (role === OrgRole.OWNER) {
55-
const ownerCount = await prisma.userToOrg.count({
56-
where: {
57-
orgId: org.id,
58-
role: OrgRole.OWNER,
59-
},
60-
});
77+
const guardError = await prisma.$transaction(async (tx) => {
78+
if (role === OrgRole.OWNER) {
79+
const ownerCount = await tx.userToOrg.count({
80+
where: {
81+
orgId: org.id,
82+
role: OrgRole.OWNER,
83+
},
84+
});
6185

62-
if (ownerCount <= 1) {
63-
return {
64-
statusCode: StatusCodes.FORBIDDEN,
65-
errorCode: ErrorCode.OWNER_CANNOT_LEAVE_ORG,
66-
message: "You are the last owner of this organization. Promote another member to owner before leaving.",
67-
} satisfies ServiceError;
86+
if (ownerCount <= 1) {
87+
return {
88+
statusCode: StatusCodes.FORBIDDEN,
89+
errorCode: ErrorCode.LAST_OWNER_CANNOT_BE_REMOVED,
90+
message: "You are the last owner of this organization. Promote another member to owner before leaving.",
91+
} satisfies ServiceError;
92+
}
6893
}
69-
}
7094

71-
await prisma.$transaction(async (tx) => {
7295
await tx.userToOrg.delete({
7396
where: {
7497
orgId_userId: {
@@ -84,7 +107,13 @@ export const leaveOrg = async (): Promise<{ success: boolean } | ServiceError> =
84107
throw result;
85108
}
86109
}
87-
});
110+
111+
return null;
112+
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
113+
114+
if (guardError) {
115+
return guardError;
116+
}
88117

89118
return {
90119
success: true,

packages/web/src/lib/errorCodes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ export enum ErrorCode {
1717
INSUFFICIENT_PERMISSIONS = 'INSUFFICIENT_PERMISSIONS',
1818
CONNECTION_NOT_FAILED = 'CONNECTION_NOT_FAILED',
1919
CONNECTION_ALREADY_EXISTS = 'CONNECTION_ALREADY_EXISTS',
20-
OWNER_CANNOT_LEAVE_ORG = 'OWNER_CANNOT_LEAVE_ORG',
2120
INVALID_INVITE = 'INVALID_INVITE',
2221
INVALID_INVITE_LINK = 'INVALID_INVITE_LINK',
2322
INVITE_LINK_NOT_ENABLED = 'INVITE_LINK_NOT_ENABLED',
@@ -37,4 +36,5 @@ export enum ErrorCode {
3736
FAILED_TO_PARSE_QUERY = 'FAILED_TO_PARSE_QUERY',
3837
INVALID_GIT_REF = 'INVALID_GIT_REF',
3938
LAST_OWNER_CANNOT_BE_DEMOTED = 'LAST_OWNER_CANNOT_BE_DEMOTED',
39+
LAST_OWNER_CANNOT_BE_REMOVED = 'LAST_OWNER_CANNOT_BE_REMOVED',
4040
}

0 commit comments

Comments
 (0)