diff --git a/app/kubernetes/__module.js b/app/kubernetes/__module.js
index 2528e67bc..19567a36b 100644
--- a/app/kubernetes/__module.js
+++ b/app/kubernetes/__module.js
@@ -489,12 +489,12 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
},
};
- const resourcePoolAccess = {
+ const namespaceAccess = {
name: 'kubernetes.resourcePools.resourcePool.access',
url: '/access',
views: {
'content@': {
- component: 'kubernetesResourcePoolAccessView',
+ component: 'kubernetesNamespaceAccessView',
},
},
data: {
@@ -647,7 +647,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
$stateRegistryProvider.register(resourcePools);
$stateRegistryProvider.register(namespaceCreation);
$stateRegistryProvider.register(resourcePool);
- $stateRegistryProvider.register(resourcePoolAccess);
+ $stateRegistryProvider.register(namespaceAccess);
$stateRegistryProvider.register(volumes);
$stateRegistryProvider.register(volume);
$stateRegistryProvider.register(registries);
diff --git a/app/kubernetes/converters/configMap.js b/app/kubernetes/converters/configMap.js
index be0bdffcf..7c86df603 100644
--- a/app/kubernetes/converters/configMap.js
+++ b/app/kubernetes/converters/configMap.js
@@ -1,9 +1,8 @@
import _ from 'lodash-es';
import { KubernetesConfigMap, KubernetesPortainerAccessConfigMap } from 'Kubernetes/models/config-map/models';
import { KubernetesConfigMapCreatePayload, KubernetesConfigMapUpdatePayload } from 'Kubernetes/models/config-map/payloads';
-import { KubernetesPortainerConfigurationOwnerLabel } from 'Kubernetes/models/configuration/models';
import { KubernetesConfigurationFormValuesEntry } from 'Kubernetes/models/configuration/formvalues';
-
+import { ConfigurationOwnerUsernameLabel } from '@/react/kubernetes/configs/constants';
class KubernetesConfigMapConverter {
static apiToPortainerAccessConfigMap(data) {
const res = new KubernetesPortainerAccessConfigMap();
@@ -35,7 +34,7 @@ class KubernetesConfigMapConverter {
res.Id = data.metadata.uid;
res.Name = data.metadata.name;
res.Namespace = data.metadata.namespace;
- res.ConfigurationOwner = data.metadata.labels ? data.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] : '';
+ res.ConfigurationOwner = data.metadata.labels ? data.metadata.labels[ConfigurationOwnerUsernameLabel] : '';
res.CreationDate = data.metadata.creationTimestamp;
res.Yaml = yaml ? yaml.data : '';
res.Labels = data.metadata.labels;
@@ -79,7 +78,7 @@ class KubernetesConfigMapConverter {
res.metadata.name = data.Name;
res.metadata.namespace = data.Namespace.Namespace.Name;
const configurationOwner = _.truncate(data.ConfigurationOwner, { length: 63, omission: '' });
- res.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] = configurationOwner;
+ res.metadata.labels[ConfigurationOwnerUsernameLabel] = configurationOwner;
_.forEach(data.Data, (entry) => {
if (entry.IsBinary) {
@@ -100,7 +99,7 @@ class KubernetesConfigMapConverter {
res.metadata.name = data.Name;
res.metadata.namespace = data.Namespace;
res.metadata.labels = data.Labels || {};
- res.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] = data.ConfigurationOwner;
+ res.metadata.labels[ConfigurationOwnerUsernameLabel] = data.ConfigurationOwner;
_.forEach(data.Data, (entry) => {
if (entry.IsBinary) {
res.binaryData[entry.Key] = entry.Value;
diff --git a/app/kubernetes/converters/secret.js b/app/kubernetes/converters/secret.js
index 64594d9c0..01425d4f0 100644
--- a/app/kubernetes/converters/secret.js
+++ b/app/kubernetes/converters/secret.js
@@ -2,7 +2,7 @@ import _ from 'lodash-es';
import { KubernetesSecretCreatePayload, KubernetesSecretUpdatePayload } from 'Kubernetes/models/secret/payloads';
import { KubernetesApplicationSecret } from 'Kubernetes/models/secret/models';
import { KubernetesPortainerConfigurationDataAnnotation } from 'Kubernetes/models/configuration/models';
-import { KubernetesPortainerConfigurationOwnerLabel } from 'Kubernetes/models/configuration/models';
+import { ConfigurationOwnerUsernameLabel } from '@/react/kubernetes/configs/constants';
import { KubernetesConfigurationFormValuesEntry } from 'Kubernetes/models/configuration/formvalues';
import { KubernetesSecretTypeOptions } from 'Kubernetes/models/configuration/models';
class KubernetesSecretConverter {
@@ -12,7 +12,7 @@ class KubernetesSecretConverter {
res.metadata.namespace = secret.Namespace.Namespace.Name;
res.type = secret.Type;
const configurationOwner = _.truncate(secret.ConfigurationOwner, { length: 63, omission: '' });
- res.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] = configurationOwner;
+ res.metadata.labels[ConfigurationOwnerUsernameLabel] = configurationOwner;
let annotation = '';
_.forEach(secret.Data, (entry) => {
@@ -40,7 +40,7 @@ class KubernetesSecretConverter {
res.metadata.namespace = secret.Namespace;
res.type = secret.Type;
res.metadata.labels = secret.Labels || {};
- res.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] = secret.ConfigurationOwner;
+ res.metadata.labels[ConfigurationOwnerUsernameLabel] = secret.ConfigurationOwner;
let annotation = '';
_.forEach(secret.Data, (entry) => {
@@ -69,7 +69,7 @@ class KubernetesSecretConverter {
res.Namespace = payload.metadata.namespace;
res.Type = payload.type;
res.Labels = payload.metadata.labels || {};
- res.ConfigurationOwner = payload.metadata.labels ? payload.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] : '';
+ res.ConfigurationOwner = payload.metadata.labels ? payload.metadata.labels[ConfigurationOwnerUsernameLabel] : '';
res.CreationDate = payload.metadata.creationTimestamp;
res.Annotations = payload.metadata.annotations;
diff --git a/app/kubernetes/react/components/namespaces.ts b/app/kubernetes/react/components/namespaces.ts
index 58f36a3fa..4e5c09447 100644
--- a/app/kubernetes/react/components/namespaces.ts
+++ b/app/kubernetes/react/components/namespaces.ts
@@ -6,7 +6,7 @@ import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { NamespacesDatatable } from '@/react/kubernetes/namespaces/ListView/NamespacesDatatable';
import { NamespaceAppsDatatable } from '@/react/kubernetes/namespaces/ItemView/NamespaceAppsDatatable';
-import { NamespaceAccessDatatable } from '@/react/kubernetes/namespaces/AccessView/AccessDatatable';
+import { AccessDatatable } from '@/react/kubernetes/namespaces/AccessView/AccessDatatable/AccessDatatable';
export const namespacesModule = angular
.module('portainer.kubernetes.react.components.namespaces', [])
@@ -24,8 +24,5 @@ export const namespacesModule = angular
)
.component(
'namespaceAccessDatatable',
- r2a(withUIRouter(withReactQuery(NamespaceAccessDatatable)), [
- 'dataset',
- 'onRemove',
- ])
+ r2a(withUIRouter(withReactQuery(AccessDatatable)), [])
).name;
diff --git a/app/kubernetes/react/views/index.ts b/app/kubernetes/react/views/index.ts
index 8ee8538e3..55b4ac927 100644
--- a/app/kubernetes/react/views/index.ts
+++ b/app/kubernetes/react/views/index.ts
@@ -19,6 +19,7 @@ import { ServiceAccountsView } from '@/react/kubernetes/more-resources/ServiceAc
import { ClusterRolesView } from '@/react/kubernetes/more-resources/ClusterRolesView';
import { RolesView } from '@/react/kubernetes/more-resources/RolesView';
import { VolumesView } from '@/react/kubernetes/volumes/ListView/VolumesView';
+import { AccessView } from '@/react/kubernetes/namespaces/AccessView/AccessView';
export const viewsModule = angular
.module('portainer.kubernetes.react.views', [])
@@ -30,6 +31,10 @@ export const viewsModule = angular
'kubernetesNamespacesView',
r2a(withUIRouter(withReactQuery(withCurrentUser(NamespacesView))), [])
)
+ .component(
+ 'kubernetesNamespaceAccessView',
+ r2a(withUIRouter(withReactQuery(withCurrentUser(AccessView))), [])
+ )
.component(
'kubernetesServicesView',
r2a(withUIRouter(withReactQuery(withCurrentUser(ServicesView))), [])
diff --git a/app/kubernetes/services/configMapService.js b/app/kubernetes/services/configMapService.js
index 8e7cee884..b976859cf 100644
--- a/app/kubernetes/services/configMapService.js
+++ b/app/kubernetes/services/configMapService.js
@@ -3,7 +3,6 @@ import _ from 'lodash-es';
import PortainerError from 'Portainer/error';
import KubernetesConfigMapConverter from 'Kubernetes/converters/configMap';
import { KubernetesCommonParams } from 'Kubernetes/models/common/params';
-import { KubernetesPortainerAccessConfigMap } from 'Kubernetes/models/config-map/models';
class KubernetesConfigMapService {
/* @ngInject */
@@ -18,22 +17,6 @@ class KubernetesConfigMapService {
this.deleteAsync = this.deleteAsync.bind(this);
}
- getAccess(namespace, name) {
- return this.$async(async () => {
- try {
- const params = new KubernetesCommonParams();
- params.id = name;
- const raw = await this.KubernetesConfigMaps(namespace).get(params).$promise;
- return KubernetesConfigMapConverter.apiToPortainerAccessConfigMap(raw);
- } catch (err) {
- if (err.status === 404) {
- return new KubernetesPortainerAccessConfigMap();
- }
- throw new PortainerError('Unable to retrieve Portainer accesses', err);
- }
- });
- }
-
createAccess(config) {
return this.$async(async () => {
try {
diff --git a/app/kubernetes/views/resource-pools/access/resourcePoolAccess.html b/app/kubernetes/views/resource-pools/access/resourcePoolAccess.html
deleted file mode 100644
index 08c68c3e9..000000000
--- a/app/kubernetes/views/resource-pools/access/resourcePoolAccess.html
+++ /dev/null
@@ -1,116 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Name |
-
- {{ ctrl.pool.Namespace.Name }}
- |
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/kubernetes/views/resource-pools/access/resourcePoolAccess.js b/app/kubernetes/views/resource-pools/access/resourcePoolAccess.js
deleted file mode 100644
index 4bb584b9f..000000000
--- a/app/kubernetes/views/resource-pools/access/resourcePoolAccess.js
+++ /dev/null
@@ -1,9 +0,0 @@
-angular.module('portainer.kubernetes').component('kubernetesResourcePoolAccessView', {
- templateUrl: './resourcePoolAccess.html',
- controller: 'KubernetesResourcePoolAccessController',
- controllerAs: 'ctrl',
- bindings: {
- $transition$: '<',
- endpoint: '<',
- },
-});
diff --git a/app/kubernetes/views/resource-pools/access/resourcePoolAccessController.js b/app/kubernetes/views/resource-pools/access/resourcePoolAccessController.js
deleted file mode 100644
index d9802586f..000000000
--- a/app/kubernetes/views/resource-pools/access/resourcePoolAccessController.js
+++ /dev/null
@@ -1,145 +0,0 @@
-import angular from 'angular';
-import _ from 'lodash-es';
-import { KubernetesPortainerConfigMapConfigName, KubernetesPortainerConfigMapNamespace, KubernetesPortainerConfigMapAccessKey } from 'Kubernetes/models/config-map/models';
-import { UserAccessViewModel, TeamAccessViewModel } from 'Portainer/models/access';
-import KubernetesConfigMapHelper from 'Kubernetes/helpers/configMapHelper';
-import { getIsRBACEnabled } from '@/react/kubernetes/cluster/getIsRBACEnabled';
-
-class KubernetesResourcePoolAccessController {
- /* @ngInject */
- constructor($async, $state, $scope, Notifications, KubernetesResourcePoolService, KubernetesConfigMapService, GroupService, AccessService, EndpointProvider) {
- this.$async = $async;
- this.$state = $state;
- this.$scope = $scope;
- this.EndpointProvider = EndpointProvider;
- this.Notifications = Notifications;
- this.KubernetesResourcePoolService = KubernetesResourcePoolService;
- this.KubernetesConfigMapService = KubernetesConfigMapService;
-
- this.GroupService = GroupService;
- this.AccessService = AccessService;
-
- this.onInit = this.onInit.bind(this);
- this.authorizeAccessAsync = this.authorizeAccessAsync.bind(this);
- this.unauthorizeAccessAsync = this.unauthorizeAccessAsync.bind(this);
- this.onUsersAndTeamsChange = this.onUsersAndTeamsChange.bind(this);
- this.unauthorizeAccess = this.unauthorizeAccess.bind(this);
- }
-
- initAccessConfigMap(configMap) {
- configMap.Name = KubernetesPortainerConfigMapConfigName;
- configMap.Namespace = KubernetesPortainerConfigMapNamespace;
- configMap.Data[KubernetesPortainerConfigMapAccessKey] = {};
- return configMap;
- }
-
- /**
- * Init
- */
- async onInit() {
- const endpoint = this.endpoint;
- this.state = {
- actionInProgress: false,
- viewReady: false,
- };
-
- this.formValues = {
- multiselectOutput: [],
- };
-
- // default to true if error is thrown
- this.isRBACEnabled = true;
-
- try {
- const name = this.$transition$.params().id;
- let [pool, configMap] = await Promise.all([
- this.KubernetesResourcePoolService.get(name),
- this.KubernetesConfigMapService.getAccess(KubernetesPortainerConfigMapNamespace, KubernetesPortainerConfigMapConfigName),
- ]);
- this.isRBACEnabled = await getIsRBACEnabled(this.EndpointProvider.endpointID());
- const group = await this.GroupService.group(endpoint.GroupId);
- const roles = [];
- const endpointAccesses = await this.AccessService.accesses(endpoint, group, roles);
- this.pool = pool;
- if (configMap.Id === 0) {
- configMap = this.initAccessConfigMap(configMap);
- }
- configMap = KubernetesConfigMapHelper.parseJSONData(configMap);
-
- this.authorizedUsersAndTeams = [];
- this.accessConfigMap = configMap;
- const poolAccesses = configMap.Data[KubernetesPortainerConfigMapAccessKey][name];
- if (poolAccesses) {
- this.authorizedUsersAndTeams = _.filter(endpointAccesses.authorizedUsersAndTeams, (item) => {
- if (item instanceof UserAccessViewModel && poolAccesses.UserAccessPolicies) {
- return poolAccesses.UserAccessPolicies[item.Id] !== undefined;
- } else if (item instanceof TeamAccessViewModel && poolAccesses.TeamAccessPolicies) {
- return poolAccesses.TeamAccessPolicies[item.Id] !== undefined;
- }
- return false;
- });
- }
-
- this.availableUsersAndTeams = _.without(endpointAccesses.authorizedUsersAndTeams, ...this.authorizedUsersAndTeams);
- } catch (err) {
- this.Notifications.error('Failure', err, 'Unable to retrieve namespace information');
- } finally {
- this.state.viewReady = true;
- }
- }
-
- $onInit() {
- return this.$async(this.onInit);
- }
-
- /**
- * Authorize access
- */
- async authorizeAccessAsync() {
- try {
- this.state.actionInProgress = true;
- const newAccesses = _.concat(this.authorizedUsersAndTeams, this.formValues.multiselectOutput);
- const accessConfigMap = KubernetesConfigMapHelper.modifiyNamespaceAccesses(angular.copy(this.accessConfigMap), this.pool.Namespace.Name, newAccesses);
- await this.KubernetesConfigMapService.updateAccess(accessConfigMap);
- this.Notifications.success('Success', 'Access successfully created');
- this.$state.reload(this.$state.current);
- } catch (err) {
- this.Notifications.error('Failure', err, 'Unable to create accesses');
- }
- }
-
- onUsersAndTeamsChange(value) {
- this.$scope.$evalAsync(() => {
- this.formValues.multiselectOutput = value;
- });
- }
-
- authorizeAccess() {
- return this.$async(this.authorizeAccessAsync);
- }
-
- /**
- *
- */
- async unauthorizeAccessAsync(selectedItems) {
- try {
- this.state.actionInProgress = true;
- const newAccesses = _.without(this.authorizedUsersAndTeams, ...selectedItems);
- const accessConfigMap = KubernetesConfigMapHelper.modifiyNamespaceAccesses(angular.copy(this.accessConfigMap), this.pool.Namespace.Name, newAccesses);
- await this.KubernetesConfigMapService.updateAccess(accessConfigMap);
- this.Notifications.success('Success', 'Access successfully removed');
- this.$state.reload(this.$state.current);
- } catch (err) {
- this.Notifications.error('Failure', err, 'Unable to remove accesses');
- } finally {
- this.state.actionInProgress = false;
- }
- }
-
- unauthorizeAccess(selectedItems) {
- return this.$async(this.unauthorizeAccessAsync, selectedItems);
- }
-}
-
-export default KubernetesResourcePoolAccessController;
-angular.module('portainer.kubernetes').controller('KubernetesResourcePoolAccessController', KubernetesResourcePoolAccessController);
diff --git a/app/portainer/users/user.helpers.ts b/app/portainer/users/user.helpers.ts
index 4ce6c1dc6..c087d4d1e 100644
--- a/app/portainer/users/user.helpers.ts
+++ b/app/portainer/users/user.helpers.ts
@@ -10,7 +10,9 @@ export function filterNonAdministratorUsers(users: User[]) {
type UserLike = Pick;
// To avoid creating divergence between CE and EE
-// isAdmin checks if the user is portainer admin or edge admin
+/**
+ * isEdgeAdmin checks if the user is edge admin or admin
+ */
export function isEdgeAdmin(
user: UserLike | undefined,
environment?: Pick | null
@@ -23,8 +25,10 @@ export function isEdgeAdmin(
}
// To avoid creating divergence between CE and EE
-// isPureAdmin checks only if the user is portainer admin
-// See bouncer.IsAdmin and bouncer.PureAdminAccess
+/**
+ * isPureAdmin checks if the user is portainer admin.
+ * See bouncer.IsAdmin and bouncer.PureAdminAccess
+ */
export function isPureAdmin(user?: UserLike): boolean {
return !!user && user.Role === Role.Admin;
}
diff --git a/app/react/edge/components/EdgeScriptForm/types.ts b/app/react/edge/components/EdgeScriptForm/types.ts
index 4113563e7..472a0f3f8 100644
--- a/app/react/edge/components/EdgeScriptForm/types.ts
+++ b/app/react/edge/components/EdgeScriptForm/types.ts
@@ -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';
diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/Selectors/GroupSelector.tsx b/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/Selectors/GroupSelector.tsx
index 3d561dd74..d87abe1c1 100644
--- a/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/Selectors/GroupSelector.tsx
+++ b/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/Selectors/GroupSelector.tsx
@@ -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';
diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/types.ts b/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/types.ts
index 480069523..c0fad7e67 100644
--- a/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/types.ts
+++ b/app/react/edge/edge-devices/WaitingRoomView/Datatable/AssignmentDialog/types.ts
@@ -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;
diff --git a/app/react/hooks/useUser.tsx b/app/react/hooks/useUser.tsx
index fffd7409e..829803e7c 100644
--- a/app/react/hooks/useUser.tsx
+++ b/app/react/hooks/useUser.tsx
@@ -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.
*/
diff --git a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationVolumeConfigsTable.tsx b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationVolumeConfigsTable.tsx
index bd5911f53..b3cff0f19 100644
--- a/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationVolumeConfigsTable.tsx
+++ b/app/react/kubernetes/applications/DetailsView/ApplicationDetailsWidget/ApplicationVolumeConfigsTable.tsx
@@ -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;
diff --git a/app/react/kubernetes/applications/components/ConfigurationsFormSection/ConfigMapsFormSection.tsx b/app/react/kubernetes/applications/components/ConfigurationsFormSection/ConfigMapsFormSection.tsx
index 84a9fd5e0..03761146d 100644
--- a/app/react/kubernetes/applications/components/ConfigurationsFormSection/ConfigMapsFormSection.tsx
+++ b/app/react/kubernetes/applications/components/ConfigurationsFormSection/ConfigMapsFormSection.tsx
@@ -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) {
diff --git a/app/react/kubernetes/applications/components/ConfigurationsFormSection/SecretsFormSection.tsx b/app/react/kubernetes/applications/components/ConfigurationsFormSection/SecretsFormSection.tsx
index 2262550b1..220b9754d 100644
--- a/app/react/kubernetes/applications/components/ConfigurationsFormSection/SecretsFormSection.tsx
+++ b/app/react/kubernetes/applications/components/ConfigurationsFormSection/SecretsFormSection.tsx
@@ -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) {
diff --git a/app/react/kubernetes/cluster/ConfigureView/.keep b/app/react/kubernetes/cluster/ConfigureView/.keep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/ConfigureForm.tsx b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/ConfigureForm.tsx
index 7c809f52e..cc87798c2 100644
--- a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/ConfigureForm.tsx
+++ b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/ConfigureForm.tsx
@@ -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[]) =>
diff --git a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/EnableMetricsInput.tsx.rej b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/EnableMetricsInput.tsx.rej
deleted file mode 100644
index 8866d8e9c..000000000
--- a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/EnableMetricsInput.tsx.rej
+++ /dev/null
@@ -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) {
-
- Unable to reach metrics API. You can enable the metrics-server
- addon in the{' '}
-- Cluster Details view.
-+
-+ Cluster Details view
-+
-+ .
-
- )}
- {metricsFound === true && (
diff --git a/app/react/kubernetes/cluster/getIsRBACEnabled.ts b/app/react/kubernetes/cluster/useIsRBACEnabled.ts
similarity index 69%
rename from app/react/kubernetes/cluster/getIsRBACEnabled.ts
rename to app/react/kubernetes/cluster/useIsRBACEnabled.ts
index e6f9e5963..02f604071 100644
--- a/app/react/kubernetes/cluster/getIsRBACEnabled.ts
+++ b/app/react/kubernetes/cluster/useIsRBACEnabled.ts
@@ -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(
['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(
`kubernetes/${environmentId}/rbac_enabled`
diff --git a/app/react/kubernetes/configs/constants.ts b/app/react/kubernetes/configs/constants.ts
index a444ddf74..c0d15a55d 100644
--- a/app/react/kubernetes/configs/constants.ts
+++ b/app/react/kubernetes/configs/constants.ts
@@ -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',
+};
diff --git a/app/react/kubernetes/configs/queries/useConfigMap.ts b/app/react/kubernetes/configs/queries/useConfigMap.ts
index 2dfbed86f..e7937d328 100644
--- a/app/react/kubernetes/configs/queries/useConfigMap.ts
+++ b/app/react/kubernetes/configs/queries/useConfigMap.ts
@@ -9,33 +9,37 @@ import { Configuration } from '../types';
import { configMapQueryKeys } from './query-keys';
import { ConfigMapQueryParams } from './types';
-export function useConfigMap(
+export function useConfigMap(
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(
+ const { data } = await axios.get(
`/kubernetes/${environmentId}/namespaces/${namespace}/configmaps/${configMap}`,
{ params }
);
diff --git a/app/react/kubernetes/configs/queries/useK8sConfigMaps.ts b/app/react/kubernetes/configs/queries/useK8sConfigMaps.ts
new file mode 100644
index 000000000..75aabc1a2
--- /dev/null
+++ b/app/react/kubernetes/configs/queries/useK8sConfigMaps.ts
@@ -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(
+ 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;
+}
diff --git a/app/react/kubernetes/configs/queries/useK8sSecrets.ts b/app/react/kubernetes/configs/queries/useK8sSecrets.ts
new file mode 100644
index 000000000..71649ddb2
--- /dev/null
+++ b/app/react/kubernetes/configs/queries/useK8sSecrets.ts
@@ -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(
+ 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;
+}
diff --git a/app/react/kubernetes/configs/queries/useUpdateK8sConfigMapMutation.ts b/app/react/kubernetes/configs/queries/useUpdateK8sConfigMapMutation.ts
new file mode 100644
index 000000000..789e8a3a1
--- /dev/null
+++ b/app/react/kubernetes/configs/queries/useUpdateK8sConfigMapMutation.ts
@@ -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}'`
+ );
+ }
+}
diff --git a/app/react/kubernetes/configs/secret.service.ts b/app/react/kubernetes/configs/secret.service.ts
deleted file mode 100644
index 232714a8e..000000000
--- a/app/react/kubernetes/configs/secret.service.ts
+++ /dev/null
@@ -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(
- 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;
-}
diff --git a/app/react/kubernetes/ingresses/CreateIngressView/CreateIngressView.tsx b/app/react/kubernetes/ingresses/CreateIngressView/CreateIngressView.tsx
index 414c6d625..04b544dbe 100644
--- a/app/react/kubernetes/ingresses/CreateIngressView/CreateIngressView.tsx
+++ b/app/react/kubernetes/ingresses/CreateIngressView/CreateIngressView.tsx
@@ -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);
diff --git a/app/react/kubernetes/namespaces/AccessView/.keep b/app/react/kubernetes/namespaces/AccessView/.keep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/app/react/kubernetes/namespaces/AccessView/AccessDatatable.tsx b/app/react/kubernetes/namespaces/AccessView/AccessDatatable.tsx
deleted file mode 100644
index 20915e494..000000000
--- a/app/react/kubernetes/namespaces/AccessView/AccessDatatable.tsx
+++ /dev/null
@@ -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;
- onRemove(items: Array): void;
-}) {
- const tableState = useTableState(store, tableKey);
-
- return (
- (
-
- )}
- />
- );
-}
diff --git a/app/react/kubernetes/namespaces/AccessView/AccessDatatable/AccessDatatable.tsx b/app/react/kubernetes/namespaces/AccessView/AccessDatatable/AccessDatatable.tsx
new file mode 100644
index 000000000..c30a06a31
--- /dev/null
+++ b/app/react/kubernetes/namespaces/AccessView/AccessDatatable/AccessDatatable.tsx
@@ -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 (
+ `${row.type}-${row.id}`}
+ renderTableActions={(selectedItems) => (
+ handleUpdate(selectedItems)}
+ disabled={
+ selectedItems.length === 0 ||
+ usersQuery.isLoading ||
+ teamsQuery.isLoading ||
+ accessConfigMapQuery.isLoading
+ }
+ data-cy="remove-access-button"
+ />
+ )}
+ />
+ );
+
+ async function handleUpdate(selectedItemsToRemove: Array) {
+ 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);
+ }
+ }
+}
diff --git a/app/react/kubernetes/namespaces/AccessView/AccessDatatable/columns/helper.ts b/app/react/kubernetes/namespaces/AccessView/AccessDatatable/columns/helper.ts
new file mode 100644
index 000000000..19d260766
--- /dev/null
+++ b/app/react/kubernetes/namespaces/AccessView/AccessDatatable/columns/helper.ts
@@ -0,0 +1,5 @@
+import { createColumnHelper } from '@tanstack/react-table';
+
+import { NamespaceAccess } from '../../types';
+
+export const helper = createColumnHelper();
diff --git a/app/react/kubernetes/namespaces/AccessView/AccessDatatable/columns/name.ts b/app/react/kubernetes/namespaces/AccessView/AccessDatatable/columns/name.ts
new file mode 100644
index 000000000..34b63728c
--- /dev/null
+++ b/app/react/kubernetes/namespaces/AccessView/AccessDatatable/columns/name.ts
@@ -0,0 +1,5 @@
+import { helper } from './helper';
+
+export const name = helper.accessor('name', {
+ header: 'Name',
+});
diff --git a/app/react/kubernetes/namespaces/AccessView/AccessDatatable/columns/type.ts b/app/react/kubernetes/namespaces/AccessView/AccessDatatable/columns/type.ts
new file mode 100644
index 000000000..317c4998d
--- /dev/null
+++ b/app/react/kubernetes/namespaces/AccessView/AccessDatatable/columns/type.ts
@@ -0,0 +1,5 @@
+import { helper } from './helper';
+
+export const entityType = helper.accessor('type', {
+ header: 'Type',
+});
diff --git a/app/react/kubernetes/namespaces/AccessView/AccessView.tsx b/app/react/kubernetes/namespaces/AccessView/AccessView.tsx
new file mode 100644
index 000000000..495c99b50
--- /dev/null
+++ b/app/react/kubernetes/namespaces/AccessView/AccessView.tsx
@@ -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 (
+ <>
+
+
+
+
+ >
+ );
+}
diff --git a/app/react/kubernetes/namespaces/AccessView/CreateAccessWidget/CreateAccessInnerForm.tsx b/app/react/kubernetes/namespaces/AccessView/CreateAccessWidget/CreateAccessInnerForm.tsx
new file mode 100644
index 000000000..78797394d
--- /dev/null
+++ b/app/react/kubernetes/namespaces/AccessView/CreateAccessWidget/CreateAccessInnerForm.tsx
@@ -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 & {
+ 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 (
+
+ );
+}
+
+/**
+ * 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,
+ ]);
+}
diff --git a/app/react/kubernetes/namespaces/AccessView/CreateAccessWidget/CreateAccessWidget.tsx b/app/react/kubernetes/namespaces/AccessView/CreateAccessWidget/CreateAccessWidget.tsx
new file mode 100644
index 000000000..3625d373e
--- /dev/null
+++ b/app/react/kubernetes/namespaces/AccessView/CreateAccessWidget/CreateAccessWidget.tsx
@@ -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 (
+
+
+
+
+
+ {isRBACEnabledQuery.data === false && }
+
+ Adding user access will require the affected user(s) to logout and
+ login for the changes to be taken into account.
+
+ {isRBACEnabledQuery.data !== false && (
+
+ initialValues={initialValues}
+ enableReinitialize
+ validationSchema={validationSchema}
+ onSubmit={onSubmit}
+ validateOnMount
+ >
+ {(formikProps) => (
+
+ )}
+
+ )}
+
+
+
+
+ );
+
+ 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);
+ }
+ }
+}
diff --git a/app/react/kubernetes/namespaces/AccessView/CreateAccessWidget/createAccess.validation.ts b/app/react/kubernetes/namespaces/AccessView/CreateAccessWidget/createAccess.validation.ts
new file mode 100644
index 000000000..1f634a776
--- /dev/null
+++ b/app/react/kubernetes/namespaces/AccessView/CreateAccessWidget/createAccess.validation.ts
@@ -0,0 +1,19 @@
+import { object, array, string, number, SchemaOf, mixed } from 'yup';
+
+import { CreateAccessValues } from './types';
+
+export function validationSchema(): SchemaOf {
+ 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),
+ });
+}
diff --git a/app/react/kubernetes/namespaces/AccessView/CreateAccessWidget/types.ts b/app/react/kubernetes/namespaces/AccessView/CreateAccessWidget/types.ts
new file mode 100644
index 000000000..a5421a105
--- /dev/null
+++ b/app/react/kubernetes/namespaces/AccessView/CreateAccessWidget/types.ts
@@ -0,0 +1,5 @@
+import { EnvironmentAccess } from '../types';
+
+export type CreateAccessValues = {
+ selectedUsersAndTeams: EnvironmentAccess[];
+};
diff --git a/app/react/kubernetes/namespaces/AccessView/NamespaceAccessUsersSelector.tsx b/app/react/kubernetes/namespaces/AccessView/NamespaceAccessUsersSelector.tsx
index 77c371f28..f3d9f02f1 100644
--- a/app/react/kubernetes/namespaces/AccessView/NamespaceAccessUsersSelector.tsx
+++ b/app/react/kubernetes/namespaces/AccessView/NamespaceAccessUsersSelector.tsx
@@ -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({