mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
feat(concord): assign CORD-04 roles from the Members roster
`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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
97b861dd5e
commit
4e7242a295
+18
@@ -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<String>,
|
||||
) = 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,
|
||||
|
||||
+144
-3
@@ -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<AssignableRole>()
|
||||
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<AssignableRole>,
|
||||
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<AssignableRole>,
|
||||
current: Set<String>,
|
||||
onConfirm: (List<String>) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val selected = remember(current) { mutableStateListOf<String>().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<String>,
|
||||
)
|
||||
|
||||
/** 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. */
|
||||
|
||||
@@ -381,6 +381,13 @@
|
||||
<string name="concord_members_remove_title">Remove member?</string>
|
||||
<string name="concord_members_remove_message">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.</string>
|
||||
<string name="concord_members_remove_confirm">Remove</string>
|
||||
<string name="concord_members_roles">Roles…</string>
|
||||
<string name="concord_members_roles_title">Assign roles</string>
|
||||
<string name="concord_members_roles_message">Pick every role this member should hold. Unchecking a role removes it.</string>
|
||||
<string name="concord_members_roles_save">Save</string>
|
||||
<string name="concord_members_roles_out_of_reach">You don\'t outrank this member</string>
|
||||
<string name="concord_members_roles_none_assignable">No roles you can assign</string>
|
||||
<string name="concord_members_roles_failed">Could not update this member\'s roles.</string>
|
||||
<string name="concord_role_owner">Owner</string>
|
||||
<string name="concord_role_admin">Admin</string>
|
||||
<string name="concord_role_banned">Banned</string>
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user