1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-24 07:49:41 +02:00

refactor(namespace): migrate namespace access view to react [r8s-141] (#87)

This commit is contained in:
Ali 2024-11-11 08:17:20 +13:00 committed by GitHub
parent 8ed7cd80cb
commit e9fc6d5598
62 changed files with 1018 additions and 610 deletions

View file

@ -0,0 +1,199 @@
import { Form, FormikProps } from 'formik';
import { Plus } from 'lucide-react';
import { useMemo } from 'react';
import { useEnvironment } from '@/react/portainer/environments/queries';
import { useGroup } from '@/react/portainer/environments/environment-groups/queries';
import { useUsers } from '@/portainer/users/queries';
import { useTeams } from '@/react/portainer/users/teams/queries/useTeams';
import { User } from '@/portainer/users/types';
import { Environment } from '@/react/portainer/environments/types';
import { Team } from '@/react/portainer/users/teams/types';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { EnvironmentGroup } from '@/react/portainer/environments/environment-groups/types';
import { useIsEdgeAdmin } from '@/react/hooks/useUser';
import { LoadingButton } from '@@/buttons';
import { FormControl } from '@@/form-components/FormControl';
import { Link } from '@@/Link';
import { NamespaceAccessUsersSelector } from '../NamespaceAccessUsersSelector';
import { EnvironmentAccess, NamespaceAccess } from '../types';
import { CreateAccessValues } from './types';
export function CreateAccessInnerForm({
values,
handleSubmit,
setFieldValue,
isSubmitting,
isValid,
dirty,
namespaceAccessesGranted,
}: FormikProps<CreateAccessValues> & {
namespaceAccessesGranted: NamespaceAccess[];
}) {
const environmentId = useEnvironmentId();
const environmentQuery = useEnvironment(environmentId);
const groupQuery = useGroup(environmentQuery.data?.GroupId);
const usersQuery = useUsers(false, environmentId);
const teamsQuery = useTeams();
const availableTeamOrUserOptions: EnvironmentAccess[] =
useAvailableTeamOrUserOptions(
values.selectedUsersAndTeams,
namespaceAccessesGranted,
environmentQuery.data,
groupQuery.data,
usersQuery.data,
teamsQuery.data
);
const isAdminQuery = useIsEdgeAdmin();
return (
<Form className="form-horizontal" onSubmit={handleSubmit} noValidate>
<FormControl label="Select user(s) and/or team(s)">
{availableTeamOrUserOptions.length > 0 ||
values.selectedUsersAndTeams.length > 0 ? (
<NamespaceAccessUsersSelector
inputId="users-selector"
options={availableTeamOrUserOptions}
onChange={(opts) => setFieldValue('selectedUsersAndTeams', opts)}
value={values.selectedUsersAndTeams}
dataCy="namespaceAccess-usersSelector"
/>
) : (
<span className="small text-muted pt-2">
No user or team access has been set on the environment.
{isAdminQuery.isAdmin && (
<>
{' '}
Head over to the{' '}
<Link
to="portainer.endpoints"
data-cy="namespaceAccess-environmentsLink"
>
Environments view
</Link>{' '}
to manage them.
</>
)}
</span>
)}
</FormControl>
<div className="form-group mt-5">
<div className="col-sm-12">
<LoadingButton
disabled={!isValid || !dirty}
data-cy="namespaceAccess-createAccessButton"
isLoading={isSubmitting}
loadingText="Creating access..."
icon={Plus}
className="!ml-0"
>
Create access
</LoadingButton>
</div>
</div>
</Form>
);
}
/**
* Returns the team and user options that can be added to the namespace, excluding the ones that already have access.
*/
function useAvailableTeamOrUserOptions(
selectedAccesses: EnvironmentAccess[],
namespaceAccessesGranted: NamespaceAccess[],
environment?: Environment,
group?: EnvironmentGroup,
users?: User[],
teams?: Team[]
) {
return useMemo(() => {
// get unique users and teams from environment accesses (the keys are the IDs)
const environmentAccessPolicies = environment?.UserAccessPolicies ?? {};
const environmentTeamAccessPolicies = environment?.TeamAccessPolicies ?? {};
const environmentGroupAccessPolicies = group?.UserAccessPolicies ?? {};
const environmentGroupTeamAccessPolicies = group?.TeamAccessPolicies ?? {};
// get all users that have access to the environment
const userAccessPolicies = {
...environmentAccessPolicies,
...environmentGroupAccessPolicies,
};
const uniqueUserIds = new Set(Object.keys(userAccessPolicies));
const userAccessOptions: EnvironmentAccess[] = Array.from(uniqueUserIds)
.map((id) => {
const userId = parseInt(id, 10);
const user = users?.find((u) => u.Id === userId);
if (!user) {
return null;
}
// role from the userAccessPolicies is used by default, if not found, role from the environmentTeamAccessPolicies is used
const userAccessPolicy =
environmentAccessPolicies[userId] ??
environmentGroupAccessPolicies[userId];
const userAccess: EnvironmentAccess = {
id: user?.Id,
name: user?.Username,
type: 'user',
role: {
name: 'Standard user',
id: userAccessPolicy?.RoleId,
},
};
return userAccess;
})
.filter((u) => u !== null);
// get all teams that have access to the environment
const teamAccessPolicies = {
...environmentTeamAccessPolicies,
...environmentGroupTeamAccessPolicies,
};
const uniqueTeamIds = new Set(Object.keys(teamAccessPolicies));
const teamAccessOptions: EnvironmentAccess[] = Array.from(uniqueTeamIds)
.map((id) => {
const teamId = parseInt(id, 10);
const team = teams?.find((t) => t.Id === teamId);
if (!team) {
return null;
}
const teamAccessPolicy =
environmentTeamAccessPolicies[teamId] ??
environmentGroupTeamAccessPolicies[teamId];
const teamAccess: EnvironmentAccess = {
id: team?.Id,
name: team?.Name,
type: 'team',
role: {
name: 'Standard user',
id: teamAccessPolicy?.RoleId,
},
};
return teamAccess;
})
.filter((t) => t !== null);
// filter out users and teams that already have access to the namespace
const userAndTeamEnvironmentAccesses = [
...userAccessOptions,
...teamAccessOptions,
];
const filteredAccessOptions = userAndTeamEnvironmentAccesses.filter(
(t) =>
!selectedAccesses.some((e) => e.id === t.id && e.type === t.type) &&
!namespaceAccessesGranted.some(
(e) => e.id === t.id && e.type === t.type
)
);
return filteredAccessOptions;
}, [
namespaceAccessesGranted,
selectedAccesses,
environment,
group,
users,
teams,
]);
}

View file

@ -0,0 +1,119 @@
import { UserPlusIcon } from 'lucide-react';
import { Formik } from 'formik';
import { useMemo } from 'react';
import { useCurrentStateAndParams } from '@uirouter/react';
import { useIsRBACEnabled } from '@/react/kubernetes/cluster/useIsRBACEnabled';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { RBACAlert } from '@/react/kubernetes/cluster/ConfigureView/ConfigureForm/RBACAlert';
import { useUsers } from '@/portainer/users/queries';
import { PortainerNamespaceAccessesConfigMap } from '@/react/kubernetes/configs/constants';
import { useConfigMap } from '@/react/kubernetes/configs/queries/useConfigMap';
import { useTeams } from '@/react/portainer/users/teams/queries';
import { useUpdateK8sConfigMapMutation } from '@/react/kubernetes/configs/queries/useUpdateK8sConfigMapMutation';
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
import { TextTip } from '@@/Tip/TextTip';
import { EnvironmentAccess } from '../types';
import { createAuthorizeAccessConfigMapPayload } from '../createAccessConfigMapPayload';
import { parseNamespaceAccesses } from '../parseNamespaceAccesses';
import { CreateAccessValues } from './types';
import { CreateAccessInnerForm } from './CreateAccessInnerForm';
import { validationSchema } from './createAccess.validation';
export function CreateAccessWidget() {
const {
params: { id: namespaceName },
} = useCurrentStateAndParams();
const environmentId = useEnvironmentId();
const isRBACEnabledQuery = useIsRBACEnabled(environmentId);
const initialValues: {
selectedUsersAndTeams: EnvironmentAccess[];
} = {
selectedUsersAndTeams: [],
};
const usersQuery = useUsers(false, environmentId);
const teamsQuery = useTeams(false, environmentId);
const accessConfigMapQuery = useConfigMap(
environmentId,
PortainerNamespaceAccessesConfigMap.namespace,
PortainerNamespaceAccessesConfigMap.configMapName
);
const namespaceAccesses = useMemo(
() =>
parseNamespaceAccesses(
accessConfigMapQuery.data ?? null,
namespaceName,
usersQuery.data ?? [],
teamsQuery.data ?? []
),
[accessConfigMapQuery.data, usersQuery.data, teamsQuery.data, namespaceName]
);
const configMap = accessConfigMapQuery.data;
const updateConfigMapMutation = useUpdateK8sConfigMapMutation(
environmentId,
PortainerNamespaceAccessesConfigMap.namespace
);
return (
<div className="row">
<div className="col-sm-12">
<Widget aria-label="Create access">
<WidgetTitle icon={UserPlusIcon} title="Create access" />
<WidgetBody>
{isRBACEnabledQuery.data === false && <RBACAlert />}
<TextTip className="mb-2" childrenWrapperClassName="text-warning">
Adding user access will require the affected user(s) to logout and
login for the changes to be taken into account.
</TextTip>
{isRBACEnabledQuery.data !== false && (
<Formik<CreateAccessValues>
initialValues={initialValues}
enableReinitialize
validationSchema={validationSchema}
onSubmit={onSubmit}
validateOnMount
>
{(formikProps) => (
<CreateAccessInnerForm
// eslint-disable-next-line react/jsx-props-no-spreading
{...formikProps}
namespaceAccessesGranted={namespaceAccesses}
/>
)}
</Formik>
)}
</WidgetBody>
</Widget>
</div>
</div>
);
async function onSubmit(
values: {
selectedUsersAndTeams: EnvironmentAccess[];
},
{ resetForm }: { resetForm: () => void }
) {
try {
const configMapPayload = createAuthorizeAccessConfigMapPayload(
namespaceAccesses,
values.selectedUsersAndTeams,
namespaceName,
configMap
);
await updateConfigMapMutation.mutateAsync({
data: configMapPayload,
configMapName: PortainerNamespaceAccessesConfigMap.configMapName,
});
notifySuccess('Success', 'Namespace access updated');
resetForm();
} catch (error) {
notifyError('Failed to update namespace access', error as Error);
}
}
}

View file

@ -0,0 +1,19 @@
import { object, array, string, number, SchemaOf, mixed } from 'yup';
import { CreateAccessValues } from './types';
export function validationSchema(): SchemaOf<CreateAccessValues> {
return object().shape({
selectedUsersAndTeams: array(
object().shape({
type: mixed().oneOf(['team', 'user']).required(),
name: string().required(),
id: number().required(),
role: object().shape({
id: number().required(),
name: string().required(),
}),
})
).min(1),
});
}

View file

@ -0,0 +1,5 @@
import { EnvironmentAccess } from '../types';
export type CreateAccessValues = {
selectedUsersAndTeams: EnvironmentAccess[];
};