1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-02 20:35:25 +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

@ -1,5 +1,5 @@
import { TagId } from '@/portainer/tags/types';
import { EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types';
import { EnvironmentGroupId } from '@/react/portainer/environments/types';
import { EdgeGroup } from '../../edge-groups/types';

View file

@ -1,7 +1,7 @@
import { useField } from 'formik';
import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
import { EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types';
import { EnvironmentGroupId } from '@/react/portainer/environments/types';
import { useCreateGroupMutation } from '@/react/portainer/environments/environment-groups/queries/useCreateGroupMutation';
import { notifySuccess } from '@/portainer/services/notifications';

View file

@ -1,6 +1,6 @@
import { TagId } from '@/portainer/tags/types';
import { EdgeGroup } from '@/react/edge/edge-groups/types';
import { EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types';
import { EnvironmentGroupId } from '@/react/portainer/environments/types';
export interface FormValues {
group: EnvironmentGroupId | null;

View file

@ -54,7 +54,7 @@ export function useIsPureAdmin() {
}
/**
* Load the admin status of the user, (admin >= edge admin)
* Load the admin status of the user, returning true if the user is edge admin or admin.
* @param forceEnvironmentId to force the environment id, used where the environment id can't be loaded from the router, like sidebar
* @returns query result with isLoading and isAdmin - isAdmin is true if the user edge admin or admin.
*/

View file

@ -2,7 +2,7 @@ import { KeyToPath, Pod, Secret } from 'kubernetes-types/core/v1';
import { Asterisk, Plus } from 'lucide-react';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useSecrets } from '@/react/kubernetes/configs/secret.service';
import { useK8sSecrets } from '@/react/kubernetes/configs/queries/useK8sSecrets';
import { Icon } from '@@/Icon';
import { Link } from '@@/Link';
@ -18,7 +18,7 @@ type Props = {
export function ApplicationVolumeConfigsTable({ namespace, app }: Props) {
const containerVolumeConfigs = getApplicationVolumeConfigs(app);
const { data: secrets } = useSecrets(useEnvironmentId(), namespace);
const { data: secrets } = useK8sSecrets(useEnvironmentId(), namespace);
if (containerVolumeConfigs.length === 0) {
return null;

View file

@ -1,7 +1,7 @@
import { FormikErrors } from 'formik';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useConfigMaps } from '@/react/kubernetes/configs/configmap.service';
import { useK8sConfigMaps } from '@/react/kubernetes/configs/queries/useK8sConfigMaps';
import { FormSection } from '@@/form-components/FormSection/FormSection';
import { TextTip } from '@@/Tip/TextTip';
@ -24,7 +24,7 @@ export function ConfigMapsFormSection({
errors,
namespace,
}: Props) {
const configMapsQuery = useConfigMaps(useEnvironmentId(), namespace);
const configMapsQuery = useK8sConfigMaps(useEnvironmentId(), namespace);
const configMaps = configMapsQuery.data || [];
if (configMapsQuery.isLoading) {

View file

@ -1,7 +1,7 @@
import { FormikErrors } from 'formik';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useSecrets } from '@/react/kubernetes/configs/secret.service';
import { useK8sSecrets } from '@/react/kubernetes/configs/queries/useK8sSecrets';
import { FormSection } from '@@/form-components/FormSection/FormSection';
import { TextTip } from '@@/Tip/TextTip';
@ -24,7 +24,7 @@ export function SecretsFormSection({
errors,
namespace,
}: Props) {
const secretsQuery = useSecrets(useEnvironmentId(), namespace);
const secretsQuery = useK8sSecrets(useEnvironmentId(), namespace);
const secrets = secretsQuery.data || [];
if (secretsQuery.isLoading) {

View file

@ -23,7 +23,7 @@ import { InsightsBox } from '@@/InsightsBox';
import { useIngressControllerClassMapQuery } from '../../ingressClass/useIngressControllerClassMap';
import { IngressControllerClassMap } from '../../ingressClass/types';
import { useIsRBACEnabledQuery } from '../../getIsRBACEnabled';
import { useIsRBACEnabled } from '../../useIsRBACEnabled';
import { getIngressClassesFormValues } from '../../ingressClass/IngressClassDatatable/utils';
import { useStorageClassesFormValues } from './useStorageClasses';
@ -102,7 +102,7 @@ function InnerForm({
environmentId: EnvironmentId;
}) {
const { data: isRBACEnabled, ...isRBACEnabledQuery } =
useIsRBACEnabledQuery(environmentId);
useIsRBACEnabled(environmentId);
const onChangeControllers = useCallback(
(controllerClassMap: IngressControllerClassMap[]) =>

View file

@ -1,13 +0,0 @@
diff a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/EnableMetricsInput.tsx b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/EnableMetricsInput.tsx (rejected hunks)
@@ -103,7 +103,10 @@ export function EnableMetricsInput({ value, error, environmentId }: Props) {
<TextTip color="red" icon={XCircle}>
Unable to reach metrics API. You can enable the metrics-server
addon in the{' '}
- <Link to="kubernetes.cluster">Cluster Details view</Link>.
+ <Link to="kubernetes.cluster" data-cy="cluster-details-view-link">
+ Cluster Details view
+ </Link>
+ .
</TextTip>
)}
{metricsFound === true && (

View file

@ -2,20 +2,20 @@ import { useQuery } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { withError } from '@/react-tools/react-query';
import { withGlobalError } from '@/react-tools/react-query';
export function useIsRBACEnabledQuery(environmentId: EnvironmentId) {
export function useIsRBACEnabled(environmentId: EnvironmentId) {
return useQuery<boolean, Error>(
['environments', environmentId, 'rbacEnabled'],
() => getIsRBACEnabled(environmentId),
{
enabled: !!environmentId,
...withError('Unable to check if RBAC is enabled.'),
...withGlobalError('Unable to check if RBAC is enabled.'),
}
);
}
export async function getIsRBACEnabled(environmentId: EnvironmentId) {
async function getIsRBACEnabled(environmentId: EnvironmentId) {
try {
const { data } = await axios.get<boolean>(
`kubernetes/${environmentId}/rbac_enabled`

View file

@ -1,2 +1,11 @@
export const configurationOwnerUsernameLabel =
export const ConfigurationOwnerUsernameLabel =
'io.portainer.kubernetes.configuration.owner';
export const ConfigurationOwnerIdLabel =
'io.portainer.kubernetes.configuration.owner.id';
export const PortainerNamespaceAccessesConfigMap = {
namespace: 'portainer',
configMapName: 'portainer-config',
accessKey: 'NamespaceAccessPolicies',
};

View file

@ -9,33 +9,37 @@ import { Configuration } from '../types';
import { configMapQueryKeys } from './query-keys';
import { ConfigMapQueryParams } from './types';
export function useConfigMap(
export function useConfigMap<T = Configuration>(
environmentId: EnvironmentId,
namespace: string,
configMap: string,
options?: { autoRefreshRate?: number } & ConfigMapQueryParams
options?: {
autoRefreshRate?: number;
select?: (data: Configuration) => T;
enabled?: boolean;
} & ConfigMapQueryParams
) {
return useQuery(
configMapQueryKeys.configMap(environmentId, namespace, configMap),
() => getConfigMap(environmentId, namespace, configMap, { withData: true }),
{
...withGlobalError('Unable to retrieve ConfigMaps for cluster'),
refetchInterval() {
return options?.autoRefreshRate ?? false;
},
select: options?.select,
enabled: options?.enabled,
refetchInterval: () => options?.autoRefreshRate ?? false,
...withGlobalError(`Unable to retrieve ConfigMap '${configMap}'`),
}
);
}
// get a configmap
async function getConfigMap(
export async function getConfigMap(
environmentId: EnvironmentId,
namespace: string,
configMap: string,
params?: { withData?: boolean }
) {
try {
const { data } = await axios.get<Configuration[]>(
const { data } = await axios.get<Configuration>(
`/kubernetes/${environmentId}/namespaces/${namespace}/configmaps/${configMap}`,
{ params }
);

View file

@ -0,0 +1,63 @@
import { ConfigMap, ConfigMapList } from 'kubernetes-types/core/v1';
import { useQuery } from '@tanstack/react-query';
import axios from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { error as notifyError } from '@/portainer/services/notifications';
import { parseKubernetesAxiosError } from '../../axiosError';
export const configMapQueryKeys = {
configMaps: (environmentId: EnvironmentId, namespace?: string) => [
'environments',
environmentId,
'kubernetes',
'configmaps',
'namespaces',
namespace,
],
};
/**
* returns a usequery hook for the list of configmaps from the kubernetes API
*/
export function useK8sConfigMaps(
environmentId: EnvironmentId,
namespace?: string
) {
return useQuery(
configMapQueryKeys.configMaps(environmentId, namespace),
() => (namespace ? getConfigMaps(environmentId, namespace) : []),
{
onError: (err) => {
notifyError(
'Failure',
err as Error,
`Unable to get ConfigMaps in namespace '${namespace}'`
);
},
enabled: !!namespace,
}
);
}
// get all configmaps for a namespace
async function getConfigMaps(environmentId: EnvironmentId, namespace: string) {
try {
const { data } = await axios.get<ConfigMapList>(
buildUrl(environmentId, namespace)
);
const configMapsWithKind: ConfigMap[] = data.items.map((configmap) => ({
...configmap,
kind: 'ConfigMap',
}));
return configMapsWithKind;
} catch (e) {
throw parseKubernetesAxiosError(e, 'Unable to retrieve ConfigMaps');
}
}
function buildUrl(environmentId: number, namespace: string, name?: string) {
const url = `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/configmaps`;
return name ? `${url}/${name}` : url;
}

View file

@ -0,0 +1,63 @@
import { Secret, SecretList } from 'kubernetes-types/core/v1';
import { useQuery } from '@tanstack/react-query';
import axios from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { error as notifyError } from '@/portainer/services/notifications';
import { parseKubernetesAxiosError } from '../../axiosError';
export const secretQueryKeys = {
secrets: (environmentId: EnvironmentId, namespace?: string) => [
'environments',
environmentId,
'kubernetes',
'secrets',
'namespaces',
namespace,
],
};
/**
* returns a usequery hook for the list of secrets from the kubernetes API
*/
export function useK8sSecrets(
environmentId: EnvironmentId,
namespace?: string
) {
return useQuery(
secretQueryKeys.secrets(environmentId, namespace),
() => (namespace ? getSecrets(environmentId, namespace) : []),
{
onError: (err) => {
notifyError(
'Failure',
err as Error,
`Unable to get secrets in namespace '${namespace}'`
);
},
enabled: !!namespace,
}
);
}
// get all secrets for a namespace
async function getSecrets(environmentId: EnvironmentId, namespace: string) {
try {
const { data } = await axios.get<SecretList>(
buildUrl(environmentId, namespace)
);
const secretsWithKind: Secret[] = data.items.map((secret) => ({
...secret,
kind: 'Secret',
}));
return secretsWithKind;
} catch (e) {
throw parseKubernetesAxiosError(e, 'Unable to retrieve secrets');
}
}
function buildUrl(environmentId: number, namespace: string, name?: string) {
const url = `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/secrets`;
return name ? `${url}/${name}` : url;
}

View file

@ -0,0 +1,52 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ConfigMap } from 'kubernetes-types/core/v1';
import axios from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { withInvalidate } from '@/react-tools/react-query';
import { parseKubernetesAxiosError } from '../../axiosError';
import { configMapQueryKeys } from './useK8sConfigMaps';
/**
* useUpdateK8sConfigMapMutation returns a mutation hook for updating a Kubernetes ConfigMap using the Kubernetes proxy API.
*/
export function useUpdateK8sConfigMapMutation(
environmentId: EnvironmentId,
namespace: string
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
data,
configMapName,
}: {
data: ConfigMap;
configMapName: string;
}) => updateConfigMap(environmentId, namespace, configMapName, data),
...withInvalidate(queryClient, [
configMapQueryKeys.configMaps(environmentId, namespace),
]),
// handle success notifications in the calling component
});
}
async function updateConfigMap(
environmentId: EnvironmentId,
namespace: string,
configMap: string,
data: ConfigMap
) {
try {
return await axios.put(
`/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/configmaps/${configMap}`,
data
);
} catch (e) {
throw parseKubernetesAxiosError(
e,
`Unable to update ConfigMap '${configMap}'`
);
}
}

View file

@ -1,157 +0,0 @@
import { Secret, SecretList } from 'kubernetes-types/core/v1';
import { useMutation, useQuery } from '@tanstack/react-query';
import { queryClient, withError } from '@/react-tools/react-query';
import axios from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import {
error as notifyError,
notifySuccess,
} from '@/portainer/services/notifications';
import { isFulfilled, isRejected } from '@/portainer/helpers/promise-utils';
import { pluralize } from '@/portainer/helpers/strings';
import { parseKubernetesAxiosError } from '../axiosError';
export const secretQueryKeys = {
secrets: (environmentId: EnvironmentId, namespace?: string) => [
'environments',
environmentId,
'kubernetes',
'secrets',
'namespaces',
namespace,
],
secretsForCluster: (environmentId: EnvironmentId) => [
'environments',
environmentId,
'kubernetes',
'secrets',
],
};
// returns a usequery hook for the list of secrets from the kubernetes API
export function useSecrets(environmentId: EnvironmentId, namespace?: string) {
return useQuery(
secretQueryKeys.secrets(environmentId, namespace),
() => (namespace ? getSecrets(environmentId, namespace) : []),
{
onError: (err) => {
notifyError(
'Failure',
err as Error,
`Unable to get secrets in namespace '${namespace}'`
);
},
enabled: !!namespace,
}
);
}
export function useSecretsForCluster(
environmentId: EnvironmentId,
namespaces?: string[],
options?: { autoRefreshRate?: number }
) {
return useQuery(
secretQueryKeys.secretsForCluster(environmentId),
() => namespaces && getSecretsForCluster(environmentId, namespaces),
{
...withError('Unable to retrieve secrets for cluster'),
enabled: !!namespaces?.length,
refetchInterval() {
return options?.autoRefreshRate ?? false;
},
}
);
}
export function useMutationDeleteSecrets(environmentId: EnvironmentId) {
return useMutation(
async (secrets: { namespace: string; name: string }[]) => {
const promises = await Promise.allSettled(
secrets.map(({ namespace, name }) =>
deleteSecret(environmentId, namespace, name)
)
);
const successfulSecrets = promises
.filter(isFulfilled)
.map((_, index) => secrets[index].name);
const failedSecrets = promises
.filter(isRejected)
.map(({ reason }, index) => ({
name: secrets[index].name,
reason,
}));
return { failedSecrets, successfulSecrets };
},
{
...withError('Unable to remove secrets'),
onSuccess: ({ failedSecrets, successfulSecrets }) => {
queryClient.invalidateQueries(
secretQueryKeys.secretsForCluster(environmentId)
);
// show an error message for each secret that failed to delete
failedSecrets.forEach(({ name, reason }) => {
notifyError(
`Failed to remove secret '${name}'`,
new Error(reason.message) as Error
);
});
// show one summary message for all successful deletes
if (successfulSecrets.length) {
notifySuccess(
`${pluralize(
successfulSecrets.length,
'Secret'
)} successfully removed`,
successfulSecrets.join(', ')
);
}
},
}
);
}
async function getSecretsForCluster(
environmentId: EnvironmentId,
namespaces: string[]
) {
const secrets = await Promise.all(
namespaces.map((namespace) => getSecrets(environmentId, namespace))
);
return secrets.flat();
}
// get all secrets for a namespace
async function getSecrets(environmentId: EnvironmentId, namespace: string) {
try {
const { data } = await axios.get<SecretList>(
buildUrl(environmentId, namespace)
);
const secretsWithKind: Secret[] = data.items.map((secret) => ({
...secret,
kind: 'Secret',
}));
return secretsWithKind;
} catch (e) {
throw parseKubernetesAxiosError(e, 'Unable to retrieve secrets');
}
}
async function deleteSecret(
environmentId: EnvironmentId,
namespace: string,
name: string
) {
try {
await axios.delete(buildUrl(environmentId, namespace, name));
} catch (e) {
throw parseKubernetesAxiosError(e, 'Unable to remove secret');
}
}
function buildUrl(environmentId: number, namespace: string, name?: string) {
const url = `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/secrets`;
return name ? `${url}/${name}` : url;
}

View file

@ -4,7 +4,7 @@ import { v4 as uuidv4 } from 'uuid';
import { debounce } from 'lodash';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useSecrets } from '@/react/kubernetes/configs/secret.service';
import { useK8sSecrets } from '@/react/kubernetes/configs/queries/useK8sSecrets';
import { useNamespaceServices } from '@/react/kubernetes/networks/services/queries';
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import { useAuthorizations } from '@/react/hooks/useUser';
@ -70,7 +70,7 @@ export function CreateIngressView() {
useNamespacesQuery(environmentId);
const { data: allServices } = useNamespaceServices(environmentId, namespace);
const secretsResults = useSecrets(environmentId, namespace);
const secretsResults = useK8sSecrets(environmentId, namespace);
const ingressesResults = useIngresses(environmentId);
const { data: ingressControllers, ...ingressControllersQuery } =
useIngressControllers(environmentId, namespace);

View file

@ -1,39 +0,0 @@
import { UserX } from 'lucide-react';
import { name } from '@/react/portainer/access-control/AccessManagement/AccessDatatable/columns/name';
import { type } from '@/react/portainer/access-control/AccessManagement/AccessDatatable/columns/type';
import { Access } from '@/react/portainer/access-control/AccessManagement/AccessDatatable/types';
import { RemoveAccessButton } from '@/react/portainer/access-control/AccessManagement/AccessDatatable/RemoveAccessButton';
import { createPersistedStore } from '@@/datatables/types';
import { useTableState } from '@@/datatables/useTableState';
import { Datatable } from '@@/datatables';
const tableKey = 'kubernetes_resourcepool_access';
const columns = [name, type];
const store = createPersistedStore(tableKey);
export function NamespaceAccessDatatable({
dataset,
onRemove,
}: {
dataset?: Array<Access>;
onRemove(items: Array<Access>): void;
}) {
const tableState = useTableState(store, tableKey);
return (
<Datatable
data-cy="kube-namespace-access-datatable"
title="Namespace Access"
titleIcon={UserX}
dataset={dataset || []}
isLoading={!dataset}
columns={columns}
settingsManager={tableState}
renderTableActions={(selectedItems) => (
<RemoveAccessButton items={selectedItems} onClick={onRemove} />
)}
/>
);
}

View file

@ -0,0 +1,107 @@
import { UserX } from 'lucide-react';
import { useMemo } from 'react';
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
import { useUsers } from '@/portainer/users/queries';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useTeams } from '@/react/portainer/users/teams/queries';
import { PortainerNamespaceAccessesConfigMap } from '@/react/kubernetes/configs/constants';
import { useConfigMap } from '@/react/kubernetes/configs/queries/useConfigMap';
import { useUpdateK8sConfigMapMutation } from '@/react/kubernetes/configs/queries/useUpdateK8sConfigMapMutation';
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import { createPersistedStore } from '@@/datatables/types';
import { useTableState } from '@@/datatables/useTableState';
import { Datatable } from '@@/datatables';
import { DeleteButton } from '@@/buttons/DeleteButton';
import { parseNamespaceAccesses } from '../parseNamespaceAccesses';
import { NamespaceAccess } from '../types';
import { createUnauthorizeAccessConfigMapPayload } from '../createAccessConfigMapPayload';
import { entityType } from './columns/type';
import { name } from './columns/name';
const tableKey = 'kubernetes_resourcepool_access';
const columns = [name, entityType];
const store = createPersistedStore(tableKey);
export function AccessDatatable() {
const {
params: { id: namespaceName },
} = useCurrentStateAndParams();
const router = useRouter();
const environmentId = useEnvironmentId();
const tableState = useTableState(store, tableKey);
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 (
<Datatable
data-cy="kube-namespace-access-datatable"
title="Namespace access"
titleIcon={UserX}
dataset={namespaceAccesses}
isLoading={accessConfigMapQuery.isLoading}
columns={columns}
settingsManager={tableState}
// the user id and team id can be the same, so add the type to the id
getRowId={(row) => `${row.type}-${row.id}`}
renderTableActions={(selectedItems) => (
<DeleteButton
isLoading={updateConfigMapMutation.isLoading}
loadingText="Removing..."
confirmMessage="Are you sure you want to unauthorized the selected users or teams?"
onConfirmed={() => handleUpdate(selectedItems)}
disabled={
selectedItems.length === 0 ||
usersQuery.isLoading ||
teamsQuery.isLoading ||
accessConfigMapQuery.isLoading
}
data-cy="remove-access-button"
/>
)}
/>
);
async function handleUpdate(selectedItemsToRemove: Array<NamespaceAccess>) {
try {
const configMapPayload = createUnauthorizeAccessConfigMapPayload(
namespaceAccesses,
selectedItemsToRemove,
namespaceName,
configMap
);
await updateConfigMapMutation.mutateAsync({
data: configMapPayload,
configMapName: PortainerNamespaceAccessesConfigMap.configMapName,
});
notifySuccess('Success', 'Namespace access updated');
router.stateService.reload();
} catch (error) {
notifyError('Failed to update namespace access', error as Error);
}
}
}

View file

@ -0,0 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
import { NamespaceAccess } from '../../types';
export const helper = createColumnHelper<NamespaceAccess>();

View file

@ -0,0 +1,5 @@
import { helper } from './helper';
export const name = helper.accessor('name', {
header: 'Name',
});

View file

@ -0,0 +1,5 @@
import { helper } from './helper';
export const entityType = helper.accessor('type', {
header: 'Type',
});

View file

@ -0,0 +1,39 @@
import { useCurrentStateAndParams } from '@uirouter/react';
import { useUnauthorizedRedirect } from '@/react/hooks/useUnauthorizedRedirect';
import { PageHeader } from '@@/PageHeader';
import { NamespaceDetailsWidget } from './NamespaceDetailsWidget';
import { AccessDatatable } from './AccessDatatable/AccessDatatable';
import { CreateAccessWidget } from './CreateAccessWidget/CreateAccessWidget';
export function AccessView() {
const {
params: { id: namespaceName },
} = useCurrentStateAndParams();
useUnauthorizedRedirect(
{ authorizations: ['K8sResourcePoolDetailsW'] },
{ to: 'kubernetes.resourcePools' }
);
return (
<>
<PageHeader
title="Namespace access management"
breadcrumbs={[
{ label: 'Namespaces', link: 'kubernetes.resourcePools' },
{
label: namespaceName,
link: 'kubernetes.resourcePools.resourcePool',
linkParams: { id: namespaceName },
},
'Access management',
]}
reload
/>
<NamespaceDetailsWidget />
<CreateAccessWidget />
<AccessDatatable />
</>
);
}

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[];
};

View file

@ -3,14 +3,13 @@ import { OptionProps, components, MultiValueGenericProps } from 'react-select';
import { Select } from '@@/form-components/ReactSelect';
type Role = { Name: string };
type Option = { Type: 'user' | 'team'; Id: number; Name: string; Role: Role };
import { EnvironmentAccess } from './types';
interface Props {
name?: string;
value: Option[];
onChange(value: readonly Option[]): void;
options: Option[];
value: EnvironmentAccess[];
onChange(value: readonly EnvironmentAccess[]): void;
options: EnvironmentAccess[];
dataCy: string;
inputId?: string;
placeholder?: string;
@ -29,8 +28,8 @@ export function NamespaceAccessUsersSelector({
<Select
isMulti
name={name}
getOptionLabel={(option) => option.Name}
getOptionValue={(option) => `${option.Id}-${option.Type}`}
getOptionLabel={(option) => option.name}
getOptionValue={(option) => `${option.id}-${option.type}`}
options={options}
value={value}
closeMenuOnSelect={false}
@ -43,11 +42,14 @@ export function NamespaceAccessUsersSelector({
);
}
function isOption(option: unknown): option is Option {
return !!option && typeof option === 'object' && 'Type' in option;
function isOption(option: unknown): option is EnvironmentAccess {
return !!option && typeof option === 'object' && 'type' in option;
}
function OptionComponent({ data, ...props }: OptionProps<Option, true>) {
function OptionComponent({
data,
...props
}: OptionProps<EnvironmentAccess, true>) {
return (
// eslint-disable-next-line react/jsx-props-no-spreading
<components.Option data={data} {...props}>
@ -59,7 +61,7 @@ function OptionComponent({ data, ...props }: OptionProps<Option, true>) {
function MultiValueLabel({
data,
...props
}: MultiValueGenericProps<Option, true>) {
}: MultiValueGenericProps<EnvironmentAccess, true>) {
return (
// eslint-disable-next-line react/jsx-props-no-spreading
<components.MultiValueLabel data={data} {...props}>
@ -68,15 +70,15 @@ function MultiValueLabel({
);
}
function Label({ option }: { option: Option }) {
const Icon = option.Type === 'user' ? UserIcon : TeamIcon;
function Label({ option }: { option: EnvironmentAccess }) {
const Icon = option.type === 'user' ? UserIcon : TeamIcon;
return (
<div className="flex items-center gap-1">
<Icon />
<span>{option.Name}</span>
<span>{option.name}</span>
<span>|</span>
<span>{option.Role.Name}</span>
<span>{option.role.name}</span>
</div>
);
}

View file

@ -0,0 +1,29 @@
import { Layers } from 'lucide-react';
import { useCurrentStateAndParams } from '@uirouter/react';
import { WidgetTitle, WidgetBody, Widget } from '@@/Widget';
export function NamespaceDetailsWidget() {
const {
params: { id: namespaceName },
} = useCurrentStateAndParams();
return (
<div className="row">
<div className="col-sm-12">
<Widget aria-label="Namespace details">
<WidgetTitle icon={Layers} title="Namespace" />
<WidgetBody>
<table className="table">
<tbody>
<tr>
<td>Name</td>
<td>{namespaceName}</td>
</tr>
</tbody>
</table>
</WidgetBody>
</Widget>
</div>
</div>
);
}

View file

@ -0,0 +1,90 @@
import { ConfigMap } from 'kubernetes-types/core/v1';
import { concat, without } from 'lodash';
import { PortainerNamespaceAccessesConfigMap } from '@/react/kubernetes/configs/constants';
import { Configuration } from '@/react/kubernetes/configs/types';
import { NamespaceAccess } from './types';
export function createAuthorizeAccessConfigMapPayload(
namespaceAccesses: NamespaceAccess[],
selectedItems: NamespaceAccess[],
namespaceName: string,
configMap?: Configuration
): ConfigMap {
const newRemainingAccesses = concat(namespaceAccesses, ...selectedItems);
return createAccessConfigMapPayload(
newRemainingAccesses,
namespaceName,
configMap
);
}
export function createUnauthorizeAccessConfigMapPayload(
namespaceAccesses: NamespaceAccess[],
selectedItems: NamespaceAccess[],
namespaceName: string,
configMap?: Configuration
): ConfigMap {
const newRemainingAccesses = without(namespaceAccesses, ...selectedItems);
return createAccessConfigMapPayload(
newRemainingAccesses,
namespaceName,
configMap
);
}
function createAccessConfigMapPayload(
newRemainingAccesses: NamespaceAccess[],
namespaceName: string,
configMap?: Configuration
): ConfigMap {
const configMapAccessesValue = JSON.parse(
configMap?.Data?.[PortainerNamespaceAccessesConfigMap.accessKey] || '{}'
);
const newNamespaceAccesses = newRemainingAccesses.reduce(
(namespaceAccesses, accessItem) => {
if (accessItem.type === 'user') {
return {
...namespaceAccesses,
UserAccessPolicies: {
...namespaceAccesses.UserAccessPolicies,
// hardcode to 0, as they use their environment role
[`${accessItem.id}`]: { RoleId: 0 },
},
};
}
return {
...namespaceAccesses,
TeamAccessPolicies: {
...namespaceAccesses.TeamAccessPolicies,
// hardcode to 0, as they use their environment role
[`${accessItem.id}`]: { RoleId: 0 },
},
};
},
{
UserAccessPolicies: {},
TeamAccessPolicies: {},
}
);
const newConfigMapAccessesValue = {
...configMapAccessesValue,
[namespaceName]: newNamespaceAccesses,
};
const updatedConfigMap: ConfigMap = {
metadata: {
name: PortainerNamespaceAccessesConfigMap.configMapName,
namespace: PortainerNamespaceAccessesConfigMap.namespace,
uid: configMap?.UID,
},
data: {
...configMap?.Data,
[PortainerNamespaceAccessesConfigMap.accessKey]: JSON.stringify(
newConfigMapAccessesValue
),
},
};
return updatedConfigMap;
}

View file

@ -0,0 +1,41 @@
import { PortainerNamespaceAccessesConfigMap } from '@/react/kubernetes/configs/constants';
import { User } from '@/portainer/users/types';
import { Team } from '@/react/portainer/users/teams/types';
import { Configuration } from '@/react/kubernetes/configs/types';
import { NamespaceAccess, NamespaceAccessesMap } from './types';
export function parseNamespaceAccesses(
data: Configuration | null,
namespaceName: string,
users: User[],
teams: Team[]
) {
if (!data) {
return [];
}
const namespacesAccesses: NamespaceAccessesMap = JSON.parse(
data?.Data?.[PortainerNamespaceAccessesConfigMap.accessKey] ?? '{}'
);
const userAccessesIds = Object.keys(
namespacesAccesses[namespaceName]?.UserAccessPolicies ?? {}
);
const userAccesses: NamespaceAccess[] = users
.filter((user) => userAccessesIds.includes(`${user.Id}`))
.map((user) => ({
id: user.Id,
name: user.Username,
type: 'user',
}));
const teamAccessesIds = Object.keys(
namespacesAccesses[namespaceName]?.TeamAccessPolicies ?? {}
);
const teamAccesses: NamespaceAccess[] = teams
.filter((team) => teamAccessesIds.includes(`${team.Id}`))
.map((team) => ({
id: team.Id,
name: team.Name,
type: 'team',
}));
return [...userAccesses, ...teamAccesses];
}

View file

@ -0,0 +1,23 @@
import {
TeamAccessPolicies,
UserAccessPolicies,
} from '@/react/portainer/environments/types';
export type NamespaceAccess = {
id: number;
name: string;
type: 'user' | 'team';
};
export type EnvironmentAccess = NamespaceAccess & {
role: { name: string; id: number };
};
export interface NamespaceAccesses {
UserAccessPolicies?: UserAccessPolicies;
TeamAccessPolicies?: TeamAccessPolicies;
}
export interface NamespaceAccessesMap {
[key: string]: NamespaceAccesses;
}

View file

@ -2,10 +2,9 @@ import { http, HttpResponse } from 'msw';
import { render } from '@testing-library/react';
import {
EnvironmentGroup,
EnvironmentGroupId,
} from '@/react/portainer/environments/environment-groups/types';
import { Environment } from '@/react/portainer/environments/types';
Environment,
} from '@/react/portainer/environments/types';
import { UserViewModel } from '@/portainer/models/user';
import { Tag } from '@/portainer/tags/types';
import { createMockEnvironment } from '@/react-tools/test-mocks';
@ -13,6 +12,7 @@ import { server } from '@/setup-tests/server';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { EnvironmentGroup } from '@/react/portainer/environments/environment-groups/types';
import { EnvironmentItem } from './EnvironmentItem';

View file

@ -10,8 +10,8 @@ import {
EnvironmentStatus,
PlatformType,
EdgeTypes,
EnvironmentGroupId,
} from '@/react/portainer/environments/types';
import { EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types';
import {
refetchIfAnyOffline,
useEnvironmentList,

View file

@ -56,6 +56,7 @@ export function AccessDatatable({
isLoading={!dataset}
columns={columns}
settingsManager={tableState}
getRowId={(row) => `${row.Type}-${row.Id}`}
extendTableOptions={mergeOptions(
withMeta({
table: 'access-table',

View file

@ -1,14 +1,16 @@
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentGroupId } from '../types';
import { buildUrl } from './queries/build-url';
import { EnvironmentGroup, EnvironmentGroupId } from './types';
import { EnvironmentGroup } from './types';
export async function getGroup(id: EnvironmentGroupId) {
try {
const { data: group } = await axios.get<EnvironmentGroup>(buildUrl(id));
return group;
} catch (e) {
throw parseAxiosError(e as Error, '');
throw parseAxiosError(e, 'Unable to retrieve group');
}
}
@ -17,6 +19,6 @@ export async function getGroups() {
const { data: groups } = await axios.get<EnvironmentGroup[]>(buildUrl());
return groups;
} catch (e) {
throw parseAxiosError(e as Error, '');
throw parseAxiosError(e, 'Unable to retrieve groups');
}
}

View file

@ -1,8 +1,10 @@
import { useQuery } from '@tanstack/react-query';
import { error as notifyError } from '@/portainer/services/notifications';
import { withGlobalError } from '@/react-tools/react-query';
import { EnvironmentGroup, EnvironmentGroupId } from './types';
import { EnvironmentGroupId } from '../types';
import { EnvironmentGroup } from './types';
import { getGroup, getGroups } from './environment-groups.service';
import { queryKeys } from './queries/query-keys';
@ -17,16 +19,22 @@ export function useGroups<T = EnvironmentGroup[]>({
}
export function useGroup<T = EnvironmentGroup>(
groupId: EnvironmentGroupId,
select?: (group: EnvironmentGroup) => T
groupId?: EnvironmentGroupId,
select?: (group: EnvironmentGroup | null) => T
) {
const { data } = useQuery(queryKeys.group(groupId), () => getGroup(groupId), {
staleTime: 50,
select,
onError(error) {
notifyError('Failed loading group', error as Error);
return useQuery(
queryKeys.group(groupId),
() => {
if (groupId === undefined) {
return null;
}
return getGroup(groupId);
},
});
return data;
{
staleTime: 50,
select,
enabled: groupId !== undefined,
...withGlobalError('Failed loading group'),
}
);
}

View file

@ -1,4 +1,4 @@
import { EnvironmentGroupId } from '../types';
import { EnvironmentGroupId } from '../../types';
export function buildUrl(id?: EnvironmentGroupId, action?: string) {
let url = '/endpoint_groups';

View file

@ -1,6 +1,6 @@
import { EnvironmentGroupId } from '../types';
import { EnvironmentGroupId } from '../../types';
export const queryKeys = {
base: () => ['environment-groups'] as const,
group: (id: EnvironmentGroupId) => [...queryKeys.base(), id] as const,
group: (id?: EnvironmentGroupId) => [...queryKeys.base(), id] as const,
};

View file

@ -1,6 +1,10 @@
import { TagId } from '@/portainer/tags/types';
export type EnvironmentGroupId = number;
import {
TeamAccessPolicies,
UserAccessPolicies,
EnvironmentGroupId,
} from '../types';
export interface EnvironmentGroup {
// Environment(Endpoint) group Identifier
@ -11,4 +15,6 @@ export interface EnvironmentGroup {
Description: string;
// List of tags associated to this environment(endpoint) group
TagIds: TagId[];
UserAccessPolicies?: UserAccessPolicies;
TeamAccessPolicies?: TeamAccessPolicies;
}

View file

@ -3,15 +3,14 @@ import axios, {
json2formData,
arrayToJson,
} from '@/portainer/services/axios';
import { type EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types';
import { type TagId } from '@/portainer/tags/types';
import { EdgeAsyncIntervalsValues } from '@/react/edge/components/EdgeAsyncIntervalsForm';
import {
type EnvironmentGroupId,
type Environment,
ContainerEngine,
EnvironmentCreationTypes,
} from '../types';
} from '@/react/portainer/environments/types';
import { type TagId } from '@/portainer/tags/types';
import { EdgeAsyncIntervalsValues } from '@/react/edge/components/EdgeAsyncIntervalsForm';
import { buildUrl } from './utils';

View file

@ -1,5 +1,12 @@
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { type EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types';
import {
Environment,
EnvironmentId,
EnvironmentType,
EnvironmentSecuritySettings,
EnvironmentStatus,
EnvironmentGroupId,
} from '@/react/portainer/environments/types';
import { type TagId } from '@/portainer/tags/types';
import { UserId } from '@/portainer/users/types';
import { TeamId } from '@/react/portainer/users/teams/types';
@ -9,13 +16,6 @@ import {
} from '@/react/edge/edge-stacks/types';
import { getPublicSettings } from '../../settings/settings.service';
import type {
Environment,
EnvironmentId,
EnvironmentType,
EnvironmentSecuritySettings,
EnvironmentStatus,
} from '../types';
import { buildUrl } from './utils';

View file

@ -8,11 +8,11 @@ import {
KubernetesSettings,
DeploymentOptions,
EndpointChangeWindow,
EnvironmentGroupId,
} from '@/react/portainer/environments/types';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { TagId } from '@/portainer/tags/types';
import { EnvironmentGroupId } from '../environment-groups/types';
import { buildUrl } from '../environment.service/utils';
import { environmentQueryKeys } from './query-keys';

View file

@ -12,9 +12,8 @@ import { queryKeys as edgeGroupQueryKeys } from '@/react/edge/edge-groups/querie
import { queryKeys as groupQueryKeys } from '@/react/portainer/environments/environment-groups/queries/query-keys';
import { tagKeys } from '@/portainer/tags/queries';
import { EnvironmentId } from '../types';
import { EnvironmentId, EnvironmentGroupId } from '../types';
import { buildUrl } from '../environment.service/utils';
import { EnvironmentGroupId } from '../environment-groups/types';
import { environmentQueryKeys } from './query-keys';

View file

@ -1,7 +1,16 @@
import { TagId } from '@/portainer/tags/types';
import { EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types';
import { DockerSnapshot } from '@/react/docker/snapshots/types';
export type EnvironmentGroupId = number;
type RoleId = number;
interface AccessPolicy {
RoleId: RoleId;
}
export type UserAccessPolicies = Record<number, AccessPolicy>; // map[UserID]AccessPolicy
export type TeamAccessPolicies = Record<number, AccessPolicy>;
export type EnvironmentId = number;
/**
@ -158,6 +167,8 @@ export type Environment = {
* A message that describes the status. Should be included for Status Provisioning or Error.
*/
StatusMessage?: EnvironmentStatusMessage;
UserAccessPolicies?: UserAccessPolicies;
TeamAccessPolicies?: TeamAccessPolicies;
};
/**

View file

@ -1,7 +1,7 @@
import { useField } from 'formik';
import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
import { EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types';
import { EnvironmentGroupId } from '@/react/portainer/environments/types';
import { FormControl } from '@@/form-components/FormControl';
import { Select } from '@@/form-components/Input';

View file

@ -1,7 +1,8 @@
import { TeamId } from '@/react/portainer/users/teams/types';
import { UserId } from '@/portainer/users/types';
import { TLSConfiguration } from '../../settings/types';
import {
TeamAccessPolicies,
UserAccessPolicies,
} from '../../environments/types';
export type Catalog = {
repositories: string[];
@ -19,14 +20,6 @@ export enum RegistryTypes {
GITHUB,
}
export type RoleId = number;
interface AccessPolicy {
RoleId: RoleId;
}
type UserAccessPolicies = Record<UserId, AccessPolicy>; // map[UserID]AccessPolicy
type TeamAccessPolicies = Record<TeamId, AccessPolicy>;
export interface RegistryAccess {
UserAccessPolicies: UserAccessPolicies;
TeamAccessPolicies: TeamAccessPolicies;

View file

@ -122,6 +122,7 @@ export function CreateTeamForm({ users, teams }: Props) {
isLoading={isSubmitting || addTeamMutation.isLoading}
loadingText="Creating team..."
icon={Plus}
className="!ml-0"
>
Create team
</LoadingButton>