Adjusting role actions
Deploy / deploy-dev (push) Successful in 2m29s
Deploy / deploy-prod (push) Has been skipped

This commit is contained in:
blaisadmin
2026-06-05 21:09:31 -04:00
parent c3bec15c63
commit bb589e3489
4 changed files with 419 additions and 24 deletions
+44 -2
View File
@@ -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);
@@ -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,
@@ -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<WorkspaceMemberRow>(
`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 & {
+106 -19
View File
@@ -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<SettingsSection | null>(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() {
})
}
>
<option value="owner">Owner</option>
<option value="assistant">Assistant</option>
<option value="caregiver">Caregiver</option>
<option value="viewer">Viewer</option>
@@ -7890,23 +7937,63 @@ function App() {
<div className="recent-list">
{workspaceMembers.length ? (
workspaceMembers.map((member) => (
<article key={member.id} className="vet-visit-card">
<strong>{member.name}</strong>
<span>
{formatWorkspaceRole(member.role)} {member.email || member.inviteEmail}
</span>
<small>{member.acceptedAt ? 'Active access' : 'Invitation pending'}</small>
<button
className="secondary-button"
onClick={() => handleRemoveWorkspaceMember(member.id)}
type="button"
disabled={removingWorkspaceMemberId === member.id || member.role === 'owner'}
>
{member.role === 'owner' ? 'Owner' : removingWorkspaceMemberId === member.id ? 'Removing...' : 'Remove'}
</button>
</article>
))
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 (
<article key={member.id} className="vet-visit-card">
<strong>{member.name}</strong>
<span>
{formatWorkspaceRole(member.role)} {member.email || member.inviteEmail}
</span>
<small>{member.acceptedAt ? 'Active access' : 'Invitation pending'}</small>
<label>
Role
<select
value={member.role}
onChange={(event) => handleUpdateWorkspaceMemberRole(member.id, event.target.value as WorkspaceRole)}
disabled={
(member.role === 'owner' && !canChangeOwnerRole) ||
updatingWorkspaceMemberId === member.id ||
removingWorkspaceMemberId === member.id
}
>
{member.role === 'owner' ? <option value="owner">Owner</option> : null}
<option value="assistant">Assistant</option>
<option value="caregiver">Caregiver</option>
<option value="viewer">Viewer</option>
</select>
</label>
<button
className="danger-button"
onClick={() => handleRemoveWorkspaceMember(member.id)}
type="button"
disabled={
removingWorkspaceMemberId === member.id ||
updatingWorkspaceMemberId === member.id ||
!canRemoveMember
}
>
{removingWorkspaceMemberId === member.id
? 'Removing...'
: canRemoveMember
? 'Remove access'
: 'Owner'}
</button>
</article>
);
})
) : (
<article className="vet-visit-card empty-card">
<strong>No collaborators yet</strong>