diff --git a/api/dataservices/teammembership/teammembership.go b/api/dataservices/teammembership/teammembership.go
index f3ce14521..4c8e80dbc 100644
--- a/api/dataservices/teammembership/teammembership.go
+++ b/api/dataservices/teammembership/teammembership.go
@@ -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)
diff --git a/app/portainer/__module.js b/app/portainer/__module.js
index f4b8fbdaa..7f51e6e70 100644
--- a/app/portainer/__module.js
+++ b/app/portainer/__module.js
@@ -381,8 +381,7 @@ angular
url: '/users',
views: {
'content@': {
- templateUrl: './views/users/users.html',
- controller: 'UsersController',
+ component: 'usersListView',
},
},
data: {
diff --git a/app/portainer/react/components/users.ts b/app/portainer/react/components/users.ts
index 587acbcc4..ffb280bbe 100644
--- a/app/portainer/react/components/users.ts
+++ b/app/portainer/react/components/users.ts
@@ -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)), [
diff --git a/app/portainer/react/views/index.ts b/app/portainer/react/views/index.ts
index 52c994926..d699f903d 100644
--- a/app/portainer/react/views/index.ts
+++ b/app/portainer/react/views/index.ts
@@ -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',
diff --git a/app/portainer/react/views/users.ts b/app/portainer/react/views/users.ts
new file mode 100644
index 000000000..22991e975
--- /dev/null
+++ b/app/portainer/react/views/users.ts
@@ -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;
diff --git a/app/portainer/services/api/userService.js b/app/portainer/services/api/userService.js
index ae0bfd834..1ee22a7b5 100644
--- a/app/portainer/services/api/userService.js
+++ b/app/portainer/services/api/userService.js
@@ -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;
};
diff --git a/app/portainer/views/users/users.html b/app/portainer/views/users/users.html
deleted file mode 100644
index 1eb9f3b19..000000000
--- a/app/portainer/views/users/users.html
+++ /dev/null
@@ -1,169 +0,0 @@
-
-
-
-
-
diff --git a/app/portainer/views/users/usersController.js b/app/portainer/views/users/usersController.js
deleted file mode 100644
index c24aae7c8..000000000
--- a/app/portainer/views/users/usersController.js
+++ /dev/null
@@ -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],
- }));
-}
diff --git a/app/react-tools/react-query.ts b/app/react-tools/react-query.ts
index 8ab6d611d..d919bb07b 100644
--- a/app/react-tools/react-query.ts
+++ b/app/react-tools/react-query.ts
@@ -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 | Readonly;
export function withInvalidate(
queryClient: QueryClient,
- queryKeysToInvalidate: Array>>,
+ queryKeysToInvalidate: Array>>,
// 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 } = {}
diff --git a/app/react/components/TeamsSelector/TeamsSelector.tsx b/app/react/components/TeamsSelector/TeamsSelector.tsx
index 8b00686e6..775811471 100644
--- a/app/react/components/TeamsSelector/TeamsSelector.tsx
+++ b/app/react/components/TeamsSelector/TeamsSelector.tsx
@@ -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;
diff --git a/app/react/components/form-components/FormActions.tsx b/app/react/components/form-components/FormActions.tsx
index 7a8ac2aac..9bfce6964 100644
--- a/app/react/components/form-components/FormActions.tsx
+++ b/app/react/components/form-components/FormActions.tsx
@@ -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['icon'];
}
export function FormActions({
@@ -21,6 +22,7 @@ export function FormActions({
children,
isValid,
errors,
+ submitIcon,
'data-cy': dataCy,
}: PropsWithChildren) {
return (
@@ -34,6 +36,7 @@ export function FormActions({
isLoading={isLoading}
disabled={!isValid}
data-cy={dataCy}
+ icon={submitIcon}
>
{submitLabel}
diff --git a/app/react/components/form-components/PortainerSelect.tsx b/app/react/components/form-components/PortainerSelect.tsx
index 108b54e8e..04f9517a9 100644
--- a/app/react/components/form-components/PortainerSelect.tsx
+++ b/app/react/components/form-components/PortainerSelect.tsx
@@ -36,7 +36,7 @@ interface SharedProps
interface MultiProps extends SharedProps {
value: readonly TValue[];
- onChange(value: readonly TValue[]): void;
+ onChange(value: TValue[]): void;
options: Options;
isMulti: true;
components?: SelectComponentsConfig<
diff --git a/app/react/portainer/users/ListView/ListView.tsx b/app/react/portainer/users/ListView/ListView.tsx
new file mode 100644
index 000000000..68449b32b
--- /dev/null
+++ b/app/react/portainer/users/ListView/ListView.tsx
@@ -0,0 +1,16 @@
+import { PageHeader } from '@@/PageHeader';
+
+import { NewUserForm } from './NewUserForm/NewUserForm';
+import { UsersDatatable } from './UsersDatatable/UsersDatatable';
+
+export function ListView() {
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
diff --git a/app/react/portainer/users/ListView/NewUserForm/AdminSwitch.tsx b/app/react/portainer/users/ListView/NewUserForm/AdminSwitch.tsx
new file mode 100644
index 000000000..7d46a573c
--- /dev/null
+++ b/app/react/portainer/users/ListView/NewUserForm/AdminSwitch.tsx
@@ -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('isAdmin');
+ return (
+
+
+ setValue(checked)}
+ name={name}
+ labelClass="col-sm-3 col-lg-2"
+ />
+
+
+ );
+}
diff --git a/app/react/portainer/users/ListView/NewUserForm/ConfirmPasswordField.tsx b/app/react/portainer/users/ListView/NewUserForm/ConfirmPasswordField.tsx
new file mode 100644
index 000000000..bd0798d21
--- /dev/null
+++ b/app/react/portainer/users/ListView/NewUserForm/ConfirmPasswordField.tsx
@@ -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('confirmPassword');
+ return (
+
+
+
+
+ {error ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
diff --git a/app/react/portainer/users/ListView/NewUserForm/FormValues.tsx b/app/react/portainer/users/ListView/NewUserForm/FormValues.tsx
new file mode 100644
index 000000000..d09a38e04
--- /dev/null
+++ b/app/react/portainer/users/ListView/NewUserForm/FormValues.tsx
@@ -0,0 +1,9 @@
+import { TeamId } from '../../teams/types';
+
+export interface FormValues {
+ username: string;
+ password: string;
+ confirmPassword: string;
+ isAdmin: boolean;
+ teams: TeamId[];
+}
diff --git a/app/react/portainer/users/ListView/NewUserForm/NewUserForm.tsx b/app/react/portainer/users/ListView/NewUserForm/NewUserForm.tsx
new file mode 100644
index 000000000..88dc6811b
--- /dev/null
+++ b/app/react/portainer/users/ListView/NewUserForm/NewUserForm.tsx
@@ -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 (
+
+
+
+
+
+
+ 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 }) => (
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/app/react/portainer/users/ListView/NewUserForm/PasswordField.tsx b/app/react/portainer/users/ListView/NewUserForm/PasswordField.tsx
new file mode 100644
index 000000000..170d37482
--- /dev/null
+++ b/app/react/portainer/users/ListView/NewUserForm/PasswordField.tsx
@@ -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('password');
+ return (
+
+
+
+ );
+}
diff --git a/app/react/portainer/users/ListView/NewUserForm/TeamsField.tsx b/app/react/portainer/users/ListView/NewUserForm/TeamsField.tsx
new file mode 100644
index 000000000..4f5886ad5
--- /dev/null
+++ b/app/react/portainer/users/ListView/NewUserForm/TeamsField.tsx
@@ -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;
+ disabled?: boolean;
+}) {
+ const [{ name, value }, { error }, { setValue }] =
+ useField('teams');
+
+ return (
+
+ setValue(value)}
+ value={value}
+ name={name}
+ teams={teams}
+ inputId="teams-field"
+ disabled={disabled}
+ />
+
+ );
+}
diff --git a/app/react/portainer/users/ListView/NewUserForm/TeamsFieldset.tsx b/app/react/portainer/users/ListView/NewUserForm/TeamsFieldset.tsx
new file mode 100644
index 000000000..08c3e6cb5
--- /dev/null
+++ b/app/react/portainer/users/ListView/NewUserForm/TeamsFieldset.tsx
@@ -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();
+ const { isPureAdmin } = useCurrentUser();
+ const teamsQuery = useTeams(!isPureAdmin);
+ const settingsQuery = usePublicSettings();
+ if (!teamsQuery.data || !settingsQuery.data) {
+ return null;
+ }
+
+ const { TeamSync: teamSync } = settingsQuery.data;
+
+ return (
+ <>
+ {isPureAdmin && }
+
+ {!values.isAdmin && (
+
+ )}
+
+ {teamSync && }
+
+ {isPureAdmin && !values.isAdmin && values.teams.length === 0 && (
+
+ )}
+ >
+ );
+}
+
+function TeamSyncMessage() {
+ return (
+
+
+
+ The team leader feature is disabled as external authentication is
+ currently enabled with team sync.
+
+
+
+ );
+}
+
+function NoTeamSelected() {
+ return (
+
+
+
+ Note: non-administrator users who aren't in a team don't
+ have access to any environments by default. Head over to the{' '}
+
+ Environments view
+ {' '}
+ to manage their accesses.
+
+
+
+ );
+}
diff --git a/app/react/portainer/users/ListView/NewUserForm/UsernameField.tsx b/app/react/portainer/users/ListView/NewUserForm/UsernameField.tsx
new file mode 100644
index 000000000..a70b1c74f
--- /dev/null
+++ b/app/react/portainer/users/ListView/NewUserForm/UsernameField.tsx
@@ -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('username');
+
+ return (
+
+
+
+
+ {error ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
diff --git a/app/react/portainer/users/ListView/NewUserForm/useValidation.tsx b/app/react/portainer/users/ListView/NewUserForm/useValidation.tsx
new file mode 100644
index 000000000..ba7f3caf7
--- /dev/null
+++ b/app/react/portainer/users/ListView/NewUserForm/useValidation.tsx
@@ -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 {
+ 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'
+ ),
+ });
+}
diff --git a/app/react/portainer/users/ListView/UsersDatatable/UsersDatatable.tsx b/app/react/portainer/users/ListView/UsersDatatable/UsersDatatable.tsx
index 889e1e4bd..fa036c0f3 100644
--- a/app/react/portainer/users/ListView/UsersDatatable/UsersDatatable.tsx
+++ b/app/react/portainer/users/ListView/UsersDatatable/UsersDatatable.tsx
@@ -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;
- onRemove: (selectedItems: Array) => 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 | 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 (
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', '');
+ },
+ });
+ }
+}
diff --git a/app/react/portainer/users/queries/useCreateUserMutation.ts b/app/react/portainer/users/queries/useCreateUserMutation.ts
new file mode 100644
index 000000000..918e38722
--- /dev/null
+++ b/app/react/portainer/users/queries/useCreateUserMutation.ts
@@ -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;
+}
+
+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(buildUrl(), payload);
+ return data;
+ } catch (err) {
+ throw parseAxiosError(err, 'Unable to create user');
+ }
+}
diff --git a/app/react/portainer/users/queries/useDeleteUserMutation.ts b/app/react/portainer/users/queries/useDeleteUserMutation.ts
new file mode 100644
index 000000000..bdfaedcc7
--- /dev/null
+++ b/app/react/portainer/users/queries/useDeleteUserMutation.ts
@@ -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);
+ }
+}
diff --git a/app/react/portainer/users/teams/ItemView/Details.tsx b/app/react/portainer/users/teams/ItemView/Details.tsx
index 95f721a04..3f225d87c 100644
--- a/app/react/portainer/users/teams/ItemView/Details.tsx
+++ b/app/react/portainer/users/teams/ItemView/Details.tsx
@@ -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({
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']])
- )
- );
-}
diff --git a/app/react/portainer/users/teams/ListView/CreateTeamForm/CreateTeamForm.tsx b/app/react/portainer/users/teams/ListView/CreateTeamForm/CreateTeamForm.tsx
index 4efff99fe..909b4e778 100644
--- a/app/react/portainer/users/teams/ListView/CreateTeamForm/CreateTeamForm.tsx
+++ b/app/react/portainer/users/teams/ListView/CreateTeamForm/CreateTeamForm.tsx
@@ -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']);
- },
- }
- );
-}
diff --git a/app/react/portainer/users/teams/ListView/TeamsDatatable/TeamsDatatable.tsx b/app/react/portainer/users/teams/ListView/TeamsDatatable/TeamsDatatable.tsx
index a02974bf2..053f09013 100644
--- a/app/react/portainer/users/teams/ListView/TeamsDatatable/TeamsDatatable.tsx
+++ b/app/react/portainer/users/teams/ListView/TeamsDatatable/TeamsDatatable.tsx
@@ -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[] = [
diff --git a/app/react/portainer/users/teams/queries.ts b/app/react/portainer/users/teams/queries.ts
deleted file mode 100644
index 4582a5b82..000000000
--- a/app/react/portainer/users/teams/queries.ts
+++ /dev/null
@@ -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(
- 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']);
- },
- }
- );
-}
diff --git a/app/react/portainer/users/teams/queries/build-membership-url.ts b/app/react/portainer/users/teams/queries/build-membership-url.ts
new file mode 100644
index 000000000..6b974c2a1
--- /dev/null
+++ b/app/react/portainer/users/teams/queries/build-membership-url.ts
@@ -0,0 +1,11 @@
+import { TeamMembershipId } from '../types';
+
+export function buildMembershipUrl(id?: TeamMembershipId) {
+ let url = '/team_memberships';
+
+ if (id) {
+ url += `/${id}`;
+ }
+
+ return url;
+}
diff --git a/app/react/portainer/users/teams/queries/build-url.ts b/app/react/portainer/users/teams/queries/build-url.ts
new file mode 100644
index 000000000..7994a63dc
--- /dev/null
+++ b/app/react/portainer/users/teams/queries/build-url.ts
@@ -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;
+}
diff --git a/app/react/portainer/users/teams/queries/index.ts b/app/react/portainer/users/teams/queries/index.ts
new file mode 100644
index 000000000..454667bed
--- /dev/null
+++ b/app/react/portainer/users/teams/queries/index.ts
@@ -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';
diff --git a/app/react/portainer/users/teams/queries/query-keys.ts b/app/react/portainer/users/teams/queries/query-keys.ts
new file mode 100644
index 000000000..90c958da6
--- /dev/null
+++ b/app/react/portainer/users/teams/queries/query-keys.ts
@@ -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,
+};
diff --git a/app/react/portainer/users/teams/queries/useAddMemberMutation.ts b/app/react/portainer/users/teams/queries/useAddMemberMutation.ts
new file mode 100644
index 000000000..87dfeb296
--- /dev/null
+++ b/app/react/portainer/users/teams/queries/useAddMemberMutation.ts
@@ -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');
+ }
+}
diff --git a/app/react/portainer/users/teams/queries/useAddTeamMutation.ts b/app/react/portainer/users/teams/queries/useAddTeamMutation.ts
new file mode 100644
index 000000000..85791f74c
--- /dev/null
+++ b/app/react/portainer/users/teams/queries/useAddTeamMutation.ts
@@ -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');
+ }
+}
diff --git a/app/react/portainer/users/teams/queries/useDeleteTeamMutation.ts b/app/react/portainer/users/teams/queries/useDeleteTeamMutation.ts
new file mode 100644
index 000000000..426b9ca2e
--- /dev/null
+++ b/app/react/portainer/users/teams/queries/useDeleteTeamMutation.ts
@@ -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);
+ }
+}
diff --git a/app/react/portainer/users/teams/queries/useRemoveMemberMutation.ts b/app/react/portainer/users/teams/queries/useRemoveMemberMutation.ts
new file mode 100644
index 000000000..3e63e9b83
--- /dev/null
+++ b/app/react/portainer/users/teams/queries/useRemoveMemberMutation.ts
@@ -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');
+ }
+}
diff --git a/app/react/portainer/users/teams/queries/useTeam.ts b/app/react/portainer/users/teams/queries/useTeam.ts
new file mode 100644
index 000000000..adc8d8caa
--- /dev/null
+++ b/app/react/portainer/users/teams/queries/useTeam.ts
@@ -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(buildUrl(id));
+ return data;
+ } catch (error) {
+ throw parseAxiosError(error as Error);
+ }
+}
diff --git a/app/react/portainer/users/teams/queries/useTeamMemberships.ts b/app/react/portainer/users/teams/queries/useTeamMemberships.ts
new file mode 100644
index 000000000..e690e3958
--- /dev/null
+++ b/app/react/portainer/users/teams/queries/useTeamMemberships.ts
@@ -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(
+ 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(buildMembershipUrl());
+ return data;
+ } catch (e) {
+ throw parseAxiosError(e as Error, 'Unable to get team memberships');
+ }
+}
diff --git a/app/react/portainer/users/teams/queries/useTeams.ts b/app/react/portainer/users/teams/queries/useTeams.ts
new file mode 100644
index 000000000..c81c876aa
--- /dev/null
+++ b/app/react/portainer/users/teams/queries/useTeams.ts
@@ -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(
+ 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(buildUrl(), {
+ params: { onlyLedTeams, environmentId },
+ });
+ return data;
+ } catch (error) {
+ throw parseAxiosError(error as Error);
+ }
+}
diff --git a/app/react/portainer/users/teams/queries/useUpdateRoleMutation.ts b/app/react/portainer/users/teams/queries/useUpdateRoleMutation.ts
new file mode 100644
index 000000000..b794cd044
--- /dev/null
+++ b/app/react/portainer/users/teams/queries/useUpdateRoleMutation.ts
@@ -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');
+ }
+}
diff --git a/app/react/portainer/users/teams/team-membership.service.ts b/app/react/portainer/users/teams/team-membership.service.ts
deleted file mode 100644
index bac4c3013..000000000
--- a/app/react/portainer/users/teams/team-membership.service.ts
+++ /dev/null
@@ -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;
-}
diff --git a/app/react/portainer/users/teams/teams.service.ts b/app/react/portainer/users/teams/teams.service.ts
deleted file mode 100644
index 1cbcf19bb..000000000
--- a/app/react/portainer/users/teams/teams.service.ts
+++ /dev/null
@@ -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(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(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(
- 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;
-}