refactor(rbac): migrate access table to react [EE-4710] (#10823)

refactor/data-cy
Chaim Lev-Ari 8 months ago committed by GitHub
parent 6ff4fd3db2
commit e9ebef15a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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;

@ -112,18 +112,5 @@
</div>
</div>
<div class="row">
<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>
<namespace-access-datatable ng-if="ctrl.authorizedUsersAndTeams" dataset="ctrl.authorizedUsersAndTeams" on-remove="(ctrl.unauthorizeAccess)"> </namespace-access-datatable>
</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>
</div>
</div>
<div class="row">
<div class="col-sm-12">
<access-datatable
ng-if="ctrl.authorizedUsersAndTeams"
title-text="Access"
title-icon="user-x"
table-key="{{ 'access_' + ctrl.entityType }}"
order-by="Name"
show-warning="ctrl.entityType !== 'registry'"
is-update-enabled="ctrl.entityType !== 'registry'"
show-roles="ctrl.entityType !== 'registry'"
roles="ctrl.roles"
inherit-from="ctrl.inheritFrom"
dataset="ctrl.authorizedUsersAndTeams"
update-action="ctrl.updateAction"
remove-action="ctrl.unauthorizeAccess"
>
</access-datatable>
</div>
</div>
<access-datatable
ng-if="ctrl.authorizedUsersAndTeams"
table-key="'access_' + ctrl.entityType"
show-warning="ctrl.entityType !== 'registry'"
is-update-enabled="ctrl.entityType !== 'registry'"
show-roles="ctrl.entityType !== 'registry'"
roles="ctrl.roles"
inherit-from="ctrl.inheritFrom"
dataset="ctrl.authorizedUsersAndTeams"
on-update="(ctrl.updateAction)"
on-remove="(ctrl.unauthorizeAccess)"
>
</access-datatable>

@ -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;

@ -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',

@ -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…
Cancel
Save