Adding promoting to owner
Deploy / deploy-dev (push) Successful in 2m26s
Deploy / deploy-prod (push) Has been skipped

This commit is contained in:
blaisadmin
2026-06-05 21:15:55 -04:00
parent bb589e3489
commit 480bbe8fc7
4 changed files with 65 additions and 2 deletions
+7 -1
View File
@@ -3390,7 +3390,7 @@ app.post('/api/workspace/members', requireAuth, requireWriteAccess, requireWorks
});
app.put('/api/workspace/members/:memberId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => {
const parsed = z.object({ role: workspaceRoleSchema.exclude(['owner']) }).safeParse(req.body);
const parsed = z.object({ role: workspaceRoleSchema }).safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid flock member role payload', details: parsed.error.flatten() });
@@ -3400,6 +3400,12 @@ app.put('/api/workspace/members/:memberId', requireAuth, requireWriteAccess, req
try {
const billingEmail = req.auth!.workspace.billing_email ? normalizeEmail(req.auth!.workspace.billing_email) : '';
const requesterIsBillingOwner = Boolean(billingEmail && billingEmail === normalizeEmail(req.auth!.user.email));
if (parsed.data.role === 'owner' && !requesterIsBillingOwner) {
res.status(403).json({ error: 'Only the billing owner can promote collaborators to owner.' });
return;
}
const member = await updateWorkspaceMemberRole({
memberId: req.params.memberId,
workspaceId: req.auth!.workspace.id,
@@ -415,6 +415,57 @@ test('updateWorkspaceMemberRole does not let a non-billing owner change the bill
assert.equal(member, null);
});
test('updateWorkspaceMemberRole lets the billing owner promote a non-owner to owner', async () => {
const { calls } = mockDb({
rowCount: 1,
rows: [
{
id: 'member-1',
workspace_id: 42,
user_id: 'user-2',
invite_email: 'helper@example.com',
name: 'Helper',
role: 'owner',
accepted_at: '2026-04-14T00:00:00.000Z',
created_at: '2026-04-14T00:00:00.000Z',
},
],
});
const member = await updateWorkspaceMemberRole({
memberId: 'member-1',
workspaceId: 42,
role: 'owner',
requesterMemberId: 'billing-owner',
requesterIsBillingOwner: true,
requesterRole: 'owner',
billingEmail: 'billing@example.com',
});
assert.equal(member?.role, 'owner');
assert.deepEqual(calls[0].params, ['member-1', 42, 'owner', true, 'billing-owner', 'billing@example.com', 'owner']);
assert.match(calls[0].text, /\$3 <> 'owner'/);
});
test('updateWorkspaceMemberRole does not let a non-billing owner promote a member to owner', async () => {
mockDb({
rowCount: 0,
rows: [],
});
const member = await updateWorkspaceMemberRole({
memberId: 'member-1',
workspaceId: 42,
role: 'owner',
requesterMemberId: 'non-billing-owner',
requesterIsBillingOwner: false,
requesterRole: 'owner',
billingEmail: 'billing@example.com',
});
assert.equal(member, null);
});
test('deleteWorkspaceMember removes non-owner members without billing owner access', async () => {
const { calls } = mockDb({
rowCount: 1,
@@ -415,6 +415,10 @@ export const updateWorkspaceMemberRole = async ({
SET role = $3
WHERE id = $1
AND workspace_id = $2
AND (
$3 <> 'owner'
OR $4 = TRUE
)
AND (
role <> 'owner'
OR (
+3 -1
View File
@@ -7925,6 +7925,7 @@ function App() {
})
}
>
{isBillingOwner ? <option value="owner">Owner</option> : null}
<option value="assistant">Assistant</option>
<option value="caregiver">Caregiver</option>
<option value="viewer">Viewer</option>
@@ -7949,6 +7950,7 @@ function App() {
activeMembership?.role === 'owner' &&
member.id !== activeMembership.id &&
(isBillingOwner || !memberIsBillingOwner);
const canPromoteToOwner = member.role !== 'owner' && isBillingOwner && member.id !== activeMembership?.id;
const canRemoveMember = member.role !== 'owner' || canRemoveOwner;
return (
@@ -7969,7 +7971,7 @@ function App() {
removingWorkspaceMemberId === member.id
}
>
{member.role === 'owner' ? <option value="owner">Owner</option> : null}
{member.role === 'owner' || canPromoteToOwner ? <option value="owner">Owner</option> : null}
<option value="assistant">Assistant</option>
<option value="caregiver">Caregiver</option>
<option value="viewer">Viewer</option>