Merge pull request #2514 from vitorpamplona/claude/grant-user-privileges-JxW5T

Add admin privilege management for Marmot group members
This commit is contained in:
Vitor Pamplona
2026-04-22 20:49:15 -04:00
committed by GitHub
3 changed files with 232 additions and 0 deletions
@@ -2343,6 +2343,63 @@ class Account(
client.publish(outbound.signedEvent, groupRelays) client.publish(outbound.signedEvent, groupRelays)
} }
/**
* Grant admin privileges to [targetPubKey] in a Marmot MLS group by
* appending them to `admin_pubkeys` via a GroupContextExtensions commit.
*
* No-op if the group has no prior metadata (shouldn't happen outside the
* first bootstrap commit) or the target is already an admin. Callers
* must be an admin themselves — the MLS engine enforces this via the
* MIP-03 authorization gate in `enforceAuthorizedProposalSet`.
*/
suspend fun grantMarmotGroupAdmin(
nostrGroupId: HexKey,
targetPubKey: HexKey,
groupRelays: Set<NormalizedRelayUrl>,
) {
val manager = marmotManager ?: return
if (!isWriteable()) return
val metadata = manager.groupMetadata(nostrGroupId) ?: return
if (metadata.adminPubkeys.contains(targetPubKey)) return
val outboxRelayStrings = outboxRelays.flow.value.map { it.url }
val updated =
metadata
.copy(adminPubkeys = metadata.adminPubkeys + targetPubKey)
.withMergedRelays(outboxRelayStrings)
updateMarmotGroupMetadata(nostrGroupId, updated, groupRelays)
}
/**
* Revoke admin privileges from [targetPubKey]. Rejects any change that
* would leave the group with zero admins — MIP-03's admin-depletion guard
* in [com.vitorpamplona.quartz.marmot.mls.group.MlsGroup] would otherwise
* throw at commit time.
*/
suspend fun revokeMarmotGroupAdmin(
nostrGroupId: HexKey,
targetPubKey: HexKey,
groupRelays: Set<NormalizedRelayUrl>,
) {
val manager = marmotManager ?: return
if (!isWriteable()) return
val metadata = manager.groupMetadata(nostrGroupId) ?: return
if (!metadata.adminPubkeys.contains(targetPubKey)) return
val remaining = metadata.adminPubkeys.filter { it != targetPubKey }
check(remaining.isNotEmpty()) {
"Cannot revoke the last admin from a Marmot group (MIP-03)"
}
val outboxRelayStrings = outboxRelays.flow.value.map { it.url }
val updated =
metadata
.copy(adminPubkeys = remaining)
.withMergedRelays(outboxRelayStrings)
updateMarmotGroupMetadata(nostrGroupId, updated, groupRelays)
}
suspend fun createStatus(newStatus: String) = sendMyPublicAndPrivateOutbox(UserStatusAction.create(newStatus, signer)) suspend fun createStatus(newStatus: String) = sendMyPublicAndPrivateOutbox(UserStatusAction.create(newStatus, signer))
suspend fun publishCallSignaling(wrap: EphemeralGiftWrapEvent) { suspend fun publishCallSignaling(wrap: EphemeralGiftWrapEvent) {
@@ -1551,6 +1551,22 @@ class AccountViewModel(
account.removeMarmotGroupMember(nostrGroupId, targetLeafIndex, relays) account.removeMarmotGroupMember(nostrGroupId, targetLeafIndex, relays)
} }
suspend fun grantMarmotGroupAdmin(
nostrGroupId: String,
targetPubKey: String,
) {
val relays = marmotGroupRelays(nostrGroupId)
account.grantMarmotGroupAdmin(nostrGroupId, targetPubKey, relays)
}
suspend fun revokeMarmotGroupAdmin(
nostrGroupId: String,
targetPubKey: String,
) {
val relays = marmotGroupRelays(nostrGroupId)
account.revokeMarmotGroupAdmin(nostrGroupId, targetPubKey, relays)
}
suspend fun updateMarmotGroupMetadata( suspend fun updateMarmotGroupMetadata(
nostrGroupId: String, nostrGroupId: String,
name: String, name: String,
@@ -46,6 +46,8 @@ import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.PersonRemove import androidx.compose.material.icons.filled.PersonRemove
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.outlined.StarBorder
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@@ -115,6 +117,8 @@ fun MarmotGroupInfoScreen(
var showLeaveDialog by remember { mutableStateOf(false) } var showLeaveDialog by remember { mutableStateOf(false) }
var isLeaving by remember { mutableStateOf(false) } var isLeaving by remember { mutableStateOf(false) }
var memberToRemove by remember { mutableStateOf<GroupMemberInfo?>(null) } var memberToRemove by remember { mutableStateOf<GroupMemberInfo?>(null) }
var memberToPromote by remember { mutableStateOf<GroupMemberInfo?>(null) }
var memberToDemote by remember { mutableStateOf<GroupMemberInfo?>(null) }
var addSearchInput by remember { mutableStateOf("") } var addSearchInput by remember { mutableStateOf("") }
var addStatus by remember { mutableStateOf<String?>(null) } var addStatus by remember { mutableStateOf<String?>(null) }
var isAddError by remember { mutableStateOf(false) } var isAddError by remember { mutableStateOf(false) }
@@ -224,17 +228,30 @@ fun MarmotGroupInfoScreen(
) )
} }
val isMeAdmin = myPubkey in adminPubkeys
items(members, key = { it.leafIndex }) { member -> items(members, key = { it.leafIndex }) { member ->
val isMe = member.pubkey == myPubkey val isMe = member.pubkey == myPubkey
val isAdmin = member.pubkey in adminPubkeys val isAdmin = member.pubkey in adminPubkeys
// Only admins can mutate other members' roles. Self-promote
// is a no-op; self-demote must go through the leave flow so
// we hide the toggle for the current user.
val canToggleAdmin = isMeAdmin && !isMe
// Guard against removing the last admin (MIP-03 depletion
// guard would otherwise reject the commit).
val canDemote = canToggleAdmin && isAdmin && adminPubkeys.size > 1
val canPromote = canToggleAdmin && !isAdmin
MemberRow( MemberRow(
member = member, member = member,
isMe = isMe, isMe = isMe,
isAdmin = isAdmin, isAdmin = isAdmin,
canRemove = !isMe, canRemove = !isMe,
canPromote = canPromote,
canDemote = canDemote,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
onRemoveClick = { memberToRemove = member }, onRemoveClick = { memberToRemove = member },
onPromoteClick = { memberToPromote = member },
onDemoteClick = { memberToDemote = member },
) )
HorizontalDivider() HorizontalDivider()
} }
@@ -347,6 +364,66 @@ fun MarmotGroupInfoScreen(
onDismiss = { memberToRemove = null }, onDismiss = { memberToRemove = null },
) )
} }
memberToPromote?.let { member ->
ConfirmGrantAdminDialog(
memberPubkey = member.pubkey,
accountViewModel = accountViewModel,
onConfirm = {
memberToPromote = null
scope.launch(Dispatchers.IO) {
try {
accountViewModel.grantMarmotGroupAdmin(nostrGroupId, member.pubkey)
launch(Dispatchers.Main) {
Toast
.makeText(context, "Admin privileges granted", Toast.LENGTH_SHORT)
.show()
}
} catch (e: Exception) {
launch(Dispatchers.Main) {
Toast
.makeText(
context,
"Failed to grant admin: ${e.message}",
Toast.LENGTH_LONG,
).show()
}
}
}
},
onDismiss = { memberToPromote = null },
)
}
memberToDemote?.let { member ->
ConfirmRevokeAdminDialog(
memberPubkey = member.pubkey,
accountViewModel = accountViewModel,
onConfirm = {
memberToDemote = null
scope.launch(Dispatchers.IO) {
try {
accountViewModel.revokeMarmotGroupAdmin(nostrGroupId, member.pubkey)
launch(Dispatchers.Main) {
Toast
.makeText(context, "Admin privileges revoked", Toast.LENGTH_SHORT)
.show()
}
} catch (e: Exception) {
launch(Dispatchers.Main) {
Toast
.makeText(
context,
"Failed to revoke admin: ${e.message}",
Toast.LENGTH_LONG,
).show()
}
}
}
},
onDismiss = { memberToDemote = null },
)
}
} }
@Composable @Composable
@@ -424,9 +501,13 @@ fun MemberRow(
isMe: Boolean, isMe: Boolean,
isAdmin: Boolean, isAdmin: Boolean,
canRemove: Boolean, canRemove: Boolean,
canPromote: Boolean,
canDemote: Boolean,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
onRemoveClick: () -> Unit, onRemoveClick: () -> Unit,
onPromoteClick: () -> Unit,
onDemoteClick: () -> Unit,
) { ) {
Row( Row(
modifier = modifier =
@@ -460,6 +541,23 @@ fun MemberRow(
) )
} }
} }
if (canPromote) {
IconButton(onClick = onPromoteClick) {
Icon(
imageVector = Icons.Outlined.StarBorder,
contentDescription = "Grant admin privileges",
tint = MaterialTheme.colorScheme.primary,
)
}
} else if (canDemote) {
IconButton(onClick = onDemoteClick) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = "Revoke admin privileges",
tint = MaterialTheme.colorScheme.primary,
)
}
}
if (canRemove) { if (canRemove) {
IconButton(onClick = onRemoveClick) { IconButton(onClick = onRemoveClick) {
Icon( Icon(
@@ -526,6 +624,67 @@ private fun ConfirmRemoveMemberDialog(
) )
} }
@Composable
private fun ConfirmGrantAdminDialog(
memberPubkey: HexKey,
accountViewModel: AccountViewModel,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Grant Admin Privileges") },
text = {
LoadUser(baseUserHex = memberPubkey, accountViewModel = accountViewModel) { user ->
val name = user?.toBestDisplayName() ?: "${memberPubkey.take(16)}..."
Text(
"Make \"$name\" an admin of this group? Admins can add or remove members, " +
"change group info, and grant admin privileges to other members.",
)
}
},
confirmButton = {
TextButton(onClick = onConfirm) {
Text("Grant")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
},
)
}
@Composable
private fun ConfirmRevokeAdminDialog(
memberPubkey: HexKey,
accountViewModel: AccountViewModel,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Revoke Admin Privileges") },
text = {
LoadUser(baseUserHex = memberPubkey, accountViewModel = accountViewModel) { user ->
val name = user?.toBestDisplayName() ?: "${memberPubkey.take(16)}..."
Text("Revoke admin privileges from \"$name\"? They will remain a member of the group.")
}
},
confirmButton = {
TextButton(onClick = onConfirm) {
Text("Revoke", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
},
)
}
/** Window (in seconds) within which a relay is considered actively carrying this group's traffic. */ /** Window (in seconds) within which a relay is considered actively carrying this group's traffic. */
private const val RELAY_ACTIVITY_WINDOW_SECS = 7L * 24 * 60 * 60 private const val RELAY_ACTIVITY_WINDOW_SECS = 7L * 24 * 60 * 60