From 3c1441d46221feb0629716309bdacbc283b30fba Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Wed, 28 Aug 2024 14:04:32 -0600 Subject: [PATCH] refactor(users): migrate list view to react [EE-2202] (#11914) --- .../teammembership/teammembership.go | 6 +- app/portainer/__module.js | 3 +- app/portainer/react/components/users.ts | 5 - app/portainer/react/views/index.ts | 2 + app/portainer/react/views/users.ts | 15 ++ app/portainer/services/api/userService.js | 53 +----- app/portainer/views/users/users.html | 169 ------------------ app/portainer/views/users/usersController.js | 143 --------------- app/react-tools/react-query.ts | 6 +- .../TeamsSelector/TeamsSelector.tsx | 2 +- .../form-components/FormActions.tsx | 5 +- .../form-components/PortainerSelect.tsx | 2 +- .../portainer/users/ListView/ListView.tsx | 16 ++ .../ListView/NewUserForm/AdminSwitch.tsx | 25 +++ .../NewUserForm/ConfirmPasswordField.tsx | 42 +++++ .../users/ListView/NewUserForm/FormValues.tsx | 9 + .../ListView/NewUserForm/NewUserForm.tsx | 109 +++++++++++ .../ListView/NewUserForm/PasswordField.tsx | 26 +++ .../users/ListView/NewUserForm/TeamsField.tsx | 33 ++++ .../ListView/NewUserForm/TeamsFieldset.tsx | 71 ++++++++ .../ListView/NewUserForm/UsernameField.tsx | 54 ++++++ .../ListView/NewUserForm/useValidation.tsx | 52 ++++++ .../UsersDatatable/UsersDatatable.tsx | 80 ++++++++- .../users/queries/useCreateUserMutation.ts | 43 +++++ .../users/queries/useDeleteUserMutation.ts | 24 +++ .../users/teams/ItemView/Details.tsx | 24 +-- .../CreateTeamForm/CreateTeamForm.tsx | 22 +-- .../TeamsDatatable/TeamsDatatable.tsx | 3 +- app/react/portainer/users/teams/queries.ts | 135 -------------- .../teams/queries/build-membership-url.ts | 11 ++ .../users/teams/queries/build-url.ts | 15 ++ .../portainer/users/teams/queries/index.ts | 11 ++ .../users/teams/queries/query-keys.ts | 9 + .../teams/queries/useAddMemberMutation.ts | 40 +++++ .../users/teams/queries/useAddTeamMutation.ts | 39 ++++ .../teams/queries/useDeleteTeamMutation.ts | 32 ++++ .../teams/queries/useRemoveMemberMutation.ts | 43 +++++ .../portainer/users/teams/queries/useTeam.ts | 27 +++ .../users/teams/queries/useTeamMemberships.ts | 38 ++++ .../portainer/users/teams/queries/useTeams.ts | 42 +++++ .../teams/queries/useUpdateRoleMutation.ts | 44 +++++ .../users/teams/team-membership.service.ts | 47 ----- .../portainer/users/teams/teams.service.ts | 71 -------- 43 files changed, 967 insertions(+), 681 deletions(-) create mode 100644 app/portainer/react/views/users.ts delete mode 100644 app/portainer/views/users/users.html delete mode 100644 app/portainer/views/users/usersController.js create mode 100644 app/react/portainer/users/ListView/ListView.tsx create mode 100644 app/react/portainer/users/ListView/NewUserForm/AdminSwitch.tsx create mode 100644 app/react/portainer/users/ListView/NewUserForm/ConfirmPasswordField.tsx create mode 100644 app/react/portainer/users/ListView/NewUserForm/FormValues.tsx create mode 100644 app/react/portainer/users/ListView/NewUserForm/NewUserForm.tsx create mode 100644 app/react/portainer/users/ListView/NewUserForm/PasswordField.tsx create mode 100644 app/react/portainer/users/ListView/NewUserForm/TeamsField.tsx create mode 100644 app/react/portainer/users/ListView/NewUserForm/TeamsFieldset.tsx create mode 100644 app/react/portainer/users/ListView/NewUserForm/UsernameField.tsx create mode 100644 app/react/portainer/users/ListView/NewUserForm/useValidation.tsx create mode 100644 app/react/portainer/users/queries/useCreateUserMutation.ts create mode 100644 app/react/portainer/users/queries/useDeleteUserMutation.ts delete mode 100644 app/react/portainer/users/teams/queries.ts create mode 100644 app/react/portainer/users/teams/queries/build-membership-url.ts create mode 100644 app/react/portainer/users/teams/queries/build-url.ts create mode 100644 app/react/portainer/users/teams/queries/index.ts create mode 100644 app/react/portainer/users/teams/queries/query-keys.ts create mode 100644 app/react/portainer/users/teams/queries/useAddMemberMutation.ts create mode 100644 app/react/portainer/users/teams/queries/useAddTeamMutation.ts create mode 100644 app/react/portainer/users/teams/queries/useDeleteTeamMutation.ts create mode 100644 app/react/portainer/users/teams/queries/useRemoveMemberMutation.ts create mode 100644 app/react/portainer/users/teams/queries/useTeam.ts create mode 100644 app/react/portainer/users/teams/queries/useTeamMemberships.ts create mode 100644 app/react/portainer/users/teams/queries/useTeams.ts create mode 100644 app/react/portainer/users/teams/queries/useUpdateRoleMutation.ts delete mode 100644 app/react/portainer/users/teams/team-membership.service.ts delete mode 100644 app/react/portainer/users/teams/teams.service.ts 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 @@ - - -
-
- - - -
- -
- -
-
- - - - - -
-
-
- - -
- -
- -
-
- - -
- -
-
- - - - - -
-
-
- - - -
-
-
- -
-
- - - -
-
- -
-
- - -
- -
- - You don't seem to have any teams to add users into. Head over to the Teams view to create some. - - -
-
-
-
- -

- - The team leader feature is disabled as external authentication is currently enabled with team sync. -

-
-
-
- -
-
- - - 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. - -
-
-
-
- - - {{ state.userCreationError }} - -
-
-
-
-
-
-
- - 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 }) => ( +
+ + + {authMethod === AuthenticationMethod.Internal && ( + <> + + + + + + + + + )} + + + + + + )} + +
+
+
+
+ ); +} 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; -}