make and remove admin rights

This commit is contained in:
otsmr 2025-11-02 14:09:18 +01:00
parent 30086c2475
commit 8cf57224ce
9 changed files with 260 additions and 28 deletions

View file

@ -372,5 +372,10 @@
"makeAdmin": "Zum Admin machen", "makeAdmin": "Zum Admin machen",
"removeAdmin": "Als Admin entfernen", "removeAdmin": "Als Admin entfernen",
"removeFromGroup": "Aus Gruppe entfernen", "removeFromGroup": "Aus Gruppe entfernen",
"admin": "Admin" "admin": "Admin",
"revokeAdminRightsTitle": "Adminrechte von {username} entfernen?",
"revokeAdminRightsOkBtn": "Als Admin entfernen",
"makeAdminRightsTitle": "{username} zum Admin machen?",
"makeAdminRightsBody": "{username} wird diese Gruppe und ihre Mitglieder bearbeiten können.",
"makeAdminRightsOkBtn": "Zum Admin machen"
} }

View file

@ -528,5 +528,10 @@
"makeAdmin": "Make admin", "makeAdmin": "Make admin",
"removeAdmin": "Remove as admin", "removeAdmin": "Remove as admin",
"removeFromGroup": "Remove from group", "removeFromGroup": "Remove from group",
"admin": "Admin" "admin": "Admin",
"revokeAdminRightsTitle": "Revoke {username}'s admin rights?",
"revokeAdminRightsOkBtn": "Remove as admin",
"makeAdminRightsTitle": "Make {username} an admin?",
"makeAdminRightsBody": "{username} will be able to edit this group and its members.",
"makeAdminRightsOkBtn": "Make admin"
} }

View file

@ -2275,6 +2275,36 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Admin'** /// **'Admin'**
String get admin; String get admin;
/// No description provided for @revokeAdminRightsTitle.
///
/// In en, this message translates to:
/// **'Revoke {username}\'s admin rights?'**
String revokeAdminRightsTitle(Object username);
/// No description provided for @revokeAdminRightsOkBtn.
///
/// In en, this message translates to:
/// **'Remove as admin'**
String get revokeAdminRightsOkBtn;
/// No description provided for @makeAdminRightsTitle.
///
/// In en, this message translates to:
/// **'Make {username} an admin?'**
String makeAdminRightsTitle(Object username);
/// No description provided for @makeAdminRightsBody.
///
/// In en, this message translates to:
/// **'{username} will be able to edit this group and its members.'**
String makeAdminRightsBody(Object username);
/// No description provided for @makeAdminRightsOkBtn.
///
/// In en, this message translates to:
/// **'Make admin'**
String get makeAdminRightsOkBtn;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -1204,4 +1204,25 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get admin => 'Admin'; String get admin => 'Admin';
@override
String revokeAdminRightsTitle(Object username) {
return 'Adminrechte von $username entfernen?';
}
@override
String get revokeAdminRightsOkBtn => 'Als Admin entfernen';
@override
String makeAdminRightsTitle(Object username) {
return '$username zum Admin machen?';
}
@override
String makeAdminRightsBody(Object username) {
return '$username wird diese Gruppe und ihre Mitglieder bearbeiten können.';
}
@override
String get makeAdminRightsOkBtn => 'Zum Admin machen';
} }

View file

@ -1197,4 +1197,25 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get admin => 'Admin'; String get admin => 'Admin';
@override
String revokeAdminRightsTitle(Object username) {
return 'Revoke $username\'s admin rights?';
}
@override
String get revokeAdminRightsOkBtn => 'Remove as admin';
@override
String makeAdminRightsTitle(Object username) {
return 'Make $username an admin?';
}
@override
String makeAdminRightsBody(Object username) {
return '$username will be able to edit this group and its members.';
}
@override
String get makeAdminRightsOkBtn => 'Make admin';
} }

View file

@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'dart:typed_data';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:cryptography_flutter_plus/cryptography_flutter_plus.dart'; import 'package:cryptography_flutter_plus/cryptography_flutter_plus.dart';
@ -317,7 +318,12 @@ Future<bool> addNewHiddenContact(int contactId) async {
return true; return true;
} }
Future<bool> updateGroupState(Group group, EncryptedGroupState state) async { Future<bool> updateGroupState(
Group group,
EncryptedGroupState state, {
Uint8List? addAdmin,
Uint8List? removeAdmin,
}) async {
final chacha20 = FlutterChacha20.poly1305Aead(); final chacha20 = FlutterChacha20.poly1305Aead();
final encryptionNonce = chacha20.newNonce(); final encryptionNonce = chacha20.newNonce();
@ -358,6 +364,8 @@ Future<bool> updateGroupState(Group group, EncryptedGroupState state) async {
encryptedGroupState: encryptedGroupState.writeToBuffer(), encryptedGroupState: encryptedGroupState.writeToBuffer(),
publicKey: keyPair.getPublicKey().serialize(), publicKey: keyPair.getPublicKey().serialize(),
nonce: responseNonce.bodyBytes, nonce: responseNonce.bodyBytes,
addAdmin: addAdmin,
removeAdmin: removeAdmin,
); );
final random = getRandomUint8List(32); final random = getRandomUint8List(32);
@ -391,6 +399,79 @@ Future<bool> updateGroupState(Group group, EncryptedGroupState state) async {
return (await fetchGroupState(group)) != null; return (await fetchGroupState(group)) != null;
} }
Future<bool> manageAdminState(
Group group,
GroupMember member,
int contactId,
bool remove,
) async {
// ensure the latest state is used
final currentState = await fetchGroupState(group);
if (currentState == null) return false;
final (versionId, state) = currentState;
final userId = Int64(contactId);
Uint8List? addAdmin;
Uint8List? removeAdmin;
if (remove) {
if (state.adminIds.contains(userId)) {
state.adminIds.remove(userId);
removeAdmin = member.groupPublicKey;
} else {
Log.info('User was already removed as admin.');
return true;
}
} else {
if (!state.adminIds.contains(userId)) {
state.adminIds.add(userId);
addAdmin = member.groupPublicKey;
} else {
Log.info('User is already admin.');
return true;
}
}
if (addAdmin == null && removeAdmin == null) {
Log.info('User does not have a group public key.');
return false;
}
// send new state to the server
if (!await updateGroupState(
group,
state,
addAdmin: addAdmin,
removeAdmin: removeAdmin,
)) {
return false;
}
final groupActionType =
remove ? GroupActionType.demoteToMember : GroupActionType.promoteToAdmin;
await sendCipherTextToGroup(
group.groupId,
EncryptedContent(
groupUpdate: EncryptedContent_GroupUpdate(
groupActionType: groupActionType.name,
affectedContactId: Int64(contactId),
),
),
);
await twonlyDB.groupsDao.insertGroupAction(
GroupHistoriesCompanion(
groupId: Value(group.groupId),
type: Value(groupActionType),
affectedContactId: Value(contactId),
),
);
return true;
}
Future<bool> updateGroupeName(Group group, String groupName) async { Future<bool> updateGroupeName(Group group, String groupName) async {
// ensure the latest state is used // ensure the latest state is used
final currentState = await fetchGroupState(group); final currentState = await fetchGroupState(group);

View file

@ -28,13 +28,15 @@ class _ChatGroupActionState extends State<ChatGroupAction> {
} }
Future<void> initAsync() async { Future<void> initAsync() async {
if (widget.action.contactId == null) return; if (widget.action.contactId != null) {
contact = contact =
await twonlyDB.contactsDao.getContactById(widget.action.contactId!); await twonlyDB.contactsDao.getContactById(widget.action.contactId!);
}
if (widget.action.affectedContactId == null) return; if (widget.action.affectedContactId != null) {
affectedContact = await twonlyDB.contactsDao affectedContact = await twonlyDB.contactsDao
.getContactById(widget.action.affectedContactId!); .getContactById(widget.action.affectedContactId!);
}
if (mounted) setState(() {}); if (mounted) setState(() {});
} }
@ -43,14 +45,30 @@ class _ChatGroupActionState extends State<ChatGroupAction> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
var text = ''; var text = '';
if (widget.action.type == GroupActionType.updatedGroupName) { final affected = (affectedContact == null)
if (contact == null) { ? 'you'
text = : "${getContactDisplayName(affectedContact!)}'s";
'You have changed the group name to "${widget.action.newGroupName}".'; final affectedR = (affectedContact == null) ? 'your' : affected;
} else { final maker = (contact == null) ? '' : getContactDisplayName(contact!);
text =
'${getContactDisplayName(contact!)} has changed the group name to "${widget.action.newGroupName}".'; switch (widget.action.type) {
} case GroupActionType.updatedGroupName:
text = (contact == null)
? 'You have changed the group name to "${widget.action.newGroupName}".'
: '$maker has changed the group name to "${widget.action.newGroupName}".';
case GroupActionType.createdGroup:
case GroupActionType.removedMember:
case GroupActionType.addMember:
case GroupActionType.leftGroup:
break;
case GroupActionType.promoteToAdmin:
text = (contact == null)
? 'You made $affected an admin.'
: '$maker made $affected an admin.';
case GroupActionType.demoteToMember:
text = (contact == null)
? 'You revoked $affected admin rights.'
: '$maker revoked $affectedR admin rights.';
} }
if (text == '') return Container(); if (text == '') return Container();

View file

@ -75,12 +75,7 @@ class _GroupViewState extends State<GroupView> {
newGroupName != group.groupName) { newGroupName != group.groupName) {
if (!await updateGroupeName(group, newGroupName)) { if (!await updateGroupeName(group, newGroupName)) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( showNetworkIssue(context);
const SnackBar(
content: Text('Network issue. Try again later.'),
duration: Duration(seconds: 3),
),
);
} }
} }
} }
@ -143,6 +138,7 @@ class _GroupViewState extends State<GroupView> {
BetterListTile( BetterListTile(
padding: const EdgeInsets.only(left: 13), padding: const EdgeInsets.only(left: 13),
leading: AvatarIcon( leading: AvatarIcon(
key: GlobalKey(),
userData: gUser, userData: gUser,
fontSize: 16, fontSize: 16,
), ),
@ -159,12 +155,13 @@ class _GroupViewState extends State<GroupView> {
), ),
...members.map((member) { ...members.map((member) {
return GroupMemberContextMenu( return GroupMemberContextMenu(
group: widget.group, group: group,
contact: member.$1, contact: member.$1,
member: member.$2, member: member.$2,
child: BetterListTile( child: BetterListTile(
padding: const EdgeInsets.only(left: 13), padding: const EdgeInsets.only(left: 13),
leading: AvatarIcon( leading: AvatarIcon(
key: GlobalKey(),
contact: member.$1, contact: member.$1,
fontSize: 16, fontSize: 16,
), ),
@ -232,3 +229,12 @@ Future<String?> showGroupNameChangeDialog(
}, },
); );
} }
void showNetworkIssue(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Network issue. Try again later.'),
duration: Duration(seconds: 3),
),
);
}

View file

@ -1,11 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/tables/groups.table.dart'; import 'package:twonly/src/database/tables/groups.table.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/group.services.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/chats/chat_messages.view.dart'; import 'package:twonly/src/views/chats/chat_messages.view.dart';
import 'package:twonly/src/views/components/alert_dialog.dart';
import 'package:twonly/src/views/components/context_menu.component.dart'; import 'package:twonly/src/views/components/context_menu.component.dart';
import 'package:twonly/src/views/groups/group.view.dart';
class GroupMemberContextMenu extends StatelessWidget { class GroupMemberContextMenu extends StatelessWidget {
const GroupMemberContextMenu({ const GroupMemberContextMenu({
@ -52,19 +56,60 @@ class GroupMemberContextMenu extends StatelessWidget {
}, },
icon: FontAwesomeIcons.userPlus, icon: FontAwesomeIcons.userPlus,
), ),
if (group.isGroupAdmin && member.memberState == MemberState.normal) if (member.groupPublicKey != null &&
group.isGroupAdmin &&
member.memberState == MemberState.normal)
ContextMenuItem( ContextMenuItem(
title: context.lang.makeAdmin, title: context.lang.makeAdmin,
onTap: () async { onTap: () async {
// onResponseTriggered(); final ok = await showAlertDialog(
context,
context.lang
.makeAdminRightsTitle(getContactDisplayName(contact)),
context.lang
.makeAdminRightsBody(getContactDisplayName(contact)),
customOk: context.lang.makeAdminRightsOkBtn,
);
if (ok) {
if (!await manageAdminState(
group,
member,
contact.userId,
false,
)) {
if (context.mounted) {
showNetworkIssue(context);
}
}
}
}, },
icon: FontAwesomeIcons.key, icon: FontAwesomeIcons.key,
), ),
if (group.isGroupAdmin && member.memberState == MemberState.admin) if (member.groupPublicKey != null &&
group.isGroupAdmin &&
member.memberState == MemberState.admin)
ContextMenuItem( ContextMenuItem(
title: context.lang.removeAdmin, title: context.lang.removeAdmin,
onTap: () async { onTap: () async {
// onResponseTriggered(); final ok = await showAlertDialog(
context,
context.lang
.revokeAdminRightsTitle(getContactDisplayName(contact)),
'',
customOk: context.lang.revokeAdminRightsOkBtn,
);
if (ok) {
if (!await manageAdminState(
group,
member,
contact.userId,
true,
)) {
if (context.mounted) {
showNetworkIssue(context);
}
}
}
}, },
icon: FontAwesomeIcons.key, icon: FontAwesomeIcons.key,
), ),