mirror of https://github.com/portainer/portainer
parent
9b02f575ef
commit
f9427c8fb2
|
@ -4,6 +4,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
httperror "github.com/portainer/libhttp/error"
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/portainer/libhttp/request"
|
||||||
"github.com/portainer/libhttp/response"
|
"github.com/portainer/libhttp/response"
|
||||||
"github.com/portainer/portainer/api/http/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
)
|
)
|
||||||
|
@ -13,6 +14,7 @@ import (
|
||||||
// @description List teams. For non-administrator users, will only list the teams they are member of.
|
// @description List teams. For non-administrator users, will only list the teams they are member of.
|
||||||
// @description **Access policy**: restricted
|
// @description **Access policy**: restricted
|
||||||
// @tags teams
|
// @tags teams
|
||||||
|
// @param onlyLedTeams query boolean false "Only list teams that the user is leader of"
|
||||||
// @security ApiKeyAuth
|
// @security ApiKeyAuth
|
||||||
// @security jwt
|
// @security jwt
|
||||||
// @produce json
|
// @produce json
|
||||||
|
@ -22,15 +24,23 @@ import (
|
||||||
func (handler *Handler) teamList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
func (handler *Handler) teamList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
teams, err := handler.DataStore.Team().Teams()
|
teams, err := handler.DataStore.Team().Teams()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve teams from the database", err}
|
return httperror.InternalServerError("Unable to retrieve teams from the database", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
|
return httperror.InternalServerError("Unable to retrieve info from request context", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredTeams := security.FilterUserTeams(teams, securityContext)
|
onlyLedTeams, _ := request.RetrieveBooleanQueryParameter(r, "onlyLedTeams", true)
|
||||||
|
|
||||||
|
filteredTeams := teams
|
||||||
|
|
||||||
|
if onlyLedTeams {
|
||||||
|
filteredTeams = security.FilterLeaderTeams(filteredTeams, securityContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredTeams = security.FilterUserTeams(filteredTeams, securityContext)
|
||||||
|
|
||||||
return response.JSON(w, filteredTeams)
|
return response.JSON(w, filteredTeams)
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,17 +27,22 @@ func FilterUserTeams(teams []portainer.Team, context *RestrictedRequestContext)
|
||||||
// FilterLeaderTeams filters teams based on user role.
|
// FilterLeaderTeams filters teams based on user role.
|
||||||
// Team leaders only have access to team they lead.
|
// Team leaders only have access to team they lead.
|
||||||
func FilterLeaderTeams(teams []portainer.Team, context *RestrictedRequestContext) []portainer.Team {
|
func FilterLeaderTeams(teams []portainer.Team, context *RestrictedRequestContext) []portainer.Team {
|
||||||
filteredTeams := teams
|
filteredTeams := []portainer.Team{}
|
||||||
|
|
||||||
if context.IsTeamLeader {
|
if !context.IsTeamLeader {
|
||||||
filteredTeams = make([]portainer.Team, 0)
|
return filteredTeams
|
||||||
for _, membership := range context.UserMemberships {
|
}
|
||||||
for _, team := range teams {
|
|
||||||
if team.ID == membership.TeamID && membership.Role == portainer.TeamLeader {
|
leaderSet := map[portainer.TeamID]bool{}
|
||||||
filteredTeams = append(filteredTeams, team)
|
for _, membership := range context.UserMemberships {
|
||||||
break
|
if membership.Role == portainer.TeamLeader && membership.UserID == context.UserID {
|
||||||
}
|
leaderSet[membership.TeamID] = true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, team := range teams {
|
||||||
|
if leaderSet[team.ID] {
|
||||||
|
filteredTeams = append(filteredTeams, team)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,6 @@ import settingsModule from './settings';
|
||||||
import featureFlagModule from './feature-flags';
|
import featureFlagModule from './feature-flags';
|
||||||
import userActivityModule from './user-activity';
|
import userActivityModule from './user-activity';
|
||||||
import servicesModule from './services';
|
import servicesModule from './services';
|
||||||
import teamsModule from './teams';
|
|
||||||
import homeModule from './home';
|
import homeModule from './home';
|
||||||
import { accessControlModule } from './access-control';
|
import { accessControlModule } from './access-control';
|
||||||
import { reactModule } from './react';
|
import { reactModule } from './react';
|
||||||
|
@ -40,7 +39,6 @@ angular
|
||||||
userActivityModule,
|
userActivityModule,
|
||||||
'portainer.shared.datatable',
|
'portainer.shared.datatable',
|
||||||
servicesModule,
|
servicesModule,
|
||||||
teamsModule,
|
|
||||||
accessControlModule,
|
accessControlModule,
|
||||||
reactModule,
|
reactModule,
|
||||||
sidebarModule,
|
sidebarModule,
|
||||||
|
@ -425,28 +423,6 @@ angular
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
var teams = {
|
|
||||||
name: 'portainer.teams',
|
|
||||||
url: '/teams',
|
|
||||||
views: {
|
|
||||||
'content@': {
|
|
||||||
templateUrl: './views/teams/teams.html',
|
|
||||||
controller: 'TeamsController',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
var team = {
|
|
||||||
name: 'portainer.teams.team',
|
|
||||||
url: '/:id',
|
|
||||||
views: {
|
|
||||||
'content@': {
|
|
||||||
templateUrl: './views/teams/edit/team.html',
|
|
||||||
controller: 'TeamController',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
$stateRegistryProvider.register(root);
|
$stateRegistryProvider.register(root);
|
||||||
$stateRegistryProvider.register(endpointRoot);
|
$stateRegistryProvider.register(endpointRoot);
|
||||||
$stateRegistryProvider.register(portainer);
|
$stateRegistryProvider.register(portainer);
|
||||||
|
@ -478,8 +454,6 @@ angular
|
||||||
$stateRegistryProvider.register(tags);
|
$stateRegistryProvider.register(tags);
|
||||||
$stateRegistryProvider.register(users);
|
$stateRegistryProvider.register(users);
|
||||||
$stateRegistryProvider.register(user);
|
$stateRegistryProvider.register(user);
|
||||||
$stateRegistryProvider.register(teams);
|
|
||||||
$stateRegistryProvider.register(team);
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { server, rest } from '@/setup-tests/server';
|
||||||
import { UserContext } from '@/portainer/hooks/useUser';
|
import { UserContext } from '@/portainer/hooks/useUser';
|
||||||
import { UserViewModel } from '@/portainer/models/user';
|
import { UserViewModel } from '@/portainer/models/user';
|
||||||
import { renderWithQueryClient, within } from '@/react-tools/test-utils';
|
import { renderWithQueryClient, within } from '@/react-tools/test-utils';
|
||||||
import { Team, TeamId } from '@/portainer/teams/types';
|
import { Team, TeamId } from '@/react/portainer/users/teams/types';
|
||||||
import { createMockTeams } from '@/react-tools/test-mocks';
|
import { createMockTeams } from '@/react-tools/test-mocks';
|
||||||
import { UserId } from '@/portainer/users/types';
|
import { UserId } from '@/portainer/users/types';
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { useReducer } from 'react';
|
||||||
import { useUser } from '@/portainer/hooks/useUser';
|
import { useUser } from '@/portainer/hooks/useUser';
|
||||||
import { Icon } from '@/react/components/Icon';
|
import { Icon } from '@/react/components/Icon';
|
||||||
import { r2a } from '@/react-tools/react2angular';
|
import { r2a } from '@/react-tools/react2angular';
|
||||||
import { TeamMembership, Role } from '@/portainer/teams/types';
|
import { TeamMembership, TeamRole } from '@/react/portainer/users/teams/types';
|
||||||
import { useUserMembership } from '@/portainer/users/queries';
|
import { useUserMembership } from '@/portainer/users/queries';
|
||||||
|
|
||||||
import { TableContainer, TableTitle } from '@@/datatables';
|
import { TableContainer, TableTitle } from '@@/datatables';
|
||||||
|
@ -138,7 +138,7 @@ function isLeaderOfAnyRestrictedTeams(
|
||||||
) {
|
) {
|
||||||
return userMemberships.some(
|
return userMemberships.some(
|
||||||
(membership) =>
|
(membership) =>
|
||||||
membership.Role === Role.TeamLeader &&
|
membership.Role === TeamRole.Leader &&
|
||||||
resourceControl.TeamAccesses.some((ta) => ta.TeamId === membership.TeamID)
|
resourceControl.TeamAccesses.some((ta) => ta.TeamId === membership.TeamID)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,8 +4,8 @@ import _ from 'lodash';
|
||||||
|
|
||||||
import { ownershipIcon, truncate } from '@/portainer/filters/filters';
|
import { ownershipIcon, truncate } from '@/portainer/filters/filters';
|
||||||
import { UserId } from '@/portainer/users/types';
|
import { UserId } from '@/portainer/users/types';
|
||||||
import { TeamId } from '@/portainer/teams/types';
|
import { TeamId } from '@/react/portainer/users/teams/types';
|
||||||
import { useTeams } from '@/portainer/teams/queries';
|
import { useTeams } from '@/react/portainer/users/teams/queries';
|
||||||
import { useUsers } from '@/portainer/users/queries';
|
import { useUsers } from '@/portainer/users/queries';
|
||||||
|
|
||||||
import { Link } from '@@/Link';
|
import { Link } from '@@/Link';
|
||||||
|
@ -178,17 +178,20 @@ function InheritanceMessage({
|
||||||
}
|
}
|
||||||
|
|
||||||
function useAuthorizedTeams(authorizedTeamIds: TeamId[]) {
|
function useAuthorizedTeams(authorizedTeamIds: TeamId[]) {
|
||||||
return useTeams(authorizedTeamIds.length > 0, (teams) => {
|
return useTeams(false, {
|
||||||
if (authorizedTeamIds.length === 0) {
|
enabled: authorizedTeamIds.length > 0,
|
||||||
return [];
|
select: (teams) => {
|
||||||
}
|
if (authorizedTeamIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return _.compact(
|
return _.compact(
|
||||||
authorizedTeamIds.map((id) => {
|
authorizedTeamIds.map((id) => {
|
||||||
const team = teams.find((u) => u.Id === id);
|
const team = teams.find((u) => u.Id === id);
|
||||||
return team?.Name;
|
return team?.Name;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Team } from '@/portainer/teams/types';
|
import { Team } from '@/react/portainer/users/teams/types';
|
||||||
|
|
||||||
import { TeamsSelector } from '@@/TeamsSelector';
|
import { TeamsSelector } from '@@/TeamsSelector';
|
||||||
import { FormControl } from '@@/form-components/FormControl';
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { useTeams } from '@/portainer/teams/queries';
|
import { useTeams } from '@/react/portainer/users/teams/queries';
|
||||||
import { useUsers } from '@/portainer/users/queries';
|
import { useUsers } from '@/portainer/users/queries';
|
||||||
|
|
||||||
export function useLoadState() {
|
export function useLoadState() {
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { buildOption } from '@/portainer/components/BoxSelector';
|
import { buildOption } from '@/portainer/components/BoxSelector';
|
||||||
import { ownershipIcon } from '@/portainer/filters/filters';
|
import { ownershipIcon } from '@/portainer/filters/filters';
|
||||||
import { Team } from '@/portainer/teams/types';
|
import { Team } from '@/react/portainer/users/teams/types';
|
||||||
|
|
||||||
import { BoxSelectorOption } from '@@/BoxSelector/types';
|
import { BoxSelectorOption } from '@@/BoxSelector/types';
|
||||||
import { BadgeIcon } from '@@/BoxSelector/BadgeIcon';
|
import { BadgeIcon } from '@@/BoxSelector/BadgeIcon';
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { TeamId } from '@/portainer/teams/types';
|
import { TeamId } from '@/react/portainer/users/teams/types';
|
||||||
import { UserId } from '@/portainer/users/types';
|
import { UserId } from '@/portainer/users/types';
|
||||||
|
|
||||||
export type ResourceControlId = number;
|
export type ResourceControlId = number;
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { TeamId } from '../teams/types';
|
import { TeamId } from '@/react/portainer/users/teams/types';
|
||||||
|
|
||||||
import { UserId } from '../users/types';
|
import { UserId } from '../users/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
|
@ -1,95 +0,0 @@
|
||||||
<div class="datatable">
|
|
||||||
<rd-widget>
|
|
||||||
<rd-widget-body classes="no-padding">
|
|
||||||
<div class="toolBar">
|
|
||||||
<div class="toolBarTitle vertical-center">
|
|
||||||
<pr-icon icon="$ctrl.titleIcon" feather="true" class-name="'icon-white icon-primary icon-nested-blue'"></pr-icon>
|
|
||||||
{{ $ctrl.titleText }}
|
|
||||||
</div>
|
|
||||||
<div class="searchBar vertical-center">
|
|
||||||
<pr-icon icon="'search'" feather="true"></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" ng-show="$ctrl.isAdmin">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-dangerlight vertical-center"
|
|
||||||
ng-disabled="$ctrl.state.selectedItemCount === 0"
|
|
||||||
ng-click="$ctrl.removeAction($ctrl.state.selectedItems)"
|
|
||||||
>
|
|
||||||
<pr-icon icon="'trash-2'" feather="true"></pr-icon>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover nowrap-cells">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>
|
|
||||||
<div class="vertical-center">
|
|
||||||
<span class="md-checkbox">
|
|
||||||
<input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" />
|
|
||||||
<label for="select_all"></label>
|
|
||||||
</span>
|
|
||||||
<table-column-header
|
|
||||||
col-title="'Name'"
|
|
||||||
can-sort="true"
|
|
||||||
is-sorted="$ctrl.state.orderBy === 'Name'"
|
|
||||||
is-sorted-desc="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"
|
|
||||||
ng-click="$ctrl.changeOrderBy('Name')"
|
|
||||||
></table-column-header>
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
</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)" />
|
|
||||||
<label for="select_{{ $index }}"></label>
|
|
||||||
</span>
|
|
||||||
<a ui-sref="portainer.teams.team({id: item.Id})">{{ item.Name }}</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr ng-if="!$ctrl.dataset">
|
|
||||||
<td class="text-center text-muted">Loading...</td>
|
|
||||||
</tr>
|
|
||||||
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
|
|
||||||
<td class="text-center text-muted">No team 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 class="space-right"> 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,14 +0,0 @@
|
||||||
angular.module('portainer.app').component('teamsDatatable', {
|
|
||||||
templateUrl: './teamsDatatable.html',
|
|
||||||
controller: 'GenericDatatableController',
|
|
||||||
bindings: {
|
|
||||||
titleText: '@',
|
|
||||||
titleIcon: '@',
|
|
||||||
dataset: '<',
|
|
||||||
tableKey: '@',
|
|
||||||
orderBy: '@',
|
|
||||||
reverseOrder: '<',
|
|
||||||
removeAction: '<',
|
|
||||||
isAdmin: '<',
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -2,7 +2,7 @@ import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
import { type EnvironmentGroupId } from '@/portainer/environment-groups/types';
|
import { type EnvironmentGroupId } from '@/portainer/environment-groups/types';
|
||||||
import { type TagId } from '@/portainer/tags/types';
|
import { type TagId } from '@/portainer/tags/types';
|
||||||
import { UserId } from '@/portainer/users/types';
|
import { UserId } from '@/portainer/users/types';
|
||||||
import { TeamId } from '@/portainer/teams/types';
|
import { TeamId } from '@/react/portainer/users/teams/types';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
Environment,
|
Environment,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
import { TeamId } from '@/portainer/teams/types';
|
import { TeamId } from '@/react/portainer/users/teams/types';
|
||||||
import { UserId } from '@/portainer/users/types';
|
import { UserId } from '@/portainer/users/types';
|
||||||
|
|
||||||
import { EnvironmentId } from '../types';
|
import { EnvironmentId } from '../types';
|
||||||
|
|
|
@ -14,8 +14,8 @@ import {
|
||||||
isEdgeEnvironment,
|
isEdgeEnvironment,
|
||||||
} from '@/portainer/environments/utils';
|
} from '@/portainer/environments/utils';
|
||||||
import type { TagId } from '@/portainer/tags/types';
|
import type { TagId } from '@/portainer/tags/types';
|
||||||
import { useIsAdmin } from '@/portainer/hooks/useUser';
|
|
||||||
import { useTags } from '@/portainer/tags/queries';
|
import { useTags } from '@/portainer/tags/queries';
|
||||||
|
import { useUser } from '@/portainer/hooks/useUser';
|
||||||
|
|
||||||
import { Icon } from '@@/Icon';
|
import { Icon } from '@@/Icon';
|
||||||
import { Link } from '@@/Link';
|
import { Link } from '@@/Link';
|
||||||
|
@ -34,7 +34,7 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EnvironmentItem({ environment, onClick, groupName }: Props) {
|
export function EnvironmentItem({ environment, onClick, groupName }: Props) {
|
||||||
const isAdmin = useIsAdmin();
|
const { isAdmin } = useUser();
|
||||||
const isEdge = isEdgeEnvironment(environment.Type);
|
const isEdge = isEdgeEnvironment(environment.Type);
|
||||||
|
|
||||||
const snapshotTime = getSnapshotTime(environment);
|
const snapshotTime = getSnapshotTime(environment);
|
||||||
|
|
|
@ -12,7 +12,6 @@ import {
|
||||||
EdgeTypes,
|
EdgeTypes,
|
||||||
} from '@/portainer/environments/types';
|
} from '@/portainer/environments/types';
|
||||||
import { EnvironmentGroupId } from '@/portainer/environment-groups/types';
|
import { EnvironmentGroupId } from '@/portainer/environment-groups/types';
|
||||||
import { useIsAdmin } from '@/portainer/hooks/useUser';
|
|
||||||
import {
|
import {
|
||||||
HomepageFilter,
|
HomepageFilter,
|
||||||
useHomePageFilter,
|
useHomePageFilter,
|
||||||
|
@ -27,6 +26,7 @@ import { useTags } from '@/portainer/tags/queries';
|
||||||
import { Filter } from '@/portainer/home/types';
|
import { Filter } from '@/portainer/home/types';
|
||||||
import { useAgentVersionsList } from '@/portainer/environments/queries/useAgentVersionsList';
|
import { useAgentVersionsList } from '@/portainer/environments/queries/useAgentVersionsList';
|
||||||
import { EnvironmentsQueryParams } from '@/portainer/environments/environment.service';
|
import { EnvironmentsQueryParams } from '@/portainer/environments/environment.service';
|
||||||
|
import { useUser } from '@/portainer/hooks/useUser';
|
||||||
|
|
||||||
import { TableFooter } from '@@/datatables/TableFooter';
|
import { TableFooter } from '@@/datatables/TableFooter';
|
||||||
import { TableActions, TableContainer, TableTitle } from '@@/datatables';
|
import { TableActions, TableContainer, TableTitle } from '@@/datatables';
|
||||||
|
@ -69,7 +69,7 @@ enum ConnectionType {
|
||||||
const storageKey = 'home_endpoints';
|
const storageKey = 'home_endpoints';
|
||||||
|
|
||||||
export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
||||||
const isAdmin = useIsAdmin();
|
const { isAdmin } = useUser();
|
||||||
|
|
||||||
const [platformTypes, setPlatformTypes] = useHomePageFilter<
|
const [platformTypes, setPlatformTypes] = useHomePageFilter<
|
||||||
Filter<PlatformType>[]
|
Filter<PlatformType>[]
|
||||||
|
|
|
@ -176,8 +176,3 @@ export function UserProvider({ children }: UserProviderProps) {
|
||||||
setUser(user);
|
setUser(user);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useIsAdmin() {
|
|
||||||
const { user } = useUser();
|
|
||||||
return !!user && isAdmin(user);
|
|
||||||
}
|
|
||||||
|
|
|
@ -9,9 +9,10 @@ import {
|
||||||
} from '@/react/portainer/registries/ListView/DefaultRegistry';
|
} from '@/react/portainer/registries/ListView/DefaultRegistry';
|
||||||
|
|
||||||
import { wizardModule } from './wizard';
|
import { wizardModule } from './wizard';
|
||||||
|
import { teamsModule } from './teams';
|
||||||
|
|
||||||
export const viewsModule = angular
|
export const viewsModule = angular
|
||||||
.module('portainer.app.react.views', [wizardModule])
|
.module('portainer.app.react.views', [wizardModule, teamsModule])
|
||||||
.component('defaultRegistryName', r2a(DefaultRegistryName, []))
|
.component('defaultRegistryName', r2a(DefaultRegistryName, []))
|
||||||
.component('defaultRegistryAction', r2a(DefaultRegistryAction, []))
|
.component('defaultRegistryAction', r2a(DefaultRegistryAction, []))
|
||||||
.component('defaultRegistryDomain', r2a(DefaultRegistryDomain, []))
|
.component('defaultRegistryDomain', r2a(DefaultRegistryDomain, []))
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
import angular from 'angular';
|
||||||
|
import { StateRegistry } from '@uirouter/angularjs';
|
||||||
|
|
||||||
|
import { ItemView, ListView } from '@/react/portainer/users/teams';
|
||||||
|
import { r2a } from '@/react-tools/react2angular';
|
||||||
|
|
||||||
|
export const teamsModule = angular
|
||||||
|
.module('portainer.app.teams', [])
|
||||||
|
.config(config)
|
||||||
|
.component('teamView', r2a(ItemView, []))
|
||||||
|
.component('teamsView', r2a(ListView, [])).name;
|
||||||
|
|
||||||
|
/* @ngInject */
|
||||||
|
function config($stateRegistryProvider: StateRegistry) {
|
||||||
|
$stateRegistryProvider.register({
|
||||||
|
name: 'portainer.teams',
|
||||||
|
url: '/teams',
|
||||||
|
views: {
|
||||||
|
'content@': {
|
||||||
|
component: 'teamsView',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
$stateRegistryProvider.register({
|
||||||
|
name: 'portainer.teams.team',
|
||||||
|
url: '/:id',
|
||||||
|
views: {
|
||||||
|
'content@': {
|
||||||
|
component: 'teamView',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
import { TeamId } from '../teams/types';
|
import { TeamId } from '@/react/portainer/users/teams/types';
|
||||||
|
|
||||||
export interface FDOConfiguration {
|
export interface FDOConfiguration {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|
|
@ -1,37 +0,0 @@
|
||||||
import { Meta } from '@storybook/react';
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { CreateTeamForm, FormValues } from './CreateTeamForm';
|
|
||||||
import { mockExampleData } from './CreateTeamForm.mocks';
|
|
||||||
|
|
||||||
const meta: Meta = {
|
|
||||||
title: 'teams/CreateTeamForm',
|
|
||||||
component: CreateTeamForm,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
|
|
||||||
export { Example };
|
|
||||||
|
|
||||||
function Example() {
|
|
||||||
const [message, setMessage] = useState('');
|
|
||||||
const { teams, users } = mockExampleData();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<CreateTeamForm users={users} teams={teams} onSubmit={handleSubmit} />
|
|
||||||
<div>{message}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleSubmit(values: FormValues) {
|
|
||||||
return new Promise<void>((resolve) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
setMessage(
|
|
||||||
`created team ${values.name} with ${values.leaders.length} leaders`
|
|
||||||
);
|
|
||||||
resolve();
|
|
||||||
}, 3000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,11 +0,0 @@
|
||||||
import { r2a } from '@/react-tools/react2angular';
|
|
||||||
|
|
||||||
import { CreateTeamForm } from './CreateTeamForm';
|
|
||||||
|
|
||||||
export { CreateTeamForm };
|
|
||||||
|
|
||||||
export const CreateTeamFormAngular = r2a(CreateTeamForm, [
|
|
||||||
'users',
|
|
||||||
'onSubmit',
|
|
||||||
'teams',
|
|
||||||
]);
|
|
|
@ -1,8 +0,0 @@
|
||||||
import angular from 'angular';
|
|
||||||
|
|
||||||
import { CreateTeamFormAngular } from './CreateTeamForm';
|
|
||||||
|
|
||||||
export default angular
|
|
||||||
.module('portainer.app.teams', [])
|
|
||||||
|
|
||||||
.component('createTeamForm', CreateTeamFormAngular).name;
|
|
|
@ -1,19 +0,0 @@
|
||||||
import { useQuery } from 'react-query';
|
|
||||||
|
|
||||||
import { getTeams } from './teams.service';
|
|
||||||
import { Team } from './types';
|
|
||||||
|
|
||||||
export function useTeams<T = Team[]>(
|
|
||||||
enabled = true,
|
|
||||||
select: (data: Team[]) => T = (data) => data as unknown as T
|
|
||||||
) {
|
|
||||||
const teams = useQuery(['teams'], () => getTeams(), {
|
|
||||||
meta: {
|
|
||||||
error: { title: 'Failure', message: 'Unable to load teams' },
|
|
||||||
},
|
|
||||||
enabled,
|
|
||||||
select,
|
|
||||||
});
|
|
||||||
|
|
||||||
return teams;
|
|
||||||
}
|
|
|
@ -1,22 +0,0 @@
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
|
||||||
|
|
||||||
import { Team, TeamId } from './types';
|
|
||||||
|
|
||||||
export async function getTeams() {
|
|
||||||
try {
|
|
||||||
const { data } = await axios.get<Team[]>(buildUrl());
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
throw parseAxiosError(error as Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildUrl(id?: TeamId) {
|
|
||||||
let url = '/teams';
|
|
||||||
|
|
||||||
if (id) {
|
|
||||||
url += `/${id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return url;
|
|
||||||
}
|
|
|
@ -1,20 +0,0 @@
|
||||||
import { UserId } from '../users/types';
|
|
||||||
|
|
||||||
export type TeamId = number;
|
|
||||||
|
|
||||||
export enum Role {
|
|
||||||
TeamLeader = 1,
|
|
||||||
TeamMember,
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Team {
|
|
||||||
Id: TeamId;
|
|
||||||
Name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TeamMembership {
|
|
||||||
Id: number;
|
|
||||||
UserID: UserId;
|
|
||||||
TeamID: TeamId;
|
|
||||||
Role: Role;
|
|
||||||
}
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { useQuery } from 'react-query';
|
import { useQuery } from 'react-query';
|
||||||
|
|
||||||
import { Role as TeamRole, TeamMembership } from '../teams/types';
|
import { TeamRole, TeamMembership } from '@/react/portainer/users/teams/types';
|
||||||
|
|
||||||
import { User, UserId } from './types';
|
import { User, UserId } from './types';
|
||||||
import { isAdmin } from './user.helpers';
|
import { isAdmin } from './user.helpers';
|
||||||
|
@ -26,14 +26,14 @@ export function useIsTeamLeader(user: User) {
|
||||||
const query = useUserMembership(user.Id, {
|
const query = useUserMembership(user.Id, {
|
||||||
enabled: !isAdmin(user),
|
enabled: !isAdmin(user),
|
||||||
select: (memberships) =>
|
select: (memberships) =>
|
||||||
memberships.some((membership) => membership.Role === TeamRole.TeamLeader),
|
memberships.some((membership) => membership.Role === TeamRole.Leader),
|
||||||
});
|
});
|
||||||
|
|
||||||
return isAdmin(user) ? true : query.data;
|
return isAdmin(user) ? true : query.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUsers<T = User[]>(
|
export function useUsers<T = User[]>(
|
||||||
includeAdministrator: boolean,
|
includeAdministrator = false,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
select: (data: User[]) => T = (data) => data as unknown as T
|
select: (data: User[]) => T = (data) => data as unknown as T
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -11,7 +11,7 @@ interface AuthorizationMap {
|
||||||
[authorization: string]: boolean;
|
[authorization: string]: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface User {
|
export type User = {
|
||||||
Id: UserId;
|
Id: UserId;
|
||||||
Username: string;
|
Username: string;
|
||||||
Role: Role;
|
Role: Role;
|
||||||
|
@ -29,4 +29,4 @@ export interface User {
|
||||||
// this.AuthenticationMethod = data.AuthenticationMethod;
|
// this.AuthenticationMethod = data.AuthenticationMethod;
|
||||||
// this.Checked = false;
|
// this.Checked = false;
|
||||||
// this.EndpointAuthorizations = data.EndpointAuthorizations;
|
// this.EndpointAuthorizations = data.EndpointAuthorizations;
|
||||||
}
|
};
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { TeamMembership } from '@/react/portainer/users/teams/types';
|
||||||
import { TeamMembership } from '../teams/types';
|
|
||||||
|
|
||||||
import { User, UserId } from './types';
|
import { User, UserId } from './types';
|
||||||
import { filterNonAdministratorUsers } from './user.helpers';
|
import { filterNonAdministratorUsers } from './user.helpers';
|
||||||
|
|
|
@ -1,223 +0,0 @@
|
||||||
<page-header title="'Team details'" breadcrumbs="[{label:'Teams', link:'portainer.teams'}, team.Name]"> </page-header>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
|
||||||
<rd-widget>
|
|
||||||
<rd-widget-header icon="users" feather-icon="true" title-text="Team details"></rd-widget-header>
|
|
||||||
<rd-widget-body classes="no-padding">
|
|
||||||
<table class="table">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>Name</td>
|
|
||||||
<td>
|
|
||||||
<span ng-if="!settings.TeamSync">{{ team.Name }}</span>
|
|
||||||
<button class="btn btn-xs btn-danger" ng-if="isAdmin" ng-click="deleteTeam()"> <pr-icon icon="'trash-2'" feather="true"></pr-icon>Delete this team </button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Leaders</td>
|
|
||||||
<td>
|
|
||||||
<span ng-if="!settings.TeamSync">{{ leaderCount }}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Total users in team</td>
|
|
||||||
<td>{{ teamMembers.length }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</rd-widget-body>
|
|
||||||
</rd-widget>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row" ng-if="team && settings.TeamSync">
|
|
||||||
<div class="col-sm-12 text-muted">
|
|
||||||
<pr-icon icon="'alert-circle'" feather="true" class-name="'icon-warning'"></pr-icon>
|
|
||||||
The team leader feature is disabled as external authentication is currently enabled with team sync.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row" ng-if="team">
|
|
||||||
<div class="col-sm-6">
|
|
||||||
<rd-widget>
|
|
||||||
<rd-widget-header classes="col-sm-12 col-md-6 nopadding" icon="users" feather-icon="true" title-text="Users">
|
|
||||||
<div class="pull-md-right pull-lg-right">
|
|
||||||
Items per page:
|
|
||||||
<select ng-model="state.pagination_count_users" ng-change="changePaginationCountUsers()">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</rd-widget-header>
|
|
||||||
<rd-widget-taskbar classes="col-sm-12 nopadding">
|
|
||||||
<div class="col-sm-12 col-md-6 nopadding">
|
|
||||||
<button class="btn btn-primary btn-sm" ng-click="addAllUsers()" ng-if="isAdmin" ng-disabled="users.length === 0 || filteredUsers.length === 0 || settings.TeamSync">
|
|
||||||
<pr-icon icon="'user-plus'" feather="true" class-name="'icon-white'" size="'sm'"></pr-icon>Add all users
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-12 col-md-6 nopadding">
|
|
||||||
<input type="text" id="filter-users" ng-model="state.filterUsers" placeholder="Filter..." class="form-control input-sm" />
|
|
||||||
</div>
|
|
||||||
</rd-widget-taskbar>
|
|
||||||
<rd-widget-body classes="no-padding">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>
|
|
||||||
<table-column-header
|
|
||||||
col-title="'Name'"
|
|
||||||
can-sort="true"
|
|
||||||
is-sorted="sortTypeUsers === 'Username'"
|
|
||||||
is-sorted-desc="sortTypeUsers === 'Username' && sortReverseUsers"
|
|
||||||
ng-click="orderUsers('Username')"
|
|
||||||
></table-column-header>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr
|
|
||||||
pagination-id="table1"
|
|
||||||
dir-paginate="user in users | filter:state.filterUsers | orderBy:sortTypeUsers:sortReverseUsers | itemsPerPage: state.pagination_count_users"
|
|
||||||
>
|
|
||||||
<td>
|
|
||||||
<span class="vertical-center">
|
|
||||||
{{ user.Username }}
|
|
||||||
<span class="space-left vertical-center">
|
|
||||||
<a class="hyperlink vertical-center" ng-click="addUser(user)" ng-class="{ 'btn disabled py-0': settings.TeamSync }">
|
|
||||||
<pr-icon icon="'plus-circle'" feather="true" size="'sm'"></pr-icon> Add
|
|
||||||
</a>
|
|
||||||
</span></span
|
|
||||||
>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr ng-if="!users">
|
|
||||||
<td colspan="2" class="text-center text-muted">Loading...</td>
|
|
||||||
</tr>
|
|
||||||
<tr ng-if="users.length === 0 || (users | filter: state.filterUsers).length === 0">
|
|
||||||
<td colspan="2" class="text-center text-muted">No users.</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div ng-if="users" class="pull-left pagination-controls">
|
|
||||||
<dir-pagination-controls pagination-id="table1"></dir-pagination-controls>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</rd-widget-body>
|
|
||||||
</rd-widget>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-6">
|
|
||||||
<rd-widget>
|
|
||||||
<rd-widget-header classes="col-sm-12 col-md-6 nopadding" icon="users" feather-icon="true" title-text="Team members">
|
|
||||||
<div class="pull-md-right pull-lg-right">
|
|
||||||
Items per page:
|
|
||||||
<select ng-model="state.pagination_count_members" ng-change="changePaginationCountGroupMembers()">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</rd-widget-header>
|
|
||||||
<rd-widget-taskbar classes="col-sm-12 nopadding">
|
|
||||||
<div class="col-sm-12 col-md-6 nopadding">
|
|
||||||
<button
|
|
||||||
class="btn btn-primary btn-sm"
|
|
||||||
ng-click="removeAllUsers()"
|
|
||||||
ng-if="isAdmin"
|
|
||||||
ng-disabled="teamMembers.length === 0 || filteredGroupMembers.length === 0 || settings.TeamSync"
|
|
||||||
>
|
|
||||||
<pr-icon icon="'user-x'" feather="true" class-name="'icon-white'" size="'sm'"></pr-icon>Remove all users
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="col-sm-12 col-md-6 nopadding">
|
|
||||||
<input type="text" id="filter-group" ng-model="state.filterGroupMembers" placeholder="Filter..." class="form-control input-sm" />
|
|
||||||
</div>
|
|
||||||
</rd-widget-taskbar>
|
|
||||||
<rd-widget-body classes="no-padding">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>
|
|
||||||
<table-column-header
|
|
||||||
col-title="'Name'"
|
|
||||||
can-sort="true"
|
|
||||||
is-sorted="sortTypeGroupMembers === 'Username'"
|
|
||||||
is-sorted-desc="sortTypeGroupMembers === 'Username' && sortReverseGroupMembers"
|
|
||||||
ng-click="orderGroupMembers('Username')"
|
|
||||||
></table-column-header>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
<table-column-header
|
|
||||||
col-title="'Team Role'"
|
|
||||||
can-sort="true"
|
|
||||||
is-sorted="sortTypeGroupMembers === 'TeamRole'"
|
|
||||||
is-sorted-desc="sortTypeGroupMembers === 'TeamRole' && sortReverseGroupMembers"
|
|
||||||
ng-click="orderGroupMembers('TeamRole')"
|
|
||||||
></table-column-header>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr
|
|
||||||
pagination-id="table2"
|
|
||||||
dir-paginate="user in teamMembers | filter:state.filterGroupMembers | orderBy:sortTypeGroupMembers:sortReverseGroupMembers | itemsPerPage: state.pagination_count_groupMembers"
|
|
||||||
>
|
|
||||||
<td>
|
|
||||||
<span class="vertical-center"
|
|
||||||
>{{ user.Username }}
|
|
||||||
<span class="space-left vertical-center" ng-if="isAdmin || user.TeamRole === 'Member'">
|
|
||||||
<a class="hyperlink vertical-center" ng-click="removeUser(user)" ng-class="{ 'btn disabled py-0': settings.TeamSync }">
|
|
||||||
<pr-icon icon="'minus-circle'" feather="true" size="'sm'"></pr-icon> Remove
|
|
||||||
</a>
|
|
||||||
</span></span
|
|
||||||
>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="vertical-center"
|
|
||||||
><pr-icon ng-if="user.TeamRole === 'Leader'" icon="'user-plus'" feather="true" size="'sm'"></pr-icon>
|
|
||||||
<pr-icon ng-if="user.TeamRole === 'Member'" icon="'user'" feather="true" size="'sm'"></pr-icon>
|
|
||||||
{{ user.TeamRole }}</span
|
|
||||||
>
|
|
||||||
<span class="space-left">
|
|
||||||
<a
|
|
||||||
class="space-left hyperlink vertical-center"
|
|
||||||
ng-click="promoteToLeader(user)"
|
|
||||||
ng-if="user.TeamRole === 'Member'"
|
|
||||||
ng-class="{ 'btn disabled py-0': settings.TeamSync }"
|
|
||||||
>
|
|
||||||
<pr-icon icon="'user-plus'" feather="true" size="'sm'"></pr-icon> Leader
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
class="space-left hyperlink vertical-center"
|
|
||||||
ng-click="demoteToMember(user)"
|
|
||||||
ng-if="isAdmin && user.TeamRole === 'Leader'"
|
|
||||||
ng-class="{ 'btn disabled py-0': settings.TeamSync }"
|
|
||||||
>
|
|
||||||
<pr-icon icon="'user-x'" feather="true" size="'sm'"></pr-icon> Member
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr ng-if="!teamMembers">
|
|
||||||
<td colspan="2" class="text-center text-muted">Loading...</td>
|
|
||||||
</tr>
|
|
||||||
<tr ng-if="teamMembers.length === 0 || (teamMembers | filter: state.filterGroupMembers).length === 0">
|
|
||||||
<td colspan="2" class="text-center text-muted">No team members.</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div ng-if="teamMembers" class="pull-left pagination-controls">
|
|
||||||
<dir-pagination-controls pagination-id="table2"></dir-pagination-controls>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</rd-widget-body>
|
|
||||||
</rd-widget>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -1,214 +0,0 @@
|
||||||
angular.module('portainer.app').controller('TeamController', [
|
|
||||||
'$q',
|
|
||||||
'$scope',
|
|
||||||
'$state',
|
|
||||||
'$transition$',
|
|
||||||
'TeamService',
|
|
||||||
'UserService',
|
|
||||||
'TeamMembershipService',
|
|
||||||
'ModalService',
|
|
||||||
'Notifications',
|
|
||||||
'PaginationService',
|
|
||||||
'Authentication',
|
|
||||||
'SettingsService',
|
|
||||||
function ($q, $scope, $state, $transition$, TeamService, UserService, TeamMembershipService, ModalService, Notifications, PaginationService, Authentication, SettingsService) {
|
|
||||||
$scope.state = {
|
|
||||||
pagination_count_users: PaginationService.getPaginationLimit('team_available_users'),
|
|
||||||
pagination_count_members: PaginationService.getPaginationLimit('team_members'),
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.sortTypeUsers = 'Username';
|
|
||||||
$scope.sortReverseUsers = true;
|
|
||||||
$scope.users = [];
|
|
||||||
$scope.teamMembers = [];
|
|
||||||
$scope.leaderCount = 0;
|
|
||||||
|
|
||||||
$scope.orderUsers = function (sortType) {
|
|
||||||
$scope.sortReverseUsers = $scope.sortTypeUsers === sortType ? !$scope.sortReverseUsers : false;
|
|
||||||
$scope.sortTypeUsers = sortType;
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.changePaginationCountUsers = function () {
|
|
||||||
PaginationService.setPaginationLimit('team_available_users', $scope.state.pagination_count_users);
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.sortTypeGroupMembers = 'TeamRole';
|
|
||||||
$scope.sortReverseGroupMembers = false;
|
|
||||||
|
|
||||||
$scope.orderGroupMembers = function (sortType) {
|
|
||||||
$scope.sortReverseGroupMembers = $scope.sortTypeGroupMembers === sortType ? !$scope.sortReverseGroupMembers : false;
|
|
||||||
$scope.sortTypeGroupMembers = sortType;
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.changePaginationCountGroupMembers = function () {
|
|
||||||
PaginationService.setPaginationLimit('team_members', $scope.state.pagination_count_members);
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.deleteTeam = function () {
|
|
||||||
ModalService.confirmDeletion('Do you want to delete this team? Users in this team will not be deleted.', function onConfirm(confirmed) {
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
deleteTeam();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.promoteToLeader = function (user) {
|
|
||||||
TeamMembershipService.updateMembership(user.MembershipId, user.Id, $scope.team.Id, 1)
|
|
||||||
.then(function success() {
|
|
||||||
$scope.leaderCount++;
|
|
||||||
user.TeamRole = 'Leader';
|
|
||||||
Notifications.success('User is now team leader', user.Username);
|
|
||||||
})
|
|
||||||
.catch(function error(err) {
|
|
||||||
Notifications.error('Failure', err, 'Unable to update user role');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.demoteToMember = function (user) {
|
|
||||||
TeamMembershipService.updateMembership(user.MembershipId, user.Id, $scope.team.Id, 2)
|
|
||||||
.then(function success() {
|
|
||||||
user.TeamRole = 'Member';
|
|
||||||
$scope.leaderCount--;
|
|
||||||
Notifications.success('User is now team member', user.Username);
|
|
||||||
})
|
|
||||||
.catch(function error(err) {
|
|
||||||
Notifications.error('Failure', err, 'Unable to update user role');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.addAllUsers = function () {
|
|
||||||
var teamMembershipQueries = [];
|
|
||||||
angular.forEach($scope.users, function (user) {
|
|
||||||
teamMembershipQueries.push(TeamMembershipService.createMembership(user.Id, $scope.team.Id, 2));
|
|
||||||
});
|
|
||||||
$q.all(teamMembershipQueries)
|
|
||||||
.then(function success(data) {
|
|
||||||
var users = $scope.users;
|
|
||||||
for (var i = 0; i < users.length; i++) {
|
|
||||||
var user = users[i];
|
|
||||||
user.MembershipId = data[i].Id;
|
|
||||||
user.TeamRole = 'Member';
|
|
||||||
}
|
|
||||||
$scope.teamMembers = $scope.teamMembers.concat(users);
|
|
||||||
$scope.users = [];
|
|
||||||
Notifications.success('Success', 'All users successfully added');
|
|
||||||
})
|
|
||||||
.catch(function error(err) {
|
|
||||||
Notifications.error('Failure', err, 'Unable to update team members');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.addUser = function (user) {
|
|
||||||
TeamMembershipService.createMembership(user.Id, $scope.team.Id, 2)
|
|
||||||
.then(function success(data) {
|
|
||||||
removeUserFromArray(user.Id, $scope.users);
|
|
||||||
user.TeamRole = 'Member';
|
|
||||||
user.MembershipId = data.Id;
|
|
||||||
$scope.teamMembers.push(user);
|
|
||||||
Notifications.success('User added to team', user.Username);
|
|
||||||
})
|
|
||||||
.catch(function error(err) {
|
|
||||||
Notifications.error('Failure', err, 'Unable to update team members');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.removeAllUsers = function () {
|
|
||||||
var teamMembershipQueries = [];
|
|
||||||
angular.forEach($scope.teamMembers, function (user) {
|
|
||||||
teamMembershipQueries.push(TeamMembershipService.deleteMembership(user.MembershipId));
|
|
||||||
});
|
|
||||||
$q.all(teamMembershipQueries)
|
|
||||||
.then(function success() {
|
|
||||||
$scope.users = $scope.users.concat($scope.teamMembers);
|
|
||||||
$scope.teamMembers = [];
|
|
||||||
$scope.leaderCount = 0;
|
|
||||||
Notifications.success('Success', 'All users successfully removed');
|
|
||||||
})
|
|
||||||
.catch(function error(err) {
|
|
||||||
Notifications.error('Failure', err, 'Unable to update team members');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.removeUser = function (user) {
|
|
||||||
TeamMembershipService.deleteMembership(user.MembershipId)
|
|
||||||
.then(function success() {
|
|
||||||
removeUserFromArray(user.Id, $scope.teamMembers);
|
|
||||||
if (user.TeamRole === 'Leader') {
|
|
||||||
$scope.leaderCount--;
|
|
||||||
}
|
|
||||||
$scope.users.push(user);
|
|
||||||
Notifications.success('User removed from team', user.Username);
|
|
||||||
})
|
|
||||||
.catch(function error(err) {
|
|
||||||
Notifications.error('Failure', err, 'Unable to update team members');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
function deleteTeam() {
|
|
||||||
TeamService.deleteTeam($scope.team.Id)
|
|
||||||
.then(function success() {
|
|
||||||
Notifications.success('Team successfully deleted', $scope.team.Name);
|
|
||||||
$state.go('portainer.teams');
|
|
||||||
})
|
|
||||||
.catch(function error(err) {
|
|
||||||
Notifications.error('Failure', err, 'Unable to remove team');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeUserFromArray(id, users) {
|
|
||||||
for (var i = 0, l = users.length; i < l; i++) {
|
|
||||||
if (users[i].Id === id) {
|
|
||||||
users.splice(i, 1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assignUsersAndMembers(users, memberships) {
|
|
||||||
for (var i = 0; i < users.length; i++) {
|
|
||||||
var user = users[i];
|
|
||||||
var member = false;
|
|
||||||
for (var j = 0; j < memberships.length; j++) {
|
|
||||||
var membership = memberships[j];
|
|
||||||
if (user.Id === membership.UserId) {
|
|
||||||
member = true;
|
|
||||||
if (membership.Role === 1) {
|
|
||||||
user.TeamRole = 'Leader';
|
|
||||||
$scope.leaderCount++;
|
|
||||||
} else {
|
|
||||||
user.TeamRole = 'Member';
|
|
||||||
}
|
|
||||||
user.MembershipId = membership.Id;
|
|
||||||
$scope.teamMembers.push(user);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!member) {
|
|
||||||
$scope.users.push(user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initView() {
|
|
||||||
$scope.isAdmin = Authentication.isAdmin();
|
|
||||||
|
|
||||||
try {
|
|
||||||
$scope.settings = await SettingsService.publicSettings();
|
|
||||||
|
|
||||||
const data = await $q.all({
|
|
||||||
team: TeamService.team($transition$.params().id),
|
|
||||||
users: UserService.users($scope.isAdmin && $scope.settings.TeamSync),
|
|
||||||
memberships: TeamService.userMemberships($transition$.params().id),
|
|
||||||
});
|
|
||||||
|
|
||||||
$scope.team = data.team;
|
|
||||||
assignUsersAndMembers(data.users, data.memberships);
|
|
||||||
} catch (err) {
|
|
||||||
Notifications.error('Failure', err, 'Unable to retrieve team details');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
initView();
|
|
||||||
},
|
|
||||||
]);
|
|
|
@ -1,9 +0,0 @@
|
||||||
<page-header title="'Teams'" breadcrumbs="['Teams management']" reload="true"> </page-header>
|
|
||||||
|
|
||||||
<create-team-form ng-if="isAdmin && users" users="users" action-in-progress="state.actionInProgress" teams="teams" on-submit="(addTeam)"></create-team-form>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<teams-datatable title-text="Teams" title-icon="users" dataset="teams" table-key="teams" order-by="Name" remove-action="removeAction" is-admin="isAdmin"></teams-datatable>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -1,103 +0,0 @@
|
||||||
import _ from 'lodash-es';
|
|
||||||
|
|
||||||
angular.module('portainer.app').controller('TeamsController', [
|
|
||||||
'$q',
|
|
||||||
'$scope',
|
|
||||||
'$state',
|
|
||||||
'TeamService',
|
|
||||||
'UserService',
|
|
||||||
'ModalService',
|
|
||||||
'Notifications',
|
|
||||||
'Authentication',
|
|
||||||
function ($q, $scope, $state, TeamService, UserService, ModalService, Notifications, Authentication) {
|
|
||||||
$scope.state = {
|
|
||||||
actionInProgress: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.formValues = {
|
|
||||||
Name: '',
|
|
||||||
Leaders: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.checkNameValidity = function (form) {
|
|
||||||
var valid = true;
|
|
||||||
for (var i = 0; i < $scope.teams.length; i++) {
|
|
||||||
if ($scope.formValues.Name === $scope.teams[i].Name) {
|
|
||||||
valid = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
form.team_name.$setValidity('validName', valid);
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.addTeam = function (formValues) {
|
|
||||||
const teamName = formValues.name;
|
|
||||||
|
|
||||||
$scope.state.actionInProgress = true;
|
|
||||||
TeamService.createTeam(teamName, formValues.leaders)
|
|
||||||
.then(function success() {
|
|
||||||
Notifications.success('Team successfully created', teamName);
|
|
||||||
$state.reload();
|
|
||||||
})
|
|
||||||
.catch(function error(err) {
|
|
||||||
Notifications.error('Failure', err, 'Unable to create team');
|
|
||||||
})
|
|
||||||
.finally(function final() {
|
|
||||||
$scope.state.actionInProgress = false;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.removeAction = function (selectedItems) {
|
|
||||||
ModalService.confirmDeletion('Do you want to delete the selected team(s)? Users in the team(s) will not be deleted.', function onConfirm(confirmed) {
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
deleteSelectedTeams(selectedItems);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
function deleteSelectedTeams(selectedItems) {
|
|
||||||
var actionCount = selectedItems.length;
|
|
||||||
angular.forEach(selectedItems, function (team) {
|
|
||||||
TeamService.deleteTeam(team.Id)
|
|
||||||
.then(function success() {
|
|
||||||
Notifications.success('Team successfully removed', team.Name);
|
|
||||||
var index = $scope.teams.indexOf(team);
|
|
||||||
$scope.teams.splice(index, 1);
|
|
||||||
})
|
|
||||||
.catch(function error(err) {
|
|
||||||
Notifications.error('Failure', err, 'Unable to remove team');
|
|
||||||
})
|
|
||||||
.finally(function final() {
|
|
||||||
--actionCount;
|
|
||||||
if (actionCount === 0) {
|
|
||||||
$state.reload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function initView() {
|
|
||||||
var userDetails = Authentication.getUserDetails();
|
|
||||||
var isAdmin = Authentication.isAdmin();
|
|
||||||
$scope.isAdmin = isAdmin;
|
|
||||||
$q.all({
|
|
||||||
users: UserService.users(false),
|
|
||||||
teams: isAdmin ? TeamService.teams() : UserService.userLeadingTeams(userDetails.ID),
|
|
||||||
})
|
|
||||||
.then(function success(data) {
|
|
||||||
var teams = data.teams;
|
|
||||||
$scope.teams = teams;
|
|
||||||
$scope.users = _.orderBy(data.users, 'Username', 'asc');
|
|
||||||
$scope.isTeamLeader = !!teams.length;
|
|
||||||
})
|
|
||||||
.catch(function error(err) {
|
|
||||||
$scope.teams = [];
|
|
||||||
$scope.users = [];
|
|
||||||
Notifications.error('Failure', err, 'Unable to retrieve teams');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
initView();
|
|
||||||
},
|
|
||||||
]);
|
|
|
@ -1,6 +1,6 @@
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
|
|
||||||
import { Team } from '@/portainer/teams/types';
|
import { Team } from '@/react/portainer/users/teams/types';
|
||||||
import { Role, User, UserId } from '@/portainer/users/types';
|
import { Role, User, UserId } from '@/portainer/users/types';
|
||||||
import { Environment } from '@/portainer/environments/types';
|
import { Environment } from '@/portainer/environments/types';
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@ import {
|
||||||
Subscription,
|
Subscription,
|
||||||
} from '@/react/azure/types';
|
} from '@/react/azure/types';
|
||||||
import { parseAccessControlFormData } from '@/portainer/access-control/utils';
|
import { parseAccessControlFormData } from '@/portainer/access-control/utils';
|
||||||
import { useIsAdmin } from '@/portainer/hooks/useUser';
|
import { useUser } from '@/portainer/hooks/useUser';
|
||||||
import { useProvider } from '@/react/azure/queries/useProvider';
|
import { useProvider } from '@/react/azure/queries/useProvider';
|
||||||
import { useResourceGroups } from '@/react/azure/queries/useResourceGroups';
|
import { useResourceGroups } from '@/react/azure/queries/useResourceGroups';
|
||||||
import { useSubscriptions } from '@/react/azure/queries/useSubscriptions';
|
import { useSubscriptions } from '@/react/azure/queries/useSubscriptions';
|
||||||
|
@ -37,7 +37,7 @@ export function useFormState(
|
||||||
resourceGroups: Record<string, ResourceGroup[]> = {},
|
resourceGroups: Record<string, ResourceGroup[]> = {},
|
||||||
providers: Record<string, ProviderViewModel> = {}
|
providers: Record<string, ProviderViewModel> = {}
|
||||||
) {
|
) {
|
||||||
const isAdmin = useIsAdmin();
|
const { isAdmin } = useUser();
|
||||||
|
|
||||||
const subscriptionOptions = subscriptions.map((s) => ({
|
const subscriptionOptions = subscriptions.map((s) => ({
|
||||||
value: s.subscriptionId,
|
value: s.subscriptionId,
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import './pagination-controls.css';
|
||||||
|
|
||||||
import { generatePagesArray } from './generatePagesArray';
|
import { generatePagesArray } from './generatePagesArray';
|
||||||
import { PageButton } from './PageButton';
|
import { PageButton } from './PageButton';
|
||||||
import { PageInput } from './PageInput';
|
import { PageInput } from './PageInput';
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Team, TeamId } from '@/portainer/teams/types';
|
import { Team, TeamId } from '@/react/portainer/users/teams/types';
|
||||||
|
|
||||||
import { Select } from '@@/form-components/ReactSelect';
|
import { Select } from '@@/form-components/ReactSelect';
|
||||||
|
|
||||||
|
|
|
@ -24,7 +24,7 @@ export function WidgetTitle({
|
||||||
return (
|
return (
|
||||||
<div className="widget-header">
|
<div className="widget-header">
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<span className={clsx('pull-left', className)}>
|
<span className={clsx('pull-left vertical-center', className)}>
|
||||||
<div className="widget-icon">
|
<div className="widget-icon">
|
||||||
<Icon
|
<Icon
|
||||||
icon={icon}
|
icon={icon}
|
||||||
|
|
|
@ -5,8 +5,6 @@ import { Columns } from 'react-feather';
|
||||||
|
|
||||||
import { Checkbox } from '@@/form-components/Checkbox';
|
import { Checkbox } from '@@/form-components/Checkbox';
|
||||||
|
|
||||||
import { useTableContext } from './TableContainer';
|
|
||||||
|
|
||||||
interface Props<D extends object> {
|
interface Props<D extends object> {
|
||||||
columns: ColumnInstance<D>[];
|
columns: ColumnInstance<D>[];
|
||||||
onChange: (value: string[]) => void;
|
onChange: (value: string[]) => void;
|
||||||
|
@ -18,8 +16,6 @@ export function ColumnVisibilityMenu<D extends object>({
|
||||||
onChange,
|
onChange,
|
||||||
value,
|
value,
|
||||||
}: Props<D>) {
|
}: Props<D>) {
|
||||||
useTableContext();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu className="setting">
|
<Menu className="setting">
|
||||||
{({ isExpanded }) => (
|
{({ isExpanded }) => (
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { CellProps, Column } from 'react-table';
|
||||||
|
|
||||||
|
import { Link } from '@@/Link';
|
||||||
|
|
||||||
|
export function buildNameColumn<T extends Record<string, unknown>>(
|
||||||
|
nameKey: string,
|
||||||
|
idKey: string,
|
||||||
|
path: string
|
||||||
|
) {
|
||||||
|
const name: Column<T> = {
|
||||||
|
Header: 'Name',
|
||||||
|
accessor: (row) => row[nameKey],
|
||||||
|
id: 'name',
|
||||||
|
Cell: NameCell,
|
||||||
|
disableFilters: true,
|
||||||
|
Filter: () => null,
|
||||||
|
canHide: false,
|
||||||
|
sortType: 'string',
|
||||||
|
};
|
||||||
|
|
||||||
|
return name;
|
||||||
|
|
||||||
|
function NameCell({ value: name, row }: CellProps<T, string>) {
|
||||||
|
return (
|
||||||
|
<Link to={path} params={{ id: row.original[idKey] }} title={name}>
|
||||||
|
{name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,15 +2,15 @@ import clsx from 'clsx';
|
||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
import { TableProps } from 'react-table';
|
import { TableProps } from 'react-table';
|
||||||
|
|
||||||
import { useTableContext, TableContainer } from './TableContainer';
|
import { TableContainer } from './TableContainer';
|
||||||
import { TableActions } from './TableActions';
|
import { TableActions } from './TableActions';
|
||||||
import { TableTitleActions } from './TableTitleActions';
|
import { TableTitleActions } from './TableTitleActions';
|
||||||
|
import { TableContent } from './TableContent';
|
||||||
import { TableHeaderCell } from './TableHeaderCell';
|
import { TableHeaderCell } from './TableHeaderCell';
|
||||||
import { TableSettingsMenu } from './TableSettingsMenu';
|
import { TableSettingsMenu } from './TableSettingsMenu';
|
||||||
import { TableTitle } from './TableTitle';
|
import { TableTitle } from './TableTitle';
|
||||||
import { TableHeaderRow } from './TableHeaderRow';
|
import { TableHeaderRow } from './TableHeaderRow';
|
||||||
import { TableRow } from './TableRow';
|
import { TableRow } from './TableRow';
|
||||||
import { TableContent } from './TableContent';
|
|
||||||
import { TableFooter } from './TableFooter';
|
import { TableFooter } from './TableFooter';
|
||||||
|
|
||||||
function MainComponent({
|
function MainComponent({
|
||||||
|
@ -19,8 +19,6 @@ function MainComponent({
|
||||||
role,
|
role,
|
||||||
style,
|
style,
|
||||||
}: PropsWithChildren<TableProps>) {
|
}: PropsWithChildren<TableProps>) {
|
||||||
useTableContext();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table
|
<table
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { PropsWithChildren } from 'react';
|
import { Children, PropsWithChildren } from 'react';
|
||||||
|
|
||||||
import { useTableContext } from './TableContainer';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
@ -11,7 +9,9 @@ export function TableActions({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
}: PropsWithChildren<Props>) {
|
}: PropsWithChildren<Props>) {
|
||||||
useTableContext();
|
if (Children.count(children) === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return <div className={clsx('actionBar', className)}>{children}</div>;
|
return <div className={clsx('actionBar', className)}>{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,25 +1,13 @@
|
||||||
import { createContext, PropsWithChildren, useContext } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
import { Widget, WidgetBody } from '@@/Widget';
|
import { Widget, WidgetBody } from '@@/Widget';
|
||||||
|
|
||||||
const Context = createContext<null | boolean>(null);
|
|
||||||
|
|
||||||
export function useTableContext() {
|
|
||||||
const context = useContext(Context);
|
|
||||||
|
|
||||||
if (context == null) {
|
|
||||||
throw new Error('Should be nested inside a TableContainer component');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TableContainer({ children }: PropsWithChildren<unknown>) {
|
export function TableContainer({ children }: PropsWithChildren<unknown>) {
|
||||||
return (
|
return (
|
||||||
<Context.Provider value>
|
<div className="datatable">
|
||||||
<div className="datatable">
|
<Widget>
|
||||||
<Widget>
|
<WidgetBody className="no-padding">{children}</WidgetBody>
|
||||||
<WidgetBody className="no-padding">{children}</WidgetBody>
|
</Widget>
|
||||||
</Widget>
|
</div>
|
||||||
</div>
|
|
||||||
</Context.Provider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,5 @@
|
||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
import { useTableContext } from './TableContainer';
|
|
||||||
|
|
||||||
export function TableFooter({ children }: PropsWithChildren<unknown>) {
|
export function TableFooter({ children }: PropsWithChildren<unknown>) {
|
||||||
useTableContext();
|
|
||||||
|
|
||||||
return <footer className="footer">{children}</footer>;
|
return <footer className="footer">{children}</footer>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,6 @@ import clsx from 'clsx';
|
||||||
import { PropsWithChildren, ReactNode } from 'react';
|
import { PropsWithChildren, ReactNode } from 'react';
|
||||||
import { TableHeaderProps } from 'react-table';
|
import { TableHeaderProps } from 'react-table';
|
||||||
|
|
||||||
import { useTableContext } from './TableContainer';
|
|
||||||
import { TableHeaderSortIcons } from './TableHeaderSortIcons';
|
import { TableHeaderSortIcons } from './TableHeaderSortIcons';
|
||||||
import styles from './TableHeaderCell.module.css';
|
import styles from './TableHeaderCell.module.css';
|
||||||
|
|
||||||
|
@ -27,8 +26,6 @@ export function TableHeaderCell({
|
||||||
canFilter,
|
canFilter,
|
||||||
renderFilter,
|
renderFilter,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
useTableContext();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<th role={role} style={style} className={className}>
|
<th role={role} style={style} className={className}>
|
||||||
<div className="flex flex-row flex-nowrap h-full items-center gap-1">
|
<div className="flex flex-row flex-nowrap h-full items-center gap-1">
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { HeaderGroup, TableHeaderProps } from 'react-table';
|
import { HeaderGroup, TableHeaderProps } from 'react-table';
|
||||||
|
|
||||||
import { useTableContext } from './TableContainer';
|
|
||||||
import { TableHeaderCell } from './TableHeaderCell';
|
import { TableHeaderCell } from './TableHeaderCell';
|
||||||
|
|
||||||
interface Props<D extends Record<string, unknown> = Record<string, unknown>> {
|
interface Props<D extends Record<string, unknown> = Record<string, unknown>> {
|
||||||
|
@ -17,8 +16,6 @@ export function TableHeaderRow<
|
||||||
role,
|
role,
|
||||||
style,
|
style,
|
||||||
}: Props<D> & TableHeaderProps) {
|
}: Props<D> & TableHeaderProps) {
|
||||||
useTableContext();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className={className} role={role} style={style}>
|
<tr className={className} role={role} style={style}>
|
||||||
{headers.map((column) => (
|
{headers.map((column) => (
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
import { Cell, TableRowProps } from 'react-table';
|
import { Cell, TableRowProps } from 'react-table';
|
||||||
|
|
||||||
import { useTableContext } from './TableContainer';
|
|
||||||
|
|
||||||
interface Props<D extends Record<string, unknown> = Record<string, unknown>>
|
interface Props<D extends Record<string, unknown> = Record<string, unknown>>
|
||||||
extends Omit<TableRowProps, 'key'> {
|
extends Omit<TableRowProps, 'key'> {
|
||||||
cells: Cell<D>[];
|
cells: Cell<D>[];
|
||||||
|
@ -10,8 +8,6 @@ interface Props<D extends Record<string, unknown> = Record<string, unknown>>
|
||||||
export function TableRow<
|
export function TableRow<
|
||||||
D extends Record<string, unknown> = Record<string, unknown>
|
D extends Record<string, unknown> = Record<string, unknown>
|
||||||
>({ cells, className, role, style }: Props<D>) {
|
>({ cells, className, role, style }: Props<D>) {
|
||||||
useTableContext();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className={className} role={role} style={style}>
|
<tr className={className} role={role} style={style}>
|
||||||
{cells.map((cell) => {
|
{cells.map((cell) => {
|
||||||
|
|
|
@ -3,8 +3,6 @@ import { Menu, MenuButton, MenuList } from '@reach/menu-button';
|
||||||
import { PropsWithChildren, ReactNode } from 'react';
|
import { PropsWithChildren, ReactNode } from 'react';
|
||||||
import { MoreVertical } from 'react-feather';
|
import { MoreVertical } from 'react-feather';
|
||||||
|
|
||||||
import { useTableContext } from './TableContainer';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
quickActions?: ReactNode;
|
quickActions?: ReactNode;
|
||||||
}
|
}
|
||||||
|
@ -13,8 +11,6 @@ export function TableSettingsMenu({
|
||||||
quickActions,
|
quickActions,
|
||||||
children,
|
children,
|
||||||
}: PropsWithChildren<Props>) {
|
}: PropsWithChildren<Props>) {
|
||||||
useTableContext();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu className="setting">
|
<Menu className="setting">
|
||||||
{({ isExpanded }) => (
|
{({ isExpanded }) => (
|
||||||
|
|
|
@ -2,8 +2,6 @@ import { ComponentType, PropsWithChildren, ReactNode } from 'react';
|
||||||
|
|
||||||
import { Icon } from '@@/Icon';
|
import { Icon } from '@@/Icon';
|
||||||
|
|
||||||
import { useTableContext } from './TableContainer';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
icon?: ReactNode | ComponentType<unknown>;
|
icon?: ReactNode | ComponentType<unknown>;
|
||||||
featherIcon?: boolean;
|
featherIcon?: boolean;
|
||||||
|
@ -16,8 +14,6 @@ export function TableTitle({
|
||||||
label,
|
label,
|
||||||
children,
|
children,
|
||||||
}: PropsWithChildren<Props>) {
|
}: PropsWithChildren<Props>) {
|
||||||
useTableContext();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="toolBar">
|
<div className="toolBar">
|
||||||
<div className="toolBarTitle">
|
<div className="toolBarTitle">
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { PropsWithChildren } from 'react';
|
import { Children, PropsWithChildren } from 'react';
|
||||||
|
|
||||||
import { useTableContext } from './TableContainer';
|
|
||||||
|
|
||||||
export function TableTitleActions({ children }: PropsWithChildren<unknown>) {
|
export function TableTitleActions({ children }: PropsWithChildren<unknown>) {
|
||||||
useTableContext();
|
if (Children.count(children) === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return <div className="settings">{children}</div>;
|
return <div className="settings">{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { useRouter } from '@uirouter/react';
|
||||||
|
import { useMutation, useQueryClient } from 'react-query';
|
||||||
|
import { Trash2, Users } from 'react-feather';
|
||||||
|
|
||||||
|
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
|
||||||
|
import { usePublicSettings } from '@/portainer/settings/queries';
|
||||||
|
import {
|
||||||
|
mutationOptions,
|
||||||
|
withError,
|
||||||
|
withInvalidate,
|
||||||
|
} from '@/react-tools/react-query';
|
||||||
|
|
||||||
|
import { Button } from '@@/buttons';
|
||||||
|
import { Widget } from '@@/Widget';
|
||||||
|
|
||||||
|
import { Team, TeamId, TeamMembership, TeamRole } from '../types';
|
||||||
|
import { deleteTeam } from '../teams.service';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
team: Team;
|
||||||
|
memberships: TeamMembership[];
|
||||||
|
isAdmin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Details({ team, memberships, isAdmin }: Props) {
|
||||||
|
const deleteMutation = useDeleteTeam();
|
||||||
|
const router = useRouter();
|
||||||
|
const teamSyncQuery = usePublicSettings<boolean>({
|
||||||
|
select: (settings) => settings.TeamSync,
|
||||||
|
});
|
||||||
|
|
||||||
|
const leaderCount = memberships.filter(
|
||||||
|
(m) => m.Role === TeamRole.Leader
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-lg-12 col-md-12 col-xs-12">
|
||||||
|
<Widget>
|
||||||
|
<Widget.Title title="Team details" icon={Users} />
|
||||||
|
|
||||||
|
<Widget.Body className="no-padding">
|
||||||
|
<table className="table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Name</td>
|
||||||
|
<td>
|
||||||
|
{!teamSyncQuery.data && team.Name}
|
||||||
|
{isAdmin && (
|
||||||
|
<Button
|
||||||
|
color="danger"
|
||||||
|
size="xsmall"
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
icon={Trash2}
|
||||||
|
>
|
||||||
|
Delete this team
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Leaders</td>
|
||||||
|
<td>{!teamSyncQuery.data && leaderCount}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Total users in team</td>
|
||||||
|
<td>{memberships.length}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Widget.Body>
|
||||||
|
</Widget>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleDeleteClick() {
|
||||||
|
const confirmed = await confirmDeletionAsync(
|
||||||
|
`Do you want to delete this team? Users in this team will not be deleted.`
|
||||||
|
);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteMutation.mutate(team.Id, {
|
||||||
|
onSuccess() {
|
||||||
|
router.stateService.go('portainer.teams');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useDeleteTeam() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation(
|
||||||
|
(id: TeamId) => deleteTeam(id),
|
||||||
|
|
||||||
|
mutationOptions(
|
||||||
|
withError('Unable to delete team'),
|
||||||
|
withInvalidate(queryClient, [['teams']])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { useRouter } from '@uirouter/react';
|
||||||
|
|
||||||
|
import { useUsers } from '@/portainer/users/queries';
|
||||||
|
import { useUser } from '@/portainer/hooks/useUser';
|
||||||
|
import { usePublicSettings } from '@/portainer/settings/queries';
|
||||||
|
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
import { PageHeader } from '@@/PageHeader';
|
||||||
|
|
||||||
|
import { useTeam, useTeamMemberships } from '../queries';
|
||||||
|
|
||||||
|
import { Details } from './Details';
|
||||||
|
import { TeamAssociationSelector } from './TeamAssociationSelector';
|
||||||
|
import { useTeamIdParam } from './useTeamIdParam';
|
||||||
|
|
||||||
|
export function ItemView() {
|
||||||
|
const teamId = useTeamIdParam();
|
||||||
|
|
||||||
|
const { isAdmin } = useUser();
|
||||||
|
const router = useRouter();
|
||||||
|
const teamQuery = useTeam(teamId, () =>
|
||||||
|
router.stateService.go('portainer.teams')
|
||||||
|
);
|
||||||
|
const usersQuery = useUsers();
|
||||||
|
const membershipsQuery = useTeamMemberships(teamId);
|
||||||
|
const teamSyncQuery = usePublicSettings<boolean>({
|
||||||
|
select: (settings) => settings.TeamSync,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!teamQuery.data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const team = teamQuery.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Team details"
|
||||||
|
breadcrumbs={[{ label: 'Teams' }, { label: team.Name }]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{membershipsQuery.data && (
|
||||||
|
<Details
|
||||||
|
team={team}
|
||||||
|
memberships={membershipsQuery.data}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{teamSyncQuery.data && (
|
||||||
|
<div className="row">
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{usersQuery.data && membershipsQuery.data && (
|
||||||
|
<TeamAssociationSelector
|
||||||
|
teamId={teamId}
|
||||||
|
memberships={membershipsQuery.data}
|
||||||
|
users={usersQuery.data}
|
||||||
|
disabled={teamSyncQuery.data}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-around;
|
||||||
|
gap: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root > * {
|
||||||
|
flex: 1;
|
||||||
|
}
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { Meta } from '@storybook/react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { createMockUsers } from '@/react-tools/test-mocks';
|
||||||
|
import { Role, User } from '@/portainer/users/types';
|
||||||
|
import { UserViewModel } from '@/portainer/models/user';
|
||||||
|
import { UserContext } from '@/portainer/hooks/useUser';
|
||||||
|
|
||||||
|
import { TeamMembership, TeamRole } from '../../types';
|
||||||
|
|
||||||
|
import { TeamAssociationSelector } from './TeamAssociationSelector';
|
||||||
|
|
||||||
|
const meta: Meta = {
|
||||||
|
title: 'teams/TeamAssociationSelector',
|
||||||
|
component: TeamAssociationSelector,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
export { Example };
|
||||||
|
|
||||||
|
interface Args {
|
||||||
|
userRole: Role;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Example({ userRole }: Args) {
|
||||||
|
const userProviderState = useMemo(
|
||||||
|
() => ({ user: new UserViewModel({ Role: userRole }) }),
|
||||||
|
[userRole]
|
||||||
|
);
|
||||||
|
const [users] = useState(createMockUsers(20) as User[]);
|
||||||
|
|
||||||
|
const [memberships] = useState<Omit<TeamMembership, 'Id' | 'TeamID'>[]>(
|
||||||
|
users
|
||||||
|
.filter(() => Math.random() > 0.5)
|
||||||
|
.map((u) => ({
|
||||||
|
UserID: u.Id,
|
||||||
|
Role: Math.random() > 0.5 ? TeamRole.Leader : TeamRole.Member,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserContext.Provider value={userProviderState}>
|
||||||
|
<TeamAssociationSelector
|
||||||
|
teamId={3}
|
||||||
|
users={users}
|
||||||
|
memberships={memberships as TeamMembership[]}
|
||||||
|
/>
|
||||||
|
</UserContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Example.args = {
|
||||||
|
userRole: TeamRole.Leader,
|
||||||
|
};
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { UserContext } from '@/portainer/hooks/useUser';
|
||||||
|
import { UserViewModel } from '@/portainer/models/user';
|
||||||
|
import { renderWithQueryClient } from '@/react-tools/test-utils';
|
||||||
|
|
||||||
|
import { TeamAssociationSelector } from './TeamAssociationSelector';
|
||||||
|
|
||||||
|
test('renders correctly', () => {
|
||||||
|
const queries = renderComponent();
|
||||||
|
|
||||||
|
expect(queries).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderComponent() {
|
||||||
|
const user = new UserViewModel({ Username: 'user' });
|
||||||
|
|
||||||
|
return renderWithQueryClient(
|
||||||
|
<UserContext.Provider value={{ user }}>
|
||||||
|
<TeamAssociationSelector users={[]} memberships={[]} teamId={3} />
|
||||||
|
</UserContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
import _ from 'lodash';
|
||||||
|
|
||||||
|
import { User } from '@/portainer/users/types';
|
||||||
|
|
||||||
|
import { TeamId, TeamMembership } from '../../types';
|
||||||
|
|
||||||
|
import { UsersList } from './UsersList';
|
||||||
|
import { TeamMembersList } from './TeamMembersList';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
users: User[];
|
||||||
|
memberships: TeamMembership[];
|
||||||
|
disabled?: boolean;
|
||||||
|
teamId: TeamId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeamAssociationSelector({
|
||||||
|
users,
|
||||||
|
memberships,
|
||||||
|
disabled,
|
||||||
|
teamId,
|
||||||
|
}: Props) {
|
||||||
|
const teamUsers = _.compact(
|
||||||
|
memberships.map((m) => users.find((user) => user.Id === m.UserID))
|
||||||
|
);
|
||||||
|
const usersNotInTeam = users.filter(
|
||||||
|
(user) => !memberships.some((m) => m.UserID === user.Id)
|
||||||
|
);
|
||||||
|
const userRoles = Object.fromEntries(
|
||||||
|
memberships.map((m) => [m.UserID, m.Role])
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-sm-6">
|
||||||
|
<UsersList users={usersNotInTeam} disabled={disabled} teamId={teamId} />
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-6">
|
||||||
|
<TeamMembersList
|
||||||
|
teamId={teamId}
|
||||||
|
disabled={disabled}
|
||||||
|
users={teamUsers}
|
||||||
|
roles={userRoles}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { TeamRole, TeamId } from '@/react/portainer/users/teams/types';
|
||||||
|
import { UserId } from '@/portainer/users/types';
|
||||||
|
|
||||||
|
import { createRowContext } from '@@/datatables/RowContext';
|
||||||
|
|
||||||
|
export interface RowContext {
|
||||||
|
getRole(userId: UserId): TeamRole;
|
||||||
|
disabled?: boolean;
|
||||||
|
teamId: TeamId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { RowProvider, useRowContext } = createRowContext<RowContext>();
|
||||||
|
|
||||||
|
export { RowProvider, useRowContext };
|
|
@ -0,0 +1,2 @@
|
||||||
|
.root {
|
||||||
|
}
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { Meta } from '@storybook/react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { UserContext } from '@/portainer/hooks/useUser';
|
||||||
|
import { createMockUsers } from '@/react-tools/test-mocks';
|
||||||
|
import { UserViewModel } from '@/portainer/models/user';
|
||||||
|
import { Role } from '@/portainer/users/types';
|
||||||
|
import { TeamRole } from '@/react/portainer/users/teams/types';
|
||||||
|
|
||||||
|
import { TeamMembersList } from './TeamMembersList';
|
||||||
|
|
||||||
|
const meta: Meta = {
|
||||||
|
title: 'Teams/TeamAssociationSelector/TeamMembersList',
|
||||||
|
component: TeamMembersList,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
export { Example };
|
||||||
|
|
||||||
|
interface Args {
|
||||||
|
userRole: Role;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Example({ userRole }: Args) {
|
||||||
|
const userProviderState = useMemo(
|
||||||
|
() => ({ user: new UserViewModel({ Role: userRole }) }),
|
||||||
|
[userRole]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [users] = useState(createMockUsers(20));
|
||||||
|
const [roles] = useState(
|
||||||
|
Object.fromEntries(
|
||||||
|
users.map((user) => [
|
||||||
|
user.Id,
|
||||||
|
Math.random() > 0.5 ? TeamRole.Leader : TeamRole.Member,
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserContext.Provider value={userProviderState}>
|
||||||
|
<TeamMembersList users={users} roles={roles} teamId={3} />
|
||||||
|
</UserContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Example.args = {
|
||||||
|
userRole: Role.Admin,
|
||||||
|
};
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { UserContext } from '@/portainer/hooks/useUser';
|
||||||
|
import { UserViewModel } from '@/portainer/models/user';
|
||||||
|
import { renderWithQueryClient } from '@/react-tools/test-utils';
|
||||||
|
|
||||||
|
import { TeamMembersList } from './TeamMembersList';
|
||||||
|
|
||||||
|
test('renders correctly', () => {
|
||||||
|
const queries = renderComponent();
|
||||||
|
|
||||||
|
expect(queries).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderComponent() {
|
||||||
|
const user = new UserViewModel({ Username: 'user' });
|
||||||
|
|
||||||
|
return renderWithQueryClient(
|
||||||
|
<UserContext.Provider value={{ user }}>
|
||||||
|
<TeamMembersList users={[]} roles={{}} teamId={3} />
|
||||||
|
</UserContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.todo('when users list is empty, add all users button is disabled');
|
||||||
|
test.todo('filter displays expected users');
|
|
@ -0,0 +1,218 @@
|
||||||
|
import {
|
||||||
|
useGlobalFilter,
|
||||||
|
usePagination,
|
||||||
|
useSortBy,
|
||||||
|
useTable,
|
||||||
|
} from 'react-table';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Users, UserX } from 'react-feather';
|
||||||
|
|
||||||
|
import { User, UserId } from '@/portainer/users/types';
|
||||||
|
import { TeamId, TeamRole } from '@/react/portainer/users/teams/types';
|
||||||
|
import { useUser } from '@/portainer/hooks/useUser';
|
||||||
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
import {
|
||||||
|
useRemoveMemberMutation,
|
||||||
|
useTeamMemberships,
|
||||||
|
} from '@/react/portainer/users/teams/queries';
|
||||||
|
|
||||||
|
import { Widget } from '@@/Widget';
|
||||||
|
import { PageSelector } from '@@/PaginationControls/PageSelector';
|
||||||
|
import { Button } from '@@/buttons';
|
||||||
|
import { Table } from '@@/datatables';
|
||||||
|
import { Input } from '@@/form-components/Input';
|
||||||
|
|
||||||
|
import { name } from './name-column';
|
||||||
|
import { RowContext, RowProvider } from './RowContext';
|
||||||
|
import { teamRole } from './team-role-column';
|
||||||
|
|
||||||
|
const columns = [name, teamRole];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
users: User[];
|
||||||
|
roles: Record<UserId, TeamRole>;
|
||||||
|
disabled?: boolean;
|
||||||
|
teamId: TeamId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeamMembersList({ users, roles, disabled, teamId }: Props) {
|
||||||
|
const membershipsQuery = useTeamMemberships(teamId);
|
||||||
|
|
||||||
|
const removeMemberMutation = useRemoveMemberMutation(
|
||||||
|
teamId,
|
||||||
|
membershipsQuery.data
|
||||||
|
);
|
||||||
|
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
|
||||||
|
const { isAdmin } = useUser();
|
||||||
|
const {
|
||||||
|
getTableProps,
|
||||||
|
getTableBodyProps,
|
||||||
|
headerGroups,
|
||||||
|
page,
|
||||||
|
prepareRow,
|
||||||
|
gotoPage,
|
||||||
|
setPageSize: setPageSizeInternal,
|
||||||
|
setGlobalFilter: setGlobalFilterInternal,
|
||||||
|
state: { pageIndex },
|
||||||
|
setSortBy,
|
||||||
|
rows,
|
||||||
|
} = useTable<User>(
|
||||||
|
{
|
||||||
|
defaultCanFilter: false,
|
||||||
|
columns,
|
||||||
|
data: users,
|
||||||
|
initialState: {
|
||||||
|
pageSize,
|
||||||
|
|
||||||
|
sortBy: [{ id: 'name', desc: false }],
|
||||||
|
globalFilter: search,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
useGlobalFilter,
|
||||||
|
useSortBy,
|
||||||
|
usePagination
|
||||||
|
);
|
||||||
|
|
||||||
|
const tableProps = getTableProps();
|
||||||
|
const tbodyProps = getTableBodyProps();
|
||||||
|
const rowContext = useMemo<RowContext>(
|
||||||
|
() => ({
|
||||||
|
getRole(userId: UserId) {
|
||||||
|
return roles[userId];
|
||||||
|
},
|
||||||
|
disabled,
|
||||||
|
teamId,
|
||||||
|
}),
|
||||||
|
[roles, disabled, teamId]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Widget>
|
||||||
|
<Widget.Title icon={Users} title="Team members">
|
||||||
|
Items per page:
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={handlePageSizeChange}
|
||||||
|
className="space-left"
|
||||||
|
>
|
||||||
|
<option value={Number.MAX_SAFE_INTEGER}>All</option>
|
||||||
|
<option value={10}>10</option>
|
||||||
|
<option value={25}>25</option>
|
||||||
|
<option value={50}>50</option>
|
||||||
|
<option value={100}>100</option>
|
||||||
|
</select>
|
||||||
|
</Widget.Title>
|
||||||
|
<Widget.Taskbar className="col-sm-12 nopadding">
|
||||||
|
<div className="col-sm-12 col-md-6 nopadding">
|
||||||
|
{isAdmin && (
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
handleRemoveMembers(rows.map(({ original }) => original.Id))
|
||||||
|
}
|
||||||
|
disabled={disabled || users.length === 0}
|
||||||
|
icon={UserX}
|
||||||
|
>
|
||||||
|
Remove all users
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-12 col-md-6 nopadding">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="filter-users"
|
||||||
|
value={search}
|
||||||
|
onChange={handleSearchBarChange}
|
||||||
|
placeholder="Filter..."
|
||||||
|
className="input-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Widget.Taskbar>
|
||||||
|
<Widget.Body className="nopadding">
|
||||||
|
<Table
|
||||||
|
className={tableProps.className}
|
||||||
|
role={tableProps.role}
|
||||||
|
style={tableProps.style}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
{headerGroups.map((headerGroup) => {
|
||||||
|
const { key, className, role, style } =
|
||||||
|
headerGroup.getHeaderGroupProps();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.HeaderRow<User>
|
||||||
|
key={key}
|
||||||
|
className={className}
|
||||||
|
role={role}
|
||||||
|
style={style}
|
||||||
|
headers={headerGroup.headers}
|
||||||
|
onSortChange={handleSortChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</thead>
|
||||||
|
<tbody
|
||||||
|
className={tbodyProps.className}
|
||||||
|
role={tbodyProps.role}
|
||||||
|
style={tbodyProps.style}
|
||||||
|
>
|
||||||
|
<Table.Content
|
||||||
|
emptyContent="No users."
|
||||||
|
prepareRow={prepareRow}
|
||||||
|
rows={page}
|
||||||
|
renderRow={(row, { key, className, role, style }) => (
|
||||||
|
<RowProvider context={rowContext} key={key}>
|
||||||
|
<Table.Row<User>
|
||||||
|
cells={row.cells}
|
||||||
|
key={key}
|
||||||
|
className={className}
|
||||||
|
role={role}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
</RowProvider>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
<Table.Footer>
|
||||||
|
{pageSize !== 0 && (
|
||||||
|
<div className="pagination-controls">
|
||||||
|
<PageSelector
|
||||||
|
maxSize={5}
|
||||||
|
onPageChange={(p) => gotoPage(p - 1)}
|
||||||
|
currentPage={pageIndex + 1}
|
||||||
|
itemsPerPage={pageSize}
|
||||||
|
totalCount={rows.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Table.Footer>
|
||||||
|
</Widget.Body>
|
||||||
|
</Widget>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handlePageSizeChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||||
|
const pageSize = parseInt(e.target.value, 10);
|
||||||
|
setPageSize(pageSize);
|
||||||
|
setPageSizeInternal(pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearchBarChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const { value } = e.target;
|
||||||
|
setSearch(value);
|
||||||
|
setGlobalFilterInternal(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSortChange(id: string, desc: boolean) {
|
||||||
|
setSortBy([{ id, desc }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveMembers(userIds: UserId[]) {
|
||||||
|
removeMemberMutation.mutate(userIds, {
|
||||||
|
onSuccess() {
|
||||||
|
notifySuccess('Success', 'All users successfully removed');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export { TeamMembersList } from './TeamMembersList';
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { CellProps, Column } from 'react-table';
|
||||||
|
import { MinusCircle } from 'react-feather';
|
||||||
|
|
||||||
|
import { User, UserId } from '@/portainer/users/types';
|
||||||
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
import {
|
||||||
|
useRemoveMemberMutation,
|
||||||
|
useTeamMemberships,
|
||||||
|
} from '@/react/portainer/users/teams/queries';
|
||||||
|
|
||||||
|
import { Button } from '@@/buttons';
|
||||||
|
|
||||||
|
import { useRowContext } from './RowContext';
|
||||||
|
|
||||||
|
export const name: Column<User> = {
|
||||||
|
Header: 'Name',
|
||||||
|
accessor: (row) => row.Username,
|
||||||
|
id: 'name',
|
||||||
|
Cell: NameCell,
|
||||||
|
disableFilters: true,
|
||||||
|
Filter: () => null,
|
||||||
|
canHide: false,
|
||||||
|
sortType: 'string',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NameCell({
|
||||||
|
value: name,
|
||||||
|
row: { original: user },
|
||||||
|
}: CellProps<User, string>) {
|
||||||
|
const { disabled, teamId } = useRowContext();
|
||||||
|
|
||||||
|
const membershipsQuery = useTeamMemberships(teamId);
|
||||||
|
|
||||||
|
const removeMemberMutation = useRemoveMemberMutation(
|
||||||
|
teamId,
|
||||||
|
membershipsQuery.data
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{name}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="link"
|
||||||
|
className="space-left nopadding"
|
||||||
|
onClick={() => handleRemoveMember(user.Id)}
|
||||||
|
disabled={disabled}
|
||||||
|
icon={MinusCircle}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleRemoveMember(userId: UserId) {
|
||||||
|
removeMemberMutation.mutate([userId], {
|
||||||
|
onSuccess() {
|
||||||
|
notifySuccess('User removed from team', name);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { CellProps, Column } from 'react-table';
|
||||||
|
import { User as UserIcon, UserPlus, UserX } from 'react-feather';
|
||||||
|
|
||||||
|
import { User } from '@/portainer/users/types';
|
||||||
|
import { useUser as useCurrentUser } from '@/portainer/hooks/useUser';
|
||||||
|
import { TeamRole } from '@/react/portainer/users/teams/types';
|
||||||
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
import {
|
||||||
|
useTeamMemberships,
|
||||||
|
useUpdateRoleMutation,
|
||||||
|
} from '@/react/portainer/users/teams/queries';
|
||||||
|
|
||||||
|
import { Button } from '@@/buttons';
|
||||||
|
import { Icon } from '@@/Icon';
|
||||||
|
|
||||||
|
import { useRowContext } from './RowContext';
|
||||||
|
|
||||||
|
export const teamRole: Column<User> = {
|
||||||
|
Header: 'Team Role',
|
||||||
|
accessor: 'Id',
|
||||||
|
id: 'role',
|
||||||
|
Cell: RoleCell,
|
||||||
|
disableFilters: true,
|
||||||
|
Filter: () => null,
|
||||||
|
canHide: false,
|
||||||
|
sortType: 'string',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RoleCell({ row: { original: user } }: CellProps<User>) {
|
||||||
|
const { getRole, disabled, teamId } = useRowContext();
|
||||||
|
const membershipsQuery = useTeamMemberships(teamId);
|
||||||
|
const updateRoleMutation = useUpdateRoleMutation(
|
||||||
|
teamId,
|
||||||
|
membershipsQuery.data
|
||||||
|
);
|
||||||
|
|
||||||
|
const role = getRole(user.Id);
|
||||||
|
|
||||||
|
const { isAdmin } = useCurrentUser();
|
||||||
|
|
||||||
|
const Cell = role === TeamRole.Leader ? LeaderCell : MemberCell;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Cell isAdmin={isAdmin} onClick={handleUpdateRole} disabled={disabled} />
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleUpdateRole(role: TeamRole, onSuccessMessage: string) {
|
||||||
|
updateRoleMutation.mutate(
|
||||||
|
{ userId: user.Id, role },
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
notifySuccess(onSuccessMessage, user.Username);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LeaderCellProps {
|
||||||
|
isAdmin: boolean;
|
||||||
|
onClick: (role: TeamRole, onSuccessMessage: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LeaderCell({ isAdmin, onClick, disabled }: LeaderCellProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Icon
|
||||||
|
className="space-right feather"
|
||||||
|
icon={UserPlus}
|
||||||
|
mode="secondary-alt"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<Button
|
||||||
|
color="link"
|
||||||
|
className="nopadding"
|
||||||
|
onClick={() => onClick(TeamRole.Member, 'User is now team member')}
|
||||||
|
disabled={disabled}
|
||||||
|
icon={UserX}
|
||||||
|
>
|
||||||
|
Member
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MemberCellProps {
|
||||||
|
onClick: (role: TeamRole, onSuccessMessage: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MemberCell({ onClick, disabled }: MemberCellProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Icon
|
||||||
|
className="space-right feather"
|
||||||
|
icon={UserIcon}
|
||||||
|
mode="secondary-alt"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
color="link"
|
||||||
|
className="nopadding"
|
||||||
|
onClick={() => onClick(TeamRole.Leader, 'User is now team leader')}
|
||||||
|
disabled={disabled}
|
||||||
|
icon={UserPlus}
|
||||||
|
>
|
||||||
|
Leader
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { TeamId } from '@/react/portainer/users/teams/types';
|
||||||
|
|
||||||
|
import { createRowContext } from '@@/datatables/RowContext';
|
||||||
|
|
||||||
|
interface RowContext {
|
||||||
|
disabled?: boolean;
|
||||||
|
teamId: TeamId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { RowProvider, useRowContext } = createRowContext<RowContext>();
|
||||||
|
|
||||||
|
export { RowProvider, useRowContext };
|
|
@ -0,0 +1,2 @@
|
||||||
|
.root {
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { Meta } from '@storybook/react';
|
||||||
|
|
||||||
|
import { createMockUsers } from '@/react-tools/test-mocks';
|
||||||
|
|
||||||
|
import { UsersList } from './UsersList';
|
||||||
|
|
||||||
|
const meta: Meta = {
|
||||||
|
title: 'Teams/TeamAssociationSelector/UsersList',
|
||||||
|
component: UsersList,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
export { Example };
|
||||||
|
|
||||||
|
function Example() {
|
||||||
|
const users = createMockUsers(20);
|
||||||
|
|
||||||
|
return <UsersList users={users} teamId={3} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
Example.args = {};
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { UserContext } from '@/portainer/hooks/useUser';
|
||||||
|
import { UserViewModel } from '@/portainer/models/user';
|
||||||
|
import { renderWithQueryClient } from '@/react-tools/test-utils';
|
||||||
|
|
||||||
|
import { UsersList } from './UsersList';
|
||||||
|
|
||||||
|
test('renders correctly', () => {
|
||||||
|
const queries = renderComponent();
|
||||||
|
|
||||||
|
expect(queries).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderComponent() {
|
||||||
|
const user = new UserViewModel({ Username: 'user' });
|
||||||
|
return renderWithQueryClient(
|
||||||
|
<UserContext.Provider value={{ user }}>
|
||||||
|
<UsersList users={[]} teamId={3} />
|
||||||
|
</UserContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.todo('when users list is empty, add all users button is disabled');
|
||||||
|
test.todo('filter displays expected users');
|
|
@ -0,0 +1,199 @@
|
||||||
|
import {
|
||||||
|
useGlobalFilter,
|
||||||
|
usePagination,
|
||||||
|
useSortBy,
|
||||||
|
useTable,
|
||||||
|
} from 'react-table';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { UserPlus, Users } from 'react-feather';
|
||||||
|
|
||||||
|
import { User, UserId } from '@/portainer/users/types';
|
||||||
|
import { useUser } from '@/portainer/hooks/useUser';
|
||||||
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
import { useAddMemberMutation } from '@/react/portainer/users/teams/queries';
|
||||||
|
import { TeamId } from '@/react/portainer/users/teams/types';
|
||||||
|
|
||||||
|
import { Widget } from '@@/Widget';
|
||||||
|
import { PageSelector } from '@@/PaginationControls/PageSelector';
|
||||||
|
import { Button } from '@@/buttons';
|
||||||
|
import { Table } from '@@/datatables';
|
||||||
|
import { TableFooter } from '@@/datatables/TableFooter';
|
||||||
|
import { Input } from '@@/form-components/Input';
|
||||||
|
|
||||||
|
import { name } from './name-column';
|
||||||
|
import { RowProvider } from './RowContext';
|
||||||
|
|
||||||
|
const columns = [name];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
users: User[];
|
||||||
|
disabled?: boolean;
|
||||||
|
teamId: TeamId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsersList({ users, disabled, teamId }: Props) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
const addMemberMutation = useAddMemberMutation(teamId);
|
||||||
|
|
||||||
|
const { isAdmin } = useUser();
|
||||||
|
const {
|
||||||
|
getTableProps,
|
||||||
|
getTableBodyProps,
|
||||||
|
headerGroups,
|
||||||
|
page,
|
||||||
|
prepareRow,
|
||||||
|
gotoPage,
|
||||||
|
setPageSize: setPageSizeInternal,
|
||||||
|
setGlobalFilter: setGlobalFilterInternal,
|
||||||
|
state: { pageIndex },
|
||||||
|
setSortBy,
|
||||||
|
rows,
|
||||||
|
} = useTable(
|
||||||
|
{
|
||||||
|
defaultCanFilter: false,
|
||||||
|
columns,
|
||||||
|
data: users,
|
||||||
|
initialState: {
|
||||||
|
pageSize,
|
||||||
|
|
||||||
|
sortBy: [{ id: 'name', desc: false }],
|
||||||
|
globalFilter: search,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
useGlobalFilter,
|
||||||
|
useSortBy,
|
||||||
|
usePagination
|
||||||
|
);
|
||||||
|
|
||||||
|
const tableProps = getTableProps();
|
||||||
|
const tbodyProps = getTableBodyProps();
|
||||||
|
const rowContext = useMemo(() => ({ disabled, teamId }), [disabled, teamId]);
|
||||||
|
return (
|
||||||
|
<Widget>
|
||||||
|
<Widget.Title icon={Users} title="Users">
|
||||||
|
Items per page:
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={handlePageSizeChange}
|
||||||
|
className="space-left"
|
||||||
|
>
|
||||||
|
<option value={Number.MAX_SAFE_INTEGER}>All</option>
|
||||||
|
<option value={10}>10</option>
|
||||||
|
<option value={25}>25</option>
|
||||||
|
<option value={50}>50</option>
|
||||||
|
<option value={100}>100</option>
|
||||||
|
</select>
|
||||||
|
</Widget.Title>
|
||||||
|
<Widget.Taskbar className="col-sm-12 nopadding">
|
||||||
|
<div className="col-sm-12 col-md-6 nopadding">
|
||||||
|
{isAdmin && (
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
handleAddAllMembers(rows.map((row) => row.original.Id))
|
||||||
|
}
|
||||||
|
disabled={disabled || rows.length === 0}
|
||||||
|
icon={UserPlus}
|
||||||
|
>
|
||||||
|
Add all users
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="col-sm-12 col-md-6 nopadding">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="filter-users"
|
||||||
|
value={search}
|
||||||
|
onChange={handleSearchBarChange}
|
||||||
|
placeholder="Filter..."
|
||||||
|
className="input-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Widget.Taskbar>
|
||||||
|
<Widget.Body className="nopadding">
|
||||||
|
<Table
|
||||||
|
className={tableProps.className}
|
||||||
|
role={tableProps.role}
|
||||||
|
style={tableProps.style}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
{headerGroups.map((headerGroup) => {
|
||||||
|
const { key, className, role, style } =
|
||||||
|
headerGroup.getHeaderGroupProps();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.HeaderRow<User>
|
||||||
|
key={key}
|
||||||
|
className={className}
|
||||||
|
role={role}
|
||||||
|
style={style}
|
||||||
|
headers={headerGroup.headers}
|
||||||
|
onSortChange={handleSortChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</thead>
|
||||||
|
<tbody
|
||||||
|
className={tbodyProps.className}
|
||||||
|
role={tbodyProps.role}
|
||||||
|
style={tbodyProps.style}
|
||||||
|
>
|
||||||
|
<Table.Content
|
||||||
|
emptyContent="No users."
|
||||||
|
prepareRow={prepareRow}
|
||||||
|
rows={page}
|
||||||
|
renderRow={(row, { key, className, role, style }) => (
|
||||||
|
<RowProvider context={rowContext} key={key}>
|
||||||
|
<Table.Row<User>
|
||||||
|
cells={row.cells}
|
||||||
|
key={key}
|
||||||
|
className={className}
|
||||||
|
role={role}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
</RowProvider>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
<TableFooter>
|
||||||
|
{pageSize !== 0 && (
|
||||||
|
<div className="pagination-controls">
|
||||||
|
<PageSelector
|
||||||
|
maxSize={5}
|
||||||
|
onPageChange={(p) => gotoPage(p - 1)}
|
||||||
|
currentPage={pageIndex + 1}
|
||||||
|
itemsPerPage={pageSize}
|
||||||
|
totalCount={rows.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableFooter>
|
||||||
|
</Widget.Body>
|
||||||
|
</Widget>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handlePageSizeChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||||
|
const pageSize = parseInt(e.target.value, 10);
|
||||||
|
setPageSize(pageSize);
|
||||||
|
setPageSizeInternal(pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearchBarChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const { value } = e.target;
|
||||||
|
setSearch(value);
|
||||||
|
setGlobalFilterInternal(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSortChange(id: string, desc: boolean) {
|
||||||
|
setSortBy([{ id, desc }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddAllMembers(userIds: UserId[]) {
|
||||||
|
addMemberMutation.mutate(userIds, {
|
||||||
|
onSuccess() {
|
||||||
|
notifySuccess('Success', 'All users successfully added');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export { UsersList } from './UsersList';
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { CellProps, Column } from 'react-table';
|
||||||
|
import { PlusCircle } from 'react-feather';
|
||||||
|
|
||||||
|
import { User } from '@/portainer/users/types';
|
||||||
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
import { useAddMemberMutation } from '@/react/portainer/users/teams/queries';
|
||||||
|
|
||||||
|
import { Button } from '@@/buttons';
|
||||||
|
|
||||||
|
import { useRowContext } from './RowContext';
|
||||||
|
|
||||||
|
export const name: Column<User> = {
|
||||||
|
Header: 'Name',
|
||||||
|
accessor: (row) => row.Username,
|
||||||
|
id: 'name',
|
||||||
|
Cell: NameCell,
|
||||||
|
disableFilters: true,
|
||||||
|
Filter: () => null,
|
||||||
|
canHide: false,
|
||||||
|
sortType: 'string',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NameCell({
|
||||||
|
value: name,
|
||||||
|
row: { original: user },
|
||||||
|
}: CellProps<User, string>) {
|
||||||
|
const { disabled, teamId } = useRowContext();
|
||||||
|
|
||||||
|
const addMemberMutation = useAddMemberMutation(teamId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{name}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="link"
|
||||||
|
className="space-left nopadding"
|
||||||
|
disabled={disabled}
|
||||||
|
icon={PlusCircle}
|
||||||
|
onClick={() => handleAddMember()}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleAddMember() {
|
||||||
|
addMemberMutation.mutate([user.Id], {
|
||||||
|
onSuccess() {
|
||||||
|
notifySuccess('User added to team', name);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export { TeamAssociationSelector } from './TeamAssociationSelector';
|
|
@ -0,0 +1 @@
|
||||||
|
export { ItemView } from './ItemView';
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||||
|
|
||||||
|
export function useTeamIdParam() {
|
||||||
|
const {
|
||||||
|
params: { id: teamIdParam },
|
||||||
|
} = useCurrentStateAndParams();
|
||||||
|
const teamId = parseInt(teamIdParam, 10);
|
||||||
|
|
||||||
|
if (!teamIdParam || Number.isNaN(teamId)) {
|
||||||
|
throw new Error('Team ID is missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
return teamId;
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { Meta } from '@storybook/react';
|
||||||
|
|
||||||
|
import { CreateTeamForm } from './CreateTeamForm';
|
||||||
|
import { mockExampleData } from './CreateTeamForm.mocks';
|
||||||
|
|
||||||
|
const meta: Meta = {
|
||||||
|
title: 'teams/CreateTeamForm',
|
||||||
|
component: CreateTeamForm,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
export { Example };
|
||||||
|
|
||||||
|
function Example() {
|
||||||
|
const { teams, users } = mockExampleData();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<CreateTeamForm users={users} teams={teams} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,12 +1,12 @@
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
import { render, waitFor } from '@/react-tools/test-utils';
|
import { renderWithQueryClient, waitFor } from '@/react-tools/test-utils';
|
||||||
|
|
||||||
import { CreateTeamForm } from './CreateTeamForm';
|
import { CreateTeamForm } from './CreateTeamForm';
|
||||||
|
|
||||||
test('filling the name should make the submit button clickable and emptying it should make it disabled', async () => {
|
test('filling the name should make the submit button clickable and emptying it should make it disabled', async () => {
|
||||||
const { findByLabelText, findByText } = render(
|
const { findByLabelText, findByText } = renderWithQueryClient(
|
||||||
<CreateTeamForm users={[]} teams={[]} onSubmit={() => {}} />
|
<CreateTeamForm users={[]} teams={[]} />
|
||||||
);
|
);
|
||||||
|
|
||||||
const button = await findByText('Create team');
|
const button = await findByText('Create team');
|
|
@ -1,29 +1,32 @@
|
||||||
import { Formik, Field, Form } from 'formik';
|
import { Formik, Field, Form } from 'formik';
|
||||||
|
import { useMutation, useQueryClient } from 'react-query';
|
||||||
|
import { useReducer } from 'react';
|
||||||
|
|
||||||
import { UserViewModel } from '@/portainer/models/user';
|
|
||||||
import { Icon } from '@/react/components/Icon';
|
import { Icon } from '@/react/components/Icon';
|
||||||
import { TeamViewModel } from '@/portainer/models/team';
|
import { User } from '@/portainer/users/types';
|
||||||
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
|
||||||
import { FormControl } from '@@/form-components/FormControl';
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
|
import { Widget } from '@@/Widget';
|
||||||
import { Input } from '@@/form-components/Input';
|
import { Input } from '@@/form-components/Input';
|
||||||
import { UsersSelector } from '@@/UsersSelector';
|
import { UsersSelector } from '@@/UsersSelector';
|
||||||
import { LoadingButton } from '@@/buttons/LoadingButton';
|
import { LoadingButton } from '@@/buttons/LoadingButton';
|
||||||
|
|
||||||
|
import { createTeam } from '../../teams.service';
|
||||||
|
import { Team } from '../../types';
|
||||||
|
|
||||||
|
import { FormValues } from './types';
|
||||||
import { validationSchema } from './CreateTeamForm.validation';
|
import { validationSchema } from './CreateTeamForm.validation';
|
||||||
|
|
||||||
export interface FormValues {
|
|
||||||
name: string;
|
|
||||||
leaders: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
users: UserViewModel[];
|
users: User[];
|
||||||
teams: TeamViewModel[];
|
teams: Team[];
|
||||||
onSubmit(values: FormValues): void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CreateTeamForm({ users, teams, onSubmit }: Props) {
|
export function CreateTeamForm({ users, teams }: Props) {
|
||||||
|
const addTeamMutation = useAddTeamMutation();
|
||||||
|
const [formKey, incFormKey] = useReducer((state: number) => state + 1, 0);
|
||||||
|
|
||||||
const initialValues = {
|
const initialValues = {
|
||||||
name: '',
|
name: '',
|
||||||
leaders: [],
|
leaders: [],
|
||||||
|
@ -33,18 +36,19 @@ export function CreateTeamForm({ users, teams, onSubmit }: Props) {
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-lg-12 col-md-12 col-xs-12">
|
<div className="col-lg-12 col-md-12 col-xs-12">
|
||||||
<Widget>
|
<Widget>
|
||||||
<WidgetTitle
|
<Widget.Title
|
||||||
icon="plus"
|
icon="plus"
|
||||||
title="Add a new team"
|
title="Add a new team"
|
||||||
featherIcon
|
featherIcon
|
||||||
className="vertical-center"
|
className="vertical-center"
|
||||||
/>
|
/>
|
||||||
<WidgetBody>
|
<Widget.Body>
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={initialValues}
|
initialValues={initialValues}
|
||||||
validationSchema={() => validationSchema(teams)}
|
validationSchema={() => validationSchema(teams)}
|
||||||
onSubmit={onSubmit}
|
onSubmit={handleAddTeamClick}
|
||||||
validateOnMount
|
validateOnMount
|
||||||
|
key={formKey}
|
||||||
>
|
>
|
||||||
{({
|
{({
|
||||||
values,
|
values,
|
||||||
|
@ -100,7 +104,7 @@ export function CreateTeamForm({ users, teams, onSubmit }: Props) {
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
disabled={!isValid}
|
disabled={!isValid}
|
||||||
data-cy="team-createTeamButton"
|
data-cy="team-createTeamButton"
|
||||||
isLoading={isSubmitting}
|
isLoading={isSubmitting || addTeamMutation.isLoading}
|
||||||
loadingText="Creating team..."
|
loadingText="Creating team..."
|
||||||
>
|
>
|
||||||
<Icon icon="plus" feather size="md" />
|
<Icon icon="plus" feather size="md" />
|
||||||
|
@ -111,9 +115,37 @@ export function CreateTeamForm({ users, teams, onSubmit }: Props) {
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
</Formik>
|
</Formik>
|
||||||
</WidgetBody>
|
</Widget.Body>
|
||||||
</Widget>
|
</Widget>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
async function handleAddTeamClick(values: FormValues) {
|
||||||
|
addTeamMutation.mutate(values, {
|
||||||
|
onSuccess() {
|
||||||
|
incFormKey();
|
||||||
|
notifySuccess('Team successfully added', '');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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']);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
|
@ -1,8 +1,8 @@
|
||||||
import { object, string, array, number } from 'yup';
|
import { object, string, array, number } from 'yup';
|
||||||
|
|
||||||
import { TeamViewModel } from '@/portainer/models/team';
|
import { Team } from '@/react/portainer/users/teams/types';
|
||||||
|
|
||||||
export function validationSchema(teams: TeamViewModel[]) {
|
export function validationSchema(teams: Team[]) {
|
||||||
return object().shape({
|
return object().shape({
|
||||||
name: string()
|
name: string()
|
||||||
.required('This field is required.')
|
.required('This field is required.')
|
|
@ -0,0 +1 @@
|
||||||
|
export { CreateTeamForm } from './CreateTeamForm';
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { UserId } from '@/portainer/users/types';
|
||||||
|
|
||||||
|
export interface FormValues {
|
||||||
|
name: string;
|
||||||
|
leaders: UserId[];
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { useUsers } from '@/portainer/users/queries';
|
||||||
|
import { useUser } from '@/portainer/hooks/useUser';
|
||||||
|
|
||||||
|
import { PageHeader } from '@@/PageHeader';
|
||||||
|
|
||||||
|
import { useTeams } from '../queries';
|
||||||
|
|
||||||
|
import { CreateTeamForm } from './CreateTeamForm';
|
||||||
|
import { TeamsDatatableContainer } from './TeamsDatatable/TeamsDatatable';
|
||||||
|
|
||||||
|
export function ListView() {
|
||||||
|
const { isAdmin } = useUser();
|
||||||
|
|
||||||
|
const usersQuery = useUsers(false);
|
||||||
|
const teamsQuery = useTeams(!isAdmin, { enabled: !!usersQuery.data });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Teams" breadcrumbs={[{ label: 'Teams management' }]} />
|
||||||
|
|
||||||
|
{usersQuery.data && teamsQuery.data && (
|
||||||
|
<CreateTeamForm users={usersQuery.data} teams={teamsQuery.data} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{teamsQuery.data && (
|
||||||
|
<TeamsDatatableContainer teams={teamsQuery.data} isAdmin={isAdmin} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,244 @@
|
||||||
|
import { useRowSelectColumn } from '@lineup-lite/hooks';
|
||||||
|
import {
|
||||||
|
Column,
|
||||||
|
useGlobalFilter,
|
||||||
|
usePagination,
|
||||||
|
useRowSelect,
|
||||||
|
useSortBy,
|
||||||
|
useTable,
|
||||||
|
} from 'react-table';
|
||||||
|
import { useMutation, useQueryClient } from 'react-query';
|
||||||
|
import { Trash2, Users } from 'react-feather';
|
||||||
|
|
||||||
|
import { notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
import { promiseSequence } from '@/portainer/helpers/promise-utils';
|
||||||
|
import { Team, TeamId } from '@/react/portainer/users/teams/types';
|
||||||
|
import { deleteTeam } from '@/react/portainer/users/teams/teams.service';
|
||||||
|
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
|
||||||
|
|
||||||
|
import { PaginationControls } from '@@/PaginationControls';
|
||||||
|
import { Checkbox } from '@@/form-components/Checkbox';
|
||||||
|
import { Table } from '@@/datatables';
|
||||||
|
import { Button } from '@@/buttons';
|
||||||
|
import { SearchBar, useSearchBarState } from '@@/datatables/SearchBar';
|
||||||
|
import { TableFooter } from '@@/datatables/TableFooter';
|
||||||
|
import { SelectedRowsCount } from '@@/datatables/SelectedRowsCount';
|
||||||
|
import {
|
||||||
|
TableSettingsProvider,
|
||||||
|
useTableSettings,
|
||||||
|
} from '@@/datatables/useTableSettings';
|
||||||
|
import { TableContent } from '@@/datatables/TableContent';
|
||||||
|
import { buildNameColumn } from '@@/datatables/NameCell';
|
||||||
|
|
||||||
|
import { TableSettings } from './types';
|
||||||
|
|
||||||
|
const tableKey = 'teams';
|
||||||
|
|
||||||
|
const columns: readonly Column<Team>[] = [
|
||||||
|
buildNameColumn('Name', 'Id', 'portainer.teams.team'),
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
teams: Team[];
|
||||||
|
isAdmin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeamsDatatable({ teams, isAdmin }: Props) {
|
||||||
|
const { handleRemove } = useRemoveMutation();
|
||||||
|
|
||||||
|
const [searchBarValue, setSearchBarValue] = useSearchBarState(tableKey);
|
||||||
|
const { settings, setTableSettings } = useTableSettings<TableSettings>();
|
||||||
|
|
||||||
|
const {
|
||||||
|
getTableProps,
|
||||||
|
getTableBodyProps,
|
||||||
|
headerGroups,
|
||||||
|
page,
|
||||||
|
prepareRow,
|
||||||
|
selectedFlatRows,
|
||||||
|
gotoPage,
|
||||||
|
setPageSize,
|
||||||
|
setGlobalFilter,
|
||||||
|
state: { pageIndex, pageSize },
|
||||||
|
} = useTable<Team>(
|
||||||
|
{
|
||||||
|
defaultCanFilter: false,
|
||||||
|
columns,
|
||||||
|
data: teams,
|
||||||
|
|
||||||
|
initialState: {
|
||||||
|
pageSize: settings.pageSize || 10,
|
||||||
|
sortBy: [settings.sortBy],
|
||||||
|
globalFilter: searchBarValue,
|
||||||
|
},
|
||||||
|
selectCheckboxComponent: Checkbox,
|
||||||
|
},
|
||||||
|
|
||||||
|
useGlobalFilter,
|
||||||
|
useSortBy,
|
||||||
|
usePagination,
|
||||||
|
useRowSelect,
|
||||||
|
isAdmin ? useRowSelectColumn : emptyPlugin
|
||||||
|
);
|
||||||
|
|
||||||
|
const tableProps = getTableProps();
|
||||||
|
const tbodyProps = getTableBodyProps();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<Table.Container>
|
||||||
|
<Table.Title icon={Users} label="Teams">
|
||||||
|
<SearchBar
|
||||||
|
value={searchBarValue}
|
||||||
|
onChange={handleSearchBarChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<Table.Actions>
|
||||||
|
<Button
|
||||||
|
color="dangerlight"
|
||||||
|
onClick={handleRemoveClick}
|
||||||
|
disabled={selectedFlatRows.length === 0}
|
||||||
|
icon={Trash2}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</Table.Actions>
|
||||||
|
)}
|
||||||
|
</Table.Title>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
className={tableProps.className}
|
||||||
|
role={tableProps.role}
|
||||||
|
style={tableProps.style}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
{headerGroups.map((headerGroup) => {
|
||||||
|
const { key, className, role, style } =
|
||||||
|
headerGroup.getHeaderGroupProps();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.HeaderRow<Team>
|
||||||
|
key={key}
|
||||||
|
className={className}
|
||||||
|
role={role}
|
||||||
|
style={style}
|
||||||
|
headers={headerGroup.headers}
|
||||||
|
onSortChange={handleSortChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</thead>
|
||||||
|
<tbody
|
||||||
|
className={tbodyProps.className}
|
||||||
|
role={tbodyProps.role}
|
||||||
|
style={tbodyProps.style}
|
||||||
|
>
|
||||||
|
<TableContent
|
||||||
|
prepareRow={prepareRow}
|
||||||
|
renderRow={(row, { key, className, role, style }) => (
|
||||||
|
<Table.Row<Team>
|
||||||
|
cells={row.cells}
|
||||||
|
key={key}
|
||||||
|
className={className}
|
||||||
|
role={role}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
rows={page}
|
||||||
|
emptyContent="No teams found"
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<TableFooter>
|
||||||
|
<SelectedRowsCount value={selectedFlatRows.length} />
|
||||||
|
<PaginationControls
|
||||||
|
showAll
|
||||||
|
pageLimit={pageSize}
|
||||||
|
page={pageIndex + 1}
|
||||||
|
onPageChange={(p) => gotoPage(p - 1)}
|
||||||
|
totalCount={teams.length}
|
||||||
|
onPageLimitChange={handlePageSizeChange}
|
||||||
|
/>
|
||||||
|
</TableFooter>
|
||||||
|
</Table.Container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handlePageSizeChange(pageSize: number) {
|
||||||
|
setPageSize(pageSize);
|
||||||
|
setTableSettings({ pageSize });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearchBarChange(value: string) {
|
||||||
|
setSearchBarValue(value);
|
||||||
|
setGlobalFilter(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSortChange(id: string, desc: boolean) {
|
||||||
|
setTableSettings({ sortBy: { id, desc } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveClick() {
|
||||||
|
const ids = selectedFlatRows.map((row) => row.original.Id);
|
||||||
|
handleRemove(ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultSettings: TableSettings = {
|
||||||
|
pageSize: 10,
|
||||||
|
sortBy: { id: 'name', desc: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TeamsDatatableContainer(props: Props) {
|
||||||
|
return (
|
||||||
|
<TableSettingsProvider<TableSettings>
|
||||||
|
defaults={defaultSettings}
|
||||||
|
storageKey={tableKey}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||||
|
<TeamsDatatable {...props} />
|
||||||
|
</TableSettingsProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useRemoveMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const deleteMutation = useMutation(
|
||||||
|
async (ids: TeamId[]) =>
|
||||||
|
promiseSequence(ids.map((id) => () => deleteTeam(id))),
|
||||||
|
{
|
||||||
|
meta: {
|
||||||
|
error: { title: 'Failure', message: 'Unable to remove team' },
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
return queryClient.invalidateQueries(['teams']);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return { handleRemove };
|
||||||
|
|
||||||
|
async function handleRemove(teams: TeamId[]) {
|
||||||
|
const confirmed = await confirmDeletionAsync(
|
||||||
|
'Are you sure you want to remove the selected teams?'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteMutation.mutate(teams, {
|
||||||
|
onSuccess: () => {
|
||||||
|
notifySuccess('Teams successfully removed', '');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyPlugin() {}
|
||||||
|
emptyPlugin.pluginName = 'emptyPlugin';
|
|
@ -0,0 +1 @@
|
||||||
|
export { TeamsDatatable } from './TeamsDatatable';
|
|
@ -0,0 +1,8 @@
|
||||||
|
import {
|
||||||
|
PaginationTableSettings,
|
||||||
|
SortableTableSettings,
|
||||||
|
} from '@@/datatables/types-old';
|
||||||
|
|
||||||
|
export interface TableSettings
|
||||||
|
extends PaginationTableSettings,
|
||||||
|
SortableTableSettings {}
|
|
@ -0,0 +1 @@
|
||||||
|
export { ListView } from './ListView';
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { ItemView } from './ItemView';
|
||||||
|
export { ListView } from './ListView';
|
|
@ -0,0 +1,134 @@
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '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,
|
||||||
|
{
|
||||||
|
enabled = true,
|
||||||
|
select = (data) => data as unknown as T,
|
||||||
|
}: {
|
||||||
|
enabled?: boolean;
|
||||||
|
select?: (data: Team[]) => T;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const teams = useQuery(
|
||||||
|
['teams', { onlyLedTeams }],
|
||||||
|
() => getTeams(onlyLedTeams),
|
||||||
|
{
|
||||||
|
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() {
|
||||||
|
return 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,47 @@
|
||||||
|
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;
|
||||||
|
}
|
|
@ -0,0 +1,71 @@
|
||||||
|
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) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get<Team[]>(buildUrl(), {
|
||||||
|
params: { onlyLedTeams },
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { type UserId } from '@/portainer/users/types';
|
||||||
|
|
||||||
|
export type TeamId = number;
|
||||||
|
|
||||||
|
export enum TeamRole {
|
||||||
|
Leader = 1,
|
||||||
|
Member,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Team = {
|
||||||
|
Id: TeamId;
|
||||||
|
Name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TeamMembershipId = number;
|
||||||
|
|
||||||
|
export interface TeamMembership {
|
||||||
|
Id: TeamMembershipId;
|
||||||
|
Role: TeamRole;
|
||||||
|
UserID: UserId;
|
||||||
|
TeamID: TeamId;
|
||||||
|
}
|
|
@ -10,6 +10,7 @@ import { Tag } from '@/portainer/tags/types';
|
||||||
import { StatusResponse } from '@/portainer/services/api/status.service';
|
import { StatusResponse } from '@/portainer/services/api/status.service';
|
||||||
import { createMockTeams } from '@/react-tools/test-mocks';
|
import { createMockTeams } from '@/react-tools/test-mocks';
|
||||||
import { PublicSettingsResponse } from '@/portainer/settings/types';
|
import { PublicSettingsResponse } from '@/portainer/settings/types';
|
||||||
|
import { UserId } from '@/portainer/users/types';
|
||||||
|
|
||||||
import { azureHandlers } from './setup-handlers/azure';
|
import { azureHandlers } from './setup-handlers/azure';
|
||||||
import { dockerHandlers } from './setup-handlers/docker';
|
import { dockerHandlers } from './setup-handlers/docker';
|
||||||
|
@ -36,6 +37,12 @@ export const handlers = [
|
||||||
res(ctx.json(createMockTeams(10)))
|
res(ctx.json(createMockTeams(10)))
|
||||||
),
|
),
|
||||||
|
|
||||||
|
rest.post<{ name: string }>('/api/teams', (req, res, ctx) =>
|
||||||
|
res(ctx.status(204))
|
||||||
|
),
|
||||||
|
rest.post<{ userId: UserId }>('/api/team_memberships', (req, res, ctx) =>
|
||||||
|
res(ctx.status(204))
|
||||||
|
),
|
||||||
...azureHandlers,
|
...azureHandlers,
|
||||||
...dockerHandlers,
|
...dockerHandlers,
|
||||||
...userHandlers,
|
...userHandlers,
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { DefaultRequestBody, PathParams, rest } from 'msw';
|
import { DefaultRequestBody, PathParams, rest } from 'msw';
|
||||||
|
|
||||||
import { TeamMembership } from '@/portainer/teams/types';
|
import { TeamMembership } from '@/react/portainer/users/teams/types';
|
||||||
import { createMockUsers } from '@/react-tools/test-mocks';
|
import { createMockUsers } from '@/react-tools/test-mocks';
|
||||||
|
|
||||||
export const userHandlers = [
|
export const userHandlers = [
|
||||||
|
|
Loading…
Reference in New Issue