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) => { 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) { if (!parsed.success) {
res.status(400).json({ error: 'Invalid flock member role payload', details: parsed.error.flatten() }); 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 { try {
const billingEmail = req.auth!.workspace.billing_email ? normalizeEmail(req.auth!.workspace.billing_email) : ''; const billingEmail = req.auth!.workspace.billing_email ? normalizeEmail(req.auth!.workspace.billing_email) : '';
const requesterIsBillingOwner = Boolean(billingEmail && billingEmail === normalizeEmail(req.auth!.user.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({ const member = await updateWorkspaceMemberRole({
memberId: req.params.memberId, memberId: req.params.memberId,
workspaceId: req.auth!.workspace.id, 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); 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 () => { test('deleteWorkspaceMember removes non-owner members without billing owner access', async () => {
const { calls } = mockDb({ const { calls } = mockDb({
rowCount: 1, rowCount: 1,
@@ -415,6 +415,10 @@ export const updateWorkspaceMemberRole = async ({
SET role = $3 SET role = $3
WHERE id = $1 WHERE id = $1
AND workspace_id = $2 AND workspace_id = $2
AND (
$3 <> 'owner'
OR $4 = TRUE
)
AND ( AND (
role <> 'owner' role <> 'owner'
OR ( 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="assistant">Assistant</option>
<option value="caregiver">Caregiver</option> <option value="caregiver">Caregiver</option>
<option value="viewer">Viewer</option> <option value="viewer">Viewer</option>
@@ -7949,6 +7950,7 @@ function App() {
activeMembership?.role === 'owner' && activeMembership?.role === 'owner' &&
member.id !== activeMembership.id && member.id !== activeMembership.id &&
(isBillingOwner || !memberIsBillingOwner); (isBillingOwner || !memberIsBillingOwner);
const canPromoteToOwner = member.role !== 'owner' && isBillingOwner && member.id !== activeMembership?.id;
const canRemoveMember = member.role !== 'owner' || canRemoveOwner; const canRemoveMember = member.role !== 'owner' || canRemoveOwner;
return ( return (
@@ -7969,7 +7971,7 @@ function App() {
removingWorkspaceMemberId === member.id 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="assistant">Assistant</option>
<option value="caregiver">Caregiver</option> <option value="caregiver">Caregiver</option>
<option value="viewer">Viewer</option> <option value="viewer">Viewer</option>