Adjusting role actions
This commit is contained in:
+44
-2
@@ -108,6 +108,7 @@ import {
|
|||||||
setWorkspaceSubscriptionStatusByStripeSubscriptionId,
|
setWorkspaceSubscriptionStatusByStripeSubscriptionId,
|
||||||
updateRescueVerificationStatus,
|
updateRescueVerificationStatus,
|
||||||
updateWorkspace,
|
updateWorkspace,
|
||||||
|
updateWorkspaceMemberRole,
|
||||||
upsertWorkspaceMember,
|
upsertWorkspaceMember,
|
||||||
} from './repositories/workspaceRepository.js';
|
} from './repositories/workspaceRepository.js';
|
||||||
import type {
|
import type {
|
||||||
@@ -3208,9 +3209,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) => {
|
app.delete('/api/workspace/members/:memberId', requireAuth, requireWriteAccess, requireWorkspaceRole(['owner', 'assistant']), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
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) {
|
if (!deleted) {
|
||||||
res.status(404).json({ error: 'Flock member not found or cannot be removed.' });
|
res.status(404).json({ error: 'Flock member not found or cannot be removed.' });
|
||||||
@@ -3218,7 +3261,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!, 'workspace_member.deleted', 'workspace_member', req.params.memberId);
|
||||||
await writeAuditLog(req.auth!, 'integration_token.revoked', 'integration_token', req.params.tokenId);
|
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
createWorkspace,
|
createWorkspace,
|
||||||
|
deleteWorkspaceMember,
|
||||||
deleteWorkspaceIfEmpty,
|
deleteWorkspaceIfEmpty,
|
||||||
ensureDefaultWorkspaceForUser,
|
ensureDefaultWorkspaceForUser,
|
||||||
ensurePersonalWorkspaceForUser,
|
ensurePersonalWorkspaceForUser,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
getPlatformAdminSummary,
|
getPlatformAdminSummary,
|
||||||
listOwnedWorkspacesByOwnerEmail,
|
listOwnedWorkspacesByOwnerEmail,
|
||||||
updateWorkspace,
|
updateWorkspace,
|
||||||
|
updateWorkspaceMemberRole,
|
||||||
} from './workspaceRepository.js';
|
} from './workspaceRepository.js';
|
||||||
import { mockDb } from '../test/mockDb.js';
|
import { mockDb } from '../test/mockDb.js';
|
||||||
import type { UserRow } from '../types.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/);
|
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 () => {
|
test('getPlatformAdminSummary counts memorialized birds separately', async () => {
|
||||||
const { calls } = mockDb({
|
const { calls } = mockDb({
|
||||||
rowCount: 1,
|
rowCount: 1,
|
||||||
|
|||||||
@@ -364,19 +364,77 @@ export const upsertWorkspaceMember = async ({
|
|||||||
return result.rows[0] ?? null;
|
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 }>(
|
const result = await db.query<{ id: string }>(
|
||||||
`DELETE FROM workspace_members
|
`DELETE FROM workspace_members
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
AND workspace_id = $2
|
AND workspace_id = $2
|
||||||
AND role <> 'owner'
|
AND (
|
||||||
|
role <> 'owner'
|
||||||
|
OR (
|
||||||
|
$3 = TRUE
|
||||||
|
AND id <> $4
|
||||||
|
)
|
||||||
|
)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[memberId, workspaceId],
|
[memberId, workspaceId, requesterIsBillingOwner, requesterMemberId],
|
||||||
);
|
);
|
||||||
|
|
||||||
return Boolean(result.rowCount);
|
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 () => {
|
export const listRescueWorkspacesForAdmin = async () => {
|
||||||
const result = await db.query<
|
const result = await db.query<
|
||||||
WorkspaceRow & {
|
WorkspaceRow & {
|
||||||
|
|||||||
+106
-19
@@ -1621,6 +1621,7 @@ function App() {
|
|||||||
const [editingMedicationId, setEditingMedicationId] = useState('');
|
const [editingMedicationId, setEditingMedicationId] = useState('');
|
||||||
const [deletingMedicationId, setDeletingMedicationId] = useState('');
|
const [deletingMedicationId, setDeletingMedicationId] = useState('');
|
||||||
const [savingMedicationAdministrationId, setSavingMedicationAdministrationId] = useState('');
|
const [savingMedicationAdministrationId, setSavingMedicationAdministrationId] = useState('');
|
||||||
|
const [updatingWorkspaceMemberId, setUpdatingWorkspaceMemberId] = useState('');
|
||||||
const [removingWorkspaceMemberId, setRemovingWorkspaceMemberId] = useState('');
|
const [removingWorkspaceMemberId, setRemovingWorkspaceMemberId] = useState('');
|
||||||
const [expandedSettingsSection, setExpandedSettingsSection] = useState<SettingsSection | null>(null);
|
const [expandedSettingsSection, setExpandedSettingsSection] = useState<SettingsSection | null>(null);
|
||||||
|
|
||||||
@@ -1628,6 +1629,11 @@ function App() {
|
|||||||
() => birds.find((bird) => bird.id === selectedBirdId) ?? null,
|
() => birds.find((bird) => bird.id === selectedBirdId) ?? null,
|
||||||
[birds, selectedBirdId],
|
[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 selectedBirdAdoptionTransferCode = selectedBird ? adoptionTransferCodes[selectedBird.id] ?? '' : '';
|
||||||
const editingBird = useMemo(
|
const editingBird = useMemo(
|
||||||
() => birds.find((bird) => bird.id === editingBirdId) ?? null,
|
() => birds.find((bird) => bird.id === editingBirdId) ?? null,
|
||||||
@@ -4712,7 +4718,15 @@ function App() {
|
|||||||
email: data.member.inviteEmail,
|
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);
|
setWorkspaceMemberForm(emptyWorkspaceMemberForm);
|
||||||
} catch (memberError) {
|
} catch (memberError) {
|
||||||
setError(memberError instanceof Error ? memberError.message : 'Unable to add rescue team member.');
|
setError(memberError instanceof Error ? memberError.message : 'Unable to add rescue team member.');
|
||||||
@@ -4721,6 +4735,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) => {
|
const handleRemoveWorkspaceMember = async (memberId: string) => {
|
||||||
setError('');
|
setError('');
|
||||||
setRemovingWorkspaceMemberId(memberId);
|
setRemovingWorkspaceMemberId(memberId);
|
||||||
@@ -7352,7 +7400,6 @@ function App() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="owner">Owner</option>
|
|
||||||
<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>
|
||||||
@@ -7365,23 +7412,63 @@ function App() {
|
|||||||
|
|
||||||
<div className="recent-list">
|
<div className="recent-list">
|
||||||
{workspaceMembers.length ? (
|
{workspaceMembers.length ? (
|
||||||
workspaceMembers.map((member) => (
|
workspaceMembers.map((member) => {
|
||||||
<article key={member.id} className="vet-visit-card">
|
const memberEmail = member.email || member.inviteEmail || '';
|
||||||
<strong>{member.name}</strong>
|
const memberIsBillingOwner = Boolean(
|
||||||
<span>
|
workspace?.billingEmail &&
|
||||||
{formatWorkspaceRole(member.role)} • {member.email || member.inviteEmail}
|
memberEmail.trim().toLowerCase() === workspace.billingEmail.trim().toLowerCase(),
|
||||||
</span>
|
);
|
||||||
<small>{member.acceptedAt ? 'Active access' : 'Invitation pending'}</small>
|
const canRemoveOwner = member.role === 'owner' && isBillingOwner && member.id !== activeMembership?.id;
|
||||||
<button
|
const canChangeOwnerRole =
|
||||||
className="secondary-button"
|
member.role === 'owner' &&
|
||||||
onClick={() => handleRemoveWorkspaceMember(member.id)}
|
activeMembership?.role === 'owner' &&
|
||||||
type="button"
|
member.id !== activeMembership.id &&
|
||||||
disabled={removingWorkspaceMemberId === member.id || member.role === 'owner'}
|
(isBillingOwner || !memberIsBillingOwner);
|
||||||
>
|
const canRemoveMember = member.role !== 'owner' || canRemoveOwner;
|
||||||
{member.role === 'owner' ? 'Owner' : removingWorkspaceMemberId === member.id ? 'Removing...' : 'Remove'}
|
|
||||||
</button>
|
return (
|
||||||
</article>
|
<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">
|
<article className="vet-visit-card empty-card">
|
||||||
<strong>No collaborators yet</strong>
|
<strong>No collaborators yet</strong>
|
||||||
|
|||||||
Reference in New Issue
Block a user