diff --git a/app/kubernetes/react/components/namespaces.ts b/app/kubernetes/react/components/namespaces.ts index 3a3c445c1..32edfc0b5 100644 --- a/app/kubernetes/react/components/namespaces.ts +++ b/app/kubernetes/react/components/namespaces.ts @@ -2,9 +2,11 @@ import angular from 'angular'; import { r2a } from '@/react-tools/react2angular'; import { withUIRouter } from '@/react-tools/withUIRouter'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withReactQuery } from '@/react-tools/withReactQuery'; import { NamespacesDatatable } from '@/react/kubernetes/namespaces/ListView/NamespacesDatatable'; import { NamespaceAppsDatatable } from '@/react/kubernetes/namespaces/ItemView/NamespaceAppsDatatable'; -import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { NamespaceAccessDatatable } from '@/react/kubernetes/namespaces/AccessView/AccessDatatable'; export const namespacesModule = angular .module('portainer.kubernetes.react.components.namespaces', []) @@ -23,4 +25,11 @@ export const namespacesModule = angular 'isLoading', 'onRefresh', ]) + ) + .component( + 'namespaceAccessDatatable', + r2a(withUIRouter(withReactQuery(NamespaceAccessDatatable)), [ + 'dataset', + 'onRemove', + ]) ).name; diff --git a/app/kubernetes/views/resource-pools/access/resourcePoolAccess.html b/app/kubernetes/views/resource-pools/access/resourcePoolAccess.html index 693cbf7c3..abc1815bf 100644 --- a/app/kubernetes/views/resource-pools/access/resourcePoolAccess.html +++ b/app/kubernetes/views/resource-pools/access/resourcePoolAccess.html @@ -112,18 +112,5 @@ -
-
- - -
-
+ diff --git a/app/portainer/components/access-datatable/accessDatatable.html b/app/portainer/components/access-datatable/accessDatatable.html deleted file mode 100644 index b8282f9a1..000000000 --- a/app/portainer/components/access-datatable/accessDatatable.html +++ /dev/null @@ -1,125 +0,0 @@ -
- - -
-
-
- -
- {{ $ctrl.titleText }} -
- -
- -
-
- -
-
- Access tagged as inherited are inherited from the group access. They cannot be removed or modified at the environment level but they can be overridden. -
-
Access tagged as override are overriding the group access for the related users/teams.
-
- -
- - - - - - - - - - - - - - - - - - - -
-
- - - - - -
-
- -
- - - - - {{ item.Name }} - inherited - override - {{ item.Type }}
Loading...
No authorized users or teams.
-
- -
-
-
diff --git a/app/portainer/components/access-datatable/accessDatatable.js b/app/portainer/components/access-datatable/accessDatatable.js deleted file mode 100644 index 4580aaca7..000000000 --- a/app/portainer/components/access-datatable/accessDatatable.js +++ /dev/null @@ -1,16 +0,0 @@ -angular.module('portainer.app').component('accessDatatable', { - templateUrl: './accessDatatable.html', - controller: 'AccessDatatableController', - bindings: { - titleText: '@', - titleIcon: '@', - dataset: '<', - roles: '<', - tableKey: '@', - orderBy: '@', - removeAction: '<', - updateAction: '<', - reverseOrder: '<', - inheritFrom: '<', - }, -}); diff --git a/app/portainer/components/access-datatable/accessDatatableController.js b/app/portainer/components/access-datatable/accessDatatableController.js deleted file mode 100644 index 653c5a436..000000000 --- a/app/portainer/components/access-datatable/accessDatatableController.js +++ /dev/null @@ -1,49 +0,0 @@ -angular.module('portainer.app').controller('AccessDatatableController', [ - '$scope', - '$controller', - 'DatatableService', - function ($scope, $controller, DatatableService) { - angular.extend(this, $controller('GenericDatatableController', { $scope: $scope })); - - this.disableRemove = function (item) { - return item.Inherited && this.inheritFrom; - }; - - this.allowSelection = function (item) { - return !this.disableRemove(item); - }; - - this.$onInit = function () { - this.setDefaults(); - this.prepareTableFromDataset(); - - this.state.orderBy = this.orderBy; - var storedOrder = DatatableService.getDataTableOrder(this.tableKey); - if (storedOrder !== null) { - this.state.reverseOrder = storedOrder.reverse; - this.state.orderBy = storedOrder.orderBy; - } - - var textFilter = DatatableService.getDataTableTextFilters(this.tableKey); - if (textFilter !== null) { - this.state.textFilter = textFilter; - this.onTextFilterChange(); - } - - var storedFilters = DatatableService.getDataTableFilters(this.tableKey); - if (storedFilters !== null) { - this.filters = storedFilters; - } - if (this.filters && this.filters.state) { - this.filters.state.open = false; - } - - var storedSettings = DatatableService.getDataTableSettings(this.tableKey); - if (storedSettings !== null) { - this.settings = storedSettings; - this.settings.open = false; - } - this.onSettingsRepeaterChange(); - }; - }, -]); diff --git a/app/portainer/components/accessManagement/porAccessManagement.html b/app/portainer/components/accessManagement/porAccessManagement.html index eb1a46550..9a93a82d7 100644 --- a/app/portainer/components/accessManagement/porAccessManagement.html +++ b/app/portainer/components/accessManagement/porAccessManagement.html @@ -62,23 +62,17 @@ -
-
- - -
-
+ + + diff --git a/app/portainer/components/accessManagement/porAccessManagementController.js b/app/portainer/components/accessManagement/porAccessManagementController.js index e0a53d01f..096d86c34 100644 --- a/app/portainer/components/accessManagement/porAccessManagementController.js +++ b/app/portainer/components/accessManagement/porAccessManagementController.js @@ -23,12 +23,10 @@ class PorAccessManagementController { }); } - updateAction() { + updateAction(updatedUserAccesses, updatedTeamAccesses) { const entity = this.accessControlledEntity; const oldUserAccessPolicies = entity.UserAccessPolicies; const oldTeamAccessPolicies = entity.TeamAccessPolicies; - const updatedUserAccesses = _.filter(this.authorizedUsersAndTeams, { Updated: true, Type: 'user', Inherited: false }); - const updatedTeamAccesses = _.filter(this.authorizedUsersAndTeams, { Updated: true, Type: 'team', Inherited: false }); const accessPolicies = this.AccessService.generateAccessPolicies(oldUserAccessPolicies, oldTeamAccessPolicies, updatedUserAccesses, updatedTeamAccesses); this.accessControlledEntity.UserAccessPolicies = accessPolicies.userAccessPolicies; diff --git a/app/portainer/react/components/index.ts b/app/portainer/react/components/index.ts index 24559a6ef..a16b10b91 100644 --- a/app/portainer/react/components/index.ts +++ b/app/portainer/react/components/index.ts @@ -48,6 +48,7 @@ import { registriesModule } from './registries'; import { accountModule } from './account'; import { usersModule } from './users'; import { activityLogsModule } from './activity-logs'; +import { rbacModule } from './rbac'; export const ngModule = angular .module('portainer.app.react.components', [ @@ -60,6 +61,7 @@ export const ngModule = angular accountModule, usersModule, activityLogsModule, + rbacModule, ]) .component( 'tagSelector', diff --git a/app/portainer/react/components/rbac.ts b/app/portainer/react/components/rbac.ts new file mode 100644 index 000000000..5d93bfaaf --- /dev/null +++ b/app/portainer/react/components/rbac.ts @@ -0,0 +1,22 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withReactQuery } from '@/react-tools/withReactQuery'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { AccessDatatable } from '@/react/portainer/access-control/AccessManagement/AccessDatatable/AccessDatatable'; + +export const rbacModule = angular + .module('portainer.app.react.components.rbac', []) + .component( + 'accessDatatable', + r2a(withUIRouter(withReactQuery(AccessDatatable)), [ + 'dataset', + 'inheritFrom', + 'isUpdateEnabled', + 'onRemove', + 'onUpdate', + 'showRoles', + 'showWarning', + 'tableKey', + ]) + ).name; diff --git a/app/react/kubernetes/namespaces/AccessView/AccessDatatable.tsx b/app/react/kubernetes/namespaces/AccessView/AccessDatatable.tsx new file mode 100644 index 000000000..20915e494 --- /dev/null +++ b/app/react/kubernetes/namespaces/AccessView/AccessDatatable.tsx @@ -0,0 +1,39 @@ +import { UserX } from 'lucide-react'; + +import { name } from '@/react/portainer/access-control/AccessManagement/AccessDatatable/columns/name'; +import { type } from '@/react/portainer/access-control/AccessManagement/AccessDatatable/columns/type'; +import { Access } from '@/react/portainer/access-control/AccessManagement/AccessDatatable/types'; +import { RemoveAccessButton } from '@/react/portainer/access-control/AccessManagement/AccessDatatable/RemoveAccessButton'; + +import { createPersistedStore } from '@@/datatables/types'; +import { useTableState } from '@@/datatables/useTableState'; +import { Datatable } from '@@/datatables'; + +const tableKey = 'kubernetes_resourcepool_access'; +const columns = [name, type]; +const store = createPersistedStore(tableKey); + +export function NamespaceAccessDatatable({ + dataset, + onRemove, +}: { + dataset?: Array; + onRemove(items: Array): void; +}) { + const tableState = useTableState(store, tableKey); + + return ( + ( + + )} + /> + ); +} diff --git a/app/react/portainer/access-control/AccessManagement/AccessDatatable/AccessDatatable.tsx b/app/react/portainer/access-control/AccessManagement/AccessDatatable/AccessDatatable.tsx new file mode 100644 index 000000000..835c4b635 --- /dev/null +++ b/app/react/portainer/access-control/AccessManagement/AccessDatatable/AccessDatatable.tsx @@ -0,0 +1,197 @@ +import { Check, UserX } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import _ from 'lodash'; + +import { + TeamAccessViewModel, + UserAccessViewModel, +} from '@/portainer/models/access'; +import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; + +import { Datatable } from '@@/datatables'; +import { createPersistedStore } from '@@/datatables/types'; +import { useTableState } from '@@/datatables/useTableState'; +import { withMeta } from '@@/datatables/extend-options/withMeta'; +import { Button } from '@@/buttons'; +import { TextTip } from '@@/Tip/TextTip'; + +import { useColumns } from './columns/useColumns'; +import { Access } from './types'; +import { RemoveAccessButton } from './RemoveAccessButton'; + +export function AccessDatatable({ + dataset, + tableKey, + onRemove, + onUpdate, + showWarning = false, + isUpdateEnabled = false, + showRoles = false, + inheritFrom = false, +}: { + tableKey: string; + dataset?: Array; + onRemove(items: Array): void; + onUpdate( + users: Array, + teams: Array + ): void; + showWarning?: boolean; + isUpdateEnabled?: boolean; + showRoles?: boolean; + inheritFrom?: boolean; +}) { + const columns = useColumns({ showRoles, inheritFrom }); + const [store] = useState(() => createPersistedStore(tableKey)); + const tableState = useTableState(store, tableKey); + const rolesState = useRolesState(); + + return ( + !inheritFrom || !item.Inherited} + emptyContentLabel="No authorized users or teams." + renderTableActions={(selectedItems) => ( + <> + + + {isBE && isUpdateEnabled && ( + + )} + + )} + description={ +
+ {inheritFrom && ( + <> +
+ Access tagged as inherited are inherited from the + group access. They cannot be removed or modified at the + environment level but they can be overridden. +
+
+ Access tagged as override are overriding the group +
+ + )} + {isBE && showWarning && isUpdateEnabled && ( + +
+ Updating user access will require the affected user(s) to logout + and login for the changes to be taken into account. +
+
+ )} +
+ } + /> + ); + + function handleUpdate() { + const update = rolesState.getUpdate(); + const teamsAccess = getAccess(update.teams, 'team'); + const usersAccess = getAccess(update.users, 'user'); + + onUpdate(usersAccess, teamsAccess); + + function getAccess( + accesses: Record, + type: 'team' | 'user' + ) { + return _.compact( + Object.entries(accesses).map(([strId, role]) => { + if (!strId || !role) { + return undefined; + } + + const id = parseInt(strId, 10); + const entity = dataset?.find( + (item) => item.Type === type && item.Id === id + ); + if (!entity) { + return undefined; + } + + return { + ...entity, + Role: { + Id: role, + Name: '', + }, + }; + }) + ); + } + } +} + +function useRolesState() { + const [teamRoles, setTeamRoles] = useState< + Record + >({}); + const [userRoles, setUserRoles] = useState< + Record + >({}); + + const count = useMemo( + () => Object.keys(teamRoles).length + Object.keys(userRoles).length, + [teamRoles, userRoles] + ); + + return { getRoleValue, setRolesValue, getUpdate, count }; + + function getRoleValue(id: number, entity: 'user' | 'team') { + if (entity === 'team') { + return teamRoles[id]; + } + return userRoles[id]; + } + + function setRolesValue( + id: number, + entity: 'user' | 'team', + value: number | undefined + ) { + if (entity === 'team') { + setTeamRoles(updater); + + return; + } + + setUserRoles(updater); + + function updater(roles: Record) { + const newRoles = { ...roles }; + if (typeof value === 'undefined') { + delete newRoles[id]; + } else { + newRoles[id] = value; + } + return newRoles; + } + } + + function getUpdate() { + return { + users: userRoles, + teams: teamRoles, + }; + } +} diff --git a/app/react/portainer/access-control/AccessManagement/AccessDatatable/RemoveAccessButton.tsx b/app/react/portainer/access-control/AccessManagement/AccessDatatable/RemoveAccessButton.tsx new file mode 100644 index 000000000..2e4f4618d --- /dev/null +++ b/app/react/portainer/access-control/AccessManagement/AccessDatatable/RemoveAccessButton.tsx @@ -0,0 +1,20 @@ +import { DeleteButton } from '@@/buttons/DeleteButton'; + +import { Access } from './types'; + +export function RemoveAccessButton({ + onClick, + items, +}: { + onClick(items: Array): void; + items: Array; +}) { + return ( + onClick(items)} + disabled={items.length === 0} + data-cy="remove-access-button" + /> + ); +} diff --git a/app/react/portainer/access-control/AccessManagement/AccessDatatable/columns/helper.ts b/app/react/portainer/access-control/AccessManagement/AccessDatatable/columns/helper.ts new file mode 100644 index 000000000..8cd9c720e --- /dev/null +++ b/app/react/portainer/access-control/AccessManagement/AccessDatatable/columns/helper.ts @@ -0,0 +1,5 @@ +import { createColumnHelper } from '@tanstack/react-table'; + +import { Access } from '../types'; + +export const helper = createColumnHelper(); diff --git a/app/react/portainer/access-control/AccessManagement/AccessDatatable/columns/inheritedName.tsx b/app/react/portainer/access-control/AccessManagement/AccessDatatable/columns/inheritedName.tsx new file mode 100644 index 000000000..537531dfd --- /dev/null +++ b/app/react/portainer/access-control/AccessManagement/AccessDatatable/columns/inheritedName.tsx @@ -0,0 +1,22 @@ +import { helper } from './helper'; + +export const inheritedName = helper.accessor('Name', { + cell({ row: { original: item }, getValue }) { + const name = getValue(); + return ( + <> + {name} + {item.Inherited && ( + + inherited + + )} + {item.Override && ( + + override + + )} + + ); + }, +}); diff --git a/app/react/portainer/access-control/AccessManagement/AccessDatatable/columns/name.ts b/app/react/portainer/access-control/AccessManagement/AccessDatatable/columns/name.ts new file mode 100644 index 000000000..9054a023d --- /dev/null +++ b/app/react/portainer/access-control/AccessManagement/AccessDatatable/columns/name.ts @@ -0,0 +1,3 @@ +import { helper } from './helper'; + +export const name = helper.accessor('Name', {}); diff --git a/app/react/portainer/access-control/AccessManagement/AccessDatatable/columns/role.tsx b/app/react/portainer/access-control/AccessManagement/AccessDatatable/columns/role.tsx new file mode 100644 index 000000000..4dd8b38bf --- /dev/null +++ b/app/react/portainer/access-control/AccessManagement/AccessDatatable/columns/role.tsx @@ -0,0 +1,89 @@ +import { CellContext } from '@tanstack/react-table'; +import { Edit, X } from 'lucide-react'; + +import { useRbacRoles } from '@/react/portainer/users/RolesView/useRbacRoles'; + +import { Button } from '@@/buttons'; +import { Select } from '@@/form-components/Input'; + +import { Access, getTableMeta } from '../types'; + +import { helper } from './helper'; + +export const role = helper.accessor('Role.Name', { + cell: RoleCell, + meta: { + width: 320, + }, +}); + +function RoleCell({ + row: { original: item, getCanSelect }, + table, + getValue, +}: CellContext) { + const meta = getTableMeta(table.options.meta); + const type = item.Type as 'team' | 'user'; + const updateValue = meta.roles.getRoleValue(item.Id, type); + const role = getValue(); + + if (!getCanSelect()) { + return <>{role}; + } + + if (typeof updateValue === 'undefined') { + return ( + <> + {role} + + + ); + } + return ( + meta.roles.setRolesValue(item.Id, type, value)} + /> + ); +} + +function RollEdit({ + value, + onChange, +}: { + value: number; + onChange(value?: number): void; +}) { + const rolesQuery = useRbacRoles({ + select: (roles) => roles.map((r) => ({ label: r.Name, value: r.Id })), + }); + + if (!rolesQuery.data) { + return null; + } + + return ( +
+