refactor(users): migrate users table to react [EE-4708] (#10759)
parent
86f1b8df6e
commit
a439695248
|
@ -1,132 +0,0 @@
|
||||||
<div class="datatable">
|
|
||||||
<rd-widget>
|
|
||||||
<rd-widget-body classes="no-padding">
|
|
||||||
<div class="toolBar vertical-center flex-wrap !gap-x-5 !gap-y-1">
|
|
||||||
<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'"></pr-icon>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="searchInput"
|
|
||||||
ng-model="$ctrl.state.textFilter"
|
|
||||||
ng-change="$ctrl.onTextFilterChange()"
|
|
||||||
placeholder="Search..."
|
|
||||||
ng-model-options="{ debounce: 300 }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="actionBar !gap-3" ng-show="$ctrl.isAdmin">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-dangerlight vertical-center !ml-0 h-fit"
|
|
||||||
ng-disabled="$ctrl.state.selectedItemCount === 0"
|
|
||||||
ng-click="$ctrl.removeAction($ctrl.state.selectedItems)"
|
|
||||||
data-cy="user-removeUserButton"
|
|
||||||
>
|
|
||||||
<pr-icon icon="'trash-2'"></pr-icon>Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table-hover nowrap-cells table" data-cy="user-userTable">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>
|
|
||||||
<div class="vertical-center">
|
|
||||||
<span class="md-checkbox" ng-show="$ctrl.isAdmin">
|
|
||||||
<input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" />
|
|
||||||
<label for="select_all"></label>
|
|
||||||
</span>
|
|
||||||
<table-column-header
|
|
||||||
col-title="'Name'"
|
|
||||||
can-sort="true"
|
|
||||||
is-sorted="$ctrl.state.orderBy === 'Username'"
|
|
||||||
is-sorted-desc="$ctrl.state.orderBy === 'Username' && $ctrl.state.reverseOrder"
|
|
||||||
ng-click="$ctrl.changeOrderBy('Username')"
|
|
||||||
></table-column-header>
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
<table-column-header
|
|
||||||
col-title="'Role'"
|
|
||||||
can-sort="true"
|
|
||||||
is-sorted="$ctrl.state.orderBy === 'RoleName'"
|
|
||||||
is-sorted-desc="$ctrl.state.orderBy === 'RoleName' && $ctrl.state.reverseOrder"
|
|
||||||
ng-click="$ctrl.changeOrderBy('RoleName')"
|
|
||||||
></table-column-header>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
<table-column-header
|
|
||||||
col-title="'Authentication'"
|
|
||||||
can-sort="true"
|
|
||||||
is-sorted="$ctrl.state.orderBy === 'AuthenticationMethod'"
|
|
||||||
is-sorted-desc="$ctrl.state.orderBy === 'AuthenticationMethod' && $ctrl.state.reverseOrder"
|
|
||||||
ng-click="$ctrl.changeOrderBy('AuthenticationMethod')"
|
|
||||||
></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))"
|
|
||||||
ng-class="{ active: item.Checked }"
|
|
||||||
>
|
|
||||||
<td>
|
|
||||||
<span class="md-checkbox" ng-show="$ctrl.isAdmin">
|
|
||||||
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-click="$ctrl.selectItem(item, $event)" ng-disabled="!$ctrl.allowSelection(item)" />
|
|
||||||
<label for="select_{{ $index }}"></label>
|
|
||||||
</span>
|
|
||||||
<a ui-sref="portainer.users.user({id: item.Id})" ng-show="$ctrl.isAdmin">{{ item.Username }}</a>
|
|
||||||
<span ng-show="!$ctrl.isAdmin">{{ item.Username }}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="vertical-center">
|
|
||||||
<pr-icon icon="'user'" ng-if="item.Role === 1 && !item.isTeamLeader"></pr-icon>
|
|
||||||
<pr-icon icon="'user'" ng-if="item.Role !== 1 && item.isTeamLeader"></pr-icon>
|
|
||||||
<pr-icon icon="'user-plus'" ng-if="item.Role !== 1 && !item.isTeamLeader"></pr-icon>
|
|
||||||
{{ item.RoleName ? item.RoleName : '-' }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span ng-if="item.Id === 1 || ($ctrl.authenticationMethod !== 2 && $ctrl.authenticationMethod !== 3)">Internal</span>
|
|
||||||
<span ng-if="item.Id !== 1 && $ctrl.authenticationMethod === 2">LDAP</span>
|
|
||||||
<span ng-if="item.Id !== 1 && $ctrl.authenticationMethod === 3">OAuth</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr ng-if="!$ctrl.dataset">
|
|
||||||
<td colspan="3" class="text-muted text-center">Loading...</td>
|
|
||||||
</tr>
|
|
||||||
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
|
|
||||||
<td colspan="3" class="text-muted text-center">No user available.</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,18 +0,0 @@
|
||||||
import angular from 'angular';
|
|
||||||
import UsersDatatableController from './usersDatatableController';
|
|
||||||
|
|
||||||
angular.module('portainer.app').component('usersDatatable', {
|
|
||||||
templateUrl: './usersDatatable.html',
|
|
||||||
controller: UsersDatatableController,
|
|
||||||
bindings: {
|
|
||||||
titleText: '@',
|
|
||||||
titleIcon: '@',
|
|
||||||
dataset: '<',
|
|
||||||
tableKey: '@',
|
|
||||||
orderBy: '@',
|
|
||||||
reverseOrder: '<',
|
|
||||||
removeAction: '<',
|
|
||||||
authenticationMethod: '<',
|
|
||||||
isAdmin: '<',
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -1,15 +0,0 @@
|
||||||
export default class UsersDatatableController {
|
|
||||||
/* @ngInject*/
|
|
||||||
constructor($controller, $scope) {
|
|
||||||
const allowSelection = this.allowSelection;
|
|
||||||
angular.extend(this, $controller('GenericDatatableController', { $scope }));
|
|
||||||
this.allowSelection = allowSelection.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Override this method to allow/deny selection
|
|
||||||
*/
|
|
||||||
allowSelection(item) {
|
|
||||||
return item.Id !== 1;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -47,6 +47,7 @@ import { accessControlModule } from './access-control';
|
||||||
import { environmentsModule } from './environments';
|
import { environmentsModule } from './environments';
|
||||||
import { registriesModule } from './registries';
|
import { registriesModule } from './registries';
|
||||||
import { accountModule } from './account';
|
import { accountModule } from './account';
|
||||||
|
import { usersModule } from './users';
|
||||||
|
|
||||||
export const ngModule = angular
|
export const ngModule = angular
|
||||||
.module('portainer.app.react.components', [
|
.module('portainer.app.react.components', [
|
||||||
|
@ -57,6 +58,7 @@ export const ngModule = angular
|
||||||
registriesModule,
|
registriesModule,
|
||||||
settingsModule,
|
settingsModule,
|
||||||
accountModule,
|
accountModule,
|
||||||
|
usersModule,
|
||||||
])
|
])
|
||||||
.component(
|
.component(
|
||||||
'tagSelector',
|
'tagSelector',
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
import angular from 'angular';
|
||||||
|
|
||||||
|
import { r2a } from '@/react-tools/react2angular';
|
||||||
|
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||||
|
import { UsersDatatable } from '@/react/portainer/users/ListView/UsersDatatable/UsersDatatable';
|
||||||
|
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||||
|
|
||||||
|
export const usersModule = angular
|
||||||
|
.module('portainer.app.react.components.users', [])
|
||||||
|
.component(
|
||||||
|
'usersDatatable',
|
||||||
|
r2a(withUIRouter(withCurrentUser(UsersDatatable)), ['dataset', 'onRemove'])
|
||||||
|
).name;
|
|
@ -10,6 +10,12 @@ export enum Role {
|
||||||
EdgeAdmin,
|
EdgeAdmin,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const RoleNames: { [key in Role]: string } = {
|
||||||
|
[Role.Admin]: 'administrator',
|
||||||
|
[Role.Standard]: 'user',
|
||||||
|
[Role.EdgeAdmin]: 'edge administrator',
|
||||||
|
};
|
||||||
|
|
||||||
interface AuthorizationMap {
|
interface AuthorizationMap {
|
||||||
[authorization: string]: boolean;
|
[authorization: string]: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
@ -166,17 +166,4 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<users-datatable dataset="users" on-remove="(removeAction)"></users-datatable>
|
||||||
<div class="col-sm-12">
|
|
||||||
<users-datatable
|
|
||||||
title-text="Users"
|
|
||||||
title-icon="user"
|
|
||||||
dataset="users"
|
|
||||||
table-key="users"
|
|
||||||
order-by="Username"
|
|
||||||
authentication-method="AuthenticationMethod"
|
|
||||||
remove-action="removeAction"
|
|
||||||
is-admin="isAdmin"
|
|
||||||
></users-datatable>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import _ from 'lodash-es';
|
import _ from 'lodash-es';
|
||||||
import { confirmDelete } from '@@/modals/confirm';
|
import { AuthenticationMethod } from '@/react/portainer/settings/types';
|
||||||
|
|
||||||
angular.module('portainer.app').controller('UsersController', [
|
angular.module('portainer.app').controller('UsersController', [
|
||||||
'$q',
|
'$q',
|
||||||
|
@ -91,12 +91,7 @@ angular.module('portainer.app').controller('UsersController', [
|
||||||
}
|
}
|
||||||
|
|
||||||
$scope.removeAction = function (selectedItems) {
|
$scope.removeAction = function (selectedItems) {
|
||||||
confirmDelete('Do you want to remove the selected users? They will not be able to login into Portainer anymore.').then((confirmed) => {
|
return deleteSelectedUsers(selectedItems);
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
deleteSelectedUsers(selectedItems);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function assignTeamLeaders(users, memberships) {
|
function assignTeamLeaders(users, memberships) {
|
||||||
|
@ -107,7 +102,6 @@ angular.module('portainer.app').controller('UsersController', [
|
||||||
var membership = memberships[j];
|
var membership = memberships[j];
|
||||||
if (user.Id === membership.UserId && membership.Role === 1) {
|
if (user.Id === membership.UserId && membership.Role === 1) {
|
||||||
user.isTeamLeader = true;
|
user.isTeamLeader = true;
|
||||||
user.RoleName = 'team leader';
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -125,11 +119,12 @@ angular.module('portainer.app').controller('UsersController', [
|
||||||
settings: SettingsService.publicSettings(),
|
settings: SettingsService.publicSettings(),
|
||||||
})
|
})
|
||||||
.then(function success(data) {
|
.then(function success(data) {
|
||||||
|
$scope.AuthenticationMethod = data.settings.AuthenticationMethod;
|
||||||
var users = data.users;
|
var users = data.users;
|
||||||
assignTeamLeaders(users, data.memberships);
|
assignTeamLeaders(users, data.memberships);
|
||||||
|
users = assignAuthMethod(users, $scope.AuthenticationMethod);
|
||||||
$scope.users = users;
|
$scope.users = users;
|
||||||
$scope.teams = _.orderBy(data.teams, 'Name', 'asc');
|
$scope.teams = _.orderBy(data.teams, 'Name', 'asc');
|
||||||
$scope.AuthenticationMethod = data.settings.AuthenticationMethod;
|
|
||||||
$scope.requiredPasswordLength = data.settings.RequiredPasswordLength;
|
$scope.requiredPasswordLength = data.settings.RequiredPasswordLength;
|
||||||
$scope.teamSync = data.settings.TeamSync;
|
$scope.teamSync = data.settings.TeamSync;
|
||||||
})
|
})
|
||||||
|
@ -143,3 +138,10 @@ angular.module('portainer.app').controller('UsersController', [
|
||||||
initView();
|
initView();
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
function assignAuthMethod(users, authMethod) {
|
||||||
|
return users.map((u) => ({
|
||||||
|
...u,
|
||||||
|
authMethod: AuthenticationMethod[u.Id === 1 ? AuthenticationMethod.Internal : authMethod],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
|
@ -26,7 +26,7 @@ export function WidgetTitle({
|
||||||
<div className="widget-icon">
|
<div className="widget-icon">
|
||||||
<Icon icon={icon} className="space-right" />
|
<Icon icon={icon} className="space-right" />
|
||||||
</div>
|
</div>
|
||||||
<span>{title}</span>
|
<h2 className="text-base m-0">{title}</h2>
|
||||||
</span>
|
</span>
|
||||||
<span className={clsx('pull-right', className)}>{children}</span>
|
<span className={clsx('pull-right', className)}>{children}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -20,7 +20,7 @@ export function TableRow<D extends DefaultType = DefaultType>({
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{cells.map((cell) => (
|
{cells.map((cell) => (
|
||||||
<td key={cell.id}>
|
<td key={cell.id} className={cell.column.columnDef.meta?.className}>
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -72,11 +72,11 @@ export interface OAuthSettings {
|
||||||
KubeSecretKey: string;
|
KubeSecretKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AuthenticationMethod {
|
export enum AuthenticationMethod {
|
||||||
/**
|
/**
|
||||||
* Internal represents the internal authentication method (authentication against Portainer API)
|
* Internal represents the internal authentication method (authentication against Portainer API)
|
||||||
*/
|
*/
|
||||||
Internal,
|
Internal = 1,
|
||||||
/**
|
/**
|
||||||
* LDAP represents the LDAP authentication method (authentication against a LDAP server)
|
* LDAP represents the LDAP authentication method (authentication against a LDAP server)
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { User as UserIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Datatable } from '@@/datatables';
|
||||||
|
import { useTableState } from '@@/datatables/useTableState';
|
||||||
|
import { createPersistedStore } from '@@/datatables/types';
|
||||||
|
import { DeleteButton } from '@@/buttons/DeleteButton';
|
||||||
|
|
||||||
|
import { columns } from './columns';
|
||||||
|
import { DecoratedUser } from './types';
|
||||||
|
|
||||||
|
const store = createPersistedStore('users');
|
||||||
|
|
||||||
|
export function UsersDatatable({
|
||||||
|
dataset,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
dataset?: Array<DecoratedUser>;
|
||||||
|
onRemove: (selectedItems: Array<DecoratedUser>) => void;
|
||||||
|
}) {
|
||||||
|
const tableState = useTableState(store, 'users');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Datatable
|
||||||
|
columns={columns}
|
||||||
|
dataset={dataset || []}
|
||||||
|
isLoading={!dataset}
|
||||||
|
title="Users"
|
||||||
|
titleIcon={UserIcon}
|
||||||
|
settingsManager={tableState}
|
||||||
|
isRowSelectable={(row) => row.original.Id !== 1}
|
||||||
|
renderTableActions={(selectedItems) => (
|
||||||
|
<DeleteButton
|
||||||
|
disabled={selectedItems.length === 0}
|
||||||
|
confirmMessage="Do you want to remove the selected users? They will not be able to login into Portainer anymore."
|
||||||
|
onConfirmed={() => onRemove(selectedItems)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { helper } from './helper';
|
||||||
|
|
||||||
|
export const authentication = helper.accessor('authMethod', {
|
||||||
|
header: 'Authentication',
|
||||||
|
});
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { createColumnHelper } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
import { DecoratedUser } from '../types';
|
||||||
|
|
||||||
|
export const helper = createColumnHelper<DecoratedUser>();
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { authentication } from './authentication';
|
||||||
|
import { name } from './name';
|
||||||
|
import { role } from './role';
|
||||||
|
|
||||||
|
export const columns = [name, role, authentication];
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { CellContext } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||||
|
|
||||||
|
import { Link } from '@@/Link';
|
||||||
|
|
||||||
|
import { DecoratedUser } from '../types';
|
||||||
|
|
||||||
|
import { helper } from './helper';
|
||||||
|
|
||||||
|
export const name = helper.accessor('Username', {
|
||||||
|
header: 'Name',
|
||||||
|
cell: Cell,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Cell({
|
||||||
|
getValue,
|
||||||
|
row: { original: item },
|
||||||
|
}: CellContext<DecoratedUser, 'string'>) {
|
||||||
|
const { isPureAdmin } = useCurrentUser();
|
||||||
|
const name = getValue();
|
||||||
|
|
||||||
|
if (!isPureAdmin) {
|
||||||
|
return <>{name}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link to=".user" params={{ id: item.Id }}>
|
||||||
|
{name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { User, UserPlus } from 'lucide-react';
|
||||||
|
|
||||||
|
import { isEdgeAdmin } from '@/portainer/users/user.helpers';
|
||||||
|
import { RoleNames } from '@/portainer/users/types';
|
||||||
|
|
||||||
|
import { Icon } from '@@/Icon';
|
||||||
|
|
||||||
|
import { helper } from './helper';
|
||||||
|
|
||||||
|
export const role = helper.accessor(
|
||||||
|
(item) =>
|
||||||
|
`${RoleNames[item.Role]} ${
|
||||||
|
item.isTeamLeader ? ' - team leader' : ''
|
||||||
|
}`.trim(),
|
||||||
|
{
|
||||||
|
header: 'Role',
|
||||||
|
cell: ({ getValue, row: { original: item } }) => {
|
||||||
|
const icon =
|
||||||
|
isEdgeAdmin({ Role: item.Role }) || item.isTeamLeader ? User : UserPlus;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="vertical-center">
|
||||||
|
<Icon icon={icon} />
|
||||||
|
{getValue() || '-'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { type User } from '@/portainer/users/types';
|
||||||
|
|
||||||
|
export type DecoratedUser = User & {
|
||||||
|
isTeamLeader?: boolean;
|
||||||
|
authMethod: string;
|
||||||
|
};
|
Loading…
Reference in New Issue