mirror of
https://github.com/portainer/portainer.git
synced 2025-07-24 15:59:41 +02:00
refactor(users): migrate list view to react [EE-2202] (#11914)
This commit is contained in:
parent
33ce841040
commit
3c1441d462
43 changed files with 967 additions and 681 deletions
|
@ -85,7 +85,7 @@ func (service *Service) DeleteTeamMembershipByUserID(userID portainer.UserID) er
|
||||||
BucketName,
|
BucketName,
|
||||||
&portainer.TeamMembership{},
|
&portainer.TeamMembership{},
|
||||||
func(obj any) (id int, ok bool) {
|
func(obj any) (id int, ok bool) {
|
||||||
membership, ok := obj.(portainer.TeamMembership)
|
membership, ok := obj.(*portainer.TeamMembership)
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to TeamMembership object")
|
log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to TeamMembership object")
|
||||||
//return fmt.Errorf("Failed to convert to TeamMembership object: %s", obj)
|
//return fmt.Errorf("Failed to convert to TeamMembership object: %s", obj)
|
||||||
|
@ -106,7 +106,7 @@ func (service *Service) DeleteTeamMembershipByTeamID(teamID portainer.TeamID) er
|
||||||
BucketName,
|
BucketName,
|
||||||
&portainer.TeamMembership{},
|
&portainer.TeamMembership{},
|
||||||
func(obj any) (id int, ok bool) {
|
func(obj any) (id int, ok bool) {
|
||||||
membership, ok := obj.(portainer.TeamMembership)
|
membership, ok := obj.(*portainer.TeamMembership)
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to TeamMembership object")
|
log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to TeamMembership object")
|
||||||
//return fmt.Errorf("Failed to convert to TeamMembership object: %s", obj)
|
//return fmt.Errorf("Failed to convert to TeamMembership object: %s", obj)
|
||||||
|
@ -126,7 +126,7 @@ func (service *Service) DeleteTeamMembershipByTeamIDAndUserID(teamID portainer.T
|
||||||
BucketName,
|
BucketName,
|
||||||
&portainer.TeamMembership{},
|
&portainer.TeamMembership{},
|
||||||
func(obj any) (id int, ok bool) {
|
func(obj any) (id int, ok bool) {
|
||||||
membership, ok := obj.(portainer.TeamMembership)
|
membership, ok := obj.(*portainer.TeamMembership)
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to TeamMembership object")
|
log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to TeamMembership object")
|
||||||
//return fmt.Errorf("Failed to convert to TeamMembership object: %s", obj)
|
//return fmt.Errorf("Failed to convert to TeamMembership object: %s", obj)
|
||||||
|
|
|
@ -381,8 +381,7 @@ angular
|
||||||
url: '/users',
|
url: '/users',
|
||||||
views: {
|
views: {
|
||||||
'content@': {
|
'content@': {
|
||||||
templateUrl: './views/users/users.html',
|
component: 'usersListView',
|
||||||
controller: 'UsersController',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
|
|
|
@ -2,17 +2,12 @@ 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 { UsersDatatable } from '@/react/portainer/users/ListView/UsersDatatable/UsersDatatable';
|
|
||||||
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||||
import { EffectiveAccessViewerDatatable } from '@/react/portainer/users/RolesView/AccessViewer/EffectiveAccessViewerDatatable';
|
import { EffectiveAccessViewerDatatable } from '@/react/portainer/users/RolesView/AccessViewer/EffectiveAccessViewerDatatable';
|
||||||
import { RbacRolesDatatable } from '@/react/portainer/users/RolesView/RbacRolesDatatable';
|
import { RbacRolesDatatable } from '@/react/portainer/users/RolesView/RbacRolesDatatable';
|
||||||
|
|
||||||
export const usersModule = angular
|
export const usersModule = angular
|
||||||
.module('portainer.app.react.components.users', [])
|
.module('portainer.app.react.components.users', [])
|
||||||
.component(
|
|
||||||
'usersDatatable',
|
|
||||||
r2a(withUIRouter(withCurrentUser(UsersDatatable)), ['dataset', 'onRemove'])
|
|
||||||
)
|
|
||||||
.component(
|
.component(
|
||||||
'effectiveAccessViewerDatatable',
|
'effectiveAccessViewerDatatable',
|
||||||
r2a(withUIRouter(withCurrentUser(EffectiveAccessViewerDatatable)), [
|
r2a(withUIRouter(withCurrentUser(EffectiveAccessViewerDatatable)), [
|
||||||
|
|
|
@ -20,6 +20,7 @@ import { environmentGroupModule } from './env-groups';
|
||||||
import { registriesModule } from './registries';
|
import { registriesModule } from './registries';
|
||||||
import { activityLogsModule } from './activity-logs';
|
import { activityLogsModule } from './activity-logs';
|
||||||
import { templatesModule } from './templates';
|
import { templatesModule } from './templates';
|
||||||
|
import { usersModule } from './users';
|
||||||
|
|
||||||
export const viewsModule = angular
|
export const viewsModule = angular
|
||||||
.module('portainer.app.react.views', [
|
.module('portainer.app.react.views', [
|
||||||
|
@ -30,6 +31,7 @@ export const viewsModule = angular
|
||||||
registriesModule,
|
registriesModule,
|
||||||
activityLogsModule,
|
activityLogsModule,
|
||||||
templatesModule,
|
templatesModule,
|
||||||
|
usersModule,
|
||||||
])
|
])
|
||||||
.component(
|
.component(
|
||||||
'homeView',
|
'homeView',
|
||||||
|
|
15
app/portainer/react/views/users.ts
Normal file
15
app/portainer/react/views/users.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import angular from 'angular';
|
||||||
|
|
||||||
|
import { ListView } from '@/react/portainer/users/ListView/ListView';
|
||||||
|
import { r2a } from '@/react-tools/react2angular';
|
||||||
|
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||||
|
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||||
|
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||||
|
|
||||||
|
export const usersModule = angular
|
||||||
|
.module('portainer.app.react.views.users', [])
|
||||||
|
|
||||||
|
.component(
|
||||||
|
'usersListView',
|
||||||
|
r2a(withUIRouter(withReactQuery(withCurrentUser(ListView))), [])
|
||||||
|
).name;
|
|
@ -1,13 +1,12 @@
|
||||||
import _ from 'lodash-es';
|
import _ from 'lodash-es';
|
||||||
|
import { UserViewModel } from '@/portainer/models/user';
|
||||||
import { UserTokenModel, UserViewModel } from '@/portainer/models/user';
|
|
||||||
import { getUsers } from '@/portainer/users/user.service';
|
import { getUsers } from '@/portainer/users/user.service';
|
||||||
import { getUser } from '@/portainer/users/queries/useUser';
|
import { getUser } from '@/portainer/users/queries/useUser';
|
||||||
|
|
||||||
import { TeamMembershipModel } from '../../models/teamMembership';
|
import { TeamMembershipModel } from '../../models/teamMembership';
|
||||||
|
|
||||||
/* @ngInject */
|
/* @ngInject */
|
||||||
export function UserService($q, Users, TeamService, TeamMembershipService) {
|
export function UserService($q, Users, TeamService) {
|
||||||
'use strict';
|
'use strict';
|
||||||
var service = {};
|
var service = {};
|
||||||
|
|
||||||
|
@ -23,33 +22,6 @@ export function UserService($q, Users, TeamService, TeamMembershipService) {
|
||||||
return new UserViewModel(user);
|
return new UserViewModel(user);
|
||||||
};
|
};
|
||||||
|
|
||||||
service.createUser = function (username, password, role, teamIds) {
|
|
||||||
var deferred = $q.defer();
|
|
||||||
|
|
||||||
var payload = {
|
|
||||||
username: username,
|
|
||||||
password: password,
|
|
||||||
role: role,
|
|
||||||
};
|
|
||||||
|
|
||||||
Users.create({}, payload)
|
|
||||||
.$promise.then(function success(data) {
|
|
||||||
var userId = data.Id;
|
|
||||||
var teamMembershipQueries = [];
|
|
||||||
angular.forEach(teamIds, function (teamId) {
|
|
||||||
teamMembershipQueries.push(TeamMembershipService.createMembership(userId, teamId, 2));
|
|
||||||
});
|
|
||||||
$q.all(teamMembershipQueries).then(function success() {
|
|
||||||
deferred.resolve();
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(function error(err) {
|
|
||||||
deferred.reject({ msg: 'Unable to create user', err: err });
|
|
||||||
});
|
|
||||||
|
|
||||||
return deferred.promise;
|
|
||||||
};
|
|
||||||
|
|
||||||
service.deleteUser = function (id) {
|
service.deleteUser = function (id) {
|
||||||
return Users.remove({ id: id }).$promise;
|
return Users.remove({ id: id }).$promise;
|
||||||
};
|
};
|
||||||
|
@ -112,27 +84,6 @@ export function UserService($q, Users, TeamService, TeamMembershipService) {
|
||||||
return deferred.promise;
|
return deferred.promise;
|
||||||
};
|
};
|
||||||
|
|
||||||
service.getAccessTokens = function (id) {
|
|
||||||
var deferred = $q.defer();
|
|
||||||
|
|
||||||
Users.getAccessTokens({ id: id })
|
|
||||||
.$promise.then(function success(data) {
|
|
||||||
var userTokens = data.map(function (item) {
|
|
||||||
return new UserTokenModel(item);
|
|
||||||
});
|
|
||||||
deferred.resolve(userTokens);
|
|
||||||
})
|
|
||||||
.catch(function error(err) {
|
|
||||||
deferred.reject({ msg: 'Unable to retrieve user tokens', err: err });
|
|
||||||
});
|
|
||||||
|
|
||||||
return deferred.promise;
|
|
||||||
};
|
|
||||||
|
|
||||||
service.deleteAccessToken = function (id, tokenId) {
|
|
||||||
return Users.deleteAccessToken({ id: id, tokenId: tokenId }).$promise;
|
|
||||||
};
|
|
||||||
|
|
||||||
service.initAdministrator = function (username, password) {
|
service.initAdministrator = function (username, password) {
|
||||||
return Users.initAdminUser({ Username: username, Password: password }).$promise;
|
return Users.initAdminUser({ Username: username, Password: password }).$promise;
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,169 +0,0 @@
|
||||||
<page-header title="'Users'" breadcrumbs="['User management']" reload="true"> </page-header>
|
|
||||||
|
|
||||||
<div class="row" ng-if="isAdmin">
|
|
||||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
|
||||||
<rd-widget>
|
|
||||||
<rd-widget-header icon="plus" title-text="Add a new user"> </rd-widget-header>
|
|
||||||
<rd-widget-body>
|
|
||||||
<form name="form" class="form-horizontal">
|
|
||||||
<!-- name-input -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="username" class="col-sm-3 col-lg-2 control-label required text-left">
|
|
||||||
Username
|
|
||||||
<portainer-tooltip ng-if="AuthenticationMethod === 2" message="'Username must exactly match username defined in external LDAP source.'"></portainer-tooltip>
|
|
||||||
</label>
|
|
||||||
<div class="col-sm-8">
|
|
||||||
<div class="input-group">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
data-cy="user-usernameInput"
|
|
||||||
class="form-control"
|
|
||||||
id="username"
|
|
||||||
ng-model="formValues.Username"
|
|
||||||
ng-change="checkUsernameValidity()"
|
|
||||||
placeholder="e.g. jdoe"
|
|
||||||
auto-focus
|
|
||||||
/>
|
|
||||||
<span class="input-group-addon">
|
|
||||||
<pr-icon mode="'success'" icon="'check'" ng-if="state.validUsername"></pr-icon>
|
|
||||||
<pr-icon mode="'danger'" icon="'x'" ng-if="!state.validUsername"></pr-icon>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !name-input -->
|
|
||||||
<!-- new-password-input -->
|
|
||||||
<div class="form-group" ng-if="AuthenticationMethod === 1">
|
|
||||||
<label for="password" class="col-sm-3 col-lg-2 control-label required text-left">Password</label>
|
|
||||||
<div class="col-sm-8">
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
class="form-control"
|
|
||||||
ng-model="formValues.Password"
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
ng-minlength="requiredPasswordLength"
|
|
||||||
data-cy="user-passwordInput"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !new-password-input -->
|
|
||||||
<!-- confirm-password-input -->
|
|
||||||
<div class="form-group" ng-if="AuthenticationMethod === 1">
|
|
||||||
<label for="confirm_password" class="col-sm-3 col-lg-2 control-label required text-left">Confirm password</label>
|
|
||||||
<div class="col-sm-8">
|
|
||||||
<div class="input-group">
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
class="form-control form-control--has-icon"
|
|
||||||
ng-model="formValues.ConfirmPassword"
|
|
||||||
id="confirm_password"
|
|
||||||
data-cy="user-passwordConfirmInput"
|
|
||||||
/>
|
|
||||||
<span class="input-group-addon">
|
|
||||||
<pr-icon
|
|
||||||
mode="'success'"
|
|
||||||
icon="'check'"
|
|
||||||
aria-hidden="true"
|
|
||||||
ng-if="form.password.$viewValue !== '' && form.password.$viewValue === formValues.ConfirmPassword"
|
|
||||||
></pr-icon>
|
|
||||||
<pr-icon
|
|
||||||
mode="'danger'"
|
|
||||||
icon="'x'"
|
|
||||||
aria-hidden="true"
|
|
||||||
ng-if="!(form.password.$viewValue !== '' && form.password.$viewValue === formValues.ConfirmPassword)"
|
|
||||||
></pr-icon>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !confirm-password-input -->
|
|
||||||
|
|
||||||
<!-- password-check-hint -->
|
|
||||||
<div class="form-group" ng-if="AuthenticationMethod === 1">
|
|
||||||
<div class="col-sm-3 col-lg-2"></div>
|
|
||||||
<div class="col-sm-8">
|
|
||||||
<password-check-hint password-valid="form.password.$valid && formValues.Password"></password-check-hint>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- ! password-check-hint -->
|
|
||||||
|
|
||||||
<!-- admin-checkbox -->
|
|
||||||
<div class="form-group" ng-if="isAdmin">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<por-switch-field
|
|
||||||
label-class="'col-sm-3 col-lg-2 control-label text-left'"
|
|
||||||
checked="formValues.Administrator"
|
|
||||||
label="'Administrator'"
|
|
||||||
tooltip="'Administrators have access to Portainer settings management as well as full control over all defined environments and their resources.'"
|
|
||||||
on-change="(handleAdministratorChange)"
|
|
||||||
></por-switch-field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !admin-checkbox -->
|
|
||||||
<!-- teams -->
|
|
||||||
<div class="form-group" ng-if="!formValues.Administrator">
|
|
||||||
<label class="col-sm-3 col-lg-2 control-label text-left" for="teams-selector">Add to team(s)</label>
|
|
||||||
<div class="col-sm-8">
|
|
||||||
<span class="small text-muted" ng-if="teams.length === 0">
|
|
||||||
You don't seem to have any teams to add users into. Head over to the <a ui-sref="portainer.teams">Teams view</a> to create some.
|
|
||||||
</span>
|
|
||||||
<teams-selector
|
|
||||||
ng-if="teams.length > 0"
|
|
||||||
value="formValues.TeamIds"
|
|
||||||
teams="teams"
|
|
||||||
placeholder="'Select one or more teams'"
|
|
||||||
data-cy="'user-teamSelect'"
|
|
||||||
on-change="(onChangeTeamIds)"
|
|
||||||
input-id="'teams-selector'"
|
|
||||||
disabled="teamSync"
|
|
||||||
></teams-selector>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group" ng-if="teamSync">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<span class="small">
|
|
||||||
<p class="small text-muted vertical-center">
|
|
||||||
<pr-icon icon="'alert-circle'" class-name="'icon-warning =vertical-center'"></pr-icon>
|
|
||||||
The team leader feature is disabled as external authentication is currently enabled with team sync.
|
|
||||||
</p>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !teams -->
|
|
||||||
<div class="form-group" ng-if="isAdmin && !formValues.Administrator && formValues.Teams.length === 0">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<span class="small text-muted vertical-center">
|
|
||||||
<pr-icon class="vertical-center" icon="'alert-circle'" aria-hidden="true" mode="'primary'" size="'md'"></pr-icon>
|
|
||||||
<span
|
|
||||||
>Note: non-administrator users who aren't in a team don't have access to any environments by default. Head over to the
|
|
||||||
<a ui-sref="portainer.endpoints">Environments view</a> to manage their accesses.</span
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary btn-sm !ml-0"
|
|
||||||
ng-disabled="state.actionInProgress || !state.validUsername || formValues.Username === '' || (AuthenticationMethod === 1 && (!formValues.Password || form.$invalid || formValues.Password !== formValues.ConfirmPassword))"
|
|
||||||
ng-click="addUser()"
|
|
||||||
button-spinner="state.actionInProgress"
|
|
||||||
data-cy="user-createUserButton"
|
|
||||||
>
|
|
||||||
<span ng-hide="state.actionInProgress" class="vertical-center icon-white"> <pr-icon icon="'plus'" aria-hidden="true" size="'md'"></pr-icon> Create user</span>
|
|
||||||
<span ng-show="state.actionInProgress">Creating user...</span>
|
|
||||||
</button>
|
|
||||||
<span class="text-danger" ng-if="state.userCreationError">
|
|
||||||
<pr-icon icon="'alert-circle'" aria-hidden="true" mode="'primary'" size="'md'"></pr-icon> {{ state.userCreationError }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</rd-widget-body>
|
|
||||||
</rd-widget>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<users-datatable dataset="users" on-remove="(removeAction)"></users-datatable>
|
|
|
@ -1,143 +0,0 @@
|
||||||
import _ from 'lodash-es';
|
|
||||||
import { AuthenticationMethod } from '@/react/portainer/settings/types';
|
|
||||||
import { processItemsInBatches } from '@/react/common/processItemsInBatches';
|
|
||||||
|
|
||||||
angular.module('portainer.app').controller('UsersController', [
|
|
||||||
'$q',
|
|
||||||
'$scope',
|
|
||||||
'$state',
|
|
||||||
'UserService',
|
|
||||||
'TeamService',
|
|
||||||
'TeamMembershipService',
|
|
||||||
'Notifications',
|
|
||||||
'Authentication',
|
|
||||||
'SettingsService',
|
|
||||||
function ($q, $scope, $state, UserService, TeamService, TeamMembershipService, Notifications, Authentication, SettingsService) {
|
|
||||||
$scope.state = {
|
|
||||||
userCreationError: '',
|
|
||||||
validUsername: false,
|
|
||||||
actionInProgress: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.formValues = {
|
|
||||||
Username: '',
|
|
||||||
Password: '',
|
|
||||||
ConfirmPassword: '',
|
|
||||||
Administrator: false,
|
|
||||||
TeamIds: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.handleAdministratorChange = function (checked) {
|
|
||||||
return $scope.$evalAsync(() => {
|
|
||||||
$scope.formValues.Administrator = checked;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.onChangeTeamIds = function (teamIds) {
|
|
||||||
return $scope.$evalAsync(() => {
|
|
||||||
$scope.formValues.TeamIds = teamIds;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.checkUsernameValidity = function () {
|
|
||||||
var valid = true;
|
|
||||||
for (var i = 0; i < $scope.users.length; i++) {
|
|
||||||
if ($scope.formValues.Username.toLocaleLowerCase() === $scope.users[i].Username.toLocaleLowerCase()) {
|
|
||||||
valid = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$scope.state.validUsername = valid;
|
|
||||||
$scope.state.userCreationError = valid ? '' : 'Username already taken';
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.addUser = function () {
|
|
||||||
$scope.state.actionInProgress = true;
|
|
||||||
$scope.state.userCreationError = '';
|
|
||||||
var username = $scope.formValues.Username;
|
|
||||||
var password = $scope.formValues.Password;
|
|
||||||
var role = $scope.formValues.Administrator ? 1 : 2;
|
|
||||||
UserService.createUser(username, password, role, $scope.formValues.TeamIds)
|
|
||||||
.then(function success() {
|
|
||||||
Notifications.success('User successfully created', username);
|
|
||||||
$state.reload();
|
|
||||||
})
|
|
||||||
.catch(function error(err) {
|
|
||||||
Notifications.error('Failure', err, 'Unable to create user');
|
|
||||||
})
|
|
||||||
.finally(function final() {
|
|
||||||
$scope.state.actionInProgress = false;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
async function deleteSelectedUsers(selectedItems) {
|
|
||||||
async function doRemove(user) {
|
|
||||||
return UserService.deleteUser(user.Id)
|
|
||||||
.then(function success() {
|
|
||||||
Notifications.success('User successfully removed', user.Username);
|
|
||||||
var index = $scope.users.indexOf(user);
|
|
||||||
$scope.users.splice(index, 1);
|
|
||||||
})
|
|
||||||
.catch(function error(err) {
|
|
||||||
Notifications.error('Failure', err, 'Unable to remove user');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await processItemsInBatches(selectedItems, doRemove);
|
|
||||||
$state.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
$scope.removeAction = function (selectedItems) {
|
|
||||||
return deleteSelectedUsers(selectedItems);
|
|
||||||
};
|
|
||||||
|
|
||||||
function assignTeamLeaders(users, memberships) {
|
|
||||||
for (var i = 0; i < users.length; i++) {
|
|
||||||
var user = users[i];
|
|
||||||
user.isTeamLeader = false;
|
|
||||||
for (var j = 0; j < memberships.length; j++) {
|
|
||||||
var membership = memberships[j];
|
|
||||||
if (user.Id === membership.UserId && membership.Role === 1) {
|
|
||||||
user.isTeamLeader = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function initView() {
|
|
||||||
var userDetails = Authentication.getUserDetails();
|
|
||||||
var isAdmin = Authentication.isAdmin();
|
|
||||||
$scope.isAdmin = isAdmin;
|
|
||||||
$q.all({
|
|
||||||
users: UserService.users(true),
|
|
||||||
teams: isAdmin ? TeamService.teams() : UserService.userLeadingTeams(userDetails.ID),
|
|
||||||
memberships: TeamMembershipService.memberships(),
|
|
||||||
settings: SettingsService.publicSettings(),
|
|
||||||
})
|
|
||||||
.then(function success(data) {
|
|
||||||
$scope.AuthenticationMethod = data.settings.AuthenticationMethod;
|
|
||||||
var users = data.users;
|
|
||||||
assignTeamLeaders(users, data.memberships);
|
|
||||||
users = assignAuthMethod(users, $scope.AuthenticationMethod);
|
|
||||||
$scope.users = users;
|
|
||||||
$scope.teams = _.orderBy(data.teams, 'Name', 'asc');
|
|
||||||
$scope.requiredPasswordLength = data.settings.RequiredPasswordLength;
|
|
||||||
$scope.teamSync = data.settings.TeamSync;
|
|
||||||
})
|
|
||||||
.catch(function error(err) {
|
|
||||||
Notifications.error('Failure', err, 'Unable to retrieve users and teams');
|
|
||||||
$scope.users = [];
|
|
||||||
$scope.teams = [];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
initView();
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
function assignAuthMethod(users, authMethod) {
|
|
||||||
return users.map((u) => ({
|
|
||||||
...u,
|
|
||||||
authMethod: AuthenticationMethod[u.Id === 1 ? AuthenticationMethod.Internal : authMethod],
|
|
||||||
}));
|
|
||||||
}
|
|
|
@ -9,6 +9,10 @@ import {
|
||||||
|
|
||||||
import { notifyError } from '@/portainer/services/notifications';
|
import { notifyError } from '@/portainer/services/notifications';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated use withGlobalError
|
||||||
|
* `onError` and other callbacks are not supported on react-query v5
|
||||||
|
*/
|
||||||
export function withError(fallbackMessage?: string, title = 'Failure') {
|
export function withError(fallbackMessage?: string, title = 'Failure') {
|
||||||
return {
|
return {
|
||||||
onError(error: unknown) {
|
onError(error: unknown) {
|
||||||
|
@ -29,7 +33,7 @@ type OptionalReadonly<T> = T | Readonly<T>;
|
||||||
|
|
||||||
export function withInvalidate(
|
export function withInvalidate(
|
||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
queryKeysToInvalidate: Array<OptionalReadonly<Array<string | number>>>,
|
queryKeysToInvalidate: Array<OptionalReadonly<Array<unknown>>>,
|
||||||
// skipRefresh will set the mutation state to success without waiting for the invalidated queries to refresh
|
// skipRefresh will set the mutation state to success without waiting for the invalidated queries to refresh
|
||||||
// see the following for info: https://tkdodo.eu/blog/mastering-mutations-in-react-query#awaited-promises
|
// see the following for info: https://tkdodo.eu/blog/mastering-mutations-in-react-query#awaited-promises
|
||||||
{ skipRefresh }: { skipRefresh?: boolean } = {}
|
{ skipRefresh }: { skipRefresh?: boolean } = {}
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { PortainerSelect } from '@@/form-components/PortainerSelect';
|
||||||
interface Props {
|
interface Props {
|
||||||
name?: string;
|
name?: string;
|
||||||
value: TeamId[] | readonly TeamId[];
|
value: TeamId[] | readonly TeamId[];
|
||||||
onChange(value: readonly TeamId[]): void;
|
onChange(value: TeamId[]): void;
|
||||||
teams: Team[];
|
teams: Team[];
|
||||||
dataCy: string;
|
dataCy: string;
|
||||||
inputId?: string;
|
inputId?: string;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { PropsWithChildren } from 'react';
|
import { ComponentProps, PropsWithChildren } from 'react';
|
||||||
|
|
||||||
import { AutomationTestingProps } from '@/types';
|
import { AutomationTestingProps } from '@/types';
|
||||||
|
|
||||||
|
@ -12,6 +12,7 @@ interface Props extends AutomationTestingProps {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isValid: boolean;
|
isValid: boolean;
|
||||||
errors?: unknown;
|
errors?: unknown;
|
||||||
|
submitIcon?: ComponentProps<typeof LoadingButton>['icon'];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormActions({
|
export function FormActions({
|
||||||
|
@ -21,6 +22,7 @@ export function FormActions({
|
||||||
children,
|
children,
|
||||||
isValid,
|
isValid,
|
||||||
errors,
|
errors,
|
||||||
|
submitIcon,
|
||||||
'data-cy': dataCy,
|
'data-cy': dataCy,
|
||||||
}: PropsWithChildren<Props>) {
|
}: PropsWithChildren<Props>) {
|
||||||
return (
|
return (
|
||||||
|
@ -34,6 +36,7 @@ export function FormActions({
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
disabled={!isValid}
|
disabled={!isValid}
|
||||||
data-cy={dataCy}
|
data-cy={dataCy}
|
||||||
|
icon={submitIcon}
|
||||||
>
|
>
|
||||||
{submitLabel}
|
{submitLabel}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
|
|
|
@ -36,7 +36,7 @@ interface SharedProps
|
||||||
|
|
||||||
interface MultiProps<TValue> extends SharedProps {
|
interface MultiProps<TValue> extends SharedProps {
|
||||||
value: readonly TValue[];
|
value: readonly TValue[];
|
||||||
onChange(value: readonly TValue[]): void;
|
onChange(value: TValue[]): void;
|
||||||
options: Options<TValue>;
|
options: Options<TValue>;
|
||||||
isMulti: true;
|
isMulti: true;
|
||||||
components?: SelectComponentsConfig<
|
components?: SelectComponentsConfig<
|
||||||
|
|
16
app/react/portainer/users/ListView/ListView.tsx
Normal file
16
app/react/portainer/users/ListView/ListView.tsx
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { PageHeader } from '@@/PageHeader';
|
||||||
|
|
||||||
|
import { NewUserForm } from './NewUserForm/NewUserForm';
|
||||||
|
import { UsersDatatable } from './UsersDatatable/UsersDatatable';
|
||||||
|
|
||||||
|
export function ListView() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Users" breadcrumbs="User management" reload />
|
||||||
|
|
||||||
|
<NewUserForm />
|
||||||
|
|
||||||
|
<UsersDatatable />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { useField } from 'formik';
|
||||||
|
|
||||||
|
import { SwitchField } from '@@/form-components/SwitchField';
|
||||||
|
|
||||||
|
import { FormValues } from './FormValues';
|
||||||
|
|
||||||
|
export function AdminSwitch() {
|
||||||
|
const [{ name, value }, , { setValue }] =
|
||||||
|
useField<FormValues['isAdmin']>('isAdmin');
|
||||||
|
return (
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<SwitchField
|
||||||
|
data-cy="user-adminSwitch"
|
||||||
|
label="Administrator"
|
||||||
|
tooltip="Administrators have access to Portainer settings management as well as full control over all defined environments and their resources.'"
|
||||||
|
checked={value}
|
||||||
|
onChange={(checked) => setValue(checked)}
|
||||||
|
name={name}
|
||||||
|
labelClass="col-sm-3 col-lg-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { Check, XIcon } from 'lucide-react';
|
||||||
|
import { useField } from 'formik';
|
||||||
|
|
||||||
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
|
import { InputGroup } from '@@/form-components/InputGroup';
|
||||||
|
import { Icon } from '@@/Icon';
|
||||||
|
|
||||||
|
import { FormValues } from './FormValues';
|
||||||
|
|
||||||
|
export function ConfirmPasswordField() {
|
||||||
|
const [{ name, onBlur, onChange, value }, { error }] =
|
||||||
|
useField<FormValues['confirmPassword']>('confirmPassword');
|
||||||
|
return (
|
||||||
|
<FormControl
|
||||||
|
inputId="confirm_password"
|
||||||
|
label="Confirm password"
|
||||||
|
required
|
||||||
|
errors={error}
|
||||||
|
>
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroup.Input
|
||||||
|
id="confirm_password"
|
||||||
|
name={name}
|
||||||
|
data-cy="user-passwordConfirmInput"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
onBlur={onBlur}
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
/>
|
||||||
|
<InputGroup.Addon>
|
||||||
|
{error ? (
|
||||||
|
<Icon mode="danger" icon={XIcon} />
|
||||||
|
) : (
|
||||||
|
<Icon mode="success" icon={Check} />
|
||||||
|
)}
|
||||||
|
</InputGroup.Addon>
|
||||||
|
</InputGroup>
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { TeamId } from '../../teams/types';
|
||||||
|
|
||||||
|
export interface FormValues {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
teams: TeamId[];
|
||||||
|
}
|
109
app/react/portainer/users/ListView/NewUserForm/NewUserForm.tsx
Normal file
109
app/react/portainer/users/ListView/NewUserForm/NewUserForm.tsx
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
import { PlusIcon } from 'lucide-react';
|
||||||
|
import { Form, Formik } from 'formik';
|
||||||
|
|
||||||
|
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||||
|
import { usePublicSettings } from '@/react/portainer/settings/queries';
|
||||||
|
import { AuthenticationMethod } from '@/react/portainer/settings/types';
|
||||||
|
import { Role } from '@/portainer/users/types';
|
||||||
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
|
||||||
|
import { Widget } from '@@/Widget';
|
||||||
|
import { FormActions } from '@@/form-components/FormActions';
|
||||||
|
import { PasswordCheckHint } from '@@/PasswordCheckHint';
|
||||||
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
|
|
||||||
|
import { useTeams } from '../../teams/queries';
|
||||||
|
import { useCreateUserMutation } from '../../queries/useCreateUserMutation';
|
||||||
|
|
||||||
|
import { UsernameField } from './UsernameField';
|
||||||
|
import { PasswordField } from './PasswordField';
|
||||||
|
import { ConfirmPasswordField } from './ConfirmPasswordField';
|
||||||
|
import { FormValues } from './FormValues';
|
||||||
|
import { TeamsFieldset } from './TeamsFieldset';
|
||||||
|
import { useValidation } from './useValidation';
|
||||||
|
|
||||||
|
export function NewUserForm() {
|
||||||
|
const { isPureAdmin } = useCurrentUser();
|
||||||
|
const teamsQuery = useTeams(!isPureAdmin);
|
||||||
|
const settingsQuery = usePublicSettings();
|
||||||
|
const createUserMutation = useCreateUserMutation();
|
||||||
|
const validation = useValidation();
|
||||||
|
|
||||||
|
if (!teamsQuery.data || !settingsQuery.data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { AuthenticationMethod: authMethod } = settingsQuery.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<Widget>
|
||||||
|
<Widget.Title icon={PlusIcon} title="Add a new user" />
|
||||||
|
<Widget.Body>
|
||||||
|
<Formik<FormValues>
|
||||||
|
initialValues={{
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
isAdmin: false,
|
||||||
|
teams: [],
|
||||||
|
}}
|
||||||
|
validationSchema={validation}
|
||||||
|
validateOnMount
|
||||||
|
onSubmit={(values, { resetForm }) => {
|
||||||
|
createUserMutation.mutate(
|
||||||
|
{
|
||||||
|
password: values.password,
|
||||||
|
username: values.username,
|
||||||
|
role: values.isAdmin ? Role.Admin : Role.Standard,
|
||||||
|
teams: values.teams,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
notifySuccess(
|
||||||
|
'User successfully created',
|
||||||
|
values.username
|
||||||
|
);
|
||||||
|
resetForm();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ errors, isValid }) => (
|
||||||
|
<Form className="form-horizontal">
|
||||||
|
<UsernameField authMethod={authMethod} />
|
||||||
|
|
||||||
|
{authMethod === AuthenticationMethod.Internal && (
|
||||||
|
<>
|
||||||
|
<PasswordField />
|
||||||
|
|
||||||
|
<ConfirmPasswordField />
|
||||||
|
|
||||||
|
<FormControl label="">
|
||||||
|
<PasswordCheckHint passwordValid={!errors.password} />
|
||||||
|
</FormControl>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TeamsFieldset />
|
||||||
|
|
||||||
|
<FormActions
|
||||||
|
data-cy="user-createUserButton"
|
||||||
|
submitLabel="Create user"
|
||||||
|
isLoading={createUserMutation.isLoading}
|
||||||
|
isValid={isValid}
|
||||||
|
loadingText="Creating user..."
|
||||||
|
errors={errors}
|
||||||
|
submitIcon={PlusIcon}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
</Widget.Body>
|
||||||
|
</Widget>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { useField } from 'formik';
|
||||||
|
|
||||||
|
import { Input } from '@@/form-components/Input';
|
||||||
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
|
|
||||||
|
import { FormValues } from './FormValues';
|
||||||
|
|
||||||
|
export function PasswordField() {
|
||||||
|
const [{ name, onBlur, onChange, value }, { error }] =
|
||||||
|
useField<FormValues['password']>('password');
|
||||||
|
return (
|
||||||
|
<FormControl label="Password" required inputId="psw-input" errors={error}>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
onBlur={onBlur}
|
||||||
|
id="psw-input"
|
||||||
|
data-cy="user-passwordInput"
|
||||||
|
required
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { useField } from 'formik';
|
||||||
|
|
||||||
|
import { TeamsSelector } from '@@/TeamsSelector';
|
||||||
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
|
|
||||||
|
import { Team } from '../../teams/types';
|
||||||
|
|
||||||
|
import { FormValues } from './FormValues';
|
||||||
|
|
||||||
|
export function TeamsField({
|
||||||
|
teams,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
teams: Array<Team>;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const [{ name, value }, { error }, { setValue }] =
|
||||||
|
useField<FormValues['teams']>('teams');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormControl label="Add to team(s)" inputId="teams-field" errors={error}>
|
||||||
|
<TeamsSelector
|
||||||
|
dataCy="user-teamSelect"
|
||||||
|
onChange={(value) => setValue(value)}
|
||||||
|
value={value}
|
||||||
|
name={name}
|
||||||
|
teams={teams}
|
||||||
|
inputId="teams-field"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { useFormikContext } from 'formik';
|
||||||
|
|
||||||
|
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||||
|
import { usePublicSettings } from '@/react/portainer/settings/queries';
|
||||||
|
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
import { Link } from '@@/Link';
|
||||||
|
|
||||||
|
import { useTeams } from '../../teams/queries';
|
||||||
|
|
||||||
|
import { AdminSwitch } from './AdminSwitch';
|
||||||
|
import { FormValues } from './FormValues';
|
||||||
|
import { TeamsField } from './TeamsField';
|
||||||
|
|
||||||
|
export function TeamsFieldset() {
|
||||||
|
const { values } = useFormikContext<FormValues>();
|
||||||
|
const { isPureAdmin } = useCurrentUser();
|
||||||
|
const teamsQuery = useTeams(!isPureAdmin);
|
||||||
|
const settingsQuery = usePublicSettings();
|
||||||
|
if (!teamsQuery.data || !settingsQuery.data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { TeamSync: teamSync } = settingsQuery.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isPureAdmin && <AdminSwitch />}
|
||||||
|
|
||||||
|
{!values.isAdmin && (
|
||||||
|
<TeamsField teams={teamsQuery.data} disabled={teamSync} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{teamSync && <TeamSyncMessage />}
|
||||||
|
|
||||||
|
{isPureAdmin && !values.isAdmin && values.teams.length === 0 && (
|
||||||
|
<NoTeamSelected />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TeamSyncMessage() {
|
||||||
|
return (
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<TextTip color="orange">
|
||||||
|
The team leader feature is disabled as external authentication is
|
||||||
|
currently enabled with team sync.
|
||||||
|
</TextTip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoTeamSelected() {
|
||||||
|
return (
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<TextTip color="blue">
|
||||||
|
Note: non-administrator users who aren't in a team don't
|
||||||
|
have access to any environments by default. Head over to the{' '}
|
||||||
|
<Link to="portainer.endpoints" data-cy="env-link">
|
||||||
|
Environments view
|
||||||
|
</Link>{' '}
|
||||||
|
to manage their accesses.
|
||||||
|
</TextTip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { Check, XIcon } from 'lucide-react';
|
||||||
|
import { useField } from 'formik';
|
||||||
|
|
||||||
|
import { AuthenticationMethod } from '@/react/portainer/settings/types';
|
||||||
|
|
||||||
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
|
import { InputGroup } from '@@/form-components/InputGroup';
|
||||||
|
import { Icon } from '@@/Icon';
|
||||||
|
|
||||||
|
import { FormValues } from './FormValues';
|
||||||
|
|
||||||
|
export function UsernameField({
|
||||||
|
authMethod,
|
||||||
|
}: {
|
||||||
|
authMethod: AuthenticationMethod;
|
||||||
|
}) {
|
||||||
|
const [{ name, onBlur, onChange, value }, { error }] =
|
||||||
|
useField<FormValues['username']>('username');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormControl
|
||||||
|
inputId="username-field"
|
||||||
|
label="Username"
|
||||||
|
required
|
||||||
|
errors={error}
|
||||||
|
tooltip={
|
||||||
|
authMethod === AuthenticationMethod.LDAP
|
||||||
|
? 'Username must exactly match username defined in external LDAP source.'
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroup.Input
|
||||||
|
id="username-field"
|
||||||
|
name={name}
|
||||||
|
placeholder="e.g. jdoe"
|
||||||
|
data-cy="user-usernameInput"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
onBlur={onBlur}
|
||||||
|
required
|
||||||
|
autoComplete="create-username"
|
||||||
|
/>
|
||||||
|
<InputGroup.Addon>
|
||||||
|
{error ? (
|
||||||
|
<Icon mode="danger" icon={XIcon} />
|
||||||
|
) : (
|
||||||
|
<Icon mode="success" icon={Check} />
|
||||||
|
)}
|
||||||
|
</InputGroup.Addon>
|
||||||
|
</InputGroup>
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { SchemaOf, array, boolean, number, object, ref, string } from 'yup';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { usePublicSettings } from '@/react/portainer/settings/queries';
|
||||||
|
import { AuthenticationMethod } from '@/react/portainer/settings/types';
|
||||||
|
import { useUsers } from '@/portainer/users/queries';
|
||||||
|
|
||||||
|
import { FormValues } from './FormValues';
|
||||||
|
|
||||||
|
export function useValidation(): SchemaOf<FormValues> {
|
||||||
|
const usersQuery = useUsers(true);
|
||||||
|
const settingsQuery = usePublicSettings();
|
||||||
|
|
||||||
|
const authMethod =
|
||||||
|
settingsQuery.data?.AuthenticationMethod ?? AuthenticationMethod.Internal;
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const users = usersQuery.data ?? [];
|
||||||
|
|
||||||
|
const base = object({
|
||||||
|
username: string()
|
||||||
|
.required('Username is required')
|
||||||
|
.test({
|
||||||
|
name: 'unique',
|
||||||
|
message: 'Username is already taken',
|
||||||
|
test: (value) => users.every((u) => u.Username !== value),
|
||||||
|
}),
|
||||||
|
password: string().default(''),
|
||||||
|
confirmPassword: string().default(''),
|
||||||
|
isAdmin: boolean().default(false),
|
||||||
|
teams: array(number().required()).required(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (authMethod === AuthenticationMethod.Internal) {
|
||||||
|
return base.concat(
|
||||||
|
passwordValidation(settingsQuery.data?.RequiredPasswordLength)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return base;
|
||||||
|
}, [authMethod, settingsQuery.data?.RequiredPasswordLength, usersQuery.data]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function passwordValidation(minLength: number | undefined = 12) {
|
||||||
|
return object({
|
||||||
|
password: string().required('Password is required').min(minLength, ''),
|
||||||
|
confirmPassword: string().oneOf(
|
||||||
|
[ref('password'), null],
|
||||||
|
'Passwords must match'
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
|
@ -1,24 +1,66 @@
|
||||||
import { User as UserIcon } from 'lucide-react';
|
import { User as UserIcon } from 'lucide-react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useUsers } from '@/portainer/users/queries';
|
||||||
|
import { AuthenticationMethod } from '@/react/portainer/settings/types';
|
||||||
|
import { useSettings } from '@/react/portainer/settings/queries';
|
||||||
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
import {
|
||||||
|
mutationOptions,
|
||||||
|
withError,
|
||||||
|
withInvalidate,
|
||||||
|
} from '@/react-tools/react-query';
|
||||||
|
import { processItemsInBatches } from '@/react/common/processItemsInBatches';
|
||||||
|
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||||
|
|
||||||
import { Datatable } from '@@/datatables';
|
import { Datatable } from '@@/datatables';
|
||||||
import { useTableState } from '@@/datatables/useTableState';
|
import { useTableState } from '@@/datatables/useTableState';
|
||||||
import { createPersistedStore } from '@@/datatables/types';
|
import { createPersistedStore } from '@@/datatables/types';
|
||||||
import { DeleteButton } from '@@/buttons/DeleteButton';
|
import { DeleteButton } from '@@/buttons/DeleteButton';
|
||||||
|
|
||||||
|
import { useTeamMemberships } from '../../teams/queries/useTeamMemberships';
|
||||||
|
import { TeamId, TeamRole } from '../../teams/types';
|
||||||
|
import { deleteUser } from '../../queries/useDeleteUserMutation';
|
||||||
|
|
||||||
import { columns } from './columns';
|
import { columns } from './columns';
|
||||||
import { DecoratedUser } from './types';
|
import { DecoratedUser } from './types';
|
||||||
|
|
||||||
const store = createPersistedStore('users');
|
const store = createPersistedStore('users');
|
||||||
|
|
||||||
export function UsersDatatable({
|
export function UsersDatatable() {
|
||||||
dataset,
|
const { handleRemove } = useRemoveMutation();
|
||||||
onRemove,
|
const { isPureAdmin } = useCurrentUser();
|
||||||
}: {
|
const usersQuery = useUsers(isPureAdmin);
|
||||||
dataset?: Array<DecoratedUser>;
|
const membershipsQuery = useTeamMemberships();
|
||||||
onRemove: (selectedItems: Array<DecoratedUser>) => void;
|
const settingsQuery = useSettings();
|
||||||
}) {
|
|
||||||
const tableState = useTableState(store, 'users');
|
const tableState = useTableState(store, 'users');
|
||||||
|
|
||||||
|
const dataset: Array<DecoratedUser> | null = useMemo(() => {
|
||||||
|
if (!usersQuery.data || !membershipsQuery.data || !settingsQuery.data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberships = membershipsQuery.data;
|
||||||
|
|
||||||
|
return usersQuery.data.map((user) => {
|
||||||
|
const teamMembership = memberships.find(
|
||||||
|
(membership) => membership.UserID === user.Id
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
isTeamLeader: teamMembership?.Role === TeamRole.Leader,
|
||||||
|
authMethod:
|
||||||
|
AuthenticationMethod[
|
||||||
|
user.Id === 1
|
||||||
|
? AuthenticationMethod.Internal
|
||||||
|
: settingsQuery.data.AuthenticationMethod
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [membershipsQuery.data, settingsQuery.data, usersQuery.data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Datatable
|
<Datatable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
@ -32,7 +74,7 @@ export function UsersDatatable({
|
||||||
<DeleteButton
|
<DeleteButton
|
||||||
disabled={selectedItems.length === 0}
|
disabled={selectedItems.length === 0}
|
||||||
confirmMessage="Do you want to remove the selected users? They will not be able to login into Portainer anymore."
|
confirmMessage="Do you want to remove the selected users? They will not be able to login into Portainer anymore."
|
||||||
onConfirmed={() => onRemove(selectedItems)}
|
onConfirmed={() => handleRemove(selectedItems.map((i) => i.Id))}
|
||||||
data-cy="remove-users-button"
|
data-cy="remove-users-button"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -40,3 +82,25 @@ export function UsersDatatable({
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useRemoveMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const deleteMutation = useMutation(
|
||||||
|
async (ids: TeamId[]) => processItemsInBatches(ids, deleteUser),
|
||||||
|
mutationOptions(
|
||||||
|
withError('Unable to remove users'),
|
||||||
|
withInvalidate(queryClient, [['users']])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return { handleRemove };
|
||||||
|
|
||||||
|
async function handleRemove(teams: TeamId[]) {
|
||||||
|
deleteMutation.mutate(teams, {
|
||||||
|
onSuccess: () => {
|
||||||
|
notifySuccess('Teams successfully removed', '');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
43
app/react/portainer/users/queries/useCreateUserMutation.ts
Normal file
43
app/react/portainer/users/queries/useCreateUserMutation.ts
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import { useQueryClient, useMutation } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { withGlobalError, withInvalidate } from '@/react-tools/react-query';
|
||||||
|
import { userQueryKeys } from '@/portainer/users/queries/queryKeys';
|
||||||
|
import { buildUrl } from '@/portainer/users/user.service';
|
||||||
|
import { Role, User } from '@/portainer/users/types';
|
||||||
|
|
||||||
|
import { TeamId, TeamRole } from '../teams/types';
|
||||||
|
import { createTeamMembership } from '../teams/queries';
|
||||||
|
|
||||||
|
interface CreateUserPayload {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
role: Role;
|
||||||
|
teams: Array<TeamId>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateUserMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (values: CreateUserPayload) => {
|
||||||
|
const user = await createUser(values);
|
||||||
|
return Promise.all(
|
||||||
|
values.teams.map((id) =>
|
||||||
|
createTeamMembership(user.Id, id, TeamRole.Member)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
...withInvalidate(queryClient, [userQueryKeys.base()]),
|
||||||
|
...withGlobalError('Unable to create user'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(payload: CreateUserPayload) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.post<User>(buildUrl(), payload);
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
throw parseAxiosError(err, 'Unable to create user');
|
||||||
|
}
|
||||||
|
}
|
24
app/react/portainer/users/queries/useDeleteUserMutation.ts
Normal file
24
app/react/portainer/users/queries/useDeleteUserMutation.ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { withGlobalError, withInvalidate } from '@/react-tools/react-query';
|
||||||
|
import { UserId } from '@/portainer/users/types';
|
||||||
|
import { buildUrl } from '@/portainer/users/user.service';
|
||||||
|
import { userQueryKeys } from '@/portainer/users/queries/queryKeys';
|
||||||
|
|
||||||
|
export function useDeleteUserMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: UserId) => deleteUser(id),
|
||||||
|
...withGlobalError('Unable to delete user'),
|
||||||
|
...withInvalidate(queryClient, [userQueryKeys.base()]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(id: UserId) {
|
||||||
|
try {
|
||||||
|
await axios.delete(buildUrl(id));
|
||||||
|
} catch (error) {
|
||||||
|
throw parseAxiosError(error);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,19 +1,13 @@
|
||||||
import { useRouter } from '@uirouter/react';
|
import { useRouter } from '@uirouter/react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { Users } from 'lucide-react';
|
import { Users } from 'lucide-react';
|
||||||
|
|
||||||
import { usePublicSettings } from '@/react/portainer/settings/queries';
|
import { usePublicSettings } from '@/react/portainer/settings/queries';
|
||||||
import {
|
|
||||||
mutationOptions,
|
|
||||||
withError,
|
|
||||||
withInvalidate,
|
|
||||||
} from '@/react-tools/react-query';
|
|
||||||
|
|
||||||
import { Widget } from '@@/Widget';
|
import { Widget } from '@@/Widget';
|
||||||
import { DeleteButton } from '@@/buttons/DeleteButton';
|
import { DeleteButton } from '@@/buttons/DeleteButton';
|
||||||
|
|
||||||
import { Team, TeamId, TeamMembership, TeamRole } from '../types';
|
import { Team, TeamMembership, TeamRole } from '../types';
|
||||||
import { deleteTeam } from '../teams.service';
|
import { useDeleteTeamMutation } from '../queries/useDeleteTeamMutation';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
team: Team;
|
team: Team;
|
||||||
|
@ -22,7 +16,7 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Details({ team, memberships, isAdmin }: Props) {
|
export function Details({ team, memberships, isAdmin }: Props) {
|
||||||
const deleteMutation = useDeleteTeam();
|
const deleteMutation = useDeleteTeamMutation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const teamSyncQuery = usePublicSettings<boolean>({
|
const teamSyncQuery = usePublicSettings<boolean>({
|
||||||
select: (settings) => settings.TeamSync,
|
select: (settings) => settings.TeamSync,
|
||||||
|
@ -80,15 +74,3 @@ export function Details({ team, memberships, isAdmin }: Props) {
|
||||||
deleteMutation.mutate(team.Id);
|
deleteMutation.mutate(team.Id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function useDeleteTeam() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation(
|
|
||||||
(id: TeamId) => deleteTeam(id),
|
|
||||||
|
|
||||||
mutationOptions(
|
|
||||||
withError('Unable to delete team'),
|
|
||||||
withInvalidate(queryClient, [['teams']])
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { Formik, Field, Form } from 'formik';
|
import { Formik, Field, Form } from 'formik';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { useReducer } from 'react';
|
import { useReducer } from 'react';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
@ -14,8 +13,8 @@ import { UsersSelector } from '@@/UsersSelector';
|
||||||
import { LoadingButton } from '@@/buttons/LoadingButton';
|
import { LoadingButton } from '@@/buttons/LoadingButton';
|
||||||
import { TextTip } from '@@/Tip/TextTip';
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
|
||||||
import { createTeam } from '../../teams.service';
|
|
||||||
import { Team } from '../../types';
|
import { Team } from '../../types';
|
||||||
|
import { useAddTeamMutation } from '../../queries/useAddTeamMutation';
|
||||||
|
|
||||||
import { FormValues } from './types';
|
import { FormValues } from './types';
|
||||||
import { validationSchema } from './CreateTeamForm.validation';
|
import { validationSchema } from './CreateTeamForm.validation';
|
||||||
|
@ -146,22 +145,3 @@ export function CreateTeamForm({ users, teams }: Props) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAddTeamMutation() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation(
|
|
||||||
(values: FormValues) => createTeam(values.name, values.leaders),
|
|
||||||
{
|
|
||||||
meta: {
|
|
||||||
error: {
|
|
||||||
title: 'Failure',
|
|
||||||
message: 'Failed to create team',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onSuccess() {
|
|
||||||
return queryClient.invalidateQueries(['teams']);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
@ -5,7 +5,6 @@ import { ColumnDef } from '@tanstack/react-table';
|
||||||
import { notifySuccess } from '@/portainer/services/notifications';
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
import { promiseSequence } from '@/portainer/helpers/promise-utils';
|
import { promiseSequence } from '@/portainer/helpers/promise-utils';
|
||||||
import { Team, TeamId } from '@/react/portainer/users/teams/types';
|
import { Team, TeamId } from '@/react/portainer/users/teams/types';
|
||||||
import { deleteTeam } from '@/react/portainer/users/teams/teams.service';
|
|
||||||
|
|
||||||
import { Datatable } from '@@/datatables';
|
import { Datatable } from '@@/datatables';
|
||||||
import { buildNameColumn } from '@@/datatables/buildNameColumn';
|
import { buildNameColumn } from '@@/datatables/buildNameColumn';
|
||||||
|
@ -13,6 +12,8 @@ import { createPersistedStore } from '@@/datatables/types';
|
||||||
import { useTableState } from '@@/datatables/useTableState';
|
import { useTableState } from '@@/datatables/useTableState';
|
||||||
import { DeleteButton } from '@@/buttons/DeleteButton';
|
import { DeleteButton } from '@@/buttons/DeleteButton';
|
||||||
|
|
||||||
|
import { deleteTeam } from '../../queries/useDeleteTeamMutation';
|
||||||
|
|
||||||
const storageKey = 'teams';
|
const storageKey = 'teams';
|
||||||
|
|
||||||
const columns: ColumnDef<Team>[] = [
|
const columns: ColumnDef<Team>[] = [
|
||||||
|
|
|
@ -1,135 +0,0 @@
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
import { promiseSequence } from '@/portainer/helpers/promise-utils';
|
|
||||||
import { notifyError } from '@/portainer/services/notifications';
|
|
||||||
import { UserId } from '@/portainer/users/types';
|
|
||||||
|
|
||||||
import {
|
|
||||||
createTeamMembership,
|
|
||||||
deleteTeamMembership,
|
|
||||||
updateTeamMembership,
|
|
||||||
} from './team-membership.service';
|
|
||||||
import { getTeam, getTeamMemberships, getTeams } from './teams.service';
|
|
||||||
import { Team, TeamId, TeamMembership, TeamRole } from './types';
|
|
||||||
|
|
||||||
export function useTeams<T = Team[]>(
|
|
||||||
onlyLedTeams = false,
|
|
||||||
environmentId = 0,
|
|
||||||
{
|
|
||||||
enabled = true,
|
|
||||||
select = (data) => data as unknown as T,
|
|
||||||
}: {
|
|
||||||
enabled?: boolean;
|
|
||||||
select?: (data: Team[]) => T;
|
|
||||||
} = {}
|
|
||||||
) {
|
|
||||||
const teams = useQuery(
|
|
||||||
['teams', { onlyLedTeams, environmentId }],
|
|
||||||
() => getTeams(onlyLedTeams, environmentId),
|
|
||||||
{
|
|
||||||
meta: {
|
|
||||||
error: { title: 'Failure', message: 'Unable to load teams' },
|
|
||||||
},
|
|
||||||
enabled,
|
|
||||||
select,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return teams;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTeam(id: TeamId, onError?: (error: unknown) => void) {
|
|
||||||
return useQuery(['teams', id], () => getTeam(id), {
|
|
||||||
meta: {
|
|
||||||
error: { title: 'Failure', message: 'Unable to load team' },
|
|
||||||
},
|
|
||||||
onError,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTeamMemberships(id: TeamId) {
|
|
||||||
return useQuery(['teams', id, 'memberships'], () => getTeamMemberships(id), {
|
|
||||||
meta: {
|
|
||||||
error: { title: 'Failure', message: 'Unable to load team memberships' },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAddMemberMutation(teamId: TeamId) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation(
|
|
||||||
(userIds: UserId[]) =>
|
|
||||||
promiseSequence(
|
|
||||||
userIds.map(
|
|
||||||
(userId) => () =>
|
|
||||||
createTeamMembership(userId, teamId, TeamRole.Member)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
{
|
|
||||||
onError(error) {
|
|
||||||
notifyError('Failure', error as Error, 'Failure to add membership');
|
|
||||||
},
|
|
||||||
onSuccess() {
|
|
||||||
queryClient.invalidateQueries(['teams', teamId, 'memberships']);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRemoveMemberMutation(
|
|
||||||
teamId: TeamId,
|
|
||||||
teamMemberships: TeamMembership[] = []
|
|
||||||
) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation(
|
|
||||||
(userIds: UserId[]) =>
|
|
||||||
promiseSequence(
|
|
||||||
userIds.map((userId) => () => {
|
|
||||||
const membership = teamMemberships.find(
|
|
||||||
(membership) => membership.UserID === userId
|
|
||||||
);
|
|
||||||
if (!membership) {
|
|
||||||
throw new Error('Membership not found');
|
|
||||||
}
|
|
||||||
return deleteTeamMembership(membership.Id);
|
|
||||||
})
|
|
||||||
),
|
|
||||||
{
|
|
||||||
onError(error) {
|
|
||||||
notifyError('Failure', error as Error, 'Failure to add membership');
|
|
||||||
},
|
|
||||||
onSuccess() {
|
|
||||||
queryClient.invalidateQueries(['teams', teamId, 'memberships']);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateRoleMutation(
|
|
||||||
teamId: TeamId,
|
|
||||||
teamMemberships: TeamMembership[] = []
|
|
||||||
) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation(
|
|
||||||
({ userId, role }: { userId: UserId; role: TeamRole }) => {
|
|
||||||
const membership = teamMemberships.find(
|
|
||||||
(membership) => membership.UserID === userId
|
|
||||||
);
|
|
||||||
if (!membership) {
|
|
||||||
throw new Error('Membership not found');
|
|
||||||
}
|
|
||||||
return updateTeamMembership(membership.Id, userId, teamId, role);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onError(error) {
|
|
||||||
notifyError('Failure', error as Error, 'Failure to update membership');
|
|
||||||
},
|
|
||||||
onSuccess() {
|
|
||||||
queryClient.invalidateQueries(['teams', teamId, 'memberships']);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { TeamMembershipId } from '../types';
|
||||||
|
|
||||||
|
export function buildMembershipUrl(id?: TeamMembershipId) {
|
||||||
|
let url = '/team_memberships';
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
url += `/${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
15
app/react/portainer/users/teams/queries/build-url.ts
Normal file
15
app/react/portainer/users/teams/queries/build-url.ts
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import { TeamId } from '../types';
|
||||||
|
|
||||||
|
export function buildUrl(id?: TeamId, action?: string) {
|
||||||
|
let url = '/teams';
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
url += `/${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action) {
|
||||||
|
url += `/${action}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
11
app/react/portainer/users/teams/queries/index.ts
Normal file
11
app/react/portainer/users/teams/queries/index.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
export { useTeams } from './useTeams';
|
||||||
|
export {
|
||||||
|
useAddMemberMutation,
|
||||||
|
createTeamMembership,
|
||||||
|
} from './useAddMemberMutation';
|
||||||
|
export { useAddTeamMutation } from './useAddTeamMutation';
|
||||||
|
export { deleteTeam, useDeleteTeamMutation } from './useDeleteTeamMutation';
|
||||||
|
export { useRemoveMemberMutation } from './useRemoveMemberMutation';
|
||||||
|
export { useTeam } from './useTeam';
|
||||||
|
export { useTeamMemberships } from './useTeamMemberships';
|
||||||
|
export { useUpdateRoleMutation } from './useUpdateRoleMutation';
|
9
app/react/portainer/users/teams/queries/query-keys.ts
Normal file
9
app/react/portainer/users/teams/queries/query-keys.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import { TeamId } from '../types';
|
||||||
|
|
||||||
|
export const queryKeys = {
|
||||||
|
base: () => ['teams'] as const,
|
||||||
|
list: (params: unknown) => [...queryKeys.base(), 'list', params] as const,
|
||||||
|
item: (id: TeamId) => [...queryKeys.base(), id] as const,
|
||||||
|
memberships: (id?: TeamId) =>
|
||||||
|
[...queryKeys.base(), 'memberships', id] as const,
|
||||||
|
};
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { promiseSequence } from '@/portainer/helpers/promise-utils';
|
||||||
|
import { UserId } from '@/portainer/users/types';
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { withGlobalError, withInvalidate } from '@/react-tools/react-query';
|
||||||
|
|
||||||
|
import { TeamId, TeamRole } from '../types';
|
||||||
|
|
||||||
|
import { buildMembershipUrl } from './build-membership-url';
|
||||||
|
import { queryKeys } from './query-keys';
|
||||||
|
|
||||||
|
export function useAddMemberMutation(teamId: TeamId) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (userIds: UserId[]) =>
|
||||||
|
promiseSequence(
|
||||||
|
userIds.map(
|
||||||
|
(userId) => () =>
|
||||||
|
createTeamMembership(userId, teamId, TeamRole.Member)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
...withGlobalError('Failure to add membership'),
|
||||||
|
...withInvalidate(queryClient, [queryKeys.memberships(teamId)]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTeamMembership(
|
||||||
|
userId: UserId,
|
||||||
|
teamId: TeamId,
|
||||||
|
role: TeamRole
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await axios.post(buildMembershipUrl(), { userId, teamId, role });
|
||||||
|
} catch (e) {
|
||||||
|
throw parseAxiosError(e, 'Unable to create team membership');
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { UserId } from '@/portainer/users/types';
|
||||||
|
import { withGlobalError, withInvalidate } from '@/react-tools/react-query';
|
||||||
|
|
||||||
|
import { TeamRole } from '../types';
|
||||||
|
|
||||||
|
import { createTeamMembership } from './useAddMemberMutation';
|
||||||
|
import { buildUrl } from './build-url';
|
||||||
|
import { queryKeys } from './query-keys';
|
||||||
|
|
||||||
|
interface CreatePayload {
|
||||||
|
name: string;
|
||||||
|
leaders: UserId[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddTeamMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: createTeam,
|
||||||
|
...withGlobalError('Failed to create team'),
|
||||||
|
...withInvalidate(queryClient, [queryKeys.base()]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTeam({ name, leaders }: CreatePayload) {
|
||||||
|
try {
|
||||||
|
const { data: team } = await axios.post(buildUrl(), { name });
|
||||||
|
await Promise.all(
|
||||||
|
leaders.map((leaderId) =>
|
||||||
|
createTeamMembership(leaderId, team.Id, TeamRole.Leader)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
throw parseAxiosError(e as Error, 'Unable to create team');
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import {
|
||||||
|
mutationOptions,
|
||||||
|
withGlobalError,
|
||||||
|
withInvalidate,
|
||||||
|
} from '@/react-tools/react-query';
|
||||||
|
|
||||||
|
import { TeamId } from '../types';
|
||||||
|
|
||||||
|
import { buildUrl } from './build-url';
|
||||||
|
|
||||||
|
export function useDeleteTeamMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation(
|
||||||
|
(id: TeamId) => deleteTeam(id),
|
||||||
|
|
||||||
|
mutationOptions(
|
||||||
|
withGlobalError('Unable to delete team'),
|
||||||
|
withInvalidate(queryClient, [['teams']])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTeam(id: TeamId) {
|
||||||
|
try {
|
||||||
|
await axios.delete(buildUrl(id));
|
||||||
|
} catch (error) {
|
||||||
|
throw parseAxiosError(error as Error);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { promiseSequence } from '@/portainer/helpers/promise-utils';
|
||||||
|
import { UserId } from '@/portainer/users/types';
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { withGlobalError, withInvalidate } from '@/react-tools/react-query';
|
||||||
|
|
||||||
|
import { TeamId, TeamMembership, TeamMembershipId } from '../types';
|
||||||
|
|
||||||
|
import { buildMembershipUrl } from './build-membership-url';
|
||||||
|
import { queryKeys } from './query-keys';
|
||||||
|
|
||||||
|
export function useRemoveMemberMutation(
|
||||||
|
teamId: TeamId,
|
||||||
|
teamMemberships: TeamMembership[] = []
|
||||||
|
) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (userIds: UserId[]) =>
|
||||||
|
promiseSequence(
|
||||||
|
userIds.map((userId) => () => {
|
||||||
|
const membership = teamMemberships.find(
|
||||||
|
(membership) => membership.UserID === userId
|
||||||
|
);
|
||||||
|
if (!membership) {
|
||||||
|
throw new Error('Membership not found');
|
||||||
|
}
|
||||||
|
return deleteTeamMembership(membership.Id);
|
||||||
|
})
|
||||||
|
),
|
||||||
|
...withGlobalError('Failure to remove membership'),
|
||||||
|
...withInvalidate(queryClient, [queryKeys.memberships(teamId)]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTeamMembership(id: TeamMembershipId) {
|
||||||
|
try {
|
||||||
|
await axios.delete(buildMembershipUrl(id));
|
||||||
|
} catch (e) {
|
||||||
|
throw parseAxiosError(e, 'Unable to delete team membership');
|
||||||
|
}
|
||||||
|
}
|
27
app/react/portainer/users/teams/queries/useTeam.ts
Normal file
27
app/react/portainer/users/teams/queries/useTeam.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { withGlobalError } from '@/react-tools/react-query';
|
||||||
|
|
||||||
|
import { Team, TeamId } from '../types';
|
||||||
|
|
||||||
|
import { buildUrl } from './build-url';
|
||||||
|
import { queryKeys } from './query-keys';
|
||||||
|
|
||||||
|
export function useTeam(id: TeamId, onError?: (error: unknown) => void) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.item(id),
|
||||||
|
queryFn: () => getTeam(id),
|
||||||
|
...withGlobalError('Unable to load team'),
|
||||||
|
onError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTeam(id: TeamId) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get<Team>(buildUrl(id));
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
throw parseAxiosError(error as Error);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { withGlobalError } from '@/react-tools/react-query';
|
||||||
|
|
||||||
|
import { TeamId, TeamMembership } from '../types';
|
||||||
|
|
||||||
|
import { buildUrl } from './build-url';
|
||||||
|
import { buildMembershipUrl } from './build-membership-url';
|
||||||
|
import { queryKeys } from './query-keys';
|
||||||
|
|
||||||
|
export function useTeamMemberships(id?: TeamId) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.memberships(id),
|
||||||
|
queryFn: () => (id ? getTeamMemberships(id) : getTeamsMemberships()),
|
||||||
|
...withGlobalError('Unable to load team memberships'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTeamMemberships(teamId: TeamId) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get<TeamMembership[]>(
|
||||||
|
buildUrl(teamId, 'memberships')
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
throw parseAxiosError(e as Error, 'Unable to get team memberships');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTeamsMemberships() {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get<TeamMembership[]>(buildMembershipUrl());
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
throw parseAxiosError(e as Error, 'Unable to get team memberships');
|
||||||
|
}
|
||||||
|
}
|
42
app/react/portainer/users/teams/queries/useTeams.ts
Normal file
42
app/react/portainer/users/teams/queries/useTeams.ts
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { withGlobalError } from '@/react-tools/react-query';
|
||||||
|
|
||||||
|
import { Team } from '../types';
|
||||||
|
|
||||||
|
import { buildUrl } from './build-url';
|
||||||
|
import { queryKeys } from './query-keys';
|
||||||
|
|
||||||
|
export function useTeams<T = Team[]>(
|
||||||
|
onlyLedTeams = false,
|
||||||
|
environmentId = 0,
|
||||||
|
{
|
||||||
|
enabled = true,
|
||||||
|
select = (data) => data as unknown as T,
|
||||||
|
}: {
|
||||||
|
enabled?: boolean;
|
||||||
|
select?: (data: Team[]) => T;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const teams = useQuery({
|
||||||
|
queryKey: queryKeys.list({ onlyLedTeams, environmentId }),
|
||||||
|
queryFn: () => getTeams(onlyLedTeams, environmentId),
|
||||||
|
...withGlobalError('Unable to load teams'),
|
||||||
|
enabled,
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
|
||||||
|
return teams;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTeams(onlyLedTeams = false, environmentId = 0) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get<Team[]>(buildUrl(), {
|
||||||
|
params: { onlyLedTeams, environmentId },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
throw parseAxiosError(error as Error);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { useQueryClient, useMutation } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { UserId } from '@/portainer/users/types';
|
||||||
|
import { withGlobalError, withInvalidate } from '@/react-tools/react-query';
|
||||||
|
|
||||||
|
import { TeamId, TeamMembership, TeamRole, TeamMembershipId } from '../types';
|
||||||
|
|
||||||
|
import { buildMembershipUrl } from './build-membership-url';
|
||||||
|
import { queryKeys } from './query-keys';
|
||||||
|
|
||||||
|
export function useUpdateRoleMutation(
|
||||||
|
teamId: TeamId,
|
||||||
|
teamMemberships: TeamMembership[] = []
|
||||||
|
) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ userId, role }: { userId: UserId; role: TeamRole }) => {
|
||||||
|
const membership = teamMemberships.find(
|
||||||
|
(membership) => membership.UserID === userId
|
||||||
|
);
|
||||||
|
if (!membership) {
|
||||||
|
throw new Error('Membership not found');
|
||||||
|
}
|
||||||
|
return updateTeamMembership(membership.Id, userId, teamId, role);
|
||||||
|
},
|
||||||
|
...withGlobalError('Failure to update membership'),
|
||||||
|
...withInvalidate(queryClient, [queryKeys.memberships(teamId)]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateTeamMembership(
|
||||||
|
id: TeamMembershipId,
|
||||||
|
userId: UserId,
|
||||||
|
teamId: TeamId,
|
||||||
|
role: TeamRole
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await axios.put(buildMembershipUrl(id), { userId, teamId, role });
|
||||||
|
} catch (e) {
|
||||||
|
throw parseAxiosError(e as Error, 'Unable to update team membership');
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,47 +0,0 @@
|
||||||
import { UserId } from '@/portainer/users/types';
|
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
|
||||||
|
|
||||||
import { TeamId, TeamRole, TeamMembershipId } from './types';
|
|
||||||
|
|
||||||
export async function createTeamMembership(
|
|
||||||
userId: UserId,
|
|
||||||
teamId: TeamId,
|
|
||||||
role: TeamRole
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
await axios.post(buildUrl(), { userId, teamId, role });
|
|
||||||
} catch (e) {
|
|
||||||
throw parseAxiosError(e as Error, 'Unable to create team membership');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteTeamMembership(id: TeamMembershipId) {
|
|
||||||
try {
|
|
||||||
await axios.delete(buildUrl(id));
|
|
||||||
} catch (e) {
|
|
||||||
throw parseAxiosError(e as Error, 'Unable to delete team membership');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateTeamMembership(
|
|
||||||
id: TeamMembershipId,
|
|
||||||
userId: UserId,
|
|
||||||
teamId: TeamId,
|
|
||||||
role: TeamRole
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
await axios.put(buildUrl(id), { userId, teamId, role });
|
|
||||||
} catch (e) {
|
|
||||||
throw parseAxiosError(e as Error, 'Unable to update team membership');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildUrl(id?: TeamMembershipId) {
|
|
||||||
let url = '/team_memberships';
|
|
||||||
|
|
||||||
if (id) {
|
|
||||||
url += `/${id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return url;
|
|
||||||
}
|
|
|
@ -1,71 +0,0 @@
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
|
||||||
import { type UserId } from '@/portainer/users/types';
|
|
||||||
|
|
||||||
import { createTeamMembership } from './team-membership.service';
|
|
||||||
import { Team, TeamId, TeamMembership, TeamRole } from './types';
|
|
||||||
|
|
||||||
export async function getTeams(onlyLedTeams = false, environmentId = 0) {
|
|
||||||
try {
|
|
||||||
const { data } = await axios.get<Team[]>(buildUrl(), {
|
|
||||||
params: { onlyLedTeams, environmentId },
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
throw parseAxiosError(error as Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getTeam(id: TeamId) {
|
|
||||||
try {
|
|
||||||
const { data } = await axios.get<Team>(buildUrl(id));
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
throw parseAxiosError(error as Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteTeam(id: TeamId) {
|
|
||||||
try {
|
|
||||||
await axios.delete(buildUrl(id));
|
|
||||||
} catch (error) {
|
|
||||||
throw parseAxiosError(error as Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createTeam(name: string, leaders: UserId[]) {
|
|
||||||
try {
|
|
||||||
const { data: team } = await axios.post(buildUrl(), { name });
|
|
||||||
await Promise.all(
|
|
||||||
leaders.map((leaderId) =>
|
|
||||||
createTeamMembership(leaderId, team.Id, TeamRole.Leader)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
throw parseAxiosError(e as Error, 'Unable to create team');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getTeamMemberships(teamId: TeamId) {
|
|
||||||
try {
|
|
||||||
const { data } = await axios.get<TeamMembership[]>(
|
|
||||||
buildUrl(teamId, 'memberships')
|
|
||||||
);
|
|
||||||
return data;
|
|
||||||
} catch (e) {
|
|
||||||
throw parseAxiosError(e as Error, 'Unable to get team memberships');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildUrl(id?: TeamId, action?: string) {
|
|
||||||
let url = '/teams';
|
|
||||||
|
|
||||||
if (id) {
|
|
||||||
url += `/${id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action) {
|
|
||||||
url += `/${action}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return url;
|
|
||||||
}
|
|
Loading…
Add table
Add a link
Reference in a new issue