refactor(users): migrate list view to react [EE-2202] (#11914)

pull/12155/head
Chaim Lev-Ari 2024-08-28 14:04:32 -06:00 committed by GitHub
parent 33ce841040
commit 3c1441d462
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 967 additions and 681 deletions

View File

@ -85,7 +85,7 @@ func (service *Service) DeleteTeamMembershipByUserID(userID portainer.UserID) er
BucketName,
&portainer.TeamMembership{},
func(obj any) (id int, ok bool) {
membership, ok := obj.(portainer.TeamMembership)
membership, ok := obj.(*portainer.TeamMembership)
if !ok {
log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to TeamMembership object")
//return fmt.Errorf("Failed to convert to TeamMembership object: %s", obj)
@ -106,7 +106,7 @@ func (service *Service) DeleteTeamMembershipByTeamID(teamID portainer.TeamID) er
BucketName,
&portainer.TeamMembership{},
func(obj any) (id int, ok bool) {
membership, ok := obj.(portainer.TeamMembership)
membership, ok := obj.(*portainer.TeamMembership)
if !ok {
log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to TeamMembership object")
//return fmt.Errorf("Failed to convert to TeamMembership object: %s", obj)
@ -126,7 +126,7 @@ func (service *Service) DeleteTeamMembershipByTeamIDAndUserID(teamID portainer.T
BucketName,
&portainer.TeamMembership{},
func(obj any) (id int, ok bool) {
membership, ok := obj.(portainer.TeamMembership)
membership, ok := obj.(*portainer.TeamMembership)
if !ok {
log.Debug().Str("obj", fmt.Sprintf("%#v", obj)).Msg("failed to convert to TeamMembership object")
//return fmt.Errorf("Failed to convert to TeamMembership object: %s", obj)

View File

@ -381,8 +381,7 @@ angular
url: '/users',
views: {
'content@': {
templateUrl: './views/users/users.html',
controller: 'UsersController',
component: 'usersListView',
},
},
data: {

View File

@ -2,17 +2,12 @@ import angular from 'angular';
import { r2a } from '@/react-tools/react2angular';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { UsersDatatable } from '@/react/portainer/users/ListView/UsersDatatable/UsersDatatable';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { EffectiveAccessViewerDatatable } from '@/react/portainer/users/RolesView/AccessViewer/EffectiveAccessViewerDatatable';
import { RbacRolesDatatable } from '@/react/portainer/users/RolesView/RbacRolesDatatable';
export const usersModule = angular
.module('portainer.app.react.components.users', [])
.component(
'usersDatatable',
r2a(withUIRouter(withCurrentUser(UsersDatatable)), ['dataset', 'onRemove'])
)
.component(
'effectiveAccessViewerDatatable',
r2a(withUIRouter(withCurrentUser(EffectiveAccessViewerDatatable)), [

View File

@ -20,6 +20,7 @@ import { environmentGroupModule } from './env-groups';
import { registriesModule } from './registries';
import { activityLogsModule } from './activity-logs';
import { templatesModule } from './templates';
import { usersModule } from './users';
export const viewsModule = angular
.module('portainer.app.react.views', [
@ -30,6 +31,7 @@ export const viewsModule = angular
registriesModule,
activityLogsModule,
templatesModule,
usersModule,
])
.component(
'homeView',

View File

@ -0,0 +1,15 @@
import angular from 'angular';
import { ListView } from '@/react/portainer/users/ListView/ListView';
import { r2a } from '@/react-tools/react2angular';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { withUIRouter } from '@/react-tools/withUIRouter';
export const usersModule = angular
.module('portainer.app.react.views.users', [])
.component(
'usersListView',
r2a(withUIRouter(withReactQuery(withCurrentUser(ListView))), [])
).name;

View File

@ -1,13 +1,12 @@
import _ from 'lodash-es';
import { UserTokenModel, UserViewModel } from '@/portainer/models/user';
import { UserViewModel } from '@/portainer/models/user';
import { getUsers } from '@/portainer/users/user.service';
import { getUser } from '@/portainer/users/queries/useUser';
import { TeamMembershipModel } from '../../models/teamMembership';
/* @ngInject */
export function UserService($q, Users, TeamService, TeamMembershipService) {
export function UserService($q, Users, TeamService) {
'use strict';
var service = {};
@ -23,33 +22,6 @@ export function UserService($q, Users, TeamService, TeamMembershipService) {
return new UserViewModel(user);
};
service.createUser = function (username, password, role, teamIds) {
var deferred = $q.defer();
var payload = {
username: username,
password: password,
role: role,
};
Users.create({}, payload)
.$promise.then(function success(data) {
var userId = data.Id;
var teamMembershipQueries = [];
angular.forEach(teamIds, function (teamId) {
teamMembershipQueries.push(TeamMembershipService.createMembership(userId, teamId, 2));
});
$q.all(teamMembershipQueries).then(function success() {
deferred.resolve();
});
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to create user', err: err });
});
return deferred.promise;
};
service.deleteUser = function (id) {
return Users.remove({ id: id }).$promise;
};
@ -112,27 +84,6 @@ export function UserService($q, Users, TeamService, TeamMembershipService) {
return deferred.promise;
};
service.getAccessTokens = function (id) {
var deferred = $q.defer();
Users.getAccessTokens({ id: id })
.$promise.then(function success(data) {
var userTokens = data.map(function (item) {
return new UserTokenModel(item);
});
deferred.resolve(userTokens);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve user tokens', err: err });
});
return deferred.promise;
};
service.deleteAccessToken = function (id, tokenId) {
return Users.deleteAccessToken({ id: id, tokenId: tokenId }).$promise;
};
service.initAdministrator = function (username, password) {
return Users.initAdminUser({ Username: username, Password: password }).$promise;
};

View File

@ -1,169 +0,0 @@
<page-header title="'Users'" breadcrumbs="['User management']" reload="true"> </page-header>
<div class="row" ng-if="isAdmin">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="plus" title-text="Add a new user"> </rd-widget-header>
<rd-widget-body>
<form name="form" class="form-horizontal">
<!-- name-input -->
<div class="form-group">
<label for="username" class="col-sm-3 col-lg-2 control-label required text-left">
Username
<portainer-tooltip ng-if="AuthenticationMethod === 2" message="'Username must exactly match username defined in external LDAP source.'"></portainer-tooltip>
</label>
<div class="col-sm-8">
<div class="input-group">
<input
type="text"
data-cy="user-usernameInput"
class="form-control"
id="username"
ng-model="formValues.Username"
ng-change="checkUsernameValidity()"
placeholder="e.g. jdoe"
auto-focus
/>
<span class="input-group-addon">
<pr-icon mode="'success'" icon="'check'" ng-if="state.validUsername"></pr-icon>
<pr-icon mode="'danger'" icon="'x'" ng-if="!state.validUsername"></pr-icon>
</span>
</div>
</div>
</div>
<!-- !name-input -->
<!-- new-password-input -->
<div class="form-group" ng-if="AuthenticationMethod === 1">
<label for="password" class="col-sm-3 col-lg-2 control-label required text-left">Password</label>
<div class="col-sm-8">
<input
type="password"
class="form-control"
ng-model="formValues.Password"
id="password"
name="password"
ng-minlength="requiredPasswordLength"
data-cy="user-passwordInput"
/>
</div>
</div>
<!-- !new-password-input -->
<!-- confirm-password-input -->
<div class="form-group" ng-if="AuthenticationMethod === 1">
<label for="confirm_password" class="col-sm-3 col-lg-2 control-label required text-left">Confirm password</label>
<div class="col-sm-8">
<div class="input-group">
<input
type="password"
class="form-control form-control--has-icon"
ng-model="formValues.ConfirmPassword"
id="confirm_password"
data-cy="user-passwordConfirmInput"
/>
<span class="input-group-addon">
<pr-icon
mode="'success'"
icon="'check'"
aria-hidden="true"
ng-if="form.password.$viewValue !== '' && form.password.$viewValue === formValues.ConfirmPassword"
></pr-icon>
<pr-icon
mode="'danger'"
icon="'x'"
aria-hidden="true"
ng-if="!(form.password.$viewValue !== '' && form.password.$viewValue === formValues.ConfirmPassword)"
></pr-icon>
</span>
</div>
</div>
</div>
<!-- !confirm-password-input -->
<!-- password-check-hint -->
<div class="form-group" ng-if="AuthenticationMethod === 1">
<div class="col-sm-3 col-lg-2"></div>
<div class="col-sm-8">
<password-check-hint password-valid="form.password.$valid && formValues.Password"></password-check-hint>
</div>
</div>
<!-- ! password-check-hint -->
<!-- admin-checkbox -->
<div class="form-group" ng-if="isAdmin">
<div class="col-sm-12">
<por-switch-field
label-class="'col-sm-3 col-lg-2 control-label text-left'"
checked="formValues.Administrator"
label="'Administrator'"
tooltip="'Administrators have access to Portainer settings management as well as full control over all defined environments and their resources.'"
on-change="(handleAdministratorChange)"
></por-switch-field>
</div>
</div>
<!-- !admin-checkbox -->
<!-- teams -->
<div class="form-group" ng-if="!formValues.Administrator">
<label class="col-sm-3 col-lg-2 control-label text-left" for="teams-selector">Add to team(s)</label>
<div class="col-sm-8">
<span class="small text-muted" ng-if="teams.length === 0">
You don't seem to have any teams to add users into. Head over to the <a ui-sref="portainer.teams">Teams view</a> to create some.
</span>
<teams-selector
ng-if="teams.length > 0"
value="formValues.TeamIds"
teams="teams"
placeholder="'Select one or more teams'"
data-cy="'user-teamSelect'"
on-change="(onChangeTeamIds)"
input-id="'teams-selector'"
disabled="teamSync"
></teams-selector>
</div>
</div>
<div class="form-group" ng-if="teamSync">
<div class="col-sm-12">
<span class="small">
<p class="small text-muted vertical-center">
<pr-icon icon="'alert-circle'" class-name="'icon-warning =vertical-center'"></pr-icon>
The team leader feature is disabled as external authentication is currently enabled with team sync.
</p>
</span>
</div>
</div>
<!-- !teams -->
<div class="form-group" ng-if="isAdmin && !formValues.Administrator && formValues.Teams.length === 0">
<div class="col-sm-12">
<span class="small text-muted vertical-center">
<pr-icon class="vertical-center" icon="'alert-circle'" aria-hidden="true" mode="'primary'" size="'md'"></pr-icon>
<span
>Note: non-administrator users who aren't in a team don't have access to any environments by default. Head over to the
<a ui-sref="portainer.endpoints">Environments view</a> to manage their accesses.</span
>
</span>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<button
type="button"
class="btn btn-primary btn-sm !ml-0"
ng-disabled="state.actionInProgress || !state.validUsername || formValues.Username === '' || (AuthenticationMethod === 1 && (!formValues.Password || form.$invalid || formValues.Password !== formValues.ConfirmPassword))"
ng-click="addUser()"
button-spinner="state.actionInProgress"
data-cy="user-createUserButton"
>
<span ng-hide="state.actionInProgress" class="vertical-center icon-white"> <pr-icon icon="'plus'" aria-hidden="true" size="'md'"></pr-icon> Create user</span>
<span ng-show="state.actionInProgress">Creating user...</span>
</button>
<span class="text-danger" ng-if="state.userCreationError">
<pr-icon icon="'alert-circle'" aria-hidden="true" mode="'primary'" size="'md'"></pr-icon> {{ state.userCreationError }}
</span>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<users-datatable dataset="users" on-remove="(removeAction)"></users-datatable>

View File

@ -1,143 +0,0 @@
import _ from 'lodash-es';
import { AuthenticationMethod } from '@/react/portainer/settings/types';
import { processItemsInBatches } from '@/react/common/processItemsInBatches';
angular.module('portainer.app').controller('UsersController', [
'$q',
'$scope',
'$state',
'UserService',
'TeamService',
'TeamMembershipService',
'Notifications',
'Authentication',
'SettingsService',
function ($q, $scope, $state, UserService, TeamService, TeamMembershipService, Notifications, Authentication, SettingsService) {
$scope.state = {
userCreationError: '',
validUsername: false,
actionInProgress: false,
};
$scope.formValues = {
Username: '',
Password: '',
ConfirmPassword: '',
Administrator: false,
TeamIds: [],
};
$scope.handleAdministratorChange = function (checked) {
return $scope.$evalAsync(() => {
$scope.formValues.Administrator = checked;
});
};
$scope.onChangeTeamIds = function (teamIds) {
return $scope.$evalAsync(() => {
$scope.formValues.TeamIds = teamIds;
});
};
$scope.checkUsernameValidity = function () {
var valid = true;
for (var i = 0; i < $scope.users.length; i++) {
if ($scope.formValues.Username.toLocaleLowerCase() === $scope.users[i].Username.toLocaleLowerCase()) {
valid = false;
break;
}
}
$scope.state.validUsername = valid;
$scope.state.userCreationError = valid ? '' : 'Username already taken';
};
$scope.addUser = function () {
$scope.state.actionInProgress = true;
$scope.state.userCreationError = '';
var username = $scope.formValues.Username;
var password = $scope.formValues.Password;
var role = $scope.formValues.Administrator ? 1 : 2;
UserService.createUser(username, password, role, $scope.formValues.TeamIds)
.then(function success() {
Notifications.success('User successfully created', username);
$state.reload();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to create user');
})
.finally(function final() {
$scope.state.actionInProgress = false;
});
};
async function deleteSelectedUsers(selectedItems) {
async function doRemove(user) {
return UserService.deleteUser(user.Id)
.then(function success() {
Notifications.success('User successfully removed', user.Username);
var index = $scope.users.indexOf(user);
$scope.users.splice(index, 1);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove user');
});
}
await processItemsInBatches(selectedItems, doRemove);
$state.reload();
}
$scope.removeAction = function (selectedItems) {
return deleteSelectedUsers(selectedItems);
};
function assignTeamLeaders(users, memberships) {
for (var i = 0; i < users.length; i++) {
var user = users[i];
user.isTeamLeader = false;
for (var j = 0; j < memberships.length; j++) {
var membership = memberships[j];
if (user.Id === membership.UserId && membership.Role === 1) {
user.isTeamLeader = true;
break;
}
}
}
}
function initView() {
var userDetails = Authentication.getUserDetails();
var isAdmin = Authentication.isAdmin();
$scope.isAdmin = isAdmin;
$q.all({
users: UserService.users(true),
teams: isAdmin ? TeamService.teams() : UserService.userLeadingTeams(userDetails.ID),
memberships: TeamMembershipService.memberships(),
settings: SettingsService.publicSettings(),
})
.then(function success(data) {
$scope.AuthenticationMethod = data.settings.AuthenticationMethod;
var users = data.users;
assignTeamLeaders(users, data.memberships);
users = assignAuthMethod(users, $scope.AuthenticationMethod);
$scope.users = users;
$scope.teams = _.orderBy(data.teams, 'Name', 'asc');
$scope.requiredPasswordLength = data.settings.RequiredPasswordLength;
$scope.teamSync = data.settings.TeamSync;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve users and teams');
$scope.users = [];
$scope.teams = [];
});
}
initView();
},
]);
function assignAuthMethod(users, authMethod) {
return users.map((u) => ({
...u,
authMethod: AuthenticationMethod[u.Id === 1 ? AuthenticationMethod.Internal : authMethod],
}));
}

View File

@ -9,6 +9,10 @@ import {
import { notifyError } from '@/portainer/services/notifications';
/**
* @deprecated use withGlobalError
* `onError` and other callbacks are not supported on react-query v5
*/
export function withError(fallbackMessage?: string, title = 'Failure') {
return {
onError(error: unknown) {
@ -29,7 +33,7 @@ type OptionalReadonly<T> = T | Readonly<T>;
export function withInvalidate(
queryClient: QueryClient,
queryKeysToInvalidate: Array<OptionalReadonly<Array<string | number>>>,
queryKeysToInvalidate: Array<OptionalReadonly<Array<unknown>>>,
// skipRefresh will set the mutation state to success without waiting for the invalidated queries to refresh
// see the following for info: https://tkdodo.eu/blog/mastering-mutations-in-react-query#awaited-promises
{ skipRefresh }: { skipRefresh?: boolean } = {}

View File

@ -5,7 +5,7 @@ import { PortainerSelect } from '@@/form-components/PortainerSelect';
interface Props {
name?: string;
value: TeamId[] | readonly TeamId[];
onChange(value: readonly TeamId[]): void;
onChange(value: TeamId[]): void;
teams: Team[];
dataCy: string;
inputId?: string;

View File

@ -1,4 +1,4 @@
import { PropsWithChildren } from 'react';
import { ComponentProps, PropsWithChildren } from 'react';
import { AutomationTestingProps } from '@/types';
@ -12,6 +12,7 @@ interface Props extends AutomationTestingProps {
isLoading: boolean;
isValid: boolean;
errors?: unknown;
submitIcon?: ComponentProps<typeof LoadingButton>['icon'];
}
export function FormActions({
@ -21,6 +22,7 @@ export function FormActions({
children,
isValid,
errors,
submitIcon,
'data-cy': dataCy,
}: PropsWithChildren<Props>) {
return (
@ -34,6 +36,7 @@ export function FormActions({
isLoading={isLoading}
disabled={!isValid}
data-cy={dataCy}
icon={submitIcon}
>
{submitLabel}
</LoadingButton>

View File

@ -36,7 +36,7 @@ interface SharedProps
interface MultiProps<TValue> extends SharedProps {
value: readonly TValue[];
onChange(value: readonly TValue[]): void;
onChange(value: TValue[]): void;
options: Options<TValue>;
isMulti: true;
components?: SelectComponentsConfig<

View File

@ -0,0 +1,16 @@
import { PageHeader } from '@@/PageHeader';
import { NewUserForm } from './NewUserForm/NewUserForm';
import { UsersDatatable } from './UsersDatatable/UsersDatatable';
export function ListView() {
return (
<>
<PageHeader title="Users" breadcrumbs="User management" reload />
<NewUserForm />
<UsersDatatable />
</>
);
}

View File

@ -0,0 +1,25 @@
import { useField } from 'formik';
import { SwitchField } from '@@/form-components/SwitchField';
import { FormValues } from './FormValues';
export function AdminSwitch() {
const [{ name, value }, , { setValue }] =
useField<FormValues['isAdmin']>('isAdmin');
return (
<div className="form-group">
<div className="col-sm-12">
<SwitchField
data-cy="user-adminSwitch"
label="Administrator"
tooltip="Administrators have access to Portainer settings management as well as full control over all defined environments and their resources.'"
checked={value}
onChange={(checked) => setValue(checked)}
name={name}
labelClass="col-sm-3 col-lg-2"
/>
</div>
</div>
);
}

View File

@ -0,0 +1,42 @@
import { Check, XIcon } from 'lucide-react';
import { useField } from 'formik';
import { FormControl } from '@@/form-components/FormControl';
import { InputGroup } from '@@/form-components/InputGroup';
import { Icon } from '@@/Icon';
import { FormValues } from './FormValues';
export function ConfirmPasswordField() {
const [{ name, onBlur, onChange, value }, { error }] =
useField<FormValues['confirmPassword']>('confirmPassword');
return (
<FormControl
inputId="confirm_password"
label="Confirm password"
required
errors={error}
>
<InputGroup>
<InputGroup.Input
id="confirm_password"
name={name}
data-cy="user-passwordConfirmInput"
value={value}
onChange={onChange}
onBlur={onBlur}
required
type="password"
autoComplete="one-time-code"
/>
<InputGroup.Addon>
{error ? (
<Icon mode="danger" icon={XIcon} />
) : (
<Icon mode="success" icon={Check} />
)}
</InputGroup.Addon>
</InputGroup>
</FormControl>
);
}

View File

@ -0,0 +1,9 @@
import { TeamId } from '../../teams/types';
export interface FormValues {
username: string;
password: string;
confirmPassword: string;
isAdmin: boolean;
teams: TeamId[];
}

View File

@ -0,0 +1,109 @@
import { PlusIcon } from 'lucide-react';
import { Form, Formik } from 'formik';
import { useCurrentUser } from '@/react/hooks/useUser';
import { usePublicSettings } from '@/react/portainer/settings/queries';
import { AuthenticationMethod } from '@/react/portainer/settings/types';
import { Role } from '@/portainer/users/types';
import { notifySuccess } from '@/portainer/services/notifications';
import { Widget } from '@@/Widget';
import { FormActions } from '@@/form-components/FormActions';
import { PasswordCheckHint } from '@@/PasswordCheckHint';
import { FormControl } from '@@/form-components/FormControl';
import { useTeams } from '../../teams/queries';
import { useCreateUserMutation } from '../../queries/useCreateUserMutation';
import { UsernameField } from './UsernameField';
import { PasswordField } from './PasswordField';
import { ConfirmPasswordField } from './ConfirmPasswordField';
import { FormValues } from './FormValues';
import { TeamsFieldset } from './TeamsFieldset';
import { useValidation } from './useValidation';
export function NewUserForm() {
const { isPureAdmin } = useCurrentUser();
const teamsQuery = useTeams(!isPureAdmin);
const settingsQuery = usePublicSettings();
const createUserMutation = useCreateUserMutation();
const validation = useValidation();
if (!teamsQuery.data || !settingsQuery.data) {
return null;
}
const { AuthenticationMethod: authMethod } = settingsQuery.data;
return (
<div className="row">
<div className="col-sm-12">
<Widget>
<Widget.Title icon={PlusIcon} title="Add a new user" />
<Widget.Body>
<Formik<FormValues>
initialValues={{
username: '',
password: '',
confirmPassword: '',
isAdmin: false,
teams: [],
}}
validationSchema={validation}
validateOnMount
onSubmit={(values, { resetForm }) => {
createUserMutation.mutate(
{
password: values.password,
username: values.username,
role: values.isAdmin ? Role.Admin : Role.Standard,
teams: values.teams,
},
{
onSuccess() {
notifySuccess(
'User successfully created',
values.username
);
resetForm();
},
}
);
}}
>
{({ errors, isValid }) => (
<Form className="form-horizontal">
<UsernameField authMethod={authMethod} />
{authMethod === AuthenticationMethod.Internal && (
<>
<PasswordField />
<ConfirmPasswordField />
<FormControl label="">
<PasswordCheckHint passwordValid={!errors.password} />
</FormControl>
</>
)}
<TeamsFieldset />
<FormActions
data-cy="user-createUserButton"
submitLabel="Create user"
isLoading={createUserMutation.isLoading}
isValid={isValid}
loadingText="Creating user..."
errors={errors}
submitIcon={PlusIcon}
/>
</Form>
)}
</Formik>
</Widget.Body>
</Widget>
</div>
</div>
);
}

View File

@ -0,0 +1,26 @@
import { useField } from 'formik';
import { Input } from '@@/form-components/Input';
import { FormControl } from '@@/form-components/FormControl';
import { FormValues } from './FormValues';
export function PasswordField() {
const [{ name, onBlur, onChange, value }, { error }] =
useField<FormValues['password']>('password');
return (
<FormControl label="Password" required inputId="psw-input" errors={error}>
<Input
type="password"
name={name}
value={value}
onChange={onChange}
onBlur={onBlur}
id="psw-input"
data-cy="user-passwordInput"
required
autoComplete="one-time-code"
/>
</FormControl>
);
}

View File

@ -0,0 +1,33 @@
import { useField } from 'formik';
import { TeamsSelector } from '@@/TeamsSelector';
import { FormControl } from '@@/form-components/FormControl';
import { Team } from '../../teams/types';
import { FormValues } from './FormValues';
export function TeamsField({
teams,
disabled,
}: {
teams: Array<Team>;
disabled?: boolean;
}) {
const [{ name, value }, { error }, { setValue }] =
useField<FormValues['teams']>('teams');
return (
<FormControl label="Add to team(s)" inputId="teams-field" errors={error}>
<TeamsSelector
dataCy="user-teamSelect"
onChange={(value) => setValue(value)}
value={value}
name={name}
teams={teams}
inputId="teams-field"
disabled={disabled}
/>
</FormControl>
);
}

View File

@ -0,0 +1,71 @@
import { useFormikContext } from 'formik';
import { useCurrentUser } from '@/react/hooks/useUser';
import { usePublicSettings } from '@/react/portainer/settings/queries';
import { TextTip } from '@@/Tip/TextTip';
import { Link } from '@@/Link';
import { useTeams } from '../../teams/queries';
import { AdminSwitch } from './AdminSwitch';
import { FormValues } from './FormValues';
import { TeamsField } from './TeamsField';
export function TeamsFieldset() {
const { values } = useFormikContext<FormValues>();
const { isPureAdmin } = useCurrentUser();
const teamsQuery = useTeams(!isPureAdmin);
const settingsQuery = usePublicSettings();
if (!teamsQuery.data || !settingsQuery.data) {
return null;
}
const { TeamSync: teamSync } = settingsQuery.data;
return (
<>
{isPureAdmin && <AdminSwitch />}
{!values.isAdmin && (
<TeamsField teams={teamsQuery.data} disabled={teamSync} />
)}
{teamSync && <TeamSyncMessage />}
{isPureAdmin && !values.isAdmin && values.teams.length === 0 && (
<NoTeamSelected />
)}
</>
);
}
function TeamSyncMessage() {
return (
<div className="form-group">
<div className="col-sm-12">
<TextTip color="orange">
The team leader feature is disabled as external authentication is
currently enabled with team sync.
</TextTip>
</div>
</div>
);
}
function NoTeamSelected() {
return (
<div className="form-group">
<div className="col-sm-12">
<TextTip color="blue">
Note: non-administrator users who aren&apos;t in a team don&apos;t
have access to any environments by default. Head over to the{' '}
<Link to="portainer.endpoints" data-cy="env-link">
Environments view
</Link>{' '}
to manage their accesses.
</TextTip>
</div>
</div>
);
}

View File

@ -0,0 +1,54 @@
import { Check, XIcon } from 'lucide-react';
import { useField } from 'formik';
import { AuthenticationMethod } from '@/react/portainer/settings/types';
import { FormControl } from '@@/form-components/FormControl';
import { InputGroup } from '@@/form-components/InputGroup';
import { Icon } from '@@/Icon';
import { FormValues } from './FormValues';
export function UsernameField({
authMethod,
}: {
authMethod: AuthenticationMethod;
}) {
const [{ name, onBlur, onChange, value }, { error }] =
useField<FormValues['username']>('username');
return (
<FormControl
inputId="username-field"
label="Username"
required
errors={error}
tooltip={
authMethod === AuthenticationMethod.LDAP
? 'Username must exactly match username defined in external LDAP source.'
: null
}
>
<InputGroup>
<InputGroup.Input
id="username-field"
name={name}
placeholder="e.g. jdoe"
data-cy="user-usernameInput"
value={value}
onChange={onChange}
onBlur={onBlur}
required
autoComplete="create-username"
/>
<InputGroup.Addon>
{error ? (
<Icon mode="danger" icon={XIcon} />
) : (
<Icon mode="success" icon={Check} />
)}
</InputGroup.Addon>
</InputGroup>
</FormControl>
);
}

View File

@ -0,0 +1,52 @@
import { SchemaOf, array, boolean, number, object, ref, string } from 'yup';
import { useMemo } from 'react';
import { usePublicSettings } from '@/react/portainer/settings/queries';
import { AuthenticationMethod } from '@/react/portainer/settings/types';
import { useUsers } from '@/portainer/users/queries';
import { FormValues } from './FormValues';
export function useValidation(): SchemaOf<FormValues> {
const usersQuery = useUsers(true);
const settingsQuery = usePublicSettings();
const authMethod =
settingsQuery.data?.AuthenticationMethod ?? AuthenticationMethod.Internal;
return useMemo(() => {
const users = usersQuery.data ?? [];
const base = object({
username: string()
.required('Username is required')
.test({
name: 'unique',
message: 'Username is already taken',
test: (value) => users.every((u) => u.Username !== value),
}),
password: string().default(''),
confirmPassword: string().default(''),
isAdmin: boolean().default(false),
teams: array(number().required()).required(),
});
if (authMethod === AuthenticationMethod.Internal) {
return base.concat(
passwordValidation(settingsQuery.data?.RequiredPasswordLength)
);
}
return base;
}, [authMethod, settingsQuery.data?.RequiredPasswordLength, usersQuery.data]);
}
function passwordValidation(minLength: number | undefined = 12) {
return object({
password: string().required('Password is required').min(minLength, ''),
confirmPassword: string().oneOf(
[ref('password'), null],
'Passwords must match'
),
});
}

View File

@ -1,24 +1,66 @@
import { User as UserIcon } from 'lucide-react';
import { useMemo } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useUsers } from '@/portainer/users/queries';
import { AuthenticationMethod } from '@/react/portainer/settings/types';
import { useSettings } from '@/react/portainer/settings/queries';
import { notifySuccess } from '@/portainer/services/notifications';
import {
mutationOptions,
withError,
withInvalidate,
} from '@/react-tools/react-query';
import { processItemsInBatches } from '@/react/common/processItemsInBatches';
import { useCurrentUser } from '@/react/hooks/useUser';
import { Datatable } from '@@/datatables';
import { useTableState } from '@@/datatables/useTableState';
import { createPersistedStore } from '@@/datatables/types';
import { DeleteButton } from '@@/buttons/DeleteButton';
import { useTeamMemberships } from '../../teams/queries/useTeamMemberships';
import { TeamId, TeamRole } from '../../teams/types';
import { deleteUser } from '../../queries/useDeleteUserMutation';
import { columns } from './columns';
import { DecoratedUser } from './types';
const store = createPersistedStore('users');
export function UsersDatatable({
dataset,
onRemove,
}: {
dataset?: Array<DecoratedUser>;
onRemove: (selectedItems: Array<DecoratedUser>) => void;
}) {
export function UsersDatatable() {
const { handleRemove } = useRemoveMutation();
const { isPureAdmin } = useCurrentUser();
const usersQuery = useUsers(isPureAdmin);
const membershipsQuery = useTeamMemberships();
const settingsQuery = useSettings();
const tableState = useTableState(store, 'users');
const dataset: Array<DecoratedUser> | null = useMemo(() => {
if (!usersQuery.data || !membershipsQuery.data || !settingsQuery.data) {
return null;
}
const memberships = membershipsQuery.data;
return usersQuery.data.map((user) => {
const teamMembership = memberships.find(
(membership) => membership.UserID === user.Id
);
return {
...user,
isTeamLeader: teamMembership?.Role === TeamRole.Leader,
authMethod:
AuthenticationMethod[
user.Id === 1
? AuthenticationMethod.Internal
: settingsQuery.data.AuthenticationMethod
],
};
});
}, [membershipsQuery.data, settingsQuery.data, usersQuery.data]);
return (
<Datatable
columns={columns}
@ -32,7 +74,7 @@ export function UsersDatatable({
<DeleteButton
disabled={selectedItems.length === 0}
confirmMessage="Do you want to remove the selected users? They will not be able to login into Portainer anymore."
onConfirmed={() => onRemove(selectedItems)}
onConfirmed={() => handleRemove(selectedItems.map((i) => i.Id))}
data-cy="remove-users-button"
/>
)}
@ -40,3 +82,25 @@ export function UsersDatatable({
/>
);
}
function useRemoveMutation() {
const queryClient = useQueryClient();
const deleteMutation = useMutation(
async (ids: TeamId[]) => processItemsInBatches(ids, deleteUser),
mutationOptions(
withError('Unable to remove users'),
withInvalidate(queryClient, [['users']])
)
);
return { handleRemove };
async function handleRemove(teams: TeamId[]) {
deleteMutation.mutate(teams, {
onSuccess: () => {
notifySuccess('Teams successfully removed', '');
},
});
}
}

View File

@ -0,0 +1,43 @@
import { useQueryClient, useMutation } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withGlobalError, withInvalidate } from '@/react-tools/react-query';
import { userQueryKeys } from '@/portainer/users/queries/queryKeys';
import { buildUrl } from '@/portainer/users/user.service';
import { Role, User } from '@/portainer/users/types';
import { TeamId, TeamRole } from '../teams/types';
import { createTeamMembership } from '../teams/queries';
interface CreateUserPayload {
username: string;
password: string;
role: Role;
teams: Array<TeamId>;
}
export function useCreateUserMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (values: CreateUserPayload) => {
const user = await createUser(values);
return Promise.all(
values.teams.map((id) =>
createTeamMembership(user.Id, id, TeamRole.Member)
)
);
},
...withInvalidate(queryClient, [userQueryKeys.base()]),
...withGlobalError('Unable to create user'),
});
}
async function createUser(payload: CreateUserPayload) {
try {
const { data } = await axios.post<User>(buildUrl(), payload);
return data;
} catch (err) {
throw parseAxiosError(err, 'Unable to create user');
}
}

View File

@ -0,0 +1,24 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withGlobalError, withInvalidate } from '@/react-tools/react-query';
import { UserId } from '@/portainer/users/types';
import { buildUrl } from '@/portainer/users/user.service';
import { userQueryKeys } from '@/portainer/users/queries/queryKeys';
export function useDeleteUserMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: UserId) => deleteUser(id),
...withGlobalError('Unable to delete user'),
...withInvalidate(queryClient, [userQueryKeys.base()]),
});
}
export async function deleteUser(id: UserId) {
try {
await axios.delete(buildUrl(id));
} catch (error) {
throw parseAxiosError(error);
}
}

View File

@ -1,19 +1,13 @@
import { useRouter } from '@uirouter/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Users } from 'lucide-react';
import { usePublicSettings } from '@/react/portainer/settings/queries';
import {
mutationOptions,
withError,
withInvalidate,
} from '@/react-tools/react-query';
import { Widget } from '@@/Widget';
import { DeleteButton } from '@@/buttons/DeleteButton';
import { Team, TeamId, TeamMembership, TeamRole } from '../types';
import { deleteTeam } from '../teams.service';
import { Team, TeamMembership, TeamRole } from '../types';
import { useDeleteTeamMutation } from '../queries/useDeleteTeamMutation';
interface Props {
team: Team;
@ -22,7 +16,7 @@ interface Props {
}
export function Details({ team, memberships, isAdmin }: Props) {
const deleteMutation = useDeleteTeam();
const deleteMutation = useDeleteTeamMutation();
const router = useRouter();
const teamSyncQuery = usePublicSettings<boolean>({
select: (settings) => settings.TeamSync,
@ -80,15 +74,3 @@ export function Details({ team, memberships, isAdmin }: Props) {
deleteMutation.mutate(team.Id);
}
}
function useDeleteTeam() {
const queryClient = useQueryClient();
return useMutation(
(id: TeamId) => deleteTeam(id),
mutationOptions(
withError('Unable to delete team'),
withInvalidate(queryClient, [['teams']])
)
);
}

View File

@ -1,5 +1,4 @@
import { Formik, Field, Form } from 'formik';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useReducer } from 'react';
import { Plus } from 'lucide-react';
@ -14,8 +13,8 @@ import { UsersSelector } from '@@/UsersSelector';
import { LoadingButton } from '@@/buttons/LoadingButton';
import { TextTip } from '@@/Tip/TextTip';
import { createTeam } from '../../teams.service';
import { Team } from '../../types';
import { useAddTeamMutation } from '../../queries/useAddTeamMutation';
import { FormValues } from './types';
import { validationSchema } from './CreateTeamForm.validation';
@ -146,22 +145,3 @@ export function CreateTeamForm({ users, teams }: Props) {
});
}
}
export function useAddTeamMutation() {
const queryClient = useQueryClient();
return useMutation(
(values: FormValues) => createTeam(values.name, values.leaders),
{
meta: {
error: {
title: 'Failure',
message: 'Failed to create team',
},
},
onSuccess() {
return queryClient.invalidateQueries(['teams']);
},
}
);
}

View File

@ -5,7 +5,6 @@ import { ColumnDef } from '@tanstack/react-table';
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 { Datatable } from '@@/datatables';
import { buildNameColumn } from '@@/datatables/buildNameColumn';
@ -13,6 +12,8 @@ import { createPersistedStore } from '@@/datatables/types';
import { useTableState } from '@@/datatables/useTableState';
import { DeleteButton } from '@@/buttons/DeleteButton';
import { deleteTeam } from '../../queries/useDeleteTeamMutation';
const storageKey = 'teams';
const columns: ColumnDef<Team>[] = [

View File

@ -1,135 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { promiseSequence } from '@/portainer/helpers/promise-utils';
import { notifyError } from '@/portainer/services/notifications';
import { UserId } from '@/portainer/users/types';
import {
createTeamMembership,
deleteTeamMembership,
updateTeamMembership,
} from './team-membership.service';
import { getTeam, getTeamMemberships, getTeams } from './teams.service';
import { Team, TeamId, TeamMembership, TeamRole } from './types';
export function useTeams<T = Team[]>(
onlyLedTeams = false,
environmentId = 0,
{
enabled = true,
select = (data) => data as unknown as T,
}: {
enabled?: boolean;
select?: (data: Team[]) => T;
} = {}
) {
const teams = useQuery(
['teams', { onlyLedTeams, environmentId }],
() => getTeams(onlyLedTeams, environmentId),
{
meta: {
error: { title: 'Failure', message: 'Unable to load teams' },
},
enabled,
select,
}
);
return teams;
}
export function useTeam(id: TeamId, onError?: (error: unknown) => void) {
return useQuery(['teams', id], () => getTeam(id), {
meta: {
error: { title: 'Failure', message: 'Unable to load team' },
},
onError,
});
}
export function useTeamMemberships(id: TeamId) {
return useQuery(['teams', id, 'memberships'], () => getTeamMemberships(id), {
meta: {
error: { title: 'Failure', message: 'Unable to load team memberships' },
},
});
}
export function useAddMemberMutation(teamId: TeamId) {
const queryClient = useQueryClient();
return useMutation(
(userIds: UserId[]) =>
promiseSequence(
userIds.map(
(userId) => () =>
createTeamMembership(userId, teamId, TeamRole.Member)
)
),
{
onError(error) {
notifyError('Failure', error as Error, 'Failure to add membership');
},
onSuccess() {
queryClient.invalidateQueries(['teams', teamId, 'memberships']);
},
}
);
}
export function useRemoveMemberMutation(
teamId: TeamId,
teamMemberships: TeamMembership[] = []
) {
const queryClient = useQueryClient();
return useMutation(
(userIds: UserId[]) =>
promiseSequence(
userIds.map((userId) => () => {
const membership = teamMemberships.find(
(membership) => membership.UserID === userId
);
if (!membership) {
throw new Error('Membership not found');
}
return deleteTeamMembership(membership.Id);
})
),
{
onError(error) {
notifyError('Failure', error as Error, 'Failure to add membership');
},
onSuccess() {
queryClient.invalidateQueries(['teams', teamId, 'memberships']);
},
}
);
}
export function useUpdateRoleMutation(
teamId: TeamId,
teamMemberships: TeamMembership[] = []
) {
const queryClient = useQueryClient();
return useMutation(
({ userId, role }: { userId: UserId; role: TeamRole }) => {
const membership = teamMemberships.find(
(membership) => membership.UserID === userId
);
if (!membership) {
throw new Error('Membership not found');
}
return updateTeamMembership(membership.Id, userId, teamId, role);
},
{
onError(error) {
notifyError('Failure', error as Error, 'Failure to update membership');
},
onSuccess() {
queryClient.invalidateQueries(['teams', teamId, 'memberships']);
},
}
);
}

View File

@ -0,0 +1,11 @@
import { TeamMembershipId } from '../types';
export function buildMembershipUrl(id?: TeamMembershipId) {
let url = '/team_memberships';
if (id) {
url += `/${id}`;
}
return url;
}

View File

@ -0,0 +1,15 @@
import { TeamId } from '../types';
export function buildUrl(id?: TeamId, action?: string) {
let url = '/teams';
if (id) {
url += `/${id}`;
}
if (action) {
url += `/${action}`;
}
return url;
}

View File

@ -0,0 +1,11 @@
export { useTeams } from './useTeams';
export {
useAddMemberMutation,
createTeamMembership,
} from './useAddMemberMutation';
export { useAddTeamMutation } from './useAddTeamMutation';
export { deleteTeam, useDeleteTeamMutation } from './useDeleteTeamMutation';
export { useRemoveMemberMutation } from './useRemoveMemberMutation';
export { useTeam } from './useTeam';
export { useTeamMemberships } from './useTeamMemberships';
export { useUpdateRoleMutation } from './useUpdateRoleMutation';

View File

@ -0,0 +1,9 @@
import { TeamId } from '../types';
export const queryKeys = {
base: () => ['teams'] as const,
list: (params: unknown) => [...queryKeys.base(), 'list', params] as const,
item: (id: TeamId) => [...queryKeys.base(), id] as const,
memberships: (id?: TeamId) =>
[...queryKeys.base(), 'memberships', id] as const,
};

View File

@ -0,0 +1,40 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { promiseSequence } from '@/portainer/helpers/promise-utils';
import { UserId } from '@/portainer/users/types';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withGlobalError, withInvalidate } from '@/react-tools/react-query';
import { TeamId, TeamRole } from '../types';
import { buildMembershipUrl } from './build-membership-url';
import { queryKeys } from './query-keys';
export function useAddMemberMutation(teamId: TeamId) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (userIds: UserId[]) =>
promiseSequence(
userIds.map(
(userId) => () =>
createTeamMembership(userId, teamId, TeamRole.Member)
)
),
...withGlobalError('Failure to add membership'),
...withInvalidate(queryClient, [queryKeys.memberships(teamId)]),
});
}
export async function createTeamMembership(
userId: UserId,
teamId: TeamId,
role: TeamRole
) {
try {
await axios.post(buildMembershipUrl(), { userId, teamId, role });
} catch (e) {
throw parseAxiosError(e, 'Unable to create team membership');
}
}

View File

@ -0,0 +1,39 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { UserId } from '@/portainer/users/types';
import { withGlobalError, withInvalidate } from '@/react-tools/react-query';
import { TeamRole } from '../types';
import { createTeamMembership } from './useAddMemberMutation';
import { buildUrl } from './build-url';
import { queryKeys } from './query-keys';
interface CreatePayload {
name: string;
leaders: UserId[];
}
export function useAddTeamMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createTeam,
...withGlobalError('Failed to create team'),
...withInvalidate(queryClient, [queryKeys.base()]),
});
}
async function createTeam({ name, leaders }: CreatePayload) {
try {
const { data: team } = await axios.post(buildUrl(), { name });
await Promise.all(
leaders.map((leaderId) =>
createTeamMembership(leaderId, team.Id, TeamRole.Leader)
)
);
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to create team');
}
}

View File

@ -0,0 +1,32 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import {
mutationOptions,
withGlobalError,
withInvalidate,
} from '@/react-tools/react-query';
import { TeamId } from '../types';
import { buildUrl } from './build-url';
export function useDeleteTeamMutation() {
const queryClient = useQueryClient();
return useMutation(
(id: TeamId) => deleteTeam(id),
mutationOptions(
withGlobalError('Unable to delete team'),
withInvalidate(queryClient, [['teams']])
)
);
}
export async function deleteTeam(id: TeamId) {
try {
await axios.delete(buildUrl(id));
} catch (error) {
throw parseAxiosError(error as Error);
}
}

View File

@ -0,0 +1,43 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { promiseSequence } from '@/portainer/helpers/promise-utils';
import { UserId } from '@/portainer/users/types';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withGlobalError, withInvalidate } from '@/react-tools/react-query';
import { TeamId, TeamMembership, TeamMembershipId } from '../types';
import { buildMembershipUrl } from './build-membership-url';
import { queryKeys } from './query-keys';
export function useRemoveMemberMutation(
teamId: TeamId,
teamMemberships: TeamMembership[] = []
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (userIds: UserId[]) =>
promiseSequence(
userIds.map((userId) => () => {
const membership = teamMemberships.find(
(membership) => membership.UserID === userId
);
if (!membership) {
throw new Error('Membership not found');
}
return deleteTeamMembership(membership.Id);
})
),
...withGlobalError('Failure to remove membership'),
...withInvalidate(queryClient, [queryKeys.memberships(teamId)]),
});
}
async function deleteTeamMembership(id: TeamMembershipId) {
try {
await axios.delete(buildMembershipUrl(id));
} catch (e) {
throw parseAxiosError(e, 'Unable to delete team membership');
}
}

View File

@ -0,0 +1,27 @@
import { useQuery } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withGlobalError } from '@/react-tools/react-query';
import { Team, TeamId } from '../types';
import { buildUrl } from './build-url';
import { queryKeys } from './query-keys';
export function useTeam(id: TeamId, onError?: (error: unknown) => void) {
return useQuery({
queryKey: queryKeys.item(id),
queryFn: () => getTeam(id),
...withGlobalError('Unable to load team'),
onError,
});
}
async function getTeam(id: TeamId) {
try {
const { data } = await axios.get<Team>(buildUrl(id));
return data;
} catch (error) {
throw parseAxiosError(error as Error);
}
}

View File

@ -0,0 +1,38 @@
import { useQuery } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withGlobalError } from '@/react-tools/react-query';
import { TeamId, TeamMembership } from '../types';
import { buildUrl } from './build-url';
import { buildMembershipUrl } from './build-membership-url';
import { queryKeys } from './query-keys';
export function useTeamMemberships(id?: TeamId) {
return useQuery({
queryKey: queryKeys.memberships(id),
queryFn: () => (id ? getTeamMemberships(id) : getTeamsMemberships()),
...withGlobalError('Unable to load team memberships'),
});
}
async function getTeamMemberships(teamId: TeamId) {
try {
const { data } = await axios.get<TeamMembership[]>(
buildUrl(teamId, 'memberships')
);
return data;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to get team memberships');
}
}
async function getTeamsMemberships() {
try {
const { data } = await axios.get<TeamMembership[]>(buildMembershipUrl());
return data;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to get team memberships');
}
}

View File

@ -0,0 +1,42 @@
import { useQuery } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withGlobalError } from '@/react-tools/react-query';
import { Team } from '../types';
import { buildUrl } from './build-url';
import { queryKeys } from './query-keys';
export function useTeams<T = Team[]>(
onlyLedTeams = false,
environmentId = 0,
{
enabled = true,
select = (data) => data as unknown as T,
}: {
enabled?: boolean;
select?: (data: Team[]) => T;
} = {}
) {
const teams = useQuery({
queryKey: queryKeys.list({ onlyLedTeams, environmentId }),
queryFn: () => getTeams(onlyLedTeams, environmentId),
...withGlobalError('Unable to load teams'),
enabled,
select,
});
return teams;
}
async function getTeams(onlyLedTeams = false, environmentId = 0) {
try {
const { data } = await axios.get<Team[]>(buildUrl(), {
params: { onlyLedTeams, environmentId },
});
return data;
} catch (error) {
throw parseAxiosError(error as Error);
}
}

View File

@ -0,0 +1,44 @@
import { useQueryClient, useMutation } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { UserId } from '@/portainer/users/types';
import { withGlobalError, withInvalidate } from '@/react-tools/react-query';
import { TeamId, TeamMembership, TeamRole, TeamMembershipId } from '../types';
import { buildMembershipUrl } from './build-membership-url';
import { queryKeys } from './query-keys';
export function useUpdateRoleMutation(
teamId: TeamId,
teamMemberships: TeamMembership[] = []
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ userId, role }: { userId: UserId; role: TeamRole }) => {
const membership = teamMemberships.find(
(membership) => membership.UserID === userId
);
if (!membership) {
throw new Error('Membership not found');
}
return updateTeamMembership(membership.Id, userId, teamId, role);
},
...withGlobalError('Failure to update membership'),
...withInvalidate(queryClient, [queryKeys.memberships(teamId)]),
});
}
async function updateTeamMembership(
id: TeamMembershipId,
userId: UserId,
teamId: TeamId,
role: TeamRole
) {
try {
await axios.put(buildMembershipUrl(id), { userId, teamId, role });
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to update team membership');
}
}

View File

@ -1,47 +0,0 @@
import { UserId } from '@/portainer/users/types';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { TeamId, TeamRole, TeamMembershipId } from './types';
export async function createTeamMembership(
userId: UserId,
teamId: TeamId,
role: TeamRole
) {
try {
await axios.post(buildUrl(), { userId, teamId, role });
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to create team membership');
}
}
export async function deleteTeamMembership(id: TeamMembershipId) {
try {
await axios.delete(buildUrl(id));
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to delete team membership');
}
}
export async function updateTeamMembership(
id: TeamMembershipId,
userId: UserId,
teamId: TeamId,
role: TeamRole
) {
try {
await axios.put(buildUrl(id), { userId, teamId, role });
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to update team membership');
}
}
function buildUrl(id?: TeamMembershipId) {
let url = '/team_memberships';
if (id) {
url += `/${id}`;
}
return url;
}

View File

@ -1,71 +0,0 @@
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { type UserId } from '@/portainer/users/types';
import { createTeamMembership } from './team-membership.service';
import { Team, TeamId, TeamMembership, TeamRole } from './types';
export async function getTeams(onlyLedTeams = false, environmentId = 0) {
try {
const { data } = await axios.get<Team[]>(buildUrl(), {
params: { onlyLedTeams, environmentId },
});
return data;
} catch (error) {
throw parseAxiosError(error as Error);
}
}
export async function getTeam(id: TeamId) {
try {
const { data } = await axios.get<Team>(buildUrl(id));
return data;
} catch (error) {
throw parseAxiosError(error as Error);
}
}
export async function deleteTeam(id: TeamId) {
try {
await axios.delete(buildUrl(id));
} catch (error) {
throw parseAxiosError(error as Error);
}
}
export async function createTeam(name: string, leaders: UserId[]) {
try {
const { data: team } = await axios.post(buildUrl(), { name });
await Promise.all(
leaders.map((leaderId) =>
createTeamMembership(leaderId, team.Id, TeamRole.Leader)
)
);
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to create team');
}
}
export async function getTeamMemberships(teamId: TeamId) {
try {
const { data } = await axios.get<TeamMembership[]>(
buildUrl(teamId, 'memberships')
);
return data;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to get team memberships');
}
}
function buildUrl(id?: TeamId, action?: string) {
let url = '/teams';
if (id) {
url += `/${id}`;
}
if (action) {
url += `/${action}`;
}
return url;
}