feat(roles): redesign org roles page and better perm multiselect#6027
feat(roles): redesign org roles page and better perm multiselect#6027mathnogueira wants to merge 36 commits intomainfrom
Conversation
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
| actions: readonly TOrgPermissionAction[]; | ||
| }; | ||
|
|
||
| export const ORG_PERMISSION_OBJECT: Record<string, TOrgPermissionConfig> = { |
There was a problem hiding this comment.
I would say this is an important part to be reviewed. It maps all permissions and respective actions and their descriptions.
|
@claude review this again |
|
@claude review this again, please |
4506069 to
a6f2038
Compare
|
@claude stop reviewing this PR |
1a64998 to
7af486a
Compare
|
@claude don't review this |
There was a problem hiding this comment.
Note from Matheus:
I don't think this is a problem. If user added a policy, but removed all permissions, no need to display that row anymore.
btw, if they remove all permissions, they are actually deleted from the DB. So, not a regression.
Additional findings (outside current diff — PR may have been updated during review):
-
🔴
frontend/src/pages/organization/RoleByIDPage/components/RolePermissionsSection/RolePermissionsSection.tsx:94-99— After saving a policy that was added via 'Add Policy' but never given any actions, the accordion row for that subject persists showing 'No Access' until page reload. This happens because the newreset(el)call (introduced by this PR) resets the form withpermissions[subject] = {}still present, and the render guardpermissions?.[subject] \!== undefinedtreats{}as defined — so the row stays visible despite nothing being saved to the API.Extended reasoning...
What the bug is and how it manifests
When a user clicks 'Add Policy' and selects a subject from the popover,
handleSelectPolicyinOrgPolicySelectionModal.tsxcallsform.setValuewith an empty object{}for that subject. If the user then clicks Save without selecting any actions, a phantom accordion row continues to display 'No Access' for that subject even though the save succeeded and no permissions were stored.The specific code path that triggers it
OrgPolicySelectionPopoveradds the subject by callingform.setValue('permissions.gateway', {})— sets the value to an empty object.- The user clicks Save without choosing any actions.
formRolePermission2APIiteratesObject.entries(formVal || {}). At line ~860 the guardif (\!actions) returndoes not fire because{}is truthy.Object.entries({})is empty, so no permissions are pushed for the subject. The API payload correctly omits the subject.- The API saves successfully with no permissions for the subject.
reset(el)is called (added by this PR at line 97 ofRolePermissionsSection.tsx).el.permissions[subject]is still{}— the Zod schema for that field is.optional(), which passes empty objects through without stripping them.- After the reset,
permissions?.[subject] \!== undefinedevaluates totruefor{}, so the accordion row remains mounted in the JSX and renders 'No Access'. - On page reload,
rolePermission2Form(role.permissions)does not reconstruct the subject (nothing was saved), so the row disappears.
Why existing code does not prevent it
The render guard at every conditional in
RolePermissionsSection.tsxispermissions?.[subject] \!== undefined. An empty object{}satisfies this check. There is no filter that treats{}as equivalent toundefinedbefore callingreset. Prior to this PR,reset()was called with no arguments, which reset to the last-loaded query cache value (which would not include the empty-object subject), preventing the phantom row.Impact
The UX consequence is that administrators see a 'No Access' row for a subject they never actually saved permissions for. The form reports
isDirty = falseafter save, so there is no visual cue that the state is stale. The row only disappears on a full page reload. No security impact — no permissions are granted. The confusion can lead admins to believe their save failed or that a 'No Access' policy was explicitly applied.How to fix
The simplest fix is to strip permission entries whose value is an empty object (or where all action values are false/undefined) before calling
reset. For example, beforereset(el), filterel.permissionsto exclude entries whereObject.values(v).every(b => \!b). Alternatively, callreset()after query invalidation and refetch, which restores the pre-reset(el)behavior for this edge case while still clearing dirty state for saved entries.Step-by-step proof
- On the Org Roles page, edit a custom role that has no existing permissions.
- Click 'Add Policies' and select e.g. 'Gateways' —
permissions.gatewayis now{}. - Do not select any actions. Click 'Save'.
- Observe: the save notification appears, but the 'Gateways' accordion row remains visible showing 'No Access'.
isDirtyis false. - Reload the page: the 'Gateways' row is gone, confirming nothing was persisted.
- The phantom row was caused by
reset(el)preservingpermissions.gateway = {}in form state.
| [OrgPermissionSubjects.Groups]: { | ||
| title: "Group Management", | ||
| description: "Organize users into groups for bulk permission management", | ||
| actions: [ | ||
| { | ||
| value: OrgPermissionGroupActions.Read, | ||
| label: "Read Groups", | ||
| description: "View groups and their members" | ||
| }, | ||
| { | ||
| value: OrgPermissionGroupActions.Create, | ||
| label: "Create Groups", | ||
| description: "Create new user groups" | ||
| }, | ||
| { | ||
| value: OrgPermissionGroupActions.Edit, | ||
| label: "Edit Groups", | ||
| description: "Update group membership and settings" |
There was a problem hiding this comment.
🔴 The new ORG_PERMISSION_OBJECT[Groups].actions introduced by this PR lists only 7 actions, missing AddIdentities = 'add-identities' and RemoveIdentities = 'remove-identities' that exist in the backend enum (backend/src/ee/services/permission/org-permission.ts:96-106). Because groupPermissionSchema in OrgRoleModifySection.utils.ts has no keys for these two actions and Zod uses strip mode by default, any role with add-identities or remove-identities permissions will silently lose them the first time an admin opens and saves the role through the new UI. The backend actively enforces both actions in group-service.ts (lines 967, 978, 1135, 1145) and grants them to the admin role (lines 416, 419).
Extended reasoning...
What the bug is and how it manifests
The frontend OrgPermissionGroupActions enum (frontend/src/context/OrgPermissionContext/types.ts:122-130) defines only 7 actions: Read, Create, Edit, Delete, GrantPrivileges, AddMembers, RemoveMembers. The backend enum (backend/src/ee/services/permission/org-permission.ts:96-106) defines 9 actions, additionally including AddIdentities = 'add-identities' and RemoveIdentities = 'remove-identities'. These backend actions are not dead code — group-service.ts enforces them at lines 967 and 978 (adding machine identities to groups) and lines 1135 and 1145 (removing machine identities from groups), and the admin role builder grants them at lines 416 and 419.
The specific code path that triggers it
This PR introduces ORG_PERMISSION_OBJECT[OrgPermissionSubjects.Groups].actions (OrgRoleModifySection.utils.ts lines ~583–618) with exactly 7 entries — cementing the incomplete frontend list as the authoritative source for the new UI. The groupPermissionSchema (the permissions.groups Zod schema in formSchema) only contains keys for the 7 frontend actions. Zod's z.object() strips unknown keys by default (no .passthrough()), so when zodResolver runs during handleSubmit, any add-identities or remove-identities values are silently removed before formRolePermission2API serializes the payload. The resulting API call permanently deletes these permissions from the role.
Why existing code doesn't prevent it
The rolePermission2Form() function populates the form by iterating the raw API response without Zod validation, so the permissions are present in the initial form state. But on submit, zodResolver enforces the schema and strips the unknown keys. There is no warning or error — the stripped keys are simply absent from the submitted permissions array. The ORG_PERMISSION_OBJECT[Groups].actions list introduced by this PR also means the multiselect UI can never display or allow selection of these two actions, making it impossible for admins to grant them through the new UI at all.
What the impact is
Any organization role that was granted add-identities or remove-identities permissions (e.g., the built-in admin role or a custom role configured via API or the old UI) will permanently lose those permissions the first time an admin opens it in the new redesigned editor and clicks Save. The user receives a success notification with no indication that permissions were dropped. Subsequently, any machine identity added to a group via a role relying on these permissions will get an authorization error from the backend enforcer.
Step-by-step proof
- A role has
groups.add-identities: trueset (e.g., via API or the admin role). - An admin opens the Org Roles page, clicks the role, and the new UI loads:
rolePermission2Form()populatespermissions.groupsincludingadd-identities: true. This value is in the React Hook Form state. - The admin makes any change (or just clicks Save without changes, since the initial
isDirtycheck aside, the form submits the zodResolver-validated value). handleSubmitrunszodResolver(formSchema)—formSchema.permissions.groupsis a Zod object with only 7 known keys. Zod stripsadd-identitiesandremove-identities.formRolePermission2APIreceives the stripped permissions; the resultingTPermission[]array omits both actions.updateRoleis called with the stripped array. The backend saves the role withoutadd-identities/remove-identities. No error is returned.- The admin sees "Successfully updated role" — the permissions were silently deleted.
How to fix it
Add AddIdentities and RemoveIdentities to the frontend OrgPermissionGroupActions enum, add corresponding entries to ORG_PERMISSION_OBJECT[Groups].actions, and add the two keys to groupPermissionSchema (the permissions.groups Zod object). This ensures the new multiselect UI can display and set both actions, and that Zod no longer strips them on save.
There was a problem hiding this comment.
Ignore the placement of the comment, general UI feedback:
1 - Let's use v3 here with the organization variant
2 - Let's also use v3 select here
3 - We actually don't have delete buttons for org permissions. If we look at the old version we had all of them available all the time
And I think that makes sense with the dropdown on the right
Because you can easily know if the role has access or not without having to open the accordion.
But, I also see value in your proposal with the policy selection dropdown and the delete button, but in this case, the dropdown on the right loses most of its value, because if the access policy is not on the list, then you don't have access.
I would put this for voting in the UI/UX channel.
Backend-wise, this changes nothing. So either one works!
The only extra thing we would need to add is validation if you try to add an empty policy, as we have in the project permission.
4 - The background color here is not the same as the one in the project permissions
And if we ended up going with the delete option on 3, then we could actually use the exact same component as we have in the project permissions, not only the filterable select.
There was a problem hiding this comment.
Updated 1, 2, and 4.
Kept 3 since we discussed the UX w/ Scott and decided to keep this behavior.
1f01000 to
02ea0a2
Compare
| const hasPermissions = Object.values(permissions || {}).some((v) => v !== undefined); | ||
|
|
||
| const invalidSubjectsForAddPolicy = isRootOrganization ? [] : INVALID_SUBORG_PERMISSIONS; |
There was a problem hiding this comment.
🟡 The hasPermissions check on line 105 of RolePermissionsSection.tsx counts all defined permission subjects regardless of isRootOrganization context, causing a non-root org role that has only root-org-specific permissions (Sso, Ldap, Scim, GithubOrgSync, GithubOrgSyncManual, Billing, SubOrganization) to render an empty accordion wrapper instead of the 'No policies applied' empty state. This requires an API-created role in a non-root org with exclusively root-org-specific permissions, and the UX impact is cosmetic only. Fix: change to Object.entries(permissions || {}).some(([k, v]) => v !== undefined && !invalidSubjectsForAddPolicy.includes(k)).
Extended reasoning...
What the bug is
In RolePermissionsSection.tsx (line 105), hasPermissions is computed as:
const hasPermissions = Object.values(permissions || {}).some((v) => v !== undefined);This check does not account for which permission subjects are actually renderable given the isRootOrganization context.
The specific code path that triggers it
INVALID_SUBORG_PERMISSIONS contains exactly 7 root-org-only subjects: Sso, Ldap, Scim, GithubOrgSync, GithubOrgSyncManual, Billing, SubOrganization. For non-root orgs, all 7 are excluded from accordion rendering:
Ldap/Scim/GithubOrgSync: filtered by theSIMPLE_PERMISSION_SUBJECTS.filtercheck at line 163–164 via!INVALID_SUBORG_PERMISSIONS.includes(subject)Sso/GithubOrgSyncManual/Billing/SubOrganization: gated by{isRootOrganization && ...}guards in JSX
However, hasPermissions on line 105 uses Object.values(...).some(v => v !== undefined), which checks ALL subjects including those 7. If a non-root org role has permissions set only for subjects in INVALID_SUBORG_PERMISSIONS (possible via API creation), hasPermissions=true causes the {hasPermissions && <UnstableAccordion>} branch to render — but zero rows render inside it.
Step-by-step proof
- A custom role is created via API in a non-root org with
{ sso: { read: true } }(or any other root-org-only subject). rolePermission2Formconverts the API response, settingpermissions.sso = { read: true }in form state.hasPermissions = Object.values({ sso: { read: true } }).some(v => v !== undefined)evaluates totrue.- The
{hasPermissions && <UnstableAccordion>}branch renders the accordion wrapper. - Inside the accordion,
OrgPermissionSsoRowis gated by{isRootOrganization && ...}— false for non-root orgs — so no rows render. - Result: an empty
<UnstableAccordion>wrapper with no items, instead of the 'No policies applied' empty state component.
Why existing code does not prevent it
The invalidSubjectsForAddPolicy variable (line 110) is correctly computed as isRootOrganization ? [] : INVALID_SUBORG_PERMISSIONS, and is used to filter the 'Add Policy' popover. However, this same filtering logic is not applied to the hasPermissions computation on line 105, which was introduced by this PR as part of the new hide-if-undefined rendering model.
Impact
Low severity / nit. It is cosmetic only (empty accordion vs. empty state message), requires an uncommon edge case (API-created role with only root-org-specific permissions in a non-root org), and has no security or functional impact.
How to fix
Replace line 105 with:
const hasPermissions = Object.entries(permissions || {}).some(
([k, v]) => v !== undefined && !invalidSubjectsForAddPolicy.includes(k as OrgPermissionSubjects)
);This ensures only permissions that will actually render a row contribute to the hasPermissions flag, so the empty state is correctly shown when a non-root org role has only root-org-specific permissions.
There was a problem hiding this comment.
this cache should be in the gitignore, I think you need to rebase this.
02ea0a2 to
83ec0fc
Compare
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🔴
frontend/src/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionPolicies.tsx:97-103— ThevisibleActionsfilter inGeneralPermissionPolicies.tsx(used byActionsMultiSelect) contains project-specific logic that compares subject strings againstProjectPermissionSub.Identity('identity') andProjectPermissionSub.Groups('groups') to conditionally hidegrant-privileges. BecauseOrgPermissionSubjects.IdentityandOrgPermissionSubjects.Groupsshare these exact same string values, the filter fires for org subjects too — permanently hiding the Grant Privileges action in the org role editor when it was not previously selected. Admins cannot grantgrant-privilegesfor org Identity or org Groups roles through the new UI.Extended reasoning...
What the bug is and how it manifests
In the refactored
GeneralPermissionPolicies.tsx, theActionsMultiSelectcomponent computesidentityGrantPrivilegesandgroupsGrantPrivilegesby readingrule?.[ProjectPermissionIdentityActions.GrantPrivileges]andrule?.[ProjectPermissionGroupActions.GrantPrivileges]. These values feed into avisibleActionsfilter (lines 129–139 in the file) that conditionally excludes thegrant-privilegesaction from the dropdown unless the permission is already active.The critical defect is that this filter is a raw string comparison:
subject === ProjectPermissionSub.Identity(value:'identity') matchesOrgPermissionSubjects.Identity(also'identity')subject === ProjectPermissionSub.Groups(value:'groups') matchesOrgPermissionSubjects.Groups(also'groups')ProjectPermissionIdentityActions.GrantPrivileges === 'grant-privileges'matchesOrgPermissionIdentityActions.GrantPrivileges(same string)ProjectPermissionGroupActions.GrantPrivileges === 'grant-privileges'matchesOrgPermissionGroupActions.GrantPrivileges(same string)
The specific code path that triggers it
When
RolePermissionsSection.tsx(org) renders org Identity or Groups subjects viaGeneralPermissionPolicies, the component receivessubject='identity'orsubject='groups'. InsideActionsMultiSelect, the visibleActions filter checks if the subject equalsProjectPermissionSub.Identity— the string comparison succeeds because the enum values are identical. IfidentityGrantPrivilegesisfalse(i.e.,grant-privilegeswas not already set in the role), the filter excludes the action from the multiselect options. The action simply never appears in the dropdown.Why existing code does not prevent it
The filter was written for the project roles context and relies on enum values that happen to be shared between project and org permission types. TypeScript enum members with the same underlying string values are indistinguishable at runtime. The refactoring that reused
GeneralPermissionPoliciesfor org roles did not account for this collision.Impact
Administrators creating or editing org custom roles cannot add the
grant-privilegespermission to org Identity or Groups subjects through the new UI. The option is permanently absent from the multiselect. Roles that already have this permission display it correctly (the filter checksrule?.['grant-privileges']which istrue), but any role that does not already hold the permission cannot have it added through the UI. TheORG_PERMISSION_OBJECTin the diff explicitly includesGrantPrivilegesin both Identity and Groups actions, confirming this is an intentional permission.Step-by-step proof
- Navigate to Org Roles, edit a custom role, click 'Add Policies', add the Identity subject.
- Open the Identity accordion row. The multiselect renders action options from
ORG_PERMISSION_OBJECT[Identity].actions, which includesGrantPrivileges = 'grant-privileges'. ActionsMultiSelectreceivessubject='identity'and runs the visibleActions filter:subject === ProjectPermissionSub.Identity→'identity' === 'identity'→true.- The filter checks
identityGrantPrivileges=Boolean(rule?.['grant-privileges'])=false(not yet selected). - The condition
\!identityGrantPrivilegesistrue, sogrant-privilegesis filtered out of the visible options. - The multiselect shows all other Identity actions but never shows Grant Privileges. The admin cannot select it.
- The same logic fires for subject='groups' with
groupsGrantPrivileges.
How to fix it
The visibleActions filter should only apply to project permission subjects. One approach is to guard the identity/groups checks with an explicit project-context check, e.g., only filter when the subject is known to be a project subject (by checking against
ProjectPermissionSubenum values exclusively, or by passing anisProjectPermissionprop). Alternatively, the filter logic could be moved out ofGeneralPermissionPoliciesinto the project-specific caller. -
🔴
frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/EditProjectTemplateSection/components/ProjectTemplateEditRoleForm.tsx:200-206— The PR changed the trash icon visibility in GeneralPermissionPolicies from !isDisabled to !isDisabled && (fields.length > 1 || isConditional || !!onRemoveLastRule), but ProjectTemplateEditRoleForm.tsx was not updated to pass onRemoveLastRule. For non-conditional subjects (Members, Settings, Integrations, etc.) with exactly one rule, all three conditions are false so the trash icon never renders — users editing project template roles can no longer delete individual permission rows for these subjects. Fix: pass onRemoveLastRule to GeneralPermissionPolicies in ProjectTemplateEditRoleForm.tsx, mirroring the pattern already applied in RolePermissionsSection.tsx.Extended reasoning...
What the bug is and how it manifests
This PR refactored GeneralPermissionPolicies.tsx so that the trash icon (delete rule button) is now conditionally rendered at line 354:
{!isDisabled && (fields.length > 1 || isConditional || !!onRemoveLastRule) && (
Before this PR, the condition was simply !isDisabled, so the trash icon was always visible when the form was editable. After the PR, showing the icon requires at least one of: multiple rules, a conditional subject, or an onRemoveLastRule callback.
The specific code path that triggers it
ProjectTemplateEditRoleForm.tsx (lines 200-211 in the diff) renders GeneralPermissionPolicies without isConditional or onRemoveLastRule. For non-conditional subjects (e.g., Members, Settings, Integrations, Environments) with exactly one rule: fields.length > 1 = false, isConditional = undefined, !!onRemoveLastRule = false. The guard evaluates to !false && (false || false || false) = false — the trash icon is never rendered.
Why existing code does not prevent it
RolePermissionsSection.tsx (the project roles page) was correctly updated in this same PR to pass onRemoveLastRule. TypeScript does not flag the missing optional prop as an error. The two callers of GeneralPermissionPolicies are structurally identical but only one was updated, introducing an asymmetry between the project roles editor and the project template role editor.
Impact
Admins editing project template roles can no longer remove individual permission rows for any non-conditional subject that has a single rule. The only workaround is to use the Add Policies toggle to remove the entire subject (discarding all settings). This is a direct functional regression introduced by this PR refactoring.
Step-by-step proof
- Navigate to Organization Settings > Project Templates > Edit a template > Edit a role.
- Expand a non-conditional permission subject (e.g., Members) that has one rule.
- Before this PR: trash icon is visible and functional (!isDisabled was always sufficient).
- After this PR: GeneralPermissionPolicies receives isConditional=undefined, onRemoveLastRule=undefined, and fields.length=1. Line 354 evaluates to !false && (false || false || false) = false. The trash icon IconButton is never rendered.
- The user has no per-row delete affordance — rules appear locked in.
How to fix
Pass onRemoveLastRule to GeneralPermissionPolicies in ProjectTemplateEditRoleForm.tsx, following the exact pattern used in RolePermissionsSection.tsx:
onRemoveLastRule={
!isDisabled
? () => form.setValue(permissions.subject as any, [], { shouldDirty: true })
: undefined
}





Context
We have updated the Project Roles page with a new UI experience, but the Organization page still had the old UI. This PR updates it to reuse the same components.
This also adds an improvement on the Permission MultiSelect component to allow it to filter by descriptions other than just the title. This was also applied to the Project Roles page.
Screenshots
New design matching the Project Roles UI
New Permission Multiselect component
This allows filtering by the action description as well as its title
Read-only (Platform roles)
Steps to verify the change
Type
Checklist
type(scope): short description(scope is optional, e.g.,fix: prevent crash on syncorfix(api): handle null response).