Skip to content
Open
Show file tree
Hide file tree
Changes from 76 commits
Commits
Show all changes
92 commits
Select commit Hold shift + click to select a range
9d5c3d9
add 'gi.contracts/group/updatePermissions' action to gorup.js contract
SebinSong Aug 6, 2025
dee87eb
Build boilerpate UI for AddPermissionsModal.vue / GroupMembersDropdow…
SebinSong Aug 7, 2025
c330801
structure / style group-member-dropdown component
SebinSong Aug 8, 2025
fa3c77e
improve dropdown-btn style
SebinSong Aug 8, 2025
8233f2b
build member-dropdown options list
SebinSong Aug 8, 2025
bac2f6b
correct z-index / fix linter err
SebinSong Aug 8, 2025
56a1cad
better design for dropdown list
SebinSong Aug 8, 2025
adca63b
implement v-model for member-dropdown / interactivity in add-permissi…
SebinSong Aug 11, 2025
e4297b9
build UpdatePermissionsListItem.vue / style updates to AddPermissions…
SebinSong Aug 12, 2025
9b84fdb
build RolePill.vue / add role dropdown in the modal
SebinSong Aug 12, 2025
b8bf379
build TogglePermissionItem.vue
SebinSong Aug 12, 2025
06414fe
build PermissionPiece.vue and add each styles for various states
SebinSong Aug 13, 2025
767007e
Implement link btw role dropdown and permission selection UI / add bu…
SebinSong Aug 13, 2025
628fb9b
gridify permission table / style the remove button / disabled style f…
SebinSong Aug 13, 2025
531382f
init group-creator with admin role / implement some permission relate…
SebinSong Aug 14, 2025
e2d1e03
implement real-data in roles-and-permissions table in GroupSettings.vue
SebinSong Aug 14, 2025
e4823b0
better naming for a getter - allGroupMemberPermissions
SebinSong Aug 14, 2025
d2da1be
Partial implementation of front-end logic in AddPermissionsModal.vue
SebinSong Aug 15, 2025
bb41eff
integrate 'gi.actions/group/updatePermissions' action in AddPermissio…
SebinSong Aug 18, 2025
bd40c51
[cypress] fix the critical typo
SebinSong Aug 18, 2025
46a28d5
display name should be bigge
SebinSong Aug 18, 2025
0d6a3cb
Merge remote-tracking branch 'origin/master' into sebin/task/#202-rol…
SebinSong Aug 26, 2025
49a25c5
integrate 'remove-role' action to 'Roles and Permissions' section
SebinSong Aug 27, 2025
3a49516
build RemovePermissionsModal.vue
SebinSong Aug 28, 2025
c144e42
display feedback banner after removing the role
SebinSong Aug 28, 2025
06d1114
UpdatePermission -> AddPermission / 'remove permissions' -> 'remove r…
SebinSong Aug 29, 2025
06ab362
build EditPermissionsModal.vue / build MemberName.vue
SebinSong Aug 29, 2025
a8aa8a6
add role select UI in EditPermissions Modal
SebinSong Aug 29, 2025
2388741
DRY member-name UI using MemberName.vue / fix the text-ellipsis issue
SebinSong Aug 30, 2025
18f6aac
implement permission-piece in EditPermissionsModal.vue
SebinSong Sep 1, 2025
e3abe1c
make sure Edit modal looks presentable in narrow screen
SebinSong Sep 1, 2025
8a1bd2d
integrate permission-edit action to EditPermissionsModal.vue / feedba…
SebinSong Sep 2, 2025
889a9ff
fix linter err / make sure cypress passes
SebinSong Sep 2, 2025
0e13361
missing feedback message for ADD action
SebinSong Sep 3, 2025
4a3fdde
Merge remote-tracking branch 'origin/master' into sebin/task/#202-rol…
SebinSong Oct 15, 2025
8886b67
allow GROUP_PERMISSIONS.REMOVE_MEMBER permission holder to remove imm…
SebinSong Oct 15, 2025
5b111eb
merge origin/master in
SebinSong Oct 19, 2025
bf7f9e8
resolve merge conflict from master
SebinSong Nov 5, 2025
e072a23
Merge remote-tracking branch 'origin/master' into sebin/task/#202-rol…
SebinSong Nov 10, 2025
eb1bd11
Merge remote-tracking branch 'origin/master' into sebin/task/#202-rol…
SebinSong Nov 26, 2025
4f0058d
Merge remote-tracking branch 'origin/master' into sebin/task/#202-rol…
SebinSong Nov 26, 2025
49d8045
allow immediate 'remove-member' action for the permission holder / fi…
SebinSong Nov 26, 2025
b141c88
my entry should come first in the table
SebinSong Nov 27, 2025
665dfa4
Merge branch 'sebin/task/#202-roles-and-permission-step2' into sebin/…
SebinSong Nov 27, 2025
4f61911
fix the bug where updating from custom to preset does not work
SebinSong Nov 30, 2025
55c0baa
Merge branch 'sebin/task/#202-roles-and-permission-step2' into sebin/…
SebinSong Nov 30, 2025
5814d03
remove debug stuff
SebinSong Nov 30, 2025
2bf3471
Merge branch 'sebin/task/#202-roles-and-permission-step2' into sebin/…
SebinSong Nov 30, 2025
3a0a5f5
delegator must not be able to assign another delegator
SebinSong Nov 30, 2025
57da992
Merge branch 'sebin/task/#202-roles-and-permission-step2' into sebin/…
SebinSong Nov 30, 2025
ae8cf6b
comment
SebinSong Nov 30, 2025
c37d01d
Merge branch 'sebin/task/#202-roles-and-permission-step2' into sebin/…
SebinSong Nov 30, 2025
5a74d6f
Merge remote-tracking branch 'origin/master' into sebin/task/#202-rol…
SebinSong Dec 1, 2025
f0440cf
Merge branch 'sebin/task/#202-roles-and-permission-step2' into sebin/…
SebinSong Dec 1, 2025
848d931
member with revoke-invite permission should be able to view other's i…
SebinSong Dec 1, 2025
ff8d493
expose delete-channel menu to the person with the permission / implem…
SebinSong Dec 1, 2025
e2a03d4
allow-a for the invite error banner
SebinSong Dec 1, 2025
98c1cf1
implement delete-channel permission / add contract validation for it
SebinSong Dec 1, 2025
c1c346d
fix heisenbug in group-chat-message.spec.js
SebinSong Dec 2, 2025
2b50f8e
merge the bugfix
SebinSong Dec 2, 2025
d0adad0
some cypress-related fixes
SebinSong Dec 2, 2025
cc50ca7
Merge remote-tracking branch 'origin/master' into sebin/task/#202-rol…
SebinSong Dec 3, 2025
9fa58d4
comment
SebinSong Dec 4, 2025
e18a060
Merge remote-tracking branch 'origin/master' into sebin/task/#202-rol…
SebinSong Dec 15, 2025
0656413
updates for LLM's feedbacks
SebinSong Dec 15, 2025
e8e278f
Merge remote-tracking branch 'origin/master' into sebin/task/#202-rol…
SebinSong Dec 21, 2025
39ab757
Merge branch 'master' into sebin/task/#202-roles-and-permission-step2
taoeffect Jan 14, 2026
ab36b2f
merge the latest master
SebinSong Jan 25, 2026
554b540
roles and permission adjustments
SebinSong Jan 25, 2026
3074ebf
update per llm reviews
SebinSong Jan 25, 2026
fa54b56
Merge remote-tracking branch 'origin/sebin/task/#202-roles-and-permis…
SebinSong Jan 25, 2026
c858ae0
Merge remote-tracking branch 'origin/master' into sebin/task/#202-rol…
SebinSong Jan 28, 2026
780bdc5
fix per llm review
SebinSong Jan 28, 2026
a872e16
feedback
SebinSong Jan 29, 2026
45cb45c
Merge remote-tracking branch 'origin/master' into sebin/task/#202-rol…
SebinSong Feb 5, 2026
a97ee68
replace byPermission flag with actual permission check
SebinSong Feb 6, 2026
dff35fe
resolve some feedbacks
SebinSong Feb 10, 2026
2d2508f
update role info structure / ensure delegator cannot set permissions …
SebinSong Feb 11, 2026
82b9c0d
remove nuneeded stuff
SebinSong Feb 11, 2026
6f9c624
DELETE_CHANNEL permission as TODO
SebinSong Feb 11, 2026
62b57f1
cypress fails with valid flow type declarations..
SebinSong Feb 11, 2026
8ac1bba
resolve some issues
SebinSong Feb 12, 2026
ab26b3f
Merge remote-tracking branch 'origin/master' into sebin/task/#202-rol…
SebinSong Feb 23, 2026
76a8f31
resolve one of crush feedback
SebinSong Feb 23, 2026
3ed61c2
resolve llm feedbacks
SebinSong Feb 24, 2026
44ffa2c
resolve llm feedbacks
SebinSong Feb 24, 2026
77fe0ec
updatesz per llm reviw......
SebinSong Feb 24, 2026
346472f
minor improvement
SebinSong Feb 24, 2026
18eb94b
update per feedback
SebinSong Feb 24, 2026
64d691b
Merge remote-tracking branch 'origin/master' into sebin/task/#202-rol…
SebinSong Feb 27, 2026
9ef8b08
feedback
SebinSong Feb 27, 2026
f412b42
Merge remote-tracking branch 'origin/master' into sebin/task/#202-rol…
SebinSong Mar 16, 2026
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
1 change: 1 addition & 0 deletions frontend/controller/actions/group.js
Original file line number Diff line number Diff line change
Expand Up @@ -1029,6 +1029,7 @@ export default (sbp('sbp/selectors/register', {
...encryptedAction('gi.actions/group/paymentUpdate', L('Failed to update payment.')),
...encryptedAction('gi.actions/group/sendPaymentThankYou', L('Failed to send a payment thank you note.')),
...encryptedAction('gi.actions/group/groupProfileUpdate', L('Failed to update group profile.')),
...encryptedAction('gi.actions/group/updatePermissions', L('Failed to update permission details of a member.')),
...encryptedAction('gi.actions/group/proposal', L('Failed to create proposal.'), (sendMessage, params) => {
const { contractID } = params
return sendMessage({
Expand Down
9 changes: 5 additions & 4 deletions frontend/model/contracts/chatroom.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ import {
MESSAGE_TYPES,
POLL_STATUS,
POLL_OPTION_MAX_CHARS,
POLL_QUESTION_MAX_CHARS
POLL_QUESTION_MAX_CHARS,
GROUP_PERMISSIONS
} from './shared/constants.js'
import {
createMessage,
Expand Down Expand Up @@ -278,9 +279,9 @@ sbp('chelonia/defineContract', {
}
},
'gi.contracts/chatroom/delete': {
validate: actionRequireInnerSignature((_, { state, meta, message: { innerSigningContractID } }) => {
if (state.attributes.creatorID !== innerSigningContractID) {
throw new TypeError(L('Only the channel creator can delete channel.'))
validate: actionRequireInnerSignature((data, { state, getters, meta, message: { innerSigningContractID } }) => {
if (state.attributes.creatorID !== innerSigningContractID && !getters.ourGroupPermissionsHas(GROUP_PERMISSIONS.DELETE_CHANNEL)) {
throw new TypeError(L('You do not have permission to delete this channel.'))
}
}),
process ({ meta, contractID }, { state, getters }) {
Expand Down
119 changes: 107 additions & 12 deletions frontend/model/contracts/group.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ import {
PROPOSAL_REASON_MAX_CHAR,
PROPOSAL_PROPOSAL_SETTING_CHANGE,
PROPOSAL_REMOVE_MEMBER,
STATUS_CANCELLED, STATUS_EXPIRED, STATUS_OPEN
STATUS_CANCELLED, STATUS_EXPIRED, STATUS_OPEN,
GROUP_ROLES,
GROUP_PERMISSIONS_PRESET,
GROUP_PERMISSIONS,
GROUP_PERMISSION_UPDATE_ACTIONS
} from './shared/constants.js'
import { adjustedDistribution, unadjustedDistribution } from './shared/distribution/distribution.js'
import { paymentHashesFromPaymentPeriod, referenceTally } from './shared/functions.js'
Expand All @@ -56,7 +60,7 @@ function fetchInitKV (obj: Object, key: string, initialValue: any): any {
return value
}

function initGroupProfile (joinedDate: string, joinedHeight: number, reference: string) {
function initGroupProfile (joinedDate: string, joinedHeight: number, reference: string, isGroupCreator: boolean) {
return {
globalUsername: '', // TODO: this? e.g. groupincome:greg / namecoin:bob / ens:alice
joinedDate,
Expand All @@ -65,7 +69,10 @@ function initGroupProfile (joinedDate: string, joinedHeight: number, reference:
nonMonetaryContributions: [],
status: PROFILE_STATUS.ACTIVE,
departedDate: null,
incomeDetailsLastUpdatedDate: null
incomeDetailsLastUpdatedDate: null,
role: isGroupCreator
? { name: GROUP_ROLES.ADMIN, permissions: GROUP_PERMISSIONS_PRESET.ADMIN }
: null
}
}

Expand Down Expand Up @@ -368,6 +375,11 @@ const leaveAllChatRoomsUponLeaving = (groupID, state, memberID, actorID) => {
)
}

const getMemberPermissions = ({ getters, memberID }) => {
const profile = getters.groupProfile(memberID)
return profile?.role?.permissions || []
}

export const actionRequireActiveMember = (next: Function): Function => (data, props) => {
const innerSigningContractID = props.message.innerSigningContractID
if (!innerSigningContractID || innerSigningContractID === props.contractID) {
Expand Down Expand Up @@ -809,6 +821,7 @@ sbp('chelonia/defineContract', {
const memberToRemove = data.memberID || innerSigningContractID
const membersCount = getters.groupMembersCount
const isGroupCreator = innerSigningContractID === getters.currentGroupOwnerID
const myPermissions = getMemberPermissions({ getters, memberID: innerSigningContractID })

if (!state.profiles[memberToRemove]) {
throw new GIGroupNotJoinedError(L('Not part of the group.'))
Expand All @@ -821,7 +834,7 @@ sbp('chelonia/defineContract', {
return true
}

if (isGroupCreator) {
if (isGroupCreator || myPermissions.includes(GROUP_PERMISSIONS.REMOVE_MEMBER)) {
return true
} else if (membersCount < 3) {
// In a small group only the creator can remove someone
Expand Down Expand Up @@ -883,7 +896,9 @@ sbp('chelonia/defineContract', {
if (state.profiles[innerSigningContractID]?.status === PROFILE_STATUS.ACTIVE) {
throw new Error(`[gi.contracts/group/inviteAccept] Existing members can't accept invites: ${innerSigningContractID}`)
}
state.profiles[innerSigningContractID] = initGroupProfile(meta.createdDate, height, data.reference)

const isGroupCreator = state.groupOwnerID === innerSigningContractID
state.profiles[innerSigningContractID] = initGroupProfile(meta.createdDate, height, data.reference, isGroupCreator)
// If we're triggered by handleEvent in state.js (and not latestContractState)
// then the asynchronous sideEffect function will get called next
// and we will subscribe to this new user's identity contract
Expand Down Expand Up @@ -953,14 +968,24 @@ sbp('chelonia/defineContract', {
}
},
'gi.contracts/group/inviteRevoke': {
validate: actionRequireActiveMember((data, { state }) => {
validate: actionRequireActiveMember((data, { state, getters, message: { innerSigningContractID } }) => {
objectOf({
inviteKeyId: stringMax(MAX_HASH_LEN, 'inviteKeyId')
})(data)

if (!state._vm.invites[data.inviteKeyId]) {
const invite = state._vm.invites[data.inviteKeyId]

if (!invite) {
throw new TypeError(L('The link does not exist.'))
}

const myPermissions = getMemberPermissions({ getters, memberID: innerSigningContractID })
const inviteCreatorID = state.invites?.[data.inviteKeyId]?.creatorID

// Only the creator and members with the REVOKE_INVITE permission can revoke an invite
if (inviteCreatorID !== innerSigningContractID && !myPermissions.includes(GROUP_PERMISSIONS.REVOKE_INVITE)) {
throw new TypeError(L('You do not have permission to revoke this invite.'))
}
}),
process () {
// Handled by Chelonia
Expand Down Expand Up @@ -1106,6 +1131,72 @@ sbp('chelonia/defineContract', {
}
}
},
'gi.contracts/group/updatePermissions': {
validate: actionRequireActiveMember((data, { state, getters, message: { innerSigningContractID } }) => {
arrayOf(objectMaybeOf({
memberID: stringMax(MAX_HASH_LEN, 'memberID'),
action: validatorFrom(x => Object.values(GROUP_PERMISSION_UPDATE_ACTIONS).includes(x)),
roleName: validatorFrom(x => Object.values(GROUP_ROLES).includes(x)),
permissions: arrayOf(validatorFrom(x => Object.values(GROUP_PERMISSIONS).includes(x)))
}))(data)

const myProfile = getters.groupProfile(innerSigningContractID)
const myPermissions = myProfile?.role?.permissions || []

if (data.some(item => item?.roleName === GROUP_ROLES.ADMIN)) {
throw new TypeError(L('Cannot assign admin role.'))
}

if (data.some(item => item.permissions?.includes(GROUP_PERMISSIONS.ASSIGN_DELEGATOR) &&
myProfile?.role?.name !== GROUP_ROLES.ADMIN)) {
// Only the group admin can assign the delegator role
throw new TypeError(L('You do not have permission to assign delegator role.'))
}

if (!myPermissions.includes(GROUP_PERMISSIONS.DELEGATE_PERMISSIONS)) {
Copy link
Copy Markdown
Member

@corrideat corrideat Feb 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two comments here.

First issue

The first one, which is perhaps just terminology (I see this is how it was used in #202), is that this should be called ASSIGN_PERMISSIONS, GRANT_PERMISSIONS or GIVE_PERMISSIONS or something like that. DELEGATE_PERMISSIONS, to me at least, implies that the permissions given depend on whomever is doing the delegating having those permissions. Based off the definition of 'delegate', entrust (a task or responsibility) to another person, this seems like a reasonable interpretation.

For example, if u1 gives u2 the delegate permission, and then u2 delegates the view permission to u3, I would expect that, if u2's view permission was later revoked, then u3 would no longer have the ability to view either, because u3's view permission was delegated by u2 (who 'owned' this permission, if you will). Now, the way that this currently works is that u3's permissions are independent from what happens to u2 in the future, which probably is good design, but just confusing terminology.

So, I wouldn't change how it works, but I'd change the name to use something that more clearly reflects what it does.

Second issue

The second issue is that, if I'm understanding this code correctly, the delegate permission in itself is enough to grant someone else a permission the delegator may not have. I realise that in the current implementation the 'moderator - delegator` role has all permissions, but nevertheless a check wouldn't hurt.

I've just tested this, and it's possible to just have 'delegate' permission and then delegate other permissions.

Copy link
Copy Markdown
Collaborator Author

@SebinSong SebinSong Feb 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Term DELEGATE_PERMISSIONS for the ability to grant/edit/remove permissions was suggested by Greg and also got confirmed in the first PR of this issue. (can't find the exact comment for this but) I recall having ADD_PERMISSIONS separately initially but then was asked to use the term DELEGATE_PERMISSIONS for grant/edit/remove actions. If he thinks differenly now and will change it.


Have mentioned this in another comment too but below was Greg's request re delegate-permission:

  • DELEGATE_PERMISSIONS are exclusively given to ADMIN and MODERATOR - DELEGATOR roles.
  • Admin is the only role that can assign MODERATOR - DELEGATOR role.
  • MODERATOR - DELEGATOR can add/edit/remove permissions of other members(except for himself/admin) too but that doesn't include DELEGATE_PERMISSIONS permission item.
  • By doing this, only admin and 'moderator - delegator' roles have the ability to grant/edit/remove permissions.

Current logic works as expected according to this.

the delegate permission in itself is enough to grant someone else a permission the delegator may not have. I realise that in the current implementation the 'moderator - delegator` role has all permissions, but nevertheless a check wouldn't hurt.

Will leave it as is for now, but once this check becomes needed/necessary in the future, will add the check.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regarding @corrideat's first concern, let's leave it named as-is for now. I don't think the word delegate necessarily contains within it a strict interpretation of what should be done with delegated permissions if the delegator themselves loses them.

Regading the second concern, this is a legitimate issue:

I've just tested this, and it's possible to just have 'delegate' permission and then delegate other permissions.

Delegators should only be able to delegate permissions that they have, so we should fix that.

throw new TypeError(L('You do not have permission to delegate permissions.'))
}

if (data.some(item => {
return item.action === GROUP_PERMISSION_UPDATE_ACTIONS.EDIT && !state.profiles[item.memberID]?.role
})) {
throw new TypeError(L('Cannot edit permissions for a member who does not have a role.'))
}

for (const item of data) {
if (!state.profiles[item.memberID]) {
throw new TypeError(L(`${item.memberID} is not a member of the group.`))
}

if (item.memberID === innerSigningContractID) {
throw new TypeError(L('You cannot modify your own permissions.'))
}
}
}),
process ({ data }, { state }) {
for (const item of data) {
const groupProfile = state.profiles[item.memberID]

switch (item.action) {
case GROUP_PERMISSION_UPDATE_ACTIONS.ADD:
groupProfile.role = {
name: item.roleName,
permissions: item.permissions
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I'm understanding this correctly, the role name and the permissions are stored separately, and the list of permissions is what matters, regardless of the role name.

I suggest not doing this, even if it makes checking for permissions slightly harder. One of the advantages of having roles is that they can change definitions over time. So, for example, the following is what should happen IMHO:

  1. If the custom role is used and permissions A, B and C are given, then one should have exactly those permissions.
  2. If a preset is used, containing permissions A and B, the actual permissions should depend on the preset at the current time. For example, in the future the same preset could have permissions A, B and C, or it could have just A, or it could have permissions C and D. The point in all of this is that the role is more useful if it can be dynamically changed.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why this was marked as resolved since the issue hasn't been resolved. Roles should apply current role permissions, not 'role permissions at the time the role was assigned'.

If 'role permissions at the time the role was assigned' is used, then there's no need to store the 'role' in the contract (since it's not relevant) and then the UI should be updated to dynamically display a role based on actual permissions. For example, if I'm assigned the 'moderator role' with permissions A, B and C, and later on the the 'moderator role' has permissions A, B, C and D, I shouldn't be shown as a 'moderator' in the UI because I no longer have the permissions this role entails.

Copy link
Copy Markdown
Collaborator Author

@SebinSong SebinSong Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see what the potential issue mentioned now. (Sorry I marked it resolved as I didn't see an issue in the current logic at the time)
So it seems the mentioned concern is something like below case:

user1 is given a moderator role -> later, we decide to define moderator role to have more/less permissions, so update moderator preset -> then user1 has stale definition of moderator

If correct, I agree this is a legit issue.

There seems to be no need to store roleName info in the user profile. The UI can determine which role the member has by comparing their permissions and the presets of the roles (If no matches, then custom).

will change from:

{
  role: ...
  permissions: [ ... ]
}

to just:

permissions: [ ... ]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, staleness is the main issue.

See with @taoeffect how this should be addressed (there are two ways).

I'd find the static permissions solution (with the UI deciding dynamically a role) acceptable but suboptimal. It's suboptimal in the sense that it's not how roles typically work (they tend to be a set and forget thing).

My preferred solution would be storing the role (as you are doing) but not permissions (except for the custom permissions of course), and checking based on whether the user has the permissions or a role. I'd keep the ability to have both permissions and roles for future compat too, but can be mutually exclusive too. For example, something like: permissions = [...userPermissions, ...rolePermissions].

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've already taken some time working on this with :

permissions: [ ... ]

which is a large change as it requires changing and checking every parts that are related to this all over again...

Will go with this solution unless there is a reason to revert this back found.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are good reasons to go with the other approach:

  1. It's how roles typically work
  2. It's simpler to implement
  3. It avoids having users regularly update permissions to sync them with roles or having to write migration code to do this automatically.

I'll give my approval regardless of which of the two approaches you go with, but please check with @taoeffect whether 'static permissions' is how this feature was envisioned.

Copy link
Copy Markdown
Collaborator Author

@SebinSong SebinSong Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, Is this something that you are suggesting?

roles with preset:

role: { name: string }

Custom role:

role: { name: 'custom', permissions: string[] }

This is actually something I had in mind initally too because having role name makes certain things in the UI layer easier.

(EDIT: missed the last comment above, didn't see it getting updated while I'm writing this)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sort of, yes, that works.

For maximum flexibility, you'd have both 'role' and 'permissions' (kind of like how it was originally; the point then was mostly about how permissions were checked). However, we might not need that flexibility and it's something that can be added later.

Going back to your question then, yes. I'd have no objections to that approach, and as you say it'd simplify things on the UI side.

(There's still the possibility that this truly was intended as 'static permissions', but I doubt that, and even then we can always define new presets instead of changing existing ones)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for confirmation @corrideat . wanted to go with the structure that makes sense to you too. Will make updates accordingly.

}
break
case GROUP_PERMISSION_UPDATE_ACTIONS.EDIT:
groupProfile.role = {
name: item.roleName || groupProfile.role.name,
permissions: item.permissions || groupProfile.role.permissions
}
break
case GROUP_PERMISSION_UPDATE_ACTIONS.REMOVE:
groupProfile.role = null
break
}
}
}
},
'gi.contracts/group/updateAllVotingRules': {
validate: actionRequireActiveMember(objectMaybeOf({
ruleName: x => [RULE_PERCENTAGE, RULE_DISAGREEMENT].includes(x),
Expand Down Expand Up @@ -1200,8 +1291,10 @@ sbp('chelonia/defineContract', {
validate: actionRequireActiveMember((data, { getters, message: { innerSigningContractID } }) => {
objectOf({ chatRoomID: stringMax(MAX_HASH_LEN, 'chatRoomID') })(data)

if (getters.groupChatRooms[data.chatRoomID].creatorID !== innerSigningContractID) {
throw new TypeError(L('Only the channel creator can delete channel.'))
const myPermissions = getMemberPermissions({ getters, memberID: innerSigningContractID })

if (getters.groupChatRooms[data.chatRoomID].creatorID !== innerSigningContractID && !myPermissions.includes(GROUP_PERMISSIONS.DELETE_CHANNEL)) {
throw new TypeError(L('You do not have permission to delete this channel.'))
}
}),
process ({ contractID, data }, { state }) {
Expand All @@ -1213,7 +1306,7 @@ sbp('chelonia/defineContract', {
}
delete state.chatRooms[data.chatRoomID]
},
sideEffect ({ data, contractID, innerSigningContractID }) {
sideEffect ({ data, contractID, innerSigningContractID }, { getters }) {
// identityContractID intentionally left out because deleted chatrooms
// affect all users. If it's set, it should be set to
// sbp('state/vuex/state').loggedIn, _not_ innerSigningContractID, for
Expand All @@ -1222,8 +1315,10 @@ sbp('chelonia/defineContract', {
sbp('okTurtles.events/emit', DELETED_CHATROOM, { groupContractID: contractID, chatRoomID: data.chatRoomID })
const { identityContractID } = sbp('state/vuex/state').loggedIn
if (identityContractID === innerSigningContractID) {
sbp('gi.actions/chatroom/delete', { contractID: data.chatRoomID, data: {} }).catch(e => {
console.log(`Error sending chatroom removal action for ${data.chatRoomID}`, e)
sbp('gi.actions/chatroom/delete', {
contractID: data.chatRoomID
}).catch(e => {
console.error(`Error sending chatroom removal action for ${data.chatRoomID}`, e)
})
}
}
Expand Down
13 changes: 12 additions & 1 deletion frontend/model/contracts/shared/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export const GROUP_CURRENCY_MAX_CHAR = 10
export const GROUP_MAX_PLEDGE_AMOUNT = 1000000000
export const GROUP_MINCOME_MAX = 1000000000
export const GROUP_DISTRIBUTION_PERIOD_MAX_DAYS = 365
export const GROUP_PERMISSION_UPDATE_ACTIONS = {
ADD: 'add',
EDIT: 'edit',
REMOVE: 'remove'
}

// group-proposal related

Expand Down Expand Up @@ -65,8 +70,8 @@ export const STREAK_NOT_LOGGED_IN_DAYS = 14

export const GROUP_ROLES = {
ADMIN: 'admin',
MODERATOR: 'moderator',
MODERATOR_DELEGATOR: 'moderator-delegator',
MODERATOR: 'moderator',
CUSTOM: 'custom'
}

Expand Down Expand Up @@ -101,6 +106,12 @@ export const GROUP_PERMISSIONS_PRESET = {
GP.REMOVE_MEMBER,
GP.REVOKE_INVITE,
GP.DELETE_CHANNEL
],
CUSTOM: [
GP.VIEW_PERMISSIONS,
GP.REMOVE_MEMBER,
GP.REVOKE_INVITE,
GP.DELETE_CHANNEL
]
}

Expand Down
23 changes: 23 additions & 0 deletions frontend/model/getters.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,29 @@ const getters: { [x: string]: (state: Object, getters: { [x: string]: any }) =>
ourGroupProfile (state, getters) {
return getters.ourGroupProfileForGroup(getters.currentGroupState)
},
ourGroupPermissions (state, getters) {
return getters.ourGroupProfile?.role?.permissions || []
},
ourGroupPermissionsHas (state, getters) {
return (permission) => getters.ourGroupPermissions.includes(permission)
},
getGroupMemberRoleNameById (state, getters) {
return (memberID) => {
const profile = getters.groupProfiles[memberID]
return profile?.role?.name || ''
}
},
getGroupMemberPermissionsById (state, getters) {
return (memberID) => {
const profile = getters.groupProfiles[memberID]
return profile?.role?.permissions || []
}
},
allGroupMemberPermissions (state, getters) {
return Object.entries(getters.groupProfiles)
.filter(([, profile]: [string, any]) => Boolean(profile.role))
.map(([memberID, profile]: [string, any]) => ({ roleName: profile.role.name, permissions: profile.role.permissions, memberID }))
},
ourUserDisplayName (state, getters) {
// TODO - refactor Profile and Welcome and any other component that needs this
const userContract = getters.currentIdentityState || {}
Expand Down
3 changes: 2 additions & 1 deletion frontend/setupChelonia.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,8 @@ const setupChelonia = async (): Promise<*> => {
'chelonia/out/deleteContract',
'chelonia/contract/waitingForKeyShareTo',
'chelonia/contract/successfulKeySharesByContractID',
'gi.actions/group/removeOurselves', 'gi.actions/group/groupProfileUpdate', 'gi.actions/group/displayMincomeChangedPrompt', 'gi.actions/group/addChatRoom',
'gi.actions/group/removeOurselves', 'gi.actions/group/groupProfileUpdate', 'gi.actions/group/updatePermissions',
'gi.actions/group/displayMincomeChangedPrompt', 'gi.actions/group/addChatRoom',
'gi.actions/group/join', 'gi.actions/group/joinChatRoom',
'gi.actions/identity/addJoinDirectMessageKey', 'gi.actions/identity/leaveGroup',
'gi.actions/chatroom/delete',
Expand Down
2 changes: 2 additions & 0 deletions frontend/utils/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,5 @@ export const NEW_CHATROOM_NOTIFICATION_SETTINGS = 'new-chatroom-notification-set
export const CONTRACT_SYNCS_RESET = 'new-current-syncs'

export const SERIOUS_ERROR = 'serious-error'

export const GROUP_PERMISSIONS_UPDATE_SUCCESS = 'group-permissions-update-success'
3 changes: 3 additions & 0 deletions frontend/utils/lazyLoadedView.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ lazyModal('ThankYouNoteModal', () => import('../views/containers/payments/ThankY
lazyModal('AvatarEditorModal', () => import('../views/components/avatar-editor/AvatarEditorModal.vue'))
lazyModal('ChatFileAttachmentWarningModal', () => import('../views/containers/chatroom/file-attachment/ChatFileAttachmentWarningModal.vue'))
lazyModal('ImageViewerModal', () => import('../views/containers/chatroom/image-viewer/ImageViewerModal.vue'))
lazyModal('AddPermissionsModal', () => import('../views/containers/group-settings/roles-and-permissions/AddPermissionsModal.vue'))
lazyModal('RemoveRoleModal', () => import('../views/containers/group-settings/roles-and-permissions/RemoveRoleModal.vue'))
lazyModal('EditPermissionsModal', () => import('../views/containers/group-settings/roles-and-permissions/EditPermissionsModal.vue'))
lazyModal('VideoViewerModal', () => import('../views/containers/chatroom/video-viewer/VideoViewerModal.vue'))
lazyModalFullScreen('GroupCreationModal', () => import('../views/containers/group-settings/GroupCreationModal.vue'))
lazyModalFullScreen('GroupJoinModal', () => import('../views/containers/group-settings/GroupJoinModal.vue'))
Expand Down
Loading
Loading