Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions app/admin/members/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
applyOptimisticRole,
applyOptimisticRemoveRole,
} from "@/lib/api/optimistic";
import { roleRemovalConfirmationMessage } from "@/lib/api/role-removal";
import { AddressText } from "@/components/wallet/address-text";
import { isWalletAddress, normalizeAddress } from "@/lib/wallet/address";

Expand Down Expand Up @@ -202,6 +203,21 @@ export default function MembersPage() {
},
});

const requestRoleRemoval = (member: MemberRow, roleToRemove: Role) => {
const confirmationMessage = roleRemovalConfirmationMessage(
member.address,
roleToRemove,
member.roles,
);

if (confirmationMessage && !window.confirm(confirmationMessage)) return;

removeRoleMutation.mutate({
address: member.address,
role: roleToRemove,
});
};

return (
<AdminGuard>
<div className="space-y-4">
Expand Down Expand Up @@ -281,7 +297,7 @@ export default function MembersPage() {
className="text-sm text-green-700 dark:text-green-400"
role="status"
>
Role "{successAssignment.role}" saved for{" "}
Role &quot;{successAssignment.role}&quot; saved for{" "}
<AddressText
address={successAssignment.address}
className="text-green-700 dark:text-green-400"
Expand Down Expand Up @@ -343,12 +359,7 @@ export default function MembersPage() {
key={r}
type="button"
className="inline-flex items-center rounded-md border border-transparent bg-secondary px-2 py-0.5 text-xs font-semibold text-secondary-foreground transition-colors hover:bg-destructive hover:text-destructive-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
onClick={() =>
removeRoleMutation.mutate({
address: m.address,
role: r,
})
}
onClick={() => requestRoleRemoval(m, r)}
aria-label={`Remove ${r} role from ${m.address}`}
title={`Remove ${r} role`}
>
Expand Down
29 changes: 29 additions & 0 deletions lib/api/role-removal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { Role } from './types'

/**
* Returns true when removing a role should require an explicit confirmation.
*
* Admin removal is sensitive because it changes governance access. Removing a
* member's last role is also sensitive because it can leave the member without
* any role-based access path.
*/
export function roleRemovalNeedsConfirmation(
role: Role,
currentRoles: readonly Role[],
): boolean {
return role === 'admin' || currentRoles.length <= 1
}

export function roleRemovalConfirmationMessage(
address: string,
role: Role,
currentRoles: readonly Role[],
): string | null {
if (!roleRemovalNeedsConfirmation(role, currentRoles)) return null

const reasons = []
if (role === 'admin') reasons.push('the admin role')
if (currentRoles.length <= 1) reasons.push("the member's last remaining role")

return `Remove ${role} role from ${address}? This removes ${reasons.join(' and ')}.`
}
37 changes: 37 additions & 0 deletions test/role-removal-confirmation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { test } from 'node:test'
import * as assert from 'node:assert/strict'
import {
roleRemovalConfirmationMessage,
roleRemovalNeedsConfirmation,
} from '../lib/api/role-removal'

test('role removal confirmation is not required for non-sensitive extra roles', () => {
assert.equal(roleRemovalNeedsConfirmation('moderator', ['member', 'moderator']), false)
assert.equal(
roleRemovalConfirmationMessage('0xabc', 'moderator', ['member', 'moderator']),
null,
)
})

test('role removal confirmation is required for admin roles', () => {
assert.equal(roleRemovalNeedsConfirmation('admin', ['member', 'admin']), true)
assert.equal(
roleRemovalConfirmationMessage('0xabc', 'admin', ['member', 'admin']),
'Remove admin role from 0xabc? This removes the admin role.',
)
})

test('role removal confirmation is required for a member last role', () => {
assert.equal(roleRemovalNeedsConfirmation('member', ['member']), true)
assert.equal(
roleRemovalConfirmationMessage('0xabc', 'member', ['member']),
"Remove member role from 0xabc? This removes the member's last remaining role.",
)
})

test('role removal confirmation explains when admin is also the last role', () => {
assert.equal(
roleRemovalConfirmationMessage('0xabc', 'admin', ['admin']),
"Remove admin role from 0xabc? This removes the admin role and the member's last remaining role.",
)
})