diff --git a/backend/src/app.ts b/backend/src/app.ts index 20ab95d..98138ca 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -3210,7 +3210,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() }); @@ -3220,6 +3220,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, diff --git a/backend/src/repositories/workspaceRepository.test.ts b/backend/src/repositories/workspaceRepository.test.ts index fca4660..645c0be 100644 --- a/backend/src/repositories/workspaceRepository.test.ts +++ b/backend/src/repositories/workspaceRepository.test.ts @@ -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, diff --git a/backend/src/repositories/workspaceRepository.ts b/backend/src/repositories/workspaceRepository.ts index 565bcbd..e817262 100644 --- a/backend/src/repositories/workspaceRepository.ts +++ b/backend/src/repositories/workspaceRepository.ts @@ -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 ( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ed66488..6ee2339 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7400,6 +7400,7 @@ function App() { }) } > + {isBillingOwner ? : null} @@ -7424,6 +7425,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 ( @@ -7444,7 +7446,7 @@ function App() { removingWorkspaceMemberId === member.id } > - {member.role === 'owner' ? : null} + {member.role === 'owner' || canPromoteToOwner ? : null}