diff --git a/backend/src/app.ts b/backend/src/app.ts index fc7a4a7..91f8af6 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -119,6 +119,7 @@ import { setWorkspaceSubscriptionStatusByStripeSubscriptionId, updateRescueVerificationStatus, updateWorkspace, + updateWorkspaceMemberRole, upsertWorkspaceMember, } from './repositories/workspaceRepository.js'; import type { @@ -3388,9 +3389,51 @@ 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); + + if (!parsed.success) { + res.status(400).json({ error: 'Invalid flock member role payload', details: parsed.error.flatten() }); + return; + } + + try { + const billingEmail = req.auth!.workspace.billing_email ? normalizeEmail(req.auth!.workspace.billing_email) : ''; + const requesterIsBillingOwner = Boolean(billingEmail && billingEmail === normalizeEmail(req.auth!.user.email)); + const member = await updateWorkspaceMemberRole({ + memberId: req.params.memberId, + workspaceId: req.auth!.workspace.id, + role: parsed.data.role, + requesterMemberId: req.auth!.membership.id, + requesterIsBillingOwner, + requesterRole: req.auth!.membership.role, + billingEmail, + }); + + if (!member) { + res.status(404).json({ error: 'Flock member not found or cannot be changed.' }); + return; + } + + await writeAuditLog(req.auth!, 'workspace_member.role_updated', 'workspace_member', member.id, member.name, { + role: member.role, + }); + res.json({ member: normalizeWorkspaceMember(member) }); + } catch (error) { + next(error); + } +}); + app.delete('/api/workspace/members/:memberId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => { try { - const deleted = await deleteWorkspaceMember(req.params.memberId, req.auth!.workspace.id); + const billingEmail = req.auth!.workspace.billing_email ? normalizeEmail(req.auth!.workspace.billing_email) : ''; + const requesterIsBillingOwner = Boolean(billingEmail && billingEmail === normalizeEmail(req.auth!.user.email)); + const deleted = await deleteWorkspaceMember({ + memberId: req.params.memberId, + workspaceId: req.auth!.workspace.id, + requesterMemberId: req.auth!.membership.id, + requesterIsBillingOwner, + }); if (!deleted) { res.status(404).json({ error: 'Flock member not found or cannot be removed.' }); @@ -3398,7 +3441,6 @@ app.delete('/api/workspace/members/:memberId', requireAuth, requireWriteAccess, } await writeAuditLog(req.auth!, 'workspace_member.deleted', 'workspace_member', req.params.memberId); - await writeAuditLog(req.auth!, 'integration_token.revoked', 'integration_token', req.params.tokenId); res.status(204).send(); } catch (error) { next(error); diff --git a/backend/src/repositories/workspaceRepository.test.ts b/backend/src/repositories/workspaceRepository.test.ts index 2160bf1..fca4660 100644 --- a/backend/src/repositories/workspaceRepository.test.ts +++ b/backend/src/repositories/workspaceRepository.test.ts @@ -3,6 +3,7 @@ import test from 'node:test'; import { createWorkspace, + deleteWorkspaceMember, deleteWorkspaceIfEmpty, ensureDefaultWorkspaceForUser, ensurePersonalWorkspaceForUser, @@ -10,6 +11,7 @@ import { getPlatformAdminSummary, listOwnedWorkspacesByOwnerEmail, updateWorkspace, + updateWorkspaceMemberRole, } from './workspaceRepository.js'; import { mockDb } from '../test/mockDb.js'; import type { UserRow } from '../types.js'; @@ -259,6 +261,212 @@ test('listOwnedWorkspacesByOwnerEmail resolves accepted owner flocks by email', assert.match(calls[0].text, /workspaces\.id <> \$2/); }); +test('updateWorkspaceMemberRole changes a non-owner member role', 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: 'viewer', + 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: 'viewer', + requesterMemberId: 'owner-member', + requesterIsBillingOwner: false, + requesterRole: 'owner', + billingEmail: 'billing@example.com', + }); + + assert.equal(member?.role, 'viewer'); + assert.deepEqual(calls[0].params, ['member-1', 42, 'viewer', false, 'owner-member', 'billing@example.com', 'owner']); + assert.match(calls[0].text, /UPDATE workspace_members/); + assert.match(calls[0].text, /role <> 'owner'/); +}); + +test('updateWorkspaceMemberRole returns null when no non-owner member matches', async () => { + mockDb({ + rowCount: 0, + rows: [], + }); + + const member = await updateWorkspaceMemberRole({ + memberId: 'owner-member', + workspaceId: 42, + role: 'viewer', + requesterMemberId: 'owner-member', + requesterIsBillingOwner: false, + requesterRole: 'owner', + billingEmail: 'billing@example.com', + }); + + assert.equal(member, null); +}); + +test('updateWorkspaceMemberRole lets the billing owner change another owner role', async () => { + const { calls } = mockDb({ + rowCount: 1, + rows: [ + { + id: 'other-owner', + workspace_id: 42, + user_id: 'user-2', + invite_email: 'other@example.com', + name: 'Other Owner', + role: 'assistant', + accepted_at: '2026-04-14T00:00:00.000Z', + created_at: '2026-04-14T00:00:00.000Z', + }, + ], + }); + + const member = await updateWorkspaceMemberRole({ + memberId: 'other-owner', + workspaceId: 42, + role: 'assistant', + requesterMemberId: 'billing-owner', + requesterIsBillingOwner: true, + requesterRole: 'owner', + billingEmail: 'billing@example.com', + }); + + assert.equal(member?.role, 'assistant'); + assert.deepEqual(calls[0].params, ['other-owner', 42, 'assistant', true, 'billing-owner', 'billing@example.com', 'owner']); + assert.match(calls[0].text, /id <> \$5/); +}); + +test('updateWorkspaceMemberRole does not let the billing owner change their own owner role', async () => { + mockDb({ + rowCount: 0, + rows: [], + }); + + const member = await updateWorkspaceMemberRole({ + memberId: 'billing-owner', + workspaceId: 42, + role: 'assistant', + requesterMemberId: 'billing-owner', + requesterIsBillingOwner: true, + requesterRole: 'owner', + billingEmail: 'billing@example.com', + }); + + assert.equal(member, null); +}); + +test('updateWorkspaceMemberRole lets a non-billing owner change another non-billing owner role', async () => { + const { calls } = mockDb({ + rowCount: 1, + rows: [ + { + id: 'other-owner', + workspace_id: 42, + user_id: 'user-2', + invite_email: 'other@example.com', + name: 'Other Owner', + role: 'assistant', + accepted_at: '2026-04-14T00:00:00.000Z', + created_at: '2026-04-14T00:00:00.000Z', + }, + ], + }); + + const member = await updateWorkspaceMemberRole({ + memberId: 'other-owner', + workspaceId: 42, + role: 'assistant', + requesterMemberId: 'non-billing-owner', + requesterIsBillingOwner: false, + requesterRole: 'owner', + billingEmail: 'billing@example.com', + }); + + assert.equal(member?.role, 'assistant'); + assert.deepEqual(calls[0].params, ['other-owner', 42, 'assistant', false, 'non-billing-owner', 'billing@example.com', 'owner']); + assert.match(calls[0].text, /LOWER\(BTRIM\(COALESCE\(invite_email, email\)\)\) <> LOWER\(BTRIM\(\$6\)\)/); +}); + +test('updateWorkspaceMemberRole does not let a non-billing owner change the billing owner role', async () => { + mockDb({ + rowCount: 0, + rows: [], + }); + + const member = await updateWorkspaceMemberRole({ + memberId: 'billing-owner', + workspaceId: 42, + role: 'assistant', + 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, + rows: [{ id: 'member-1' }], + }); + + const deleted = await deleteWorkspaceMember({ + memberId: 'member-1', + workspaceId: 42, + requesterMemberId: 'owner-member', + requesterIsBillingOwner: false, + }); + + assert.equal(deleted, true); + assert.deepEqual(calls[0].params, ['member-1', 42, false, 'owner-member']); + assert.match(calls[0].text, /role <> 'owner'/); +}); + +test('deleteWorkspaceMember lets the billing owner remove another owner', async () => { + const { calls } = mockDb({ + rowCount: 1, + rows: [{ id: 'other-owner' }], + }); + + const deleted = await deleteWorkspaceMember({ + memberId: 'other-owner', + workspaceId: 42, + requesterMemberId: 'billing-owner', + requesterIsBillingOwner: true, + }); + + assert.equal(deleted, true); + assert.deepEqual(calls[0].params, ['other-owner', 42, true, 'billing-owner']); + assert.match(calls[0].text, /id <> \$4/); +}); + +test('deleteWorkspaceMember does not let the billing owner remove their own owner membership', async () => { + mockDb({ + rowCount: 0, + rows: [], + }); + + const deleted = await deleteWorkspaceMember({ + memberId: 'billing-owner', + workspaceId: 42, + requesterMemberId: 'billing-owner', + requesterIsBillingOwner: true, + }); + + assert.equal(deleted, false); +}); + test('getPlatformAdminSummary counts memorialized birds separately', async () => { const { calls } = mockDb({ rowCount: 1, diff --git a/backend/src/repositories/workspaceRepository.ts b/backend/src/repositories/workspaceRepository.ts index 44d9171..565bcbd 100644 --- a/backend/src/repositories/workspaceRepository.ts +++ b/backend/src/repositories/workspaceRepository.ts @@ -364,19 +364,77 @@ export const upsertWorkspaceMember = async ({ return result.rows[0] ?? null; }; -export const deleteWorkspaceMember = async (memberId: string, workspaceId: number) => { +export const deleteWorkspaceMember = async ({ + memberId, + workspaceId, + requesterMemberId, + requesterIsBillingOwner, +}: { + memberId: string; + workspaceId: number; + requesterMemberId: string; + requesterIsBillingOwner: boolean; +}) => { const result = await db.query<{ id: string }>( `DELETE FROM workspace_members WHERE id = $1 AND workspace_id = $2 - AND role <> 'owner' + AND ( + role <> 'owner' + OR ( + $3 = TRUE + AND id <> $4 + ) + ) RETURNING id`, - [memberId, workspaceId], + [memberId, workspaceId, requesterIsBillingOwner, requesterMemberId], ); return Boolean(result.rowCount); }; +export const updateWorkspaceMemberRole = async ({ + memberId, + workspaceId, + role, + requesterMemberId, + requesterIsBillingOwner, + requesterRole, + billingEmail, +}: { + memberId: string; + workspaceId: number; + role: WorkspaceMemberRow['role']; + requesterMemberId: string; + requesterIsBillingOwner: boolean; + requesterRole: WorkspaceMemberRow['role']; + billingEmail: string; +}) => { + const result = await db.query( + `UPDATE workspace_members + SET role = $3 + WHERE id = $1 + AND workspace_id = $2 + AND ( + role <> 'owner' + OR ( + id <> $5 + AND ( + $4 = TRUE + OR ( + $7 = 'owner' + AND LOWER(BTRIM(COALESCE(invite_email, email))) <> LOWER(BTRIM($6)) + ) + ) + ) + ) + RETURNING id, workspace_id, user_id, COALESCE(invite_email, email) AS invite_email, name, role, accepted_at::text, created_at`, + [memberId, workspaceId, role, requesterIsBillingOwner, requesterMemberId, billingEmail, requesterRole], + ); + + return result.rows[0] ?? null; +}; + export const listRescueWorkspacesForAdmin = async () => { const result = await db.query< WorkspaceRow & { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8774d28..d0ef1b0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1676,6 +1676,7 @@ function App() { const [editingMedicationId, setEditingMedicationId] = useState(''); const [deletingMedicationId, setDeletingMedicationId] = useState(''); const [savingMedicationAdministrationId, setSavingMedicationAdministrationId] = useState(''); + const [updatingWorkspaceMemberId, setUpdatingWorkspaceMemberId] = useState(''); const [removingWorkspaceMemberId, setRemovingWorkspaceMemberId] = useState(''); const [expandedSettingsSection, setExpandedSettingsSection] = useState(null); @@ -1683,6 +1684,11 @@ function App() { () => birds.find((bird) => bird.id === selectedBirdId) ?? null, [birds, selectedBirdId], ); + const isBillingOwner = Boolean( + authSession?.user.email && + workspace?.billingEmail && + authSession.user.email.trim().toLowerCase() === workspace.billingEmail.trim().toLowerCase(), + ); const selectedBirdAdoptionTransferCode = selectedBird ? adoptionTransferCodes[selectedBird.id] ?? '' : ''; const editingBird = useMemo( () => birds.find((bird) => bird.id === editingBirdId) ?? null, @@ -4986,7 +4992,15 @@ function App() { email: data.member.inviteEmail, }; - setWorkspaceMembers((current) => [...current, nextMember]); + setWorkspaceMembers((current) => { + const existingIndex = current.findIndex((member) => member.id === nextMember.id); + + if (existingIndex === -1) { + return [...current, nextMember]; + } + + return current.map((member) => (member.id === nextMember.id ? nextMember : member)); + }); setWorkspaceMemberForm(emptyWorkspaceMemberForm); } catch (memberError) { setError(memberError instanceof Error ? memberError.message : 'Unable to add rescue team member.'); @@ -4995,6 +5009,40 @@ function App() { } }; + const handleUpdateWorkspaceMemberRole = async (memberId: string, role: WorkspaceRole) => { + setError(''); + setUpdatingWorkspaceMemberId(memberId); + + try { + const response = await apiFetch(`/workspace/members/${memberId}`, authToken, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ role }), + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to update collaborator role.')); + } + + const data = (await readJsonSafely<{ member?: WorkspaceMember }>(response)) ?? {}; + + if (!data.member) { + throw new Error('Unable to update collaborator role.'); + } + + const updatedMember = { + ...data.member, + email: data.member.inviteEmail, + }; + + setWorkspaceMembers((current) => current.map((member) => (member.id === memberId ? updatedMember : member))); + } catch (memberError) { + setError(memberError instanceof Error ? memberError.message : 'Unable to update collaborator role.'); + } finally { + setUpdatingWorkspaceMemberId(''); + } + }; + const handleRemoveWorkspaceMember = async (memberId: string) => { setError(''); setRemovingWorkspaceMemberId(memberId); @@ -7877,7 +7925,6 @@ function App() { }) } > - @@ -7890,23 +7937,63 @@ function App() {
{workspaceMembers.length ? ( - workspaceMembers.map((member) => ( -
- {member.name} - - {formatWorkspaceRole(member.role)} • {member.email || member.inviteEmail} - - {member.acceptedAt ? 'Active access' : 'Invitation pending'} - -
- )) + workspaceMembers.map((member) => { + const memberEmail = member.email || member.inviteEmail || ''; + const memberIsBillingOwner = Boolean( + workspace?.billingEmail && + memberEmail.trim().toLowerCase() === workspace.billingEmail.trim().toLowerCase(), + ); + const canRemoveOwner = member.role === 'owner' && isBillingOwner && member.id !== activeMembership?.id; + const canChangeOwnerRole = + member.role === 'owner' && + activeMembership?.role === 'owner' && + member.id !== activeMembership.id && + (isBillingOwner || !memberIsBillingOwner); + const canRemoveMember = member.role !== 'owner' || canRemoveOwner; + + return ( +
+ {member.name} + + {formatWorkspaceRole(member.role)} • {member.email || member.inviteEmail} + + {member.acceptedAt ? 'Active access' : 'Invitation pending'} + + +
+ ); + }) ) : (
No collaborators yet