From 4e7242a29560e224b24545eef235ffa3ad3ea94c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 20 Jul 2026 11:35:48 -0400 Subject: [PATCH] feat(concord): assign CORD-04 roles from the Members roster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Account.grantConcordRole` had been implemented with zero callers, so role grants were unreachable from the app while the changelog claimed they ship. This adds the missing surface: a "Roles…" item beside "Make admin" opening a multi-select over the roles the viewer may hand out. Both rank rules are enforced by delegating to `AuthorityResolver` rather than reimplementing them: - assignable roles are `roles().filter { myRank < it.position }` — the fold drops a grant whose granter does not strictly outrank every assigned role, so offering one at or above our own position would publish an edition that every client then silently discards; - reachable members are `authority.canActOn(me, target, MANAGE_ROLES)`, which already folds the whole rule (hold the bit, not banned, target isn't the owner, strictly outrank) and makes self-promotion fall out for free. Out-of-reach members show the item disabled *with a reason* instead of omitting it, so there is no silently no-op control. The grant REPLACES a member's role set rather than merging into it, so the dialog preselects their current roles. That preselection is provably complete: a member's rank is the lowest position they hold, and the dialog only opens when we strictly outrank that rank, so every role they hold sits strictly below us and is therefore rendered — no held role can be silently stripped. `amy concord roles` also gained a `grants:` section reading the post-fixpoint `authority.roleHolders()`. It previously printed role *definitions* but never the *grants*, which made the fold outcome unverifiable from the CLI; a rank-violating grant now shows up as visibly absent rather than as if it landed. Device-verified on a test community (Admin/QA Lead/Helper/Greeter): the picker hides roles above the viewer, disables on members who outrank them, preselects correctly, and a saved grant survived the fold and a fresh relay drain read back from a second client. Co-Authored-By: Claude Opus 4.8 --- .../ui/screen/loggedIn/AccountViewModel.kt | 18 +++ .../concord/ConcordMembersScreen.kt | 147 +++++++++++++++++- amethyst/src/main/res/values/strings.xml | 7 + .../cli/commands/ConcordModCommands.kt | 11 ++ 4 files changed, 180 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 1621a794e4..794915bb44 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -632,6 +632,24 @@ class AccountViewModel( if (makeAdmin) account.makeConcordAdmin(communityId, member) else account.removeConcordAdmin(communityId, member) } + /** + * Set [member]'s CORD-04 roles in [communityId] to exactly [roleIds] (empty revokes + * everything). The Control Plane grant REPLACES the member's role set rather than + * merging into it, so [roleIds] must be the *complete* list the member should end up + * holding — the caller (the Members roster dialog) preselects their current roles for + * that reason. Authority is re-checked at fold time by every client, so the caller must + * also have offered only roles it strictly outranks on a member it strictly outranks. + */ + fun setConcordRoles( + communityId: String, + member: HexKey, + roleIds: List, + ) = launchSigner { + if (!account.grantConcordRole(communityId, member, roleIds)) { + toastManager.toast(R.string.concord_members_roles_title, R.string.concord_members_roles_failed) + } + } + /** Ban/unban [member] from [communityId] (from the Members roster). */ fun setConcordBan( communityId: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt index feb28e6c4b..b443ffd3fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -30,6 +32,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -43,6 +46,7 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -124,12 +128,28 @@ fun ConcordMembersScreen( .minByOrNull { r -> r.position } ?.name ?.takeIf { n -> n.isNotBlank() } - RosterEntry(it, ConcordMembership.of(authority, it), roleName) + RosterEntry(it, ConcordMembership.of(authority, it), roleName, authority.rolesOf(it)) }.sortedWith(compareBy({ it.membership.sortRank() }, { it.pubkey })) } val iAmOwner = state?.authority?.isOwner(myPubKey) == true val iCanBan = state?.let { it.authority.isOwner(myPubKey) || it.authority.effectivePermissions(myPubKey).has(ConcordPermissions.BAN) } == true + val iCanManageRoles = state?.authority?.hasPermission(myPubKey, ConcordPermissions.MANAGE_ROLES) == true + + // The roles this viewer may actually hand out. The fold drops a grant whose granter does + // not *strictly* outrank every assigned role, so offering a role at or above our own + // position would publish an edition that every client then silently discards. The owner + // sits at rank 0 and no role may claim position 0, so this admits everything for them. + val assignableRoles = + remember(state, myPubKey) { + val authority = state?.authority ?: return@remember emptyList() + val myRank = authority.rank(myPubKey) ?: return@remember emptyList() + authority + .roles() + .filter { (_, role) -> myRank < role.position } + .map { (id, role) -> AssignableRole(id, role.name, role.position) } + .sortedBy { it.position } + } Scaffold( topBar = { @@ -168,6 +188,12 @@ fun ConcordMembersScreen( isSelf = entry.pubkey.equals(myPubKey, ignoreCase = true), viewerIsOwner = iAmOwner, viewerCanBan = iCanBan, + viewerCanManageRoles = iCanManageRoles, + // canActOn folds the whole rank rule for us: we hold MANAGE_ROLES, we're not + // banned, the target isn't the owner (unremovable), and we strictly outrank + // them — which also rules out acting on ourselves (equal cannot act on equal). + canManageRolesOnTarget = state?.authority?.canActOn(myPubKey, entry.pubkey, ConcordPermissions.MANAGE_ROLES) == true, + assignableRoles = assignableRoles, accountViewModel = accountViewModel, nav = nav, ) @@ -185,6 +211,9 @@ private fun ConcordMemberRow( isSelf: Boolean, viewerIsOwner: Boolean, viewerCanBan: Boolean, + viewerCanManageRoles: Boolean, + canManageRolesOnTarget: Boolean, + assignableRoles: List, accountViewModel: AccountViewModel, nav: INav, ) { @@ -199,7 +228,31 @@ private fun ConcordMemberRow( val canBan = viewerCanBan && !isOwnerTarget && !isSelf // Hard removal (CORD-06 Refounding) rotates the community key; same authority as ban. val canRemove = viewerCanBan && !isOwnerTarget && !isSelf - val hasMenu = canToggleAdmin || canBan || canRemove + // Shown to any MANAGE_ROLES holder, but disabled with a reason when this particular + // member (or every defined role) is out of our reach — a grant we don't outrank + // publishes fine and is then dropped by every client's fold, so a silently no-op + // control would be worse than none. The owner's own row never offers it: the owner + // is unremovable and outranks everyone, so canManageRolesOnTarget is false there. + val rolesBlockedReason = + when { + !canManageRolesOnTarget -> stringRes(R.string.concord_members_roles_out_of_reach) + assignableRoles.isEmpty() -> stringRes(R.string.concord_members_roles_none_assignable) + else -> null + } + val hasMenu = canToggleAdmin || canBan || canRemove || viewerCanManageRoles + + var editRoles by remember { mutableStateOf(false) } + if (editRoles) { + ConcordRolesDialog( + assignable = assignableRoles, + current = entry.roleIds, + onConfirm = { selected -> + accountViewModel.setConcordRoles(communityId, entry.pubkey, selected) + editRoles = false + }, + onDismiss = { editRoles = false }, + ) + } var confirmRemove by remember { mutableStateOf(false) } if (confirmRemove) { @@ -212,7 +265,7 @@ private fun ConcordMemberRow( ) } - androidx.compose.foundation.layout.Row( + Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -241,6 +294,27 @@ private fun ConcordMemberRow( }, ) } + if (viewerCanManageRoles) { + DropdownMenuItem( + text = { + Column { + Text(stringRes(R.string.concord_members_roles)) + rolesBlockedReason?.let { + Text( + it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + enabled = rolesBlockedReason == null, + onClick = { + editRoles = true + expanded = false + }, + ) + } if (canBan) { DropdownMenuItem( text = { Text(stringRes(if (isBanned) R.string.concord_members_unban else R.string.concord_members_ban)) }, @@ -298,6 +372,64 @@ private fun MemberBadge( } } +/** + * Multi-select over the roles the viewer may assign (CORD-04 role grant). + * + * A grant REPLACES the member's role set rather than merging into it, so the box starts + * checked on everything they already hold — otherwise saving would silently strip the + * roles that weren't re-checked. Every currently-held role is guaranteed to appear in + * [assignable]: the caller only opens this when it strictly outranks the member, and the + * member's rank is the *lowest* position they hold, so all of their roles sit strictly + * below us too. Like "Make admin", saving applies immediately — no extra confirmation. + */ +@Composable +private fun ConcordRolesDialog( + assignable: List, + current: Set, + onConfirm: (List) -> Unit, + onDismiss: () -> Unit, +) { + val selected = remember(current) { mutableStateListOf().apply { addAll(current) } } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.concord_members_roles_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + stringRes(R.string.concord_members_roles_message), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + assignable.forEach { role -> + val checked = role.id in selected + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { + if (checked) selected.remove(role.id) else selected.add(role.id) + }.padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Checkbox(checked = checked, onCheckedChange = null) + Text(role.name.ifBlank { role.id.take(8) }, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(selected.toList()) }) { + Text(stringRes(R.string.concord_members_roles_save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } + }, + ) +} + /** Confirms a hard removal — spells out that it rotates the community key (CORD-06). */ @Composable private fun ConcordRemoveMemberDialog( @@ -324,6 +456,15 @@ private class RosterEntry( val membership: ConcordMembership, /** The member's most-privileged role name (e.g. "Admin"/"Moderator"), null for a plain member. */ val roleName: String?, + /** Every role id the member currently holds — the preselection for the role picker. */ + val roleIds: Set, +) + +/** One role the viewer is allowed to hand out, ordered by [position] (lower ranks higher). */ +private class AssignableRole( + val id: String, + val name: String, + val position: Long, ) /** Owner first, then admins, then plain members, then banned last. */ diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index cb13a8fdd5..f37c43ae9e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -381,6 +381,13 @@ Remove member? This rotates the community\'s encryption key so this member can no longer read anything sent afterwards. Everyone else is re-keyed automatically. This can\'t be undone. Remove + Roles… + Assign roles + Pick every role this member should hold. Unchecking a role removes it. + Save + You don\'t outrank this member + No roles you can assign + Could not update this member\'s roles. Owner Admin Banned diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt index 22f25b25e9..16fc1b2757 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt @@ -59,6 +59,17 @@ object ConcordModCommands { state.roles.map { (id, r) -> mapOf("id" to id, "name" to r.name, "position" to r.position, "permissions" to r.permissions) }, + // The role-holder roster AFTER the authority fixpoint, so a grant that was + // published but dropped on fold (granter didn't outrank the role or the member) + // is visibly absent here rather than looking like it landed. + "grants" to + state.authority.roleHolders().sorted().map { member -> + mapOf( + "member" to member, + "rank" to state.authority.rank(member), + "roles" to state.authority.rolesFor(member).map { it.name }, + ) + }, "banned" to ConcordModeration.currentBanned(editions, sc.communityId.hexToByteArray(), sc.owner).toList(), ), )