From bc96cc406b75174229557b6eac11f6ee895c9d88 Mon Sep 17 00:00:00 2001 From: kyle-ssg Date: Wed, 27 May 2026 11:56:47 +0100 Subject: [PATCH 1/6] feat: sort users permissions table by name and role Adds the standard PanelSearch sort dropdown to the users permissions table. Name sorts on a composite first/last name; Role sorts on the derived role label (Organisation/{Level} Administrator, Regular User) so the order matches what is displayed. Shared by the project and environment Permissions tabs. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/web/components/EditPermissions.tsx | 266 +++++++++++--------- 1 file changed, 151 insertions(+), 115 deletions(-) diff --git a/frontend/web/components/EditPermissions.tsx b/frontend/web/components/EditPermissions.tsx index cf99407bd9ad..f39284b250ff 100644 --- a/frontend/web/components/EditPermissions.tsx +++ b/frontend/web/components/EditPermissions.tsx @@ -22,7 +22,12 @@ import Switch from './Switch' import TabItem from './navigation/TabMenu/TabItem' import Tabs from './navigation/TabMenu/Tabs' import UserGroupList from './UserGroupList' -import { PermissionLevel, Req, PermissionRoleType } from 'common/types/requests' +import { + PermissionLevel, + Req, + PermissionRoleType, + SortOrder, +} from 'common/types/requests' import { useGetAvailablePermissionsQuery } from 'common/services/useAvailablePermissions' import ConfigProvider from 'common/providers/ConfigProvider' import Icon from './icons/Icon' @@ -1086,125 +1091,156 @@ const EditPermissions: FC = (props) => { > - {({ isLoading, users }) => ( -
- {isLoading && !users?.length && ( -
- -
- )} - {!!users?.length && ( -
- -
- - User - Role -
- Action -
- - } - renderRow={(user) => { - const { email, first_name, id, last_name, role } = - user - const onClick = () => { - if (role !== 'ADMIN') { - editUserPermissions(user) - } - } - const matchingPermissions = permissions?.find( - (v) => v.user.id === id, - ) - - return ( - - -
- {`${first_name} ${last_name}`}{' '} - {String(id) === - String(AccountStore.getUserId()) && - '(You)'} -
-
- {email} -
-
- {role === 'ADMIN' ? ( - - - { - 'Organisation administrators have all permissions enabled.
To change the role of this user, visit Organisation Settings.' - } -
-
- ) : ( - - {matchingPermissions && - matchingPermissions.admin - ? `${Format.camelCase( - level, - )} Administrator` - : 'Regular User'} - - )} + {({ isLoading, users }) => { + const sortableUsers = (users || []).map((user: User) => { + const matching = permissions?.find((v) => v.user.id === user.id) + let sortRole = 'Regular User' + if (user.role === 'ADMIN') { + sortRole = 'Organisation Administrator' + } else if (matching?.admin) { + sortRole = `${Format.camelCase(level)} Administrator` + } + return { + ...user, + sortName: `${user.first_name} ${user.last_name}` + .trim() + .toLowerCase(), + sortRole, + } + }) + return ( +
+ {isLoading && !users?.length && ( +
+ +
+ )} + {!!users?.length && ( +
+ +
+ + User + Role
- {role !== 'ADMIN' && ( - - )} + Action
- ) - }} - renderNoResults={ -
You have no users in this organisation.
- } - filterRow={(item: User, search: string) => { - const strToSearch = `${item.first_name} ${item.last_name} ${item.email}` - return ( - strToSearch - .toLowerCase() - .indexOf(search.toLowerCase()) !== -1 - ) - }} - /> -
- -
- -
- )} -
- )} + } + renderRow={(user) => { + const { email, first_name, id, last_name, role } = + user + const onClick = () => { + if (role !== 'ADMIN') { + editUserPermissions(user) + } + } + const matchingPermissions = permissions?.find( + (v) => v.user.id === id, + ) + + return ( + + +
+ {`${first_name} ${last_name}`}{' '} + {String(id) === + String(AccountStore.getUserId()) && + '(You)'} +
+
+ {email} +
+
+ {role === 'ADMIN' ? ( + + + { + 'Organisation administrators have all permissions enabled.
To change the role of this user, visit Organisation Settings.' + } +
+
+ ) : ( + + {matchingPermissions && + matchingPermissions.admin + ? `${Format.camelCase( + level, + )} Administrator` + : 'Regular User'} + + )} +
+ {role !== 'ADMIN' && ( + + )} +
+
+ ) + }} + renderNoResults={ +
You have no users in this organisation.
+ } + filterRow={(item: User, search: string) => { + const strToSearch = `${item.first_name} ${item.last_name} ${item.email}` + return ( + strToSearch + .toLowerCase() + .indexOf(search.toLowerCase()) !== -1 + ) + }} + /> +
+ +
+ +
+ )} +
+ ) + }} From 4f711444a52dbbc3b69bc4d7cdd5e734935158a3 Mon Sep 17 00:00:00 2001 From: kyle-ssg Date: Wed, 27 May 2026 12:05:46 +0100 Subject: [PATCH 2/6] perf: O(1) permission lookup when decorating users for sort Build a Map of permissions by user id once per render instead of calling Array.find per user, avoiding O(N*M) work on the users permissions table. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/web/components/EditPermissions.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/web/components/EditPermissions.tsx b/frontend/web/components/EditPermissions.tsx index f39284b250ff..f7183b1fde06 100644 --- a/frontend/web/components/EditPermissions.tsx +++ b/frontend/web/components/EditPermissions.tsx @@ -1092,8 +1092,11 @@ const EditPermissions: FC = (props) => { {({ isLoading, users }) => { + const permissionsByUserId = new Map( + permissions?.map((p) => [p.user.id, p]), + ) const sortableUsers = (users || []).map((user: User) => { - const matching = permissions?.find((v) => v.user.id === user.id) + const matching = permissionsByUserId.get(user.id) let sortRole = 'Regular User' if (user.role === 'ADMIN') { sortRole = 'Organisation Administrator' From da08140c14fc9df5f598dc0c73ffe68a899e75e1 Mon Sep 17 00:00:00 2001 From: kyle-ssg Date: Wed, 27 May 2026 13:18:38 +0100 Subject: [PATCH 3/6] feat: inspect a user's permissions from the project/environment settings Adds a per-user inspect action to the permissions table that opens the existing Permissions view scoped to the current project or environment (no org-wide project picker). RBAC-gated, reuses the user-detailed-permissions endpoint. Widens levelId to accept an environment api_key. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/common/types/requests.ts | 2 +- frontend/web/components/EditPermissions.tsx | 43 ++++++++++++++++--- .../inspect-permissions/Permissions.tsx | 2 +- 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index 7dd713129e41..171a41106881 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -824,7 +824,7 @@ export type Req = { userId: number } getUserPermissions: { - id?: number + id?: number | string userId: number | undefined level: PermissionLevel } diff --git a/frontend/web/components/EditPermissions.tsx b/frontend/web/components/EditPermissions.tsx index f7183b1fde06..81010db46e8b 100644 --- a/frontend/web/components/EditPermissions.tsx +++ b/frontend/web/components/EditPermissions.tsx @@ -71,6 +71,7 @@ import Utils from 'common/utils/utils' import RemoveViewPermissionModal from './RemoveViewPermissionModal' import { useHistory } from 'react-router-dom' import getUserDisplayName from 'common/utils/getUserDisplayName' +import Permissions from './inspect-permissions/Permissions' import Project from 'common/project' @@ -1018,6 +1019,21 @@ const EditPermissions: FC = (props) => { } = props const [tab, setTab] = useState() + const hasRbac = !!Utils.getPlansPermission('RBAC') + const inspectUserPermissions = (user: User) => { + openModal( + getUserDisplayName(user), +
+ +
, + 'p-0 side-modal', + ) + } const editUserPermissions = (user: User) => { openModal( `Edit ${Format.camelCase(level)} Permissions`, @@ -1213,11 +1229,28 @@ const EditPermissions: FC = (props) => { className='text-center' > {role !== 'ADMIN' && ( - + + {hasRbac && ( + { + e.stopPropagation() + inspectUserPermissions(user) + }} + > + + + )} + + )}
diff --git a/frontend/web/components/inspect-permissions/Permissions.tsx b/frontend/web/components/inspect-permissions/Permissions.tsx index 8ba9c1a0b168..488b35ad2f31 100644 --- a/frontend/web/components/inspect-permissions/Permissions.tsx +++ b/frontend/web/components/inspect-permissions/Permissions.tsx @@ -18,7 +18,7 @@ const Permissions = ({ userId, }: { level: PermissionLevel - levelId: number + levelId: number | string userId?: number projectId?: number className?: string From 6dc8468c70d55c04a12eae127c0e63deca67c146 Mon Sep 17 00:00:00 2001 From: kyle-ssg Date: Wed, 27 May 2026 13:26:37 +0100 Subject: [PATCH 4/6] feat: sort organisation members table; share user sort helper Adds Name/Role sorting to the organisation members table to match the project and environment permissions tables. Extracts the shared decoration (composite sort name + derived role label) and sort options into sortUsers.ts so all three tables reuse it. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/web/components/EditPermissions.tsx | 44 +++++-------------- frontend/web/components/PanelSearch.tsx | 2 +- .../OrganisationUsersTable.tsx | 20 ++++++++- .../components/users-permissions/sortUsers.ts | 26 +++++++++++ 4 files changed, 56 insertions(+), 36 deletions(-) create mode 100644 frontend/web/components/users-permissions/sortUsers.ts diff --git a/frontend/web/components/EditPermissions.tsx b/frontend/web/components/EditPermissions.tsx index 81010db46e8b..cb6cecc2c988 100644 --- a/frontend/web/components/EditPermissions.tsx +++ b/frontend/web/components/EditPermissions.tsx @@ -22,12 +22,7 @@ import Switch from './Switch' import TabItem from './navigation/TabMenu/TabItem' import Tabs from './navigation/TabMenu/Tabs' import UserGroupList from './UserGroupList' -import { - PermissionLevel, - Req, - PermissionRoleType, - SortOrder, -} from 'common/types/requests' +import { PermissionLevel, Req, PermissionRoleType } from 'common/types/requests' import { useGetAvailablePermissionsQuery } from 'common/services/useAvailablePermissions' import ConfigProvider from 'common/providers/ConfigProvider' import Icon from './icons/Icon' @@ -72,6 +67,10 @@ import RemoveViewPermissionModal from './RemoveViewPermissionModal' import { useHistory } from 'react-router-dom' import getUserDisplayName from 'common/utils/getUserDisplayName' import Permissions from './inspect-permissions/Permissions' +import { + decorateUsersForSort, + userTableSorting, +} from './users-permissions/sortUsers' import Project from 'common/project' @@ -1111,21 +1110,12 @@ const EditPermissions: FC = (props) => { const permissionsByUserId = new Map( permissions?.map((p) => [p.user.id, p]), ) - const sortableUsers = (users || []).map((user: User) => { - const matching = permissionsByUserId.get(user.id) - let sortRole = 'Regular User' - if (user.role === 'ADMIN') { - sortRole = 'Organisation Administrator' - } else if (matching?.admin) { - sortRole = `${Format.camelCase(level)} Administrator` - } - return { - ...user, - sortName: `${user.first_name} ${user.last_name}` - .trim() - .toLowerCase(), - sortRole, + const sortableUsers = decorateUsersForSort(users, (user) => { + if (user.role === 'ADMIN') return 'Organisation Administrator' + if (permissionsByUserId.get(user.id)?.admin) { + return `${Format.camelCase(level)} Administrator` } + return 'Regular User' }) return (
@@ -1144,19 +1134,7 @@ const EditPermissions: FC = (props) => { className='panel--transparent' items={sortableUsers} itemHeight={64} - sorting={[ - { - default: true, - label: 'Name', - order: SortOrder.ASC, - value: 'sortName', - }, - { - label: 'Role', - order: SortOrder.ASC, - value: 'sortRole', - }, - ]} + sorting={userTableSorting} header={ User diff --git a/frontend/web/components/PanelSearch.tsx b/frontend/web/components/PanelSearch.tsx index bb3d4d90c308..d435b684cfd4 100644 --- a/frontend/web/components/PanelSearch.tsx +++ b/frontend/web/components/PanelSearch.tsx @@ -23,7 +23,7 @@ import Panel from './base/grid/Panel' import Utils from 'common/utils/utils' import { SortOrder } from 'common/types/requests' -type SortOption = { +export type SortOption = { value: string order: SortOrder default?: boolean diff --git a/frontend/web/components/users-permissions/OrganisationUsersTable/OrganisationUsersTable.tsx b/frontend/web/components/users-permissions/OrganisationUsersTable/OrganisationUsersTable.tsx index e4668220b5a9..ef7237de94b0 100644 --- a/frontend/web/components/users-permissions/OrganisationUsersTable/OrganisationUsersTable.tsx +++ b/frontend/web/components/users-permissions/OrganisationUsersTable/OrganisationUsersTable.tsx @@ -1,5 +1,10 @@ -import React from 'react' +import React, { useMemo } from 'react' import AppActions from 'common/dispatcher/app-actions' +import Constants from 'common/constants' +import { + decorateUsersForSort, + userTableSorting, +} from 'components/users-permissions/sortUsers' import Tabs from 'components/navigation/TabMenu/Tabs' import TabItem from 'components/navigation/TabMenu/TabItem' import { Organisation, User } from 'common/types/responses' @@ -87,14 +92,25 @@ const OrganisationUsersTable: React.FC = ({ }) } + const sortableUsers = useMemo( + () => + decorateUsersForSort( + users, + (user) => + Constants.roles[user.role as keyof typeof Constants.roles] || '', + ), + [users], + ) + return ( } - items={users} + items={sortableUsers} itemHeight={65} + sorting={userTableSorting} renderRow={(user) => { const { email, first_name, id, last_name } = user diff --git a/frontend/web/components/users-permissions/sortUsers.ts b/frontend/web/components/users-permissions/sortUsers.ts new file mode 100644 index 000000000000..f26d0913fac4 --- /dev/null +++ b/frontend/web/components/users-permissions/sortUsers.ts @@ -0,0 +1,26 @@ +import { SortOrder } from 'common/types/requests' +import { User } from 'common/types/responses' +import { SortOption } from 'components/PanelSearch' + +export type SortableUser = T & { + sortName: string + sortRole: string +} + +// Shared sort options for the user permissions tables. `sortName` and +// `sortRole` are computed by decorateUsersForSort so we can sort on a composite +// name and a derived role label rather than the raw fields. +export const userTableSorting: SortOption[] = [ + { default: true, label: 'Name', order: SortOrder.ASC, value: 'sortName' }, + { label: 'Role', order: SortOrder.ASC, value: 'sortRole' }, +] + +export const decorateUsersForSort = ( + users: T[] | undefined, + getRoleLabel: (user: T) => string, +): SortableUser[] => + (users || []).map((user) => ({ + ...user, + sortName: `${user.first_name} ${user.last_name}`.trim().toLowerCase(), + sortRole: getRoleLabel(user), + })) From 132ed5400a50d7b9282ccb9a5f2574bacfc2f4ce Mon Sep 17 00:00:00 2001 From: kyle-ssg Date: Wed, 27 May 2026 13:43:54 +0100 Subject: [PATCH 5/6] feat: use UserAction ellipsis dropdown in project/environment permissions Replaces the inline setting/inspect icons in the project and environment permissions table with the shared UserAction kebab menu (Manage user / Inspect permissions), matching the organisation members table. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/web/components/EditPermissions.tsx | 36 +++++++-------------- 1 file changed, 12 insertions(+), 24 deletions(-) diff --git a/frontend/web/components/EditPermissions.tsx b/frontend/web/components/EditPermissions.tsx index cb6cecc2c988..9d76a0408b96 100644 --- a/frontend/web/components/EditPermissions.tsx +++ b/frontend/web/components/EditPermissions.tsx @@ -25,7 +25,6 @@ import UserGroupList from './UserGroupList' import { PermissionLevel, Req, PermissionRoleType } from 'common/types/requests' import { useGetAvailablePermissionsQuery } from 'common/services/useAvailablePermissions' import ConfigProvider from 'common/providers/ConfigProvider' -import Icon from './icons/Icon' import { useCreateRolePermissionsMutation, useGetRoleEnvironmentPermissionsQuery, @@ -67,6 +66,7 @@ import RemoveViewPermissionModal from './RemoveViewPermissionModal' import { useHistory } from 'react-router-dom' import getUserDisplayName from 'common/utils/getUserDisplayName' import Permissions from './inspect-permissions/Permissions' +import UserAction from './UserAction' import { decorateUsersForSort, userTableSorting, @@ -1204,31 +1204,19 @@ const EditPermissions: FC = (props) => { )}
{role !== 'ADMIN' && ( - - {hasRbac && ( - { - e.stopPropagation() - inspectUserPermissions(user) - }} - > - - - )} - - + editUserPermissions(user)} + onRemove={() => {}} + onInspectPermissions={() => + inspectUserPermissions(user) + } + /> )}
From 0059e76a3900252735493903964c5234266d82f9 Mon Sep 17 00:00:00 2001 From: kyle-ssg Date: Wed, 27 May 2026 13:46:26 +0100 Subject: [PATCH 6/6] fix(UserAction): show dropdown when only edit + inspect are available The single-button shortcuts ignored canInspectPermissions, so an edit+inspect (no remove) configuration rendered a plain edit button and dropped the inspect option. Fall through to the dropdown whenever inspect is available. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/web/components/UserAction.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/web/components/UserAction.tsx b/frontend/web/components/UserAction.tsx index f365d908eb2e..2459e4c8dfe3 100644 --- a/frontend/web/components/UserAction.tsx +++ b/frontend/web/components/UserAction.tsx @@ -142,7 +142,7 @@ const UserAction: FC = ({ [close, onRemove, onEdit, onInspectPermissions], ) - if (!canEdit && !!canRemove) { + if (!canEdit && !!canRemove && !canInspectPermissions) { return (