mirror of https://github.com/portainer/portainer
refactor(rbac): migrate access table to react [EE-4710] (#10823)
parent
6ff4fd3db2
commit
e9ebef15a0
|
@ -2,9 +2,11 @@ import angular from 'angular';
|
||||||
|
|
||||||
import { r2a } from '@/react-tools/react2angular';
|
import { r2a } from '@/react-tools/react2angular';
|
||||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
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 { NamespacesDatatable } from '@/react/kubernetes/namespaces/ListView/NamespacesDatatable';
|
||||||
import { NamespaceAppsDatatable } from '@/react/kubernetes/namespaces/ItemView/NamespaceAppsDatatable';
|
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
|
export const namespacesModule = angular
|
||||||
.module('portainer.kubernetes.react.components.namespaces', [])
|
.module('portainer.kubernetes.react.components.namespaces', [])
|
||||||
|
@ -23,4 +25,11 @@ export const namespacesModule = angular
|
||||||
'isLoading',
|
'isLoading',
|
||||||
'onRefresh',
|
'onRefresh',
|
||||||
])
|
])
|
||||||
|
)
|
||||||
|
.component(
|
||||||
|
'namespaceAccessDatatable',
|
||||||
|
r2a(withUIRouter(withReactQuery(NamespaceAccessDatatable)), [
|
||||||
|
'dataset',
|
||||||
|
'onRemove',
|
||||||
|
])
|
||||||
).name;
|
).name;
|
||||||
|
|
|
@ -112,18 +112,5 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<namespace-access-datatable ng-if="ctrl.authorizedUsersAndTeams" dataset="ctrl.authorizedUsersAndTeams" on-remove="(ctrl.unauthorizeAccess)"> </namespace-access-datatable>
|
||||||
<div class="col-sm-12">
|
|
||||||
<access-datatable
|
|
||||||
ng-if="ctrl.authorizedUsersAndTeams"
|
|
||||||
title-text="Namespace access"
|
|
||||||
title-icon="user-x"
|
|
||||||
table-key="kubernetes_resourcepool_access"
|
|
||||||
order-by="Name"
|
|
||||||
dataset="ctrl.authorizedUsersAndTeams"
|
|
||||||
remove-action="ctrl.unauthorizeAccess"
|
|
||||||
>
|
|
||||||
</access-datatable>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,125 +0,0 @@
|
||||||
<div class="datatable">
|
|
||||||
<rd-widget>
|
|
||||||
<rd-widget-body classes="no-padding">
|
|
||||||
<div class="toolBar">
|
|
||||||
<div class="toolBarTitle vertical-center">
|
|
||||||
<div class="widget-icon space-right">
|
|
||||||
<pr-icon icon="$ctrl.titleIcon"></pr-icon>
|
|
||||||
</div>
|
|
||||||
{{ $ctrl.titleText }}
|
|
||||||
</div>
|
|
||||||
<div class="searchBar vertical-center">
|
|
||||||
<pr-icon icon="'search'" class-name="'searchIcon'"></pr-icon>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
data-cy="accessDatatable-searchInput"
|
|
||||||
class="searchInput"
|
|
||||||
ng-model="$ctrl.state.textFilter"
|
|
||||||
ng-change="$ctrl.onTextFilterChange()"
|
|
||||||
placeholder="Search..."
|
|
||||||
ng-model-options="{ debounce: 300 }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="actionBar">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-dangerlight vertical-center"
|
|
||||||
ng-disabled="$ctrl.state.selectedItemCount === 0"
|
|
||||||
ng-click="$ctrl.removeAction($ctrl.state.selectedItems)"
|
|
||||||
>
|
|
||||||
<pr-icon icon="'trash-2'"></pr-icon>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="small text-muted mx-4 mb-4">
|
|
||||||
<div class="" ng-if="$ctrl.inheritFrom">
|
|
||||||
Access tagged as <code>inherited</code> are inherited from the group access. They cannot be removed or modified at the environment level but they can be overridden.
|
|
||||||
</div>
|
|
||||||
<div class="" ng-if="$ctrl.inheritFrom"> Access tagged as <code>override</code> are overriding the group access for the related users/teams. </div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table-hover nowrap-cells table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>
|
|
||||||
<div class="vertical-center">
|
|
||||||
<span class="md-checkbox">
|
|
||||||
<input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" data-cy="select-all-checkbox" />
|
|
||||||
<label for="select_all"></label>
|
|
||||||
</span>
|
|
||||||
<table-column-header
|
|
||||||
col-title="'Name'"
|
|
||||||
can-sort="true"
|
|
||||||
is-sorted="$ctrl.state.orderBy === 'Name'"
|
|
||||||
is-sorted-desc="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"
|
|
||||||
ng-click="$ctrl.changeOrderBy('Name')"
|
|
||||||
></table-column-header>
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
<table-column-header
|
|
||||||
col-title="'Type'"
|
|
||||||
can-sort="true"
|
|
||||||
is-sorted="$ctrl.state.orderBy === 'Type'"
|
|
||||||
is-sorted-desc="$ctrl.state.orderBy === 'Type' && $ctrl.state.reverseOrder"
|
|
||||||
ng-click="$ctrl.changeOrderBy('Type')"
|
|
||||||
></table-column-header>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr
|
|
||||||
dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit)) track by $index"
|
|
||||||
ng-class="{ active: item.Checked }"
|
|
||||||
>
|
|
||||||
<td>
|
|
||||||
<span class="md-checkbox">
|
|
||||||
<input
|
|
||||||
id="select_{{ $index }}"
|
|
||||||
type="checkbox"
|
|
||||||
ng-model="item.Checked"
|
|
||||||
ng-disabled="$ctrl.disableRemove(item)"
|
|
||||||
ng-click="$ctrl.selectItem(item, $event)"
|
|
||||||
data-cy="select-checkbox"
|
|
||||||
/>
|
|
||||||
<label for="select_{{ $index }}"></label>
|
|
||||||
</span>
|
|
||||||
{{ item.Name }}
|
|
||||||
<span ng-if="$ctrl.inheritFrom && item.Inherited" class="text-muted small" style="margin-left: 2px"><code style="font-size: 85% !important">inherited</code></span>
|
|
||||||
<span ng-if="$ctrl.inheritFrom && item.Override" class="text-muted small" style="margin-left: 2px"><code style="font-size: 85% !important">override</code></span>
|
|
||||||
</td>
|
|
||||||
<td>{{ item.Type }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr ng-if="!$ctrl.dataset">
|
|
||||||
<td colspan="4" class="text-muted text-center">Loading...</td>
|
|
||||||
</tr>
|
|
||||||
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
|
|
||||||
<td colspan="4" class="text-muted text-center">No authorized users or teams.</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="footer" ng-if="$ctrl.dataset">
|
|
||||||
<div class="infoBar" ng-if="$ctrl.state.selectedItemCount !== 0"> {{ $ctrl.state.selectedItemCount }} item(s) selected </div>
|
|
||||||
<div class="paginationControls">
|
|
||||||
<form class="form-inline">
|
|
||||||
<span class="limitSelector">
|
|
||||||
<span style="margin-right: 5px"> Items per page </span>
|
|
||||||
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()" data-cy="component-paginationSelect">
|
|
||||||
<option value="0">All</option>
|
|
||||||
<option value="10">10</option>
|
|
||||||
<option value="25">25</option>
|
|
||||||
<option value="50">50</option>
|
|
||||||
<option value="100">100</option>
|
|
||||||
</select>
|
|
||||||
</span>
|
|
||||||
<dir-pagination-controls max-size="5"></dir-pagination-controls>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</rd-widget-body>
|
|
||||||
</rd-widget>
|
|
||||||
</div>
|
|
|
@ -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: '<',
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -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();
|
|
||||||
};
|
|
||||||
},
|
|
||||||
]);
|
|
|
@ -62,23 +62,17 @@
|
||||||
</rd-widget>
|
</rd-widget>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<access-datatable
|
<access-datatable
|
||||||
ng-if="ctrl.authorizedUsersAndTeams"
|
ng-if="ctrl.authorizedUsersAndTeams"
|
||||||
title-text="Access"
|
table-key="'access_' + ctrl.entityType"
|
||||||
title-icon="user-x"
|
|
||||||
table-key="{{ 'access_' + ctrl.entityType }}"
|
|
||||||
order-by="Name"
|
|
||||||
show-warning="ctrl.entityType !== 'registry'"
|
show-warning="ctrl.entityType !== 'registry'"
|
||||||
is-update-enabled="ctrl.entityType !== 'registry'"
|
is-update-enabled="ctrl.entityType !== 'registry'"
|
||||||
show-roles="ctrl.entityType !== 'registry'"
|
show-roles="ctrl.entityType !== 'registry'"
|
||||||
roles="ctrl.roles"
|
roles="ctrl.roles"
|
||||||
inherit-from="ctrl.inheritFrom"
|
inherit-from="ctrl.inheritFrom"
|
||||||
dataset="ctrl.authorizedUsersAndTeams"
|
dataset="ctrl.authorizedUsersAndTeams"
|
||||||
update-action="ctrl.updateAction"
|
on-update="(ctrl.updateAction)"
|
||||||
remove-action="ctrl.unauthorizeAccess"
|
on-remove="(ctrl.unauthorizeAccess)"
|
||||||
>
|
>
|
||||||
</access-datatable>
|
</access-datatable>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
|
@ -23,12 +23,10 @@ class PorAccessManagementController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateAction() {
|
updateAction(updatedUserAccesses, updatedTeamAccesses) {
|
||||||
const entity = this.accessControlledEntity;
|
const entity = this.accessControlledEntity;
|
||||||
const oldUserAccessPolicies = entity.UserAccessPolicies;
|
const oldUserAccessPolicies = entity.UserAccessPolicies;
|
||||||
const oldTeamAccessPolicies = entity.TeamAccessPolicies;
|
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);
|
const accessPolicies = this.AccessService.generateAccessPolicies(oldUserAccessPolicies, oldTeamAccessPolicies, updatedUserAccesses, updatedTeamAccesses);
|
||||||
this.accessControlledEntity.UserAccessPolicies = accessPolicies.userAccessPolicies;
|
this.accessControlledEntity.UserAccessPolicies = accessPolicies.userAccessPolicies;
|
||||||
|
|
|
@ -48,6 +48,7 @@ import { registriesModule } from './registries';
|
||||||
import { accountModule } from './account';
|
import { accountModule } from './account';
|
||||||
import { usersModule } from './users';
|
import { usersModule } from './users';
|
||||||
import { activityLogsModule } from './activity-logs';
|
import { activityLogsModule } from './activity-logs';
|
||||||
|
import { rbacModule } from './rbac';
|
||||||
|
|
||||||
export const ngModule = angular
|
export const ngModule = angular
|
||||||
.module('portainer.app.react.components', [
|
.module('portainer.app.react.components', [
|
||||||
|
@ -60,6 +61,7 @@ export const ngModule = angular
|
||||||
accountModule,
|
accountModule,
|
||||||
usersModule,
|
usersModule,
|
||||||
activityLogsModule,
|
activityLogsModule,
|
||||||
|
rbacModule,
|
||||||
])
|
])
|
||||||
.component(
|
.component(
|
||||||
'tagSelector',
|
'tagSelector',
|
||||||
|
|
|
@ -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;
|
|
@ -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<Access>;
|
||||||
|
onRemove(items: Array<Access>): void;
|
||||||
|
}) {
|
||||||
|
const tableState = useTableState(store, tableKey);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Datatable
|
||||||
|
data-cy="kube-namespace-access-datatable"
|
||||||
|
title="Namespace Access"
|
||||||
|
titleIcon={UserX}
|
||||||
|
dataset={dataset || []}
|
||||||
|
isLoading={!dataset}
|
||||||
|
columns={columns}
|
||||||
|
settingsManager={tableState}
|
||||||
|
renderTableActions={(selectedItems) => (
|
||||||
|
<RemoveAccessButton items={selectedItems} onClick={onRemove} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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<Access>;
|
||||||
|
onRemove(items: Array<Access>): void;
|
||||||
|
onUpdate(
|
||||||
|
users: Array<UserAccessViewModel>,
|
||||||
|
teams: Array<TeamAccessViewModel>
|
||||||
|
): 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 (
|
||||||
|
<Datatable
|
||||||
|
data-cy="access-datatable"
|
||||||
|
title="Access"
|
||||||
|
titleIcon={UserX}
|
||||||
|
dataset={dataset || []}
|
||||||
|
isLoading={!dataset}
|
||||||
|
columns={columns}
|
||||||
|
settingsManager={tableState}
|
||||||
|
extendTableOptions={withMeta({
|
||||||
|
table: 'access-table',
|
||||||
|
roles: rolesState,
|
||||||
|
})}
|
||||||
|
isRowSelectable={({ original: item }) => !inheritFrom || !item.Inherited}
|
||||||
|
emptyContentLabel="No authorized users or teams."
|
||||||
|
renderTableActions={(selectedItems) => (
|
||||||
|
<>
|
||||||
|
<RemoveAccessButton items={selectedItems} onClick={onRemove} />
|
||||||
|
|
||||||
|
{isBE && isUpdateEnabled && (
|
||||||
|
<Button
|
||||||
|
data-cy="update-access-button"
|
||||||
|
icon={Check}
|
||||||
|
disabled={rolesState.count === 0}
|
||||||
|
onClick={handleUpdate}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
description={
|
||||||
|
<div className="small text-muted mx-4 mb-4">
|
||||||
|
{inheritFrom && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
Access tagged as <code>inherited</code> are inherited from the
|
||||||
|
group access. They cannot be removed or modified at the
|
||||||
|
environment level but they can be overridden.
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Access tagged as <code>override</code> are overriding the group
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isBE && showWarning && isUpdateEnabled && (
|
||||||
|
<TextTip>
|
||||||
|
<div className="text-warning-9 th-highcontrast:text-warning-1 th-dark:text-warning-7">
|
||||||
|
Updating user access will require the affected user(s) to logout
|
||||||
|
and login for the changes to be taken into account.
|
||||||
|
</div>
|
||||||
|
</TextTip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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<number, number | undefined>,
|
||||||
|
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<number, number | undefined>
|
||||||
|
>({});
|
||||||
|
const [userRoles, setUserRoles] = useState<
|
||||||
|
Record<number, number | undefined>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
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<number, number | undefined>) {
|
||||||
|
const newRoles = { ...roles };
|
||||||
|
if (typeof value === 'undefined') {
|
||||||
|
delete newRoles[id];
|
||||||
|
} else {
|
||||||
|
newRoles[id] = value;
|
||||||
|
}
|
||||||
|
return newRoles;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUpdate() {
|
||||||
|
return {
|
||||||
|
users: userRoles,
|
||||||
|
teams: teamRoles,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { DeleteButton } from '@@/buttons/DeleteButton';
|
||||||
|
|
||||||
|
import { Access } from './types';
|
||||||
|
|
||||||
|
export function RemoveAccessButton({
|
||||||
|
onClick,
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
onClick(items: Array<Access>): void;
|
||||||
|
items: Array<Access>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DeleteButton
|
||||||
|
confirmMessage="Are you sure you want to unauthorized the selected users or teams?"
|
||||||
|
onConfirmed={() => onClick(items)}
|
||||||
|
disabled={items.length === 0}
|
||||||
|
data-cy="remove-access-button"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { createColumnHelper } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
import { Access } from '../types';
|
||||||
|
|
||||||
|
export const helper = createColumnHelper<Access>();
|
|
@ -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 && (
|
||||||
|
<span className="text-muted small">
|
||||||
|
<code className="text-sm">inherited</code>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.Override && (
|
||||||
|
<span className="text-muted small">
|
||||||
|
<code className="text-sm">override</code>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { helper } from './helper';
|
||||||
|
|
||||||
|
export const name = helper.accessor('Name', {});
|
|
@ -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<Access, string>) {
|
||||||
|
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}
|
||||||
|
<Button
|
||||||
|
color="none"
|
||||||
|
icon={Edit}
|
||||||
|
onClick={() => meta.roles.setRolesValue(item.Id, type, item.Role.Id)}
|
||||||
|
data-cy="edit-role-button"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<RollEdit
|
||||||
|
value={updateValue}
|
||||||
|
onChange={(value) => 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 (
|
||||||
|
<div className="flex items-center gap-3 max-w-xs">
|
||||||
|
<Select
|
||||||
|
aria-label="Role"
|
||||||
|
data-cy="role-select"
|
||||||
|
value={value}
|
||||||
|
options={rolesQuery.data}
|
||||||
|
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
color="none"
|
||||||
|
icon={X}
|
||||||
|
onClick={() => onChange()}
|
||||||
|
data-cy="cancel-role-button"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { helper } from './helper';
|
||||||
|
|
||||||
|
export const type = helper.accessor('Type', {});
|
|
@ -0,0 +1,27 @@
|
||||||
|
import _ from 'lodash';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||||
|
|
||||||
|
import { inheritedName } from './inheritedName';
|
||||||
|
import { name } from './name';
|
||||||
|
import { type } from './type';
|
||||||
|
import { role } from './role';
|
||||||
|
|
||||||
|
export function useColumns({
|
||||||
|
showRoles,
|
||||||
|
inheritFrom,
|
||||||
|
}: {
|
||||||
|
showRoles: boolean;
|
||||||
|
inheritFrom: boolean;
|
||||||
|
}) {
|
||||||
|
return useMemo(
|
||||||
|
() =>
|
||||||
|
_.compact([
|
||||||
|
inheritFrom ? inheritedName : name,
|
||||||
|
type,
|
||||||
|
isBE && showRoles && role,
|
||||||
|
]),
|
||||||
|
[inheritFrom, showRoles]
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
import {
|
||||||
|
TeamAccessViewModel,
|
||||||
|
UserAccessViewModel,
|
||||||
|
} from '@/portainer/models/access';
|
||||||
|
|
||||||
|
export type Access = UserAccessViewModel | TeamAccessViewModel;
|
||||||
|
|
||||||
|
export interface TableMeta {
|
||||||
|
table: 'access-table';
|
||||||
|
roles: {
|
||||||
|
getRoleValue(id: number, entity: 'user' | 'team'): number | undefined;
|
||||||
|
setRolesValue(
|
||||||
|
id: number,
|
||||||
|
entity: 'user' | 'team',
|
||||||
|
value: number | undefined
|
||||||
|
): void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTableMeta(meta?: unknown): meta is TableMeta {
|
||||||
|
return (
|
||||||
|
!!meta &&
|
||||||
|
typeof meta === 'object' &&
|
||||||
|
'table' in meta &&
|
||||||
|
meta.table === 'access-table'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTableMeta(meta: unknown) {
|
||||||
|
if (!isTableMeta(meta)) {
|
||||||
|
throw new Error('missing table meta');
|
||||||
|
}
|
||||||
|
return meta;
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { useQuery } from 'react-query';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
|
||||||
|
import { RbacRole } from './types';
|
||||||
|
|
||||||
|
export function useRbacRoles<T = Array<RbacRole>>({
|
||||||
|
select,
|
||||||
|
}: {
|
||||||
|
select: (roles: Array<RbacRole>) => T;
|
||||||
|
}) {
|
||||||
|
return useQuery({
|
||||||
|
select,
|
||||||
|
queryKey: ['roles'],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get<Array<RbacRole>>('/roles');
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
throw parseAxiosError(e, 'Failed to fetch roles');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
Loading…
Reference in New Issue