1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-05 13:55:21 +02:00

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,7 +5,6 @@ import { ColumnDef } from '@tanstack/react-table';
import { notifySuccess } from '@/portainer/services/notifications';
import { promiseSequence } from '@/portainer/helpers/promise-utils';
import { Team, TeamId } from '@/react/portainer/users/teams/types';
import { deleteTeam } from '@/react/portainer/users/teams/teams.service';
import { Datatable } from '@@/datatables';
import { buildNameColumn } from '@@/datatables/buildNameColumn';
@ -13,6 +12,8 @@ import { createPersistedStore } from '@@/datatables/types';
import { useTableState } from '@@/datatables/useTableState';
import { DeleteButton } from '@@/buttons/DeleteButton';
import { deleteTeam } from '../../queries/useDeleteTeamMutation';
const storageKey = 'teams';
const columns: ColumnDef<Team>[] = [

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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