mirror of https://github.com/portainer/portainer
refactor(access-control): create access-control-panel component [EE-2345] (#6486)
parent
07294c19bb
commit
f63b07bbb9
|
@ -14,9 +14,9 @@ import (
|
|||
type resourceControlCreatePayload struct {
|
||||
//
|
||||
ResourceID string `example:"617c5f22bb9b023d6daab7cba43a57576f83492867bc767d1c59416b065e5f08" validate:"required"`
|
||||
// Type of Docker resource. Valid values are: container, volume\
|
||||
// service, secret, config or stack
|
||||
Type string `example:"container" validate:"required"`
|
||||
// Type of Resource. Valid values are: 1 - container, 2 - service
|
||||
// 3 - volume, 4 - network, 5 - secret, 6 - stack, 7 - config, 8 - custom template, 9 - azure-container-group
|
||||
Type portainer.ResourceControlType `example:"1" validate:"required" enums:"1,2,3,4,5,6,7,8,9"`
|
||||
// Permit access to the associated resource to any user
|
||||
Public bool `example:"true"`
|
||||
// Permit access to resource only to admins
|
||||
|
@ -39,8 +39,8 @@ func (payload *resourceControlCreatePayload) Validate(r *http.Request) error {
|
|||
return errors.New("invalid payload: invalid resource identifier")
|
||||
}
|
||||
|
||||
if govalidator.IsNull(payload.Type) {
|
||||
return errors.New("invalid payload: invalid type")
|
||||
if payload.Type <= 0 || payload.Type >= 10 {
|
||||
return errors.New("invalid payload: Invalid type value. Value must be one of: 1 - container, 2 - service, 3 - volume, 4 - network, 5 - secret, 6 - stack, 7 - config, 8 - custom template, 9 - azure-container-group")
|
||||
}
|
||||
|
||||
if len(payload.Users) == 0 && len(payload.Teams) == 0 && !payload.Public && !payload.AdministratorsOnly {
|
||||
|
@ -75,29 +75,7 @@ func (handler *Handler) resourceControlCreate(w http.ResponseWriter, r *http.Req
|
|||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||
}
|
||||
|
||||
var resourceControlType portainer.ResourceControlType
|
||||
switch payload.Type {
|
||||
case "container":
|
||||
resourceControlType = portainer.ContainerResourceControl
|
||||
case "container-group":
|
||||
resourceControlType = portainer.ContainerGroupResourceControl
|
||||
case "service":
|
||||
resourceControlType = portainer.ServiceResourceControl
|
||||
case "volume":
|
||||
resourceControlType = portainer.VolumeResourceControl
|
||||
case "network":
|
||||
resourceControlType = portainer.NetworkResourceControl
|
||||
case "secret":
|
||||
resourceControlType = portainer.SecretResourceControl
|
||||
case "stack":
|
||||
resourceControlType = portainer.StackResourceControl
|
||||
case "config":
|
||||
resourceControlType = portainer.ConfigResourceControl
|
||||
default:
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid type value. Value must be one of: container, service, volume, network, secret, stack or config", errInvalidResourceControlType}
|
||||
}
|
||||
|
||||
rc, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(payload.ResourceID, resourceControlType)
|
||||
rc, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(payload.ResourceID, payload.Type)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve resource controls from the database", err}
|
||||
}
|
||||
|
@ -126,7 +104,7 @@ func (handler *Handler) resourceControlCreate(w http.ResponseWriter, r *http.Req
|
|||
resourceControl := portainer.ResourceControl{
|
||||
ResourceID: payload.ResourceID,
|
||||
SubResourceIDs: payload.SubResourceIDs,
|
||||
Type: resourceControlType,
|
||||
Type: payload.Type,
|
||||
Public: payload.Public,
|
||||
AdministratorsOnly: payload.AdministratorsOnly,
|
||||
UserAccesses: userAccesses,
|
||||
|
|
|
@ -6,10 +6,10 @@ import { Input, Select } from '@/portainer/components/form-components/Input';
|
|||
import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
|
||||
import { LoadingButton } from '@/portainer/components/Button/LoadingButton';
|
||||
import { InputListError } from '@/portainer/components/form-components/InputList/InputList';
|
||||
import { AccessControlForm } from '@/portainer/components/accessControlForm';
|
||||
import { ContainerInstanceFormValues } from '@/azure/types';
|
||||
import * as notifications from '@/portainer/services/notifications';
|
||||
import { isAdmin, useUser } from '@/portainer/hooks/useUser';
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
import { AccessControlForm } from '@/portainer/access-control/AccessControlForm';
|
||||
|
||||
import { validationSchema } from './CreateContainerInstanceForm.validation';
|
||||
import { PortMapping, PortsMappingField } from './PortsMappingField';
|
||||
|
@ -29,19 +29,14 @@ export function CreateContainerInstanceForm() {
|
|||
throw new Error('endpointId url param is required');
|
||||
}
|
||||
|
||||
const { user } = useUser();
|
||||
const isUserAdmin = isAdmin(user);
|
||||
const { isAdmin } = useUser();
|
||||
|
||||
const { initialValues, isLoading, providers, subscriptions, resourceGroups } =
|
||||
useLoadFormState(environmentId, isUserAdmin);
|
||||
useLoadFormState(environmentId, isAdmin);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { mutateAsync } = useCreateInstance(
|
||||
resourceGroups,
|
||||
environmentId,
|
||||
user?.Id
|
||||
);
|
||||
const { mutateAsync } = useCreateInstance(resourceGroups, environmentId);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
|
@ -50,7 +45,7 @@ export function CreateContainerInstanceForm() {
|
|||
return (
|
||||
<Formik<ContainerInstanceFormValues>
|
||||
initialValues={initialValues}
|
||||
validationSchema={() => validationSchema(isUserAdmin)}
|
||||
validationSchema={() => validationSchema(isAdmin)}
|
||||
onSubmit={onSubmit}
|
||||
validateOnMount
|
||||
validateOnChange
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { object, string, number, boolean } from 'yup';
|
||||
|
||||
import { validationSchema as accessControlSchema } from '@/portainer/components/accessControlForm/AccessControlForm.validation';
|
||||
import { validationSchema as accessControlSchema } from '@/portainer/access-control/AccessControlForm/AccessControlForm.validation';
|
||||
|
||||
import { validationSchema as portsSchema } from './PortsMappingField.validation';
|
||||
|
||||
|
|
|
@ -8,8 +8,7 @@ import {
|
|||
ContainerInstanceFormValues,
|
||||
ResourceGroup,
|
||||
} from '@/azure/types';
|
||||
import { UserId } from '@/portainer/users/types';
|
||||
import { applyResourceControl } from '@/portainer/resource-control/resource-control.service';
|
||||
import { applyResourceControl } from '@/portainer/access-control/access-control.service';
|
||||
|
||||
import { getSubscriptionResourceGroups } from './utils';
|
||||
|
||||
|
@ -17,8 +16,7 @@ export function useCreateInstance(
|
|||
resourceGroups: {
|
||||
[k: string]: ResourceGroup[];
|
||||
},
|
||||
environmentId: EnvironmentId,
|
||||
userId?: UserId
|
||||
environmentId: EnvironmentId
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<ContainerGroup, unknown, ContainerInstanceFormValues>(
|
||||
|
@ -47,13 +45,13 @@ export function useCreateInstance(
|
|||
},
|
||||
{
|
||||
async onSuccess(containerGroup, values) {
|
||||
if (!userId) {
|
||||
throw new Error('missing user id');
|
||||
const resourceControl = containerGroup.Portainer?.ResourceControl;
|
||||
if (!resourceControl) {
|
||||
throw new PortainerError('resource control expected after creation');
|
||||
}
|
||||
|
||||
const resourceControl = containerGroup.Portainer.ResourceControl;
|
||||
const accessControlData = values.accessControl;
|
||||
await applyResourceControl(userId, accessControlData, resourceControl);
|
||||
await applyResourceControl(accessControlData, resourceControl);
|
||||
queryClient.invalidateQueries(['azure', 'container-instances']);
|
||||
},
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ import { getResourceGroups } from '@/azure/services/resource-groups.service';
|
|||
import { getSubscriptions } from '@/azure/services/subscription.service';
|
||||
import { getContainerInstanceProvider } from '@/azure/services/provider.service';
|
||||
import { ContainerInstanceFormValues, Subscription } from '@/azure/types';
|
||||
import { parseFromResourceControl } from '@/portainer/components/accessControlForm/model';
|
||||
import { parseAccessControlFormData } from '@/portainer/access-control/utils';
|
||||
|
||||
import {
|
||||
getSubscriptionLocations,
|
||||
|
@ -58,7 +58,7 @@ export function useLoadFormState(
|
|||
cpu: 1,
|
||||
ports: [{ container: '80', host: '80', protocol: 'TCP' }],
|
||||
allocatePublicIP: true,
|
||||
accessControl: parseFromResourceControl(isUserAdmin),
|
||||
accessControl: parseAccessControlFormData(isUserAdmin),
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
|
||||
import { ResourceControlViewModel } from 'Portainer/models/resourceControl/resourceControl';
|
||||
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
|
||||
|
||||
export function ContainerGroupDefaultModel() {
|
||||
this.Location = '';
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import { AccessControlFormData } from '@/portainer/components/accessControlForm/model';
|
||||
import { ResourceControlResponse } from '@/portainer/models/resourceControl/resourceControl';
|
||||
import {
|
||||
AccessControlFormData,
|
||||
ResourceControlResponse,
|
||||
} from '@/portainer/access-control/types';
|
||||
|
||||
import { PortMapping } from './ContainerInstances/CreateContainerInstanceForm/PortsMappingField';
|
||||
|
||||
|
|
|
@ -123,8 +123,12 @@
|
|||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
<!-- access-control-panel -->
|
||||
<por-access-control-panel ng-if="$ctrl.container" resource-id="$ctrl.container.Id" resource-control="$ctrl.container.ResourceControl" resource-type="'container-group'">
|
||||
</por-access-control-panel>
|
||||
<!-- !access-control-panel -->
|
||||
|
||||
<access-control-panel
|
||||
ng-if="$ctrl.container"
|
||||
resource-id="$ctrl.container.Id"
|
||||
resource-control="$ctrl.container.ResourceControl"
|
||||
resource-type="$ctrl.resourceType"
|
||||
on-update-success="($ctrl.onUpdateSuccess)"
|
||||
></access-control-panel>
|
||||
</div>
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { ResourceControlType } from '@/portainer/access-control/types';
|
||||
|
||||
class ContainerInstanceDetailsController {
|
||||
/* @ngInject */
|
||||
constructor($state, AzureService, ContainerGroupService, Notifications, ResourceGroupService, SubscriptionService) {
|
||||
|
@ -7,9 +9,16 @@ class ContainerInstanceDetailsController {
|
|||
loading: false,
|
||||
};
|
||||
|
||||
this.resourceType = ResourceControlType.ContainerGroup;
|
||||
|
||||
this.container = null;
|
||||
this.subscription = null;
|
||||
this.resourceGroup = null;
|
||||
this.onUpdateSuccess = this.onUpdateSuccess.bind(this);
|
||||
}
|
||||
|
||||
onUpdateSuccess() {
|
||||
this.$state.reload();
|
||||
}
|
||||
|
||||
async $onInit() {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { ResourceControlOwnership as RCO } from 'Portainer/models/resourceControl/resourceControlOwnership';
|
||||
import { ResourceControlOwnership as RCO } from '@/portainer/access-control/types';
|
||||
|
||||
angular.module('portainer.docker').directive('networkRowContent', [
|
||||
function networkRowContent() {
|
||||
|
|
|
@ -2,8 +2,8 @@ import { Column } from 'react-table';
|
|||
import clsx from 'clsx';
|
||||
|
||||
import { ownershipIcon } from '@/portainer/filters/filters';
|
||||
import { ResourceControlOwnership } from '@/portainer/models/resourceControl/resourceControlOwnership';
|
||||
import type { DockerContainer } from '@/docker/containers/types';
|
||||
import { ResourceControlOwnership } from '@/portainer/access-control/types';
|
||||
|
||||
export const ownership: Column<DockerContainer> = {
|
||||
Header: 'Ownership',
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { ResourceControlViewModel } from '@/portainer/models/resourceControl/resourceControl';
|
||||
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
|
||||
|
||||
export type DockerContainerStatus =
|
||||
| 'paused'
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { ResourceControlViewModel } from 'Portainer/models/resourceControl/resourceControl';
|
||||
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
|
||||
|
||||
function b64DecodeUnicode(str) {
|
||||
try {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import _ from 'lodash-es';
|
||||
import { ResourceControlViewModel } from 'Portainer/models/resourceControl/resourceControl';
|
||||
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
|
||||
|
||||
export function createStatus(statusText) {
|
||||
var status = _.toLower(statusText);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { ResourceControlViewModel } from 'Portainer/models/resourceControl/resourceControl';
|
||||
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
|
||||
|
||||
export function NetworkViewModel(data) {
|
||||
this.Id = data.Id;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { ResourceControlViewModel } from 'Portainer/models/resourceControl/resourceControl';
|
||||
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
|
||||
|
||||
export function SecretViewModel(data) {
|
||||
this.Id = data.ID;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { ResourceControlViewModel } from 'Portainer/models/resourceControl/resourceControl';
|
||||
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
|
||||
|
||||
export function ServiceViewModel(data, runningTasks, allTasks) {
|
||||
this.Model = data;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { ResourceControlViewModel } from 'Portainer/models/resourceControl/resourceControl';
|
||||
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
|
||||
|
||||
export function VolumeViewModel(data) {
|
||||
this.Id = data.Name;
|
||||
|
|
|
@ -59,7 +59,14 @@
|
|||
</div>
|
||||
|
||||
<!-- access-control-panel -->
|
||||
<por-access-control-panel ng-if="config" resource-id="config.Id" resource-control="config.ResourceControl" resource-type="'config'"> </por-access-control-panel>
|
||||
<access-control-panel
|
||||
ng-if="config"
|
||||
resource-id="config.Id"
|
||||
resource-control="config.ResourceControl"
|
||||
resource-type="resourceType"
|
||||
on-update-success="(onUpdateResourceControlSuccess)"
|
||||
>
|
||||
</access-control-panel>
|
||||
<!-- !access-control-panel -->
|
||||
|
||||
<div class="row" ng-if="config">
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { ResourceControlType } from '@/portainer/access-control/types';
|
||||
|
||||
angular.module('portainer.docker').controller('ConfigController', [
|
||||
'$scope',
|
||||
'$transition$',
|
||||
|
@ -5,6 +7,12 @@ angular.module('portainer.docker').controller('ConfigController', [
|
|||
'ConfigService',
|
||||
'Notifications',
|
||||
function ($scope, $transition$, $state, ConfigService, Notifications) {
|
||||
$scope.resourceType = ResourceControlType.Config;
|
||||
|
||||
$scope.onUpdateResourceControlSuccess = function () {
|
||||
$state.reload();
|
||||
};
|
||||
|
||||
$scope.removeConfig = function removeConfig(configId) {
|
||||
ConfigService.remove(configId)
|
||||
.then(function success() {
|
||||
|
|
|
@ -152,7 +152,14 @@
|
|||
</div>
|
||||
|
||||
<!-- access-control-panel -->
|
||||
<por-access-control-panel ng-if="container" resource-id="container.Id" resource-control="container.ResourceControl" resource-type="'container'"> </por-access-control-panel>
|
||||
<access-control-panel
|
||||
ng-if="container"
|
||||
resource-id="container.Id"
|
||||
resource-control="container.ResourceControl"
|
||||
resource-type="resourceType"
|
||||
on-update-success="(onUpdateResourceControlSuccess)"
|
||||
>
|
||||
</access-control-panel>
|
||||
<!-- !access-control-panel -->
|
||||
|
||||
<div ng-if="container.State.Health" class="row">
|
||||
|
|
|
@ -3,6 +3,7 @@ import _ from 'lodash-es';
|
|||
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
||||
import { confirmContainerDeletion } from '@/portainer/services/modal.service/prompt';
|
||||
import { FeatureId } from 'Portainer/feature-flags/enums';
|
||||
import { ResourceControlType } from '@/portainer/access-control/types';
|
||||
|
||||
angular.module('portainer.docker').controller('ContainerController', [
|
||||
'$q',
|
||||
|
@ -45,6 +46,7 @@ angular.module('portainer.docker').controller('ContainerController', [
|
|||
Authentication,
|
||||
endpoint
|
||||
) {
|
||||
$scope.resourceType = ResourceControlType.Container;
|
||||
$scope.endpoint = endpoint;
|
||||
$scope.isAdmin = Authentication.isAdmin();
|
||||
$scope.activityTime = 0;
|
||||
|
@ -71,6 +73,10 @@ angular.module('portainer.docker').controller('ContainerController', [
|
|||
|
||||
$scope.updateRestartPolicy = updateRestartPolicy;
|
||||
|
||||
$scope.onUpdateResourceControlSuccess = function () {
|
||||
$state.reload();
|
||||
};
|
||||
|
||||
var update = function () {
|
||||
var nodeName = $transition$.params().nodeName;
|
||||
HttpRequestHelper.setPortainerAgentTargetHeader(nodeName);
|
||||
|
|
|
@ -69,14 +69,15 @@
|
|||
</div>
|
||||
|
||||
<!-- access-control-panel -->
|
||||
<por-access-control-panel
|
||||
<access-control-panel
|
||||
ng-if="network"
|
||||
resource-id="network.Id"
|
||||
resource-control="network.ResourceControl"
|
||||
resource-type="'network'"
|
||||
resource-type="resourceType"
|
||||
disable-ownership-change="isSystemNetwork()"
|
||||
on-update-success="(onUpdateResourceControlSuccess)"
|
||||
>
|
||||
</por-access-control-panel>
|
||||
</access-control-panel>
|
||||
<!-- !access-control-panel -->
|
||||
|
||||
<div class="row" ng-if="!(network.Options | emptyobject)">
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { ResourceControlType } from '@/portainer/access-control/types';
|
||||
import DockerNetworkHelper from 'Docker/helpers/networkHelper';
|
||||
|
||||
angular.module('portainer.docker').controller('NetworkController', [
|
||||
|
@ -11,6 +12,12 @@ angular.module('portainer.docker').controller('NetworkController', [
|
|||
'HttpRequestHelper',
|
||||
'NetworkHelper',
|
||||
function ($scope, $state, $transition$, $filter, NetworkService, Container, Notifications, HttpRequestHelper, NetworkHelper) {
|
||||
$scope.resourceType = ResourceControlType.Network;
|
||||
|
||||
$scope.onUpdateResourceControlSuccess = function () {
|
||||
$state.reload();
|
||||
};
|
||||
|
||||
$scope.removeNetwork = function removeNetwork() {
|
||||
NetworkService.remove($transition$.params().id, $transition$.params().id)
|
||||
.then(function success() {
|
||||
|
|
|
@ -56,5 +56,12 @@
|
|||
</div>
|
||||
|
||||
<!-- access-control-panel -->
|
||||
<por-access-control-panel ng-if="secret" resource-id="secret.Id" resource-control="secret.ResourceControl" resource-type="'secret'"> </por-access-control-panel>
|
||||
<access-control-panel
|
||||
ng-if="secret"
|
||||
resource-id="secret.Id"
|
||||
resource-control="secret.ResourceControl"
|
||||
resource-type="resourceType"
|
||||
on-update-success="(onUpdateResourceControlSuccess)"
|
||||
>
|
||||
</access-control-panel>
|
||||
<!-- !access-control-panel -->
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { ResourceControlType } from '@/portainer/access-control/types';
|
||||
|
||||
angular.module('portainer.docker').controller('SecretController', [
|
||||
'$scope',
|
||||
'$transition$',
|
||||
|
@ -5,6 +7,12 @@ angular.module('portainer.docker').controller('SecretController', [
|
|||
'SecretService',
|
||||
'Notifications',
|
||||
function ($scope, $transition$, $state, SecretService, Notifications) {
|
||||
$scope.resourceType = ResourceControlType.Secret;
|
||||
|
||||
$scope.onUpdateResourceControlSuccess = function () {
|
||||
$state.reload();
|
||||
};
|
||||
|
||||
$scope.removeSecret = function removeSecret(secretId) {
|
||||
SecretService.remove(secretId)
|
||||
.then(function success() {
|
||||
|
|
|
@ -198,7 +198,14 @@
|
|||
</div>
|
||||
|
||||
<!-- access-control-panel -->
|
||||
<por-access-control-panel ng-if="service" resource-id="service.Id" resource-control="service.ResourceControl" resource-type="'service'"> </por-access-control-panel>
|
||||
<access-control-panel
|
||||
ng-if="service"
|
||||
resource-id="service.Id"
|
||||
resource-control="service.ResourceControl"
|
||||
resource-type="resourceType"
|
||||
on-update-success="(onUpdateResourceControlSuccess)"
|
||||
>
|
||||
</access-control-panel>
|
||||
<!-- !access-control-panel -->
|
||||
|
||||
<div class="row">
|
||||
|
|
|
@ -21,6 +21,7 @@ import _ from 'lodash-es';
|
|||
|
||||
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
||||
import * as envVarsUtils from '@/portainer/helpers/env-vars';
|
||||
import { ResourceControlType } from '@/portainer/access-control/types';
|
||||
|
||||
angular.module('portainer.docker').controller('ServiceController', [
|
||||
'$q',
|
||||
|
@ -87,6 +88,12 @@ angular.module('portainer.docker').controller('ServiceController', [
|
|||
RegistryService,
|
||||
endpoint
|
||||
) {
|
||||
$scope.resourceType = ResourceControlType.Service;
|
||||
|
||||
$scope.onUpdateResourceControlSuccess = function () {
|
||||
$state.reload();
|
||||
};
|
||||
|
||||
$scope.endpoint = endpoint;
|
||||
|
||||
$scope.state = {
|
||||
|
|
|
@ -52,7 +52,14 @@
|
|||
</div>
|
||||
|
||||
<!-- access-control-panel -->
|
||||
<por-access-control-panel ng-if="volume" resource-id="volume.ResourceId" resource-control="volume.ResourceControl" resource-type="'volume'"> </por-access-control-panel>
|
||||
<access-control-panel
|
||||
ng-if="volume"
|
||||
resource-id="volume.ResourceId"
|
||||
resource-control="volume.ResourceControl"
|
||||
resource-type="resourceType"
|
||||
on-update-success="(onUpdateResourceControlSuccess)"
|
||||
>
|
||||
</access-control-panel>
|
||||
<!-- !access-control-panel -->
|
||||
|
||||
<div class="row" ng-if="!(volume.Options | emptyobject)">
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { ResourceControlType } from '@/portainer/access-control/types';
|
||||
|
||||
angular.module('portainer.docker').controller('VolumeController', [
|
||||
'$scope',
|
||||
'$state',
|
||||
|
@ -9,6 +11,12 @@ angular.module('portainer.docker').controller('VolumeController', [
|
|||
'Notifications',
|
||||
'HttpRequestHelper',
|
||||
function ($scope, $state, $transition$, $q, ModalService, VolumeService, ContainerService, Notifications, HttpRequestHelper) {
|
||||
$scope.resourceType = ResourceControlType.Volume;
|
||||
|
||||
$scope.onUpdateResourceControlSuccess = function () {
|
||||
$state.reload();
|
||||
};
|
||||
|
||||
$scope.removeVolume = function removeVolume() {
|
||||
ModalService.confirmDeletion('Do you want to remove this volume?', (confirmed) => {
|
||||
if (confirmed) {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
|
||||
import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel';
|
||||
import { ResourceControlViewModel } from '@/portainer/models/resourceControl/resourceControl';
|
||||
|
||||
class KubeEditCustomTemplateViewController {
|
||||
/* @ngInject */
|
||||
|
|
|
@ -8,6 +8,7 @@ import userActivityModule from './user-activity';
|
|||
import servicesModule from './services';
|
||||
import teamsModule from './teams';
|
||||
import homeModule from './home';
|
||||
import { accessControlModule } from './access-control';
|
||||
|
||||
async function initAuthentication(authManager, Authentication, $rootScope, $state) {
|
||||
authManager.checkAuthOnRefresh();
|
||||
|
@ -36,6 +37,7 @@ angular
|
|||
'portainer.shared.datatable',
|
||||
servicesModule,
|
||||
teamsModule,
|
||||
accessControlModule,
|
||||
])
|
||||
.config([
|
||||
'$stateRegistryProvider',
|
||||
|
|
|
@ -3,11 +3,11 @@ import { useMemo, useState } from 'react';
|
|||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
|
||||
import { UserContext } from '@/portainer/hooks/useUser';
|
||||
import { ResourceControlOwnership } from '@/portainer/models/resourceControl/resourceControlOwnership';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
|
||||
import { parseAccessControlFormData } from '../utils';
|
||||
|
||||
import { AccessControlForm } from './AccessControlForm';
|
||||
import { AccessControlFormData } from './model';
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Components/AccessControlForm',
|
||||
|
@ -30,11 +30,8 @@ interface Args {
|
|||
}
|
||||
|
||||
function Template({ userRole }: Args) {
|
||||
const defaults = new AccessControlFormData();
|
||||
defaults.ownership =
|
||||
userRole === Role.Admin
|
||||
? ResourceControlOwnership.ADMINISTRATORS
|
||||
: ResourceControlOwnership.PRIVATE;
|
||||
const isAdmin = userRole === Role.Admin;
|
||||
const defaults = parseAccessControlFormData(isAdmin);
|
||||
|
||||
const [value, setValue] = useState(defaults);
|
||||
|
||||
|
@ -46,7 +43,7 @@ function Template({ userRole }: Args) {
|
|||
return (
|
||||
<QueryClientProvider client={testQueryClient}>
|
||||
<UserContext.Provider value={userProviderState}>
|
||||
<AccessControlForm values={value} onChange={setValue} />
|
||||
<AccessControlForm values={value} onChange={setValue} errors={{}} />
|
||||
</UserContext.Provider>
|
||||
</QueryClientProvider>
|
||||
);
|
|
@ -1,36 +1,118 @@
|
|||
import { server, rest } from '@/setup-tests/server';
|
||||
import { ResourceControlOwnership as RCO } from '@/portainer/models/resourceControl/resourceControlOwnership';
|
||||
import { UserContext } from '@/portainer/hooks/useUser';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { renderWithQueryClient, within } from '@/react-tools/test-utils';
|
||||
import { Team } from '@/portainer/teams/types';
|
||||
import { ResourceControlViewModel } from '@/portainer/models/resourceControl/resourceControl';
|
||||
import { Team, TeamId } from '@/portainer/teams/types';
|
||||
import { createMockTeams } from '@/react-tools/test-mocks';
|
||||
import { UserId } from '@/portainer/users/types';
|
||||
|
||||
import { ResourceControlOwnership, AccessControlFormData } from '../types';
|
||||
import { ResourceControlViewModel } from '../models/ResourceControlViewModel';
|
||||
|
||||
import { AccessControlForm } from './AccessControlForm';
|
||||
import { AccessControlFormData } from './model';
|
||||
|
||||
test('renders correctly', async () => {
|
||||
const values: AccessControlFormData = new AccessControlFormData();
|
||||
const values = buildFormData();
|
||||
|
||||
const { findByText } = await renderComponent(values);
|
||||
|
||||
expect(await findByText('Access control')).toBeVisible();
|
||||
});
|
||||
|
||||
test('when AccessControlEnabled is true, ownership selector should be visible', async () => {
|
||||
const values = new AccessControlFormData();
|
||||
test.each([
|
||||
[ResourceControlOwnership.ADMINISTRATORS],
|
||||
[ResourceControlOwnership.PRIVATE],
|
||||
[ResourceControlOwnership.RESTRICTED],
|
||||
])(
|
||||
`when ownership is %s, ownership selector should be visible`,
|
||||
async (ownership) => {
|
||||
const values = buildFormData(ownership);
|
||||
|
||||
const { queryByRole } = await renderComponent(values);
|
||||
const { findByRole, getByLabelText } = await renderComponent(values);
|
||||
const accessSwitch = getByLabelText(/Enable access control/);
|
||||
|
||||
expect(queryByRole('radiogroup')).toBeVisible();
|
||||
});
|
||||
expect(accessSwitch).toBeEnabled();
|
||||
|
||||
test('when AccessControlEnabled is false, ownership selector should be hidden', async () => {
|
||||
const values: AccessControlFormData = {
|
||||
...new AccessControlFormData(),
|
||||
accessControlEnabled: false,
|
||||
};
|
||||
await expect(findByRole('radiogroup')).resolves.toBeVisible();
|
||||
}
|
||||
);
|
||||
|
||||
test.each([
|
||||
[ResourceControlOwnership.ADMINISTRATORS],
|
||||
[ResourceControlOwnership.PRIVATE],
|
||||
[ResourceControlOwnership.RESTRICTED],
|
||||
])(
|
||||
'when isAdmin and ownership is %s, ownership selector should show admin and restricted options',
|
||||
async (ownership) => {
|
||||
const values = buildFormData(ownership);
|
||||
|
||||
const { findByRole } = await renderComponent(values, jest.fn(), {
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
const ownershipSelector = await findByRole('radiogroup');
|
||||
|
||||
expect(ownershipSelector).toBeVisible();
|
||||
if (!ownershipSelector) {
|
||||
throw new Error('selector is missing');
|
||||
}
|
||||
|
||||
const selectorQueries = within(ownershipSelector);
|
||||
expect(
|
||||
await selectorQueries.findByLabelText(/Administrator/)
|
||||
).toBeVisible();
|
||||
expect(await selectorQueries.findByLabelText(/Restricted/)).toBeVisible();
|
||||
}
|
||||
);
|
||||
|
||||
test.each([
|
||||
[ResourceControlOwnership.ADMINISTRATORS],
|
||||
[ResourceControlOwnership.PRIVATE],
|
||||
[ResourceControlOwnership.RESTRICTED],
|
||||
])(
|
||||
`when user is not an admin and %s and no teams, should have only private option`,
|
||||
async (ownership) => {
|
||||
const values = buildFormData(ownership);
|
||||
|
||||
const { findByRole } = await renderComponent(values, jest.fn(), {
|
||||
teams: [],
|
||||
isAdmin: false,
|
||||
});
|
||||
|
||||
const ownershipSelector = await findByRole('radiogroup');
|
||||
|
||||
const selectorQueries = within(ownershipSelector);
|
||||
|
||||
expect(selectorQueries.queryByLabelText(/Private/)).toBeVisible();
|
||||
expect(selectorQueries.queryByLabelText(/Restricted/)).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
test.each([
|
||||
[ResourceControlOwnership.ADMINISTRATORS],
|
||||
[ResourceControlOwnership.PRIVATE],
|
||||
[ResourceControlOwnership.RESTRICTED],
|
||||
])(
|
||||
`when user is not an admin and %s and there is 1 team, should have private and restricted options`,
|
||||
async (ownership) => {
|
||||
const values = buildFormData(ownership);
|
||||
|
||||
const { findByRole } = await renderComponent(values, jest.fn(), {
|
||||
teams: createMockTeams(1),
|
||||
isAdmin: false,
|
||||
});
|
||||
|
||||
const ownershipSelector = await findByRole('radiogroup');
|
||||
|
||||
const selectorQueries = within(ownershipSelector);
|
||||
|
||||
expect(await selectorQueries.findByLabelText(/Private/)).toBeVisible();
|
||||
expect(await selectorQueries.findByLabelText(/Restricted/)).toBeVisible();
|
||||
}
|
||||
);
|
||||
|
||||
test('when ownership is public, ownership selector should be hidden', async () => {
|
||||
const values = buildFormData(ResourceControlOwnership.PUBLIC);
|
||||
|
||||
const { queryByRole } = await renderComponent(values);
|
||||
|
||||
|
@ -38,7 +120,7 @@ test('when AccessControlEnabled is false, ownership selector should be hidden',
|
|||
});
|
||||
|
||||
test('when hideTitle is true, title should be hidden', async () => {
|
||||
const values = new AccessControlFormData();
|
||||
const values = buildFormData();
|
||||
|
||||
const { queryByRole } = await renderComponent(values, jest.fn(), {
|
||||
hideTitle: true,
|
||||
|
@ -47,30 +129,8 @@ test('when hideTitle is true, title should be hidden', async () => {
|
|||
expect(queryByRole('Access control')).toBeNull();
|
||||
});
|
||||
|
||||
test('when isAdmin and AccessControlEnabled, ownership selector should admin and restricted options', async () => {
|
||||
const values = new AccessControlFormData();
|
||||
|
||||
const { findByRole } = await renderComponent(values, jest.fn(), {
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
const ownershipSelector = await findByRole('radiogroup');
|
||||
|
||||
expect(ownershipSelector).toBeVisible();
|
||||
if (!ownershipSelector) {
|
||||
throw new Error('selector is missing');
|
||||
}
|
||||
|
||||
const selectorQueries = within(ownershipSelector);
|
||||
expect(await selectorQueries.findByLabelText(/Administrator/)).toBeVisible();
|
||||
expect(await selectorQueries.findByLabelText(/Restricted/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('when isAdmin, AccessControlEnabled and admin ownership is selected, no extra options are visible', async () => {
|
||||
const values: AccessControlFormData = {
|
||||
...new AccessControlFormData(),
|
||||
ownership: RCO.ADMINISTRATORS,
|
||||
};
|
||||
test('when isAdmin and admin ownership is selected, no extra options are visible', async () => {
|
||||
const values = buildFormData(ResourceControlOwnership.ADMINISTRATORS);
|
||||
|
||||
const { findByRole, queryByLabelText } = await renderComponent(
|
||||
values,
|
||||
|
@ -95,11 +155,8 @@ test('when isAdmin, AccessControlEnabled and admin ownership is selected, no ext
|
|||
expect(queryByLabelText('extra-options')).toBeNull();
|
||||
});
|
||||
|
||||
test('when isAdmin, AccessControlEnabled and restricted ownership is selected, show team and users selectors', async () => {
|
||||
const values: AccessControlFormData = {
|
||||
...new AccessControlFormData(),
|
||||
ownership: RCO.RESTRICTED,
|
||||
};
|
||||
test('when isAdmin and restricted ownership is selected, show team and users selectors', async () => {
|
||||
const values = buildFormData(ResourceControlOwnership.RESTRICTED);
|
||||
|
||||
const { findByRole, findByLabelText } = await renderComponent(
|
||||
values,
|
||||
|
@ -136,43 +193,8 @@ test('when isAdmin, AccessControlEnabled and restricted ownership is selected, s
|
|||
expect(await extraQueries.findByText(/Authorized teams/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('when user is not an admin and access control is enabled and no teams, should have only private option', async () => {
|
||||
const values = new AccessControlFormData();
|
||||
|
||||
const { findByRole } = await renderComponent(values, jest.fn(), {
|
||||
teams: [],
|
||||
isAdmin: false,
|
||||
});
|
||||
|
||||
const ownershipSelector = await findByRole('radiogroup');
|
||||
|
||||
const selectorQueries = within(ownershipSelector);
|
||||
|
||||
expect(selectorQueries.queryByLabelText(/Private/)).toBeVisible();
|
||||
expect(selectorQueries.queryByLabelText(/Restricted/)).toBeNull();
|
||||
});
|
||||
|
||||
test('when user is not an admin and access control is enabled and there is 1 team, should have private and restricted options', async () => {
|
||||
const values = new AccessControlFormData();
|
||||
|
||||
const { findByRole } = await renderComponent(values, jest.fn(), {
|
||||
teams: createMockTeams(1),
|
||||
isAdmin: false,
|
||||
});
|
||||
|
||||
const ownershipSelector = await findByRole('radiogroup');
|
||||
|
||||
const selectorQueries = within(ownershipSelector);
|
||||
|
||||
expect(await selectorQueries.findByLabelText(/Private/)).toBeVisible();
|
||||
expect(await selectorQueries.findByLabelText(/Restricted/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('when user is not an admin, access control is enabled, there are more then 1 team and ownership is restricted, team selector should be visible', async () => {
|
||||
const values: AccessControlFormData = {
|
||||
...new AccessControlFormData(),
|
||||
ownership: RCO.RESTRICTED,
|
||||
};
|
||||
test('when user is not an admin, there are more then 1 team and ownership is restricted, team selector should be visible', async () => {
|
||||
const values = buildFormData(ResourceControlOwnership.RESTRICTED);
|
||||
|
||||
const { findByRole, findByLabelText } = await renderComponent(
|
||||
values,
|
||||
|
@ -202,11 +224,8 @@ test('when user is not an admin, access control is enabled, there are more then
|
|||
expect(extraQueries.queryByLabelText(/Authorized teams/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('when user is not an admin, access control is enabled, there is 1 team and ownership is restricted, team selector not should be visible', async () => {
|
||||
const values: AccessControlFormData = {
|
||||
...new AccessControlFormData(),
|
||||
ownership: RCO.RESTRICTED,
|
||||
};
|
||||
test('when user is not an admin, there is 1 team and ownership is restricted, team selector not should be visible', async () => {
|
||||
const values = buildFormData(ResourceControlOwnership.RESTRICTED);
|
||||
|
||||
const { findByRole, findByLabelText } = await renderComponent(
|
||||
values,
|
||||
|
@ -240,11 +259,8 @@ test('when user is not an admin, access control is enabled, there is 1 team and
|
|||
expect(extraQueries.queryByText(/Authorized teams/)).toBeNull();
|
||||
});
|
||||
|
||||
test('when user is not an admin, access control is enabled, and ownership is restricted, user selector not should be visible', async () => {
|
||||
const values: AccessControlFormData = {
|
||||
...new AccessControlFormData(),
|
||||
ownership: RCO.RESTRICTED,
|
||||
};
|
||||
test('when user is not an admin, and ownership is restricted, user selector not should be visible', async () => {
|
||||
const values = buildFormData(ResourceControlOwnership.RESTRICTED);
|
||||
|
||||
const { findByRole, findByLabelText } = await renderComponent(
|
||||
values,
|
||||
|
@ -299,6 +315,7 @@ async function renderComponent(
|
|||
const renderResult = renderWithQueryClient(
|
||||
<UserContext.Provider value={state}>
|
||||
<AccessControlForm
|
||||
errors={{}}
|
||||
values={values}
|
||||
onChange={onChange}
|
||||
hideTitle={hideTitle}
|
||||
|
@ -309,6 +326,13 @@ async function renderComponent(
|
|||
await expect(
|
||||
renderResult.findByLabelText(/Enable access control/)
|
||||
).resolves.toBeVisible();
|
||||
|
||||
return renderResult;
|
||||
}
|
||||
|
||||
function buildFormData(
|
||||
ownership = ResourceControlOwnership.PRIVATE,
|
||||
authorizedTeams: TeamId[] = [],
|
||||
authorizedUsers: UserId[] = []
|
||||
): AccessControlFormData {
|
||||
return { ownership, authorizedTeams, authorizedUsers };
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
|
||||
import { SwitchField } from '@/portainer/components/form-components/SwitchField';
|
||||
import { EditDetails } from '@/portainer/access-control/EditDetails/EditDetails';
|
||||
|
||||
import { ResourceControlOwnership, AccessControlFormData } from '../types';
|
||||
|
||||
export interface Props {
|
||||
values: AccessControlFormData;
|
||||
onChange(values: AccessControlFormData): void;
|
||||
hideTitle?: boolean;
|
||||
formNamespace?: string;
|
||||
errors?: FormikErrors<AccessControlFormData>;
|
||||
}
|
||||
|
||||
export function AccessControlForm({
|
||||
values,
|
||||
onChange,
|
||||
hideTitle,
|
||||
formNamespace,
|
||||
errors,
|
||||
}: Props) {
|
||||
const { isAdmin } = useUser();
|
||||
|
||||
const accessControlEnabled =
|
||||
values.ownership !== ResourceControlOwnership.PUBLIC;
|
||||
return (
|
||||
<>
|
||||
{!hideTitle && <FormSectionTitle>Access control</FormSectionTitle>}
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
checked={accessControlEnabled}
|
||||
name={withNamespace('accessControlEnabled')}
|
||||
label="Enable access control"
|
||||
tooltip="When enabled, you can restrict the access and management of this resource."
|
||||
onChange={handleToggleEnable}
|
||||
dataCy="portainer-accessMgmtToggle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{accessControlEnabled && (
|
||||
<EditDetails
|
||||
onChange={onChange}
|
||||
values={values}
|
||||
errors={errors}
|
||||
formNamespace={formNamespace}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
function withNamespace(name: string) {
|
||||
return formNamespace ? `${formNamespace}.${name}` : name;
|
||||
}
|
||||
|
||||
function handleToggleEnable(accessControlEnabled: boolean) {
|
||||
let ownership = ResourceControlOwnership.PUBLIC;
|
||||
if (accessControlEnabled) {
|
||||
ownership = isAdmin
|
||||
? ResourceControlOwnership.ADMINISTRATORS
|
||||
: ResourceControlOwnership.PRIVATE;
|
||||
}
|
||||
onChange({ ...values, ownership });
|
||||
}
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
import { ResourceControlOwnership } from '../types';
|
||||
|
||||
import { validationSchema } from './AccessControlForm.validation';
|
||||
|
||||
test('when ownership not restricted, should be valid', async () => {
|
||||
const schema = validationSchema(true);
|
||||
[
|
||||
ResourceControlOwnership.ADMINISTRATORS,
|
||||
ResourceControlOwnership.PRIVATE,
|
||||
ResourceControlOwnership.PUBLIC,
|
||||
].forEach(async (ownership) => {
|
||||
const object = { ownership };
|
||||
|
||||
await expect(
|
||||
schema.validate(object, { strict: true })
|
||||
).resolves.toStrictEqual(object);
|
||||
});
|
||||
});
|
||||
|
||||
test('when ownership is restricted and no teams or users, should be invalid', async () => {
|
||||
[true, false].forEach(async (isAdmin) => {
|
||||
const schema = validationSchema(isAdmin);
|
||||
|
||||
await expect(
|
||||
schema.validate(
|
||||
{
|
||||
ownership: ResourceControlOwnership.RESTRICTED,
|
||||
authorizedTeams: [],
|
||||
authorizedUsers: [],
|
||||
},
|
||||
{ strict: true }
|
||||
)
|
||||
).rejects.toThrowErrorMatchingSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
test('when ownership is restricted, and the user is admin should have either teams or users', async () => {
|
||||
const schema = validationSchema(true);
|
||||
const teams = {
|
||||
ownership: ResourceControlOwnership.RESTRICTED,
|
||||
authorizedTeams: [1],
|
||||
authorizedUsers: [],
|
||||
};
|
||||
|
||||
await expect(schema.validate(teams, { strict: true })).resolves.toStrictEqual(
|
||||
teams
|
||||
);
|
||||
|
||||
const users = {
|
||||
ownership: ResourceControlOwnership.RESTRICTED,
|
||||
authorizedTeams: [],
|
||||
authorizedUsers: [1],
|
||||
};
|
||||
|
||||
await expect(schema.validate(users, { strict: true })).resolves.toStrictEqual(
|
||||
users
|
||||
);
|
||||
|
||||
const both = {
|
||||
ownership: ResourceControlOwnership.RESTRICTED,
|
||||
authorizedTeams: [1],
|
||||
authorizedUsers: [2],
|
||||
};
|
||||
|
||||
await expect(schema.validate(both, { strict: true })).resolves.toStrictEqual(
|
||||
both
|
||||
);
|
||||
});
|
||||
|
||||
test('when ownership is restricted, user is not admin with teams, should be valid', async () => {
|
||||
const schema = validationSchema(false);
|
||||
|
||||
const object = {
|
||||
ownership: ResourceControlOwnership.RESTRICTED,
|
||||
authorizedTeams: [1],
|
||||
authorizedUsers: [],
|
||||
};
|
||||
|
||||
await expect(
|
||||
schema.validate(object, { strict: true })
|
||||
).resolves.toStrictEqual(object);
|
||||
});
|
|
@ -1,17 +1,13 @@
|
|||
import { object, string, array, number, bool } from 'yup';
|
||||
import { object, string, array, number } from 'yup';
|
||||
|
||||
import { ResourceControlOwnership } from '@/portainer/models/resourceControl/resourceControlOwnership';
|
||||
import { ResourceControlOwnership } from '../types';
|
||||
|
||||
export function validationSchema(isAdmin: boolean) {
|
||||
return object()
|
||||
.shape({
|
||||
accessControlEnabled: bool(),
|
||||
ownership: string()
|
||||
.oneOf(Object.values(ResourceControlOwnership))
|
||||
.when('accessControlEnabled', {
|
||||
is: true,
|
||||
then: (schema) => schema.required(),
|
||||
}),
|
||||
.required(),
|
||||
authorizedUsers: array(number()),
|
||||
authorizedTeams: array(number()),
|
||||
})
|
||||
|
@ -20,16 +16,8 @@ export function validationSchema(isAdmin: boolean) {
|
|||
isAdmin
|
||||
? 'You must specify at least one team or user.'
|
||||
: 'You must specify at least one team.',
|
||||
({
|
||||
accessControlEnabled,
|
||||
ownership,
|
||||
authorizedTeams,
|
||||
authorizedUsers,
|
||||
}) => {
|
||||
if (
|
||||
!accessControlEnabled ||
|
||||
ownership !== ResourceControlOwnership.RESTRICTED
|
||||
) {
|
||||
({ ownership, authorizedTeams, authorizedUsers }) => {
|
||||
if (ownership !== ResourceControlOwnership.RESTRICTED) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`when ownership is restricted and no teams or users, should be invalid 1`] = `"You must specify at least one team or user."`;
|
||||
|
||||
exports[`when ownership is restricted and no teams or users, should be invalid 2`] = `"You must specify at least one team."`;
|
|
@ -0,0 +1,173 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
import { createMockTeams, createMockUsers } from '@/react-tools/test-mocks';
|
||||
import { renderWithQueryClient } from '@/react-tools/test-utils';
|
||||
import { rest, server } from '@/setup-tests/server';
|
||||
import { Role } from '@/portainer/users/types';
|
||||
|
||||
import {
|
||||
ResourceControlOwnership,
|
||||
ResourceControlType,
|
||||
TeamResourceAccess,
|
||||
UserResourceAccess,
|
||||
} from '../types';
|
||||
import { ResourceControlViewModel } from '../models/ResourceControlViewModel';
|
||||
|
||||
import { AccessControlPanelDetails } from './AccessControlPanelDetails';
|
||||
|
||||
test.each([
|
||||
[ResourceControlOwnership.ADMINISTRATORS],
|
||||
[ResourceControlOwnership.PRIVATE],
|
||||
[ResourceControlOwnership.PUBLIC],
|
||||
[ResourceControlOwnership.RESTRICTED],
|
||||
])(
|
||||
'when resource control with ownership %s is supplied, show its ownership',
|
||||
async (ownership) => {
|
||||
const resourceControl = buildViewModel(ownership);
|
||||
const { queryByLabelText } = await renderComponent(
|
||||
ResourceControlType.Container,
|
||||
resourceControl
|
||||
);
|
||||
|
||||
expect(queryByLabelText('ownership')).toHaveTextContent(ownership);
|
||||
}
|
||||
);
|
||||
|
||||
test('when resource control is not supplied, show administrators', async () => {
|
||||
const { queryByLabelText } = await renderComponent(
|
||||
ResourceControlType.Container
|
||||
);
|
||||
|
||||
expect(queryByLabelText('ownership')).toHaveTextContent(
|
||||
ResourceControlOwnership.ADMINISTRATORS
|
||||
);
|
||||
});
|
||||
|
||||
const inheritanceTests = [
|
||||
{
|
||||
resourceType: ResourceControlType.Container,
|
||||
parentType: ResourceControlType.Service,
|
||||
},
|
||||
{
|
||||
resourceType: ResourceControlType.Volume,
|
||||
parentType: ResourceControlType.Container,
|
||||
},
|
||||
...[
|
||||
ResourceControlType.Config,
|
||||
ResourceControlType.Container,
|
||||
ResourceControlType.Network,
|
||||
ResourceControlType.Secret,
|
||||
ResourceControlType.Service,
|
||||
ResourceControlType.Volume,
|
||||
].map((resourceType) => ({
|
||||
resourceType,
|
||||
parentType: ResourceControlType.Stack,
|
||||
})),
|
||||
];
|
||||
|
||||
for (let i = 0; i < inheritanceTests.length; i += 1) {
|
||||
const { resourceType, parentType } = inheritanceTests[i];
|
||||
test(`when resource is ${ResourceControlType[resourceType]} and resource control is ${ResourceControlType[parentType]}, show message`, async () => {
|
||||
const resourceControl = buildViewModel(
|
||||
ResourceControlOwnership.ADMINISTRATORS,
|
||||
parentType
|
||||
);
|
||||
|
||||
const { queryByLabelText } = await renderComponent(
|
||||
resourceType,
|
||||
resourceControl
|
||||
);
|
||||
const inheritanceMessage = queryByLabelText('inheritance-message');
|
||||
expect(inheritanceMessage).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test('when resource is limited to specific users, show comma separated list of their names', async () => {
|
||||
const users = createMockUsers(10, Role.Standard);
|
||||
|
||||
server.use(rest.get('/api/users', (req, res, ctx) => res(ctx.json(users))));
|
||||
|
||||
const restrictedToUsers = _.sampleSize(users, 3);
|
||||
|
||||
const resourceControl = buildViewModel(
|
||||
ResourceControlOwnership.RESTRICTED,
|
||||
ResourceControlType.Service,
|
||||
restrictedToUsers.map((user) => ({
|
||||
UserId: user.Id,
|
||||
AccessLevel: 1,
|
||||
}))
|
||||
);
|
||||
|
||||
const { queryByText, findByLabelText } = await renderComponent(
|
||||
undefined,
|
||||
resourceControl
|
||||
);
|
||||
|
||||
expect(queryByText(/Authorized users/)).toBeVisible();
|
||||
|
||||
await expect(findByLabelText('authorized-users')).resolves.toHaveTextContent(
|
||||
restrictedToUsers.map((user) => user.Username).join(', ')
|
||||
);
|
||||
});
|
||||
|
||||
test('when resource is limited to specific teams, show comma separated list of their names', async () => {
|
||||
const teams = createMockTeams(10);
|
||||
|
||||
server.use(rest.get('/api/teams', (req, res, ctx) => res(ctx.json(teams))));
|
||||
|
||||
const restrictedToTeams = _.sampleSize(teams, 3);
|
||||
|
||||
const resourceControl = buildViewModel(
|
||||
ResourceControlOwnership.RESTRICTED,
|
||||
ResourceControlType.Config,
|
||||
[],
|
||||
restrictedToTeams.map((team) => ({
|
||||
TeamId: team.Id,
|
||||
AccessLevel: 1,
|
||||
}))
|
||||
);
|
||||
|
||||
const { queryByText, findByLabelText } = await renderComponent(
|
||||
undefined,
|
||||
resourceControl
|
||||
);
|
||||
|
||||
expect(queryByText(/Authorized teams/)).toBeVisible();
|
||||
|
||||
await expect(findByLabelText('authorized-teams')).resolves.toHaveTextContent(
|
||||
restrictedToTeams.map((team) => team.Name).join(', ')
|
||||
);
|
||||
});
|
||||
|
||||
async function renderComponent(
|
||||
resourceType: ResourceControlType = ResourceControlType.Container,
|
||||
resourceControl?: ResourceControlViewModel
|
||||
) {
|
||||
const queries = renderWithQueryClient(
|
||||
<AccessControlPanelDetails
|
||||
resourceControl={resourceControl}
|
||||
resourceType={resourceType}
|
||||
/>
|
||||
);
|
||||
await expect(queries.findByText('Ownership')).resolves.toBeVisible();
|
||||
|
||||
return queries;
|
||||
}
|
||||
|
||||
function buildViewModel(
|
||||
ownership: ResourceControlOwnership,
|
||||
type: ResourceControlType = ResourceControlType.Config,
|
||||
users: UserResourceAccess[] = [],
|
||||
teams: TeamResourceAccess[] = []
|
||||
): ResourceControlViewModel {
|
||||
return {
|
||||
Id: 0,
|
||||
Public: false,
|
||||
ResourceId: 0,
|
||||
System: false,
|
||||
TeamAccesses: teams,
|
||||
Ownership: ownership,
|
||||
Type: type,
|
||||
UserAccesses: users,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,152 @@
|
|||
import { useReducer } from 'react';
|
||||
|
||||
import { Button } from '@/portainer/components/Button';
|
||||
import { Widget, WidgetBody, WidgetTitle } from '@/portainer/components/widget';
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
import { r2a } from '@/react-tools/react2angular';
|
||||
import { TeamMembership, Role } from '@/portainer/teams/types';
|
||||
import { useUserMembership } from '@/portainer/users/queries';
|
||||
|
||||
import { ResourceControlType, ResourceId } from '../types';
|
||||
import { ResourceControlViewModel } from '../models/ResourceControlViewModel';
|
||||
|
||||
import { AccessControlPanelDetails } from './AccessControlPanelDetails';
|
||||
import { AccessControlPanelForm } from './AccessControlPanelForm';
|
||||
|
||||
interface Props {
|
||||
resourceControl?: ResourceControlViewModel;
|
||||
resourceType: ResourceControlType;
|
||||
resourceId: ResourceId;
|
||||
disableOwnershipChange?: boolean;
|
||||
onUpdateSuccess(): void;
|
||||
}
|
||||
|
||||
export function AccessControlPanel({
|
||||
resourceControl,
|
||||
resourceType,
|
||||
disableOwnershipChange,
|
||||
resourceId,
|
||||
onUpdateSuccess,
|
||||
}: Props) {
|
||||
const [isEditMode, toggleEditMode] = useReducer((state) => !state, false);
|
||||
const { isAdmin } = useUser();
|
||||
|
||||
const isInherited = checkIfInherited();
|
||||
|
||||
const { isPartOfRestrictedUsers, isLeaderOfAnyRestrictedTeams } =
|
||||
useRestrictions(resourceControl);
|
||||
|
||||
const isEditDisabled =
|
||||
disableOwnershipChange ||
|
||||
isInherited ||
|
||||
(!isAdmin && !isPartOfRestrictedUsers && !isLeaderOfAnyRestrictedTeams);
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<WidgetTitle title="Access control" icon="fa-eye" />
|
||||
<WidgetBody className="no-padding">
|
||||
<AccessControlPanelDetails
|
||||
resourceType={resourceType}
|
||||
resourceControl={resourceControl}
|
||||
/>
|
||||
|
||||
{!isEditDisabled && !isEditMode && (
|
||||
<div className="row">
|
||||
<div>
|
||||
<Button color="link" onClick={toggleEditMode}>
|
||||
<i className="fa fa-edit space-right" aria-hidden="true" />
|
||||
Change ownership
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditMode && (
|
||||
<AccessControlPanelForm
|
||||
resourceControl={resourceControl}
|
||||
onCancelClick={() => toggleEditMode()}
|
||||
resourceId={resourceId}
|
||||
resourceType={resourceType}
|
||||
onUpdateSuccess={handleUpdateSuccess}
|
||||
/>
|
||||
)}
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
function handleUpdateSuccess() {
|
||||
onUpdateSuccess();
|
||||
toggleEditMode();
|
||||
}
|
||||
|
||||
function checkIfInherited() {
|
||||
if (!resourceControl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const inheritedVolume =
|
||||
resourceControl.Type === ResourceControlType.Container &&
|
||||
resourceType === ResourceControlType.Volume;
|
||||
const inheritedContainer =
|
||||
resourceControl.Type === ResourceControlType.Service &&
|
||||
resourceType === ResourceControlType.Container;
|
||||
const inheritedFromStack =
|
||||
resourceControl.Type === ResourceControlType.Stack &&
|
||||
resourceType !== ResourceControlType.Stack;
|
||||
|
||||
return inheritedVolume || inheritedContainer || inheritedFromStack;
|
||||
}
|
||||
}
|
||||
|
||||
function useRestrictions(resourceControl?: ResourceControlViewModel) {
|
||||
const { user, isAdmin } = useUser();
|
||||
|
||||
const memberships = useUserMembership(user?.Id);
|
||||
|
||||
if (!resourceControl || isAdmin || !user) {
|
||||
return {
|
||||
isPartOfRestrictedUsers: false,
|
||||
isLeaderOfAnyRestrictedTeams: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (resourceControl.UserAccesses.some((ua) => ua.UserId === user.Id)) {
|
||||
return {
|
||||
isPartOfRestrictedUsers: true,
|
||||
isLeaderOfAnyRestrictedTeams: false,
|
||||
};
|
||||
}
|
||||
|
||||
const isTeamLeader =
|
||||
memberships.isSuccess &&
|
||||
isLeaderOfAnyRestrictedTeams(memberships.data, resourceControl);
|
||||
|
||||
return {
|
||||
isPartOfRestrictedUsers: false,
|
||||
isLeaderOfAnyRestrictedTeams: isTeamLeader,
|
||||
};
|
||||
}
|
||||
|
||||
// returns true if user is a team leader and resource is limited to this team
|
||||
function isLeaderOfAnyRestrictedTeams(
|
||||
userMemberships: TeamMembership[],
|
||||
resourceControl: ResourceControlViewModel
|
||||
) {
|
||||
return userMemberships.some(
|
||||
(membership) =>
|
||||
membership.Role === Role.TeamLeader &&
|
||||
resourceControl.TeamAccesses.some((ta) => ta.TeamId === membership.TeamID)
|
||||
);
|
||||
}
|
||||
|
||||
export const AccessControlPanelAngular = r2a(AccessControlPanel, [
|
||||
'resourceControl',
|
||||
'resourceType',
|
||||
'disableOwnershipChange',
|
||||
'resourceId',
|
||||
'onUpdateSuccess',
|
||||
]);
|
|
@ -0,0 +1,207 @@
|
|||
import clsx from 'clsx';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { ownershipIcon, truncate } from '@/portainer/filters/filters';
|
||||
import { Tooltip } from '@/portainer/components/Tip/Tooltip';
|
||||
import { Link } from '@/portainer/components/Link';
|
||||
import { UserId } from '@/portainer/users/types';
|
||||
import { TeamId } from '@/portainer/teams/types';
|
||||
import { useTeams } from '@/portainer/teams/queries';
|
||||
import { useUsers } from '@/portainer/users/queries';
|
||||
|
||||
import {
|
||||
ResourceControlOwnership,
|
||||
ResourceControlType,
|
||||
ResourceId,
|
||||
} from '../types';
|
||||
import { ResourceControlViewModel } from '../models/ResourceControlViewModel';
|
||||
|
||||
interface Props {
|
||||
resourceControl?: ResourceControlViewModel;
|
||||
resourceType: ResourceControlType;
|
||||
}
|
||||
|
||||
export function AccessControlPanelDetails({
|
||||
resourceControl,
|
||||
resourceType,
|
||||
}: Props) {
|
||||
const inheritanceMessage = getInheritanceMessage(
|
||||
resourceType,
|
||||
resourceControl
|
||||
);
|
||||
|
||||
const {
|
||||
Ownership: ownership = ResourceControlOwnership.ADMINISTRATORS,
|
||||
UserAccesses: restrictedToUsers = [],
|
||||
TeamAccesses: restrictedToTeams = [],
|
||||
} = resourceControl || {};
|
||||
|
||||
const users = useAuthorizedUsers(restrictedToUsers.map((ra) => ra.UserId));
|
||||
const teams = useAuthorizedTeams(restrictedToTeams.map((ra) => ra.TeamId));
|
||||
|
||||
return (
|
||||
<table className="table">
|
||||
<tbody>
|
||||
<tr data-cy="access-ownership">
|
||||
<td>Ownership</td>
|
||||
<td>
|
||||
<i
|
||||
className={clsx(ownershipIcon(ownership), 'space-right')}
|
||||
aria-hidden="true"
|
||||
aria-label="ownership-icon"
|
||||
/>
|
||||
<span aria-label="ownership">{ownership}</span>
|
||||
<Tooltip message={getOwnershipTooltip(ownership)} />
|
||||
</td>
|
||||
</tr>
|
||||
{inheritanceMessage}
|
||||
{restrictedToUsers.length > 0 && (
|
||||
<tr data-cy="access-authorisedUsers">
|
||||
<td>Authorized users</td>
|
||||
<td aria-label="authorized-users">
|
||||
{users.data && users.data.join(', ')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{restrictedToTeams.length > 0 && (
|
||||
<tr data-cy="access-authorisedTeams">
|
||||
<td>Authorized teams</td>
|
||||
<td aria-label="authorized-teams">
|
||||
{teams.data && teams.data.join(', ')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function getOwnershipTooltip(ownership: ResourceControlOwnership) {
|
||||
switch (ownership) {
|
||||
case ResourceControlOwnership.PRIVATE:
|
||||
return 'Management of this resource is restricted to a single user.';
|
||||
case ResourceControlOwnership.RESTRICTED:
|
||||
return 'This resource can be managed by a restricted set of users and/or teams.';
|
||||
case ResourceControlOwnership.PUBLIC:
|
||||
return 'This resource can be managed by any user with access to this environment.';
|
||||
case ResourceControlOwnership.ADMINISTRATORS:
|
||||
default:
|
||||
return 'This resource can only be managed by administrators.';
|
||||
}
|
||||
}
|
||||
|
||||
function getInheritanceMessage(
|
||||
resourceType: ResourceControlType,
|
||||
resourceControl?: ResourceControlViewModel
|
||||
) {
|
||||
if (!resourceControl || resourceControl.Type === resourceType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parentType = resourceControl.Type;
|
||||
const resourceId = resourceControl.ResourceId;
|
||||
|
||||
if (
|
||||
resourceType === ResourceControlType.Container &&
|
||||
parentType === ResourceControlType.Service
|
||||
) {
|
||||
return (
|
||||
<InheritanceMessage tooltip="Access control applied on a service is also applied on each container of that service.">
|
||||
Access control on this resource is inherited from the following service:
|
||||
<Link to="docker.services.service" params={{ id: resourceId }}>
|
||||
{truncate(resourceId)}
|
||||
</Link>
|
||||
</InheritanceMessage>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
resourceType === ResourceControlType.Volume &&
|
||||
parentType === ResourceControlType.Container
|
||||
) {
|
||||
return (
|
||||
<InheritanceMessage tooltip="Access control applied on a container created using a template is also applied on each volume associated to the container.">
|
||||
Access control on this resource is inherited from the following
|
||||
container:
|
||||
<Link to="docker.containers.container" params={{ id: resourceId }}>
|
||||
{truncate(resourceId)}
|
||||
</Link>
|
||||
</InheritanceMessage>
|
||||
);
|
||||
}
|
||||
|
||||
if (parentType === ResourceControlType.Stack) {
|
||||
return (
|
||||
<InheritanceMessage tooltip="Access control applied on a stack is also applied on each resource in the stack.">
|
||||
<span className="space-right">
|
||||
Access control on this resource is inherited from the following stack:
|
||||
</span>
|
||||
{removeEndpointIdFromStackResourceId(resourceId)}
|
||||
</InheritanceMessage>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function removeEndpointIdFromStackResourceId(stackName: ResourceId) {
|
||||
if (!stackName || typeof stackName !== 'string') {
|
||||
return stackName;
|
||||
}
|
||||
|
||||
const firstUnderlineIndex = stackName.indexOf('_');
|
||||
if (firstUnderlineIndex < 0) {
|
||||
return stackName;
|
||||
}
|
||||
return stackName.substring(firstUnderlineIndex + 1);
|
||||
}
|
||||
|
||||
interface InheritanceMessageProps {
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
function InheritanceMessage({
|
||||
children,
|
||||
tooltip,
|
||||
}: PropsWithChildren<InheritanceMessageProps>) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={2} aria-label="inheritance-message">
|
||||
<i className="fa fa-info-circle space-right" aria-hidden="true" />
|
||||
{children}
|
||||
<Tooltip message={tooltip} position="bottom" />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function useAuthorizedTeams(authorizedTeamIds: TeamId[]) {
|
||||
return useTeams(authorizedTeamIds.length > 0, (teams) => {
|
||||
if (authorizedTeamIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return _.compact(
|
||||
authorizedTeamIds.map((id) => {
|
||||
const team = teams.find((u) => u.Id === id);
|
||||
return team?.Name;
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function useAuthorizedUsers(authorizedUserIds: UserId[]) {
|
||||
return useUsers(false, authorizedUserIds.length > 0, (users) => {
|
||||
if (authorizedUserIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return _.compact(
|
||||
authorizedUserIds.map((id) => {
|
||||
const user = users.find((u) => u.Id === id);
|
||||
return user?.Username;
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
.form {
|
||||
padding: 0 20px;
|
||||
}
|
|
@ -0,0 +1,135 @@
|
|||
import { Form, Formik } from 'formik';
|
||||
import clsx from 'clsx';
|
||||
import { useMutation } from 'react-query';
|
||||
import { object } from 'yup';
|
||||
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
import { Button } from '@/portainer/components/Button';
|
||||
import { LoadingButton } from '@/portainer/components/Button/LoadingButton';
|
||||
import { confirmAsync } from '@/portainer/services/modal.service/confirm';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
|
||||
import { EditDetails } from '../EditDetails';
|
||||
import { parseAccessControlFormData } from '../utils';
|
||||
import { validationSchema } from '../AccessControlForm/AccessControlForm.validation';
|
||||
import { applyResourceControlChange } from '../access-control.service';
|
||||
import {
|
||||
ResourceControlType,
|
||||
ResourceId,
|
||||
AccessControlFormData,
|
||||
} from '../types';
|
||||
import { ResourceControlViewModel } from '../models/ResourceControlViewModel';
|
||||
|
||||
import styles from './AccessControlPanelForm.module.css';
|
||||
|
||||
interface Props {
|
||||
resourceType: ResourceControlType;
|
||||
resourceId: ResourceId;
|
||||
resourceControl?: ResourceControlViewModel;
|
||||
onCancelClick(): void;
|
||||
onUpdateSuccess(): void;
|
||||
}
|
||||
|
||||
export function AccessControlPanelForm({
|
||||
resourceId,
|
||||
resourceType,
|
||||
resourceControl,
|
||||
onCancelClick,
|
||||
onUpdateSuccess,
|
||||
}: Props) {
|
||||
const { isAdmin } = useUser();
|
||||
|
||||
const updateAccess = useMutation(
|
||||
(variables: AccessControlFormData) =>
|
||||
applyResourceControlChange(
|
||||
resourceType,
|
||||
resourceId,
|
||||
variables,
|
||||
resourceControl
|
||||
),
|
||||
{
|
||||
meta: {
|
||||
error: { title: 'Failure', message: 'Unable to update access control' },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const initialValues = {
|
||||
accessControl: parseAccessControlFormData(isAdmin, resourceControl),
|
||||
};
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
validateOnMount
|
||||
validateOnChange
|
||||
validationSchema={() =>
|
||||
object({ accessControl: validationSchema(isAdmin) })
|
||||
}
|
||||
>
|
||||
{({ setFieldValue, values, isSubmitting, isValid, errors }) => (
|
||||
<Form className={clsx('form-horizontal', styles.form)}>
|
||||
<EditDetails
|
||||
onChange={(accessControl) =>
|
||||
setFieldValue('accessControl', accessControl)
|
||||
}
|
||||
values={values.accessControl}
|
||||
isPublicVisible
|
||||
errors={errors.accessControl}
|
||||
/>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<Button size="small" color="default" onClick={onCancelClick}>
|
||||
Cancel
|
||||
</Button>
|
||||
<LoadingButton
|
||||
size="small"
|
||||
color="primary"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
disabled={!isValid}
|
||||
loadingText="Updating Ownership"
|
||||
>
|
||||
Update Ownership
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
|
||||
async function handleSubmit({
|
||||
accessControl,
|
||||
}: {
|
||||
accessControl: AccessControlFormData;
|
||||
}) {
|
||||
const confirmed = await confirmAccessControlUpdate();
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateAccess.mutate(accessControl, {
|
||||
onSuccess() {
|
||||
notifySuccess('Access control successfully updated');
|
||||
onUpdateSuccess();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function confirmAccessControlUpdate() {
|
||||
return confirmAsync({
|
||||
title: 'Are you sure?',
|
||||
message:
|
||||
'Changing the ownership of this resource will potentially restrict its management to some users.',
|
||||
buttons: {
|
||||
confirm: {
|
||||
label: 'Change ownership',
|
||||
className: 'btn-primary',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
|
@ -0,0 +1,116 @@
|
|||
import { useCallback } from 'react';
|
||||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { BoxSelector } from '@/portainer/components/BoxSelector';
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
import { FormError } from '@/portainer/components/form-components/FormError';
|
||||
|
||||
import { ResourceControlOwnership, AccessControlFormData } from '../types';
|
||||
|
||||
import { UsersField } from './UsersField';
|
||||
import { TeamsField } from './TeamsField';
|
||||
import { useLoadState } from './useLoadState';
|
||||
import { useOptions } from './useOptions';
|
||||
|
||||
interface Props {
|
||||
values: AccessControlFormData;
|
||||
onChange(values: AccessControlFormData): void;
|
||||
isPublicVisible?: boolean;
|
||||
errors?: FormikErrors<AccessControlFormData>;
|
||||
formNamespace?: string;
|
||||
}
|
||||
|
||||
export function EditDetails({
|
||||
values,
|
||||
onChange,
|
||||
isPublicVisible = false,
|
||||
errors,
|
||||
formNamespace,
|
||||
}: Props) {
|
||||
const { user, isAdmin } = useUser();
|
||||
|
||||
const { users, teams, isLoading } = useLoadState();
|
||||
const options = useOptions(isAdmin, teams, isPublicVisible);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(partialValues: Partial<typeof values>) => {
|
||||
onChange({ ...values, ...partialValues });
|
||||
},
|
||||
|
||||
[values, onChange]
|
||||
);
|
||||
|
||||
if (isLoading || !teams || !users) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<BoxSelector
|
||||
radioName={withNamespace('ownership')}
|
||||
value={values.ownership}
|
||||
options={options}
|
||||
onChange={(ownership) => handleChangeOwnership(ownership)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{values.ownership === ResourceControlOwnership.RESTRICTED && (
|
||||
<div aria-label="extra-options">
|
||||
{isAdmin && (
|
||||
<UsersField
|
||||
name={withNamespace('authorizedUsers')}
|
||||
users={users}
|
||||
onChange={(authorizedUsers) => handleChange({ authorizedUsers })}
|
||||
value={values.authorizedUsers}
|
||||
errors={errors?.authorizedUsers}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(isAdmin || teams.length > 1) && (
|
||||
<TeamsField
|
||||
name={withNamespace('authorizedTeams')}
|
||||
teams={teams}
|
||||
overrideTooltip={
|
||||
!isAdmin && teams.length > 1
|
||||
? 'As you are a member of multiple teams, you can select which teams(s) will be able to manage this resource.'
|
||||
: undefined
|
||||
}
|
||||
onChange={(authorizedTeams) => handleChange({ authorizedTeams })}
|
||||
value={values.authorizedTeams}
|
||||
errors={errors?.authorizedTeams}
|
||||
/>
|
||||
)}
|
||||
|
||||
{typeof errors === 'string' && (
|
||||
<div className="form-group col-md-12">
|
||||
<FormError>{errors}</FormError>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
function withNamespace(name: string) {
|
||||
return formNamespace ? `${formNamespace}.${name}` : name;
|
||||
}
|
||||
|
||||
function handleChangeOwnership(ownership: ResourceControlOwnership) {
|
||||
let { authorizedTeams, authorizedUsers } = values;
|
||||
|
||||
if (ownership === ResourceControlOwnership.PRIVATE && user) {
|
||||
authorizedUsers = [user.Id];
|
||||
authorizedTeams = [];
|
||||
}
|
||||
|
||||
if (ownership === ResourceControlOwnership.RESTRICTED) {
|
||||
authorizedUsers = [];
|
||||
authorizedTeams = [];
|
||||
}
|
||||
|
||||
handleChange({ ownership, authorizedTeams, authorizedUsers });
|
||||
}
|
||||
}
|
|
@ -1,11 +1,11 @@
|
|||
import { UsersSelector } from '@/portainer/components/UsersSelector';
|
||||
import { FormControl } from '@/portainer/components/form-components/FormControl';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { Link } from '@/portainer/components/Link';
|
||||
import { User } from '@/portainer/users/types';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
users: UserViewModel[];
|
||||
users: User[];
|
||||
value: number[];
|
||||
onChange(value: number[]): void;
|
||||
errors?: string | string[];
|
|
@ -0,0 +1 @@
|
|||
export { EditDetails } from './EditDetails';
|
|
@ -0,0 +1,14 @@
|
|||
import { useTeams } from '@/portainer/teams/queries';
|
||||
import { useUsers } from '@/portainer/users/queries';
|
||||
|
||||
export function useLoadState() {
|
||||
const teams = useTeams();
|
||||
|
||||
const users = useUsers(false);
|
||||
|
||||
return {
|
||||
teams: teams.data,
|
||||
users: users.data,
|
||||
isLoading: teams.isLoading || users.isLoading,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
import _ from 'lodash';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { buildOption } from '@/portainer/components/BoxSelector';
|
||||
import { BoxSelectorOption } from '@/portainer/components/BoxSelector/types';
|
||||
import { ownershipIcon } from '@/portainer/filters/filters';
|
||||
import { Team } from '@/portainer/teams/types';
|
||||
|
||||
import { ResourceControlOwnership } from '../types';
|
||||
|
||||
const publicOption: BoxSelectorOption<ResourceControlOwnership> = {
|
||||
value: ResourceControlOwnership.PUBLIC,
|
||||
label: 'Public',
|
||||
id: 'access_public',
|
||||
description:
|
||||
'I want any user with access to this environment to be able to manage this resource',
|
||||
icon: ownershipIcon('public'),
|
||||
};
|
||||
|
||||
export function useOptions(
|
||||
isAdmin: boolean,
|
||||
teams?: Team[],
|
||||
isPublicVisible = false
|
||||
) {
|
||||
const [options, setOptions] = useState<
|
||||
Array<BoxSelectorOption<ResourceControlOwnership>>
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const options = isAdmin ? adminOptions() : nonAdminOptions(teams);
|
||||
|
||||
setOptions(isPublicVisible ? [...options, publicOption] : options);
|
||||
}, [isAdmin, teams, isPublicVisible]);
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function adminOptions() {
|
||||
return [
|
||||
buildOption(
|
||||
'access_administrators',
|
||||
ownershipIcon('administrators'),
|
||||
'Administrators',
|
||||
'I want to restrict the management of this resource to administrators only',
|
||||
ResourceControlOwnership.ADMINISTRATORS
|
||||
),
|
||||
buildOption(
|
||||
'access_restricted',
|
||||
ownershipIcon('restricted'),
|
||||
'Restricted',
|
||||
'I want to restrict the management of this resource to a set of users and/or teams',
|
||||
ResourceControlOwnership.RESTRICTED
|
||||
),
|
||||
];
|
||||
}
|
||||
function nonAdminOptions(teams?: Team[]) {
|
||||
return _.compact([
|
||||
buildOption(
|
||||
'access_private',
|
||||
ownershipIcon('private'),
|
||||
'Private',
|
||||
'I want to this resource to be manageable by myself only',
|
||||
ResourceControlOwnership.PRIVATE
|
||||
),
|
||||
teams &&
|
||||
teams.length > 0 &&
|
||||
buildOption(
|
||||
'access_restricted',
|
||||
ownershipIcon('restricted'),
|
||||
'Restricted',
|
||||
teams.length === 1
|
||||
? `I want any member of my team (${teams[0].Name}) to be able to manage this resource`
|
||||
: 'I want to restrict the management of this resource to one or more of my teams',
|
||||
ResourceControlOwnership.RESTRICTED
|
||||
),
|
||||
]);
|
||||
}
|
|
@ -0,0 +1,98 @@
|
|||
import axios, { parseAxiosError } from '../services/axios';
|
||||
|
||||
import {
|
||||
AccessControlFormData,
|
||||
OwnershipParameters,
|
||||
ResourceControlId,
|
||||
ResourceControlResponse,
|
||||
ResourceControlType,
|
||||
ResourceId,
|
||||
} from './types';
|
||||
import { ResourceControlViewModel } from './models/ResourceControlViewModel';
|
||||
import { parseOwnershipParameters } from './utils';
|
||||
|
||||
/**
|
||||
* Update an existing ResourceControl or create a new one on existing resource without RC
|
||||
* @param resourceType Type of ResourceControl
|
||||
* @param resourceId ID of involved Resource
|
||||
* @param resourceControl Previous ResourceControl (can be undefined)
|
||||
* @param formValues View data generated by AccessControlPanel
|
||||
*/
|
||||
export function applyResourceControlChange(
|
||||
resourceType: ResourceControlType,
|
||||
resourceId: ResourceId,
|
||||
formValues: AccessControlFormData,
|
||||
resourceControl?: ResourceControlViewModel
|
||||
) {
|
||||
const ownershipParameters = parseOwnershipParameters(formValues);
|
||||
if (resourceControl) {
|
||||
return updateResourceControl(resourceControl.Id, ownershipParameters);
|
||||
}
|
||||
return createResourceControl(resourceType, resourceId, ownershipParameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a ResourceControl after Resource creation
|
||||
* @param accessControlData ResourceControl to apply
|
||||
* @param resourceControl ResourceControl to update
|
||||
* @param subResourcesIds SubResources managed by the ResourceControl
|
||||
*/
|
||||
export function applyResourceControl(
|
||||
accessControlData: AccessControlFormData,
|
||||
resourceControl: ResourceControlResponse,
|
||||
subResourcesIds: (number | string)[] = []
|
||||
) {
|
||||
const ownershipParameters = parseOwnershipParameters(
|
||||
accessControlData,
|
||||
subResourcesIds
|
||||
);
|
||||
return updateResourceControl(resourceControl.Id, ownershipParameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a ResourceControl
|
||||
* @param resourceControlId ID of involved resource
|
||||
* @param ownershipParameters
|
||||
*/
|
||||
async function updateResourceControl(
|
||||
resourceControlId: ResourceControlId,
|
||||
ownershipParameters: OwnershipParameters
|
||||
) {
|
||||
try {
|
||||
await axios.put(buildUrl(resourceControlId), ownershipParameters);
|
||||
} catch (error) {
|
||||
throw parseAxiosError(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ResourceControl
|
||||
* @param resourceType Type of ResourceControl
|
||||
* @param resourceId ID of involved resource
|
||||
* @param ownershipParameters Transient type from view data to payload
|
||||
*/
|
||||
async function createResourceControl(
|
||||
resourceType: ResourceControlType,
|
||||
resourceId: ResourceId,
|
||||
ownershipParameters: OwnershipParameters
|
||||
) {
|
||||
try {
|
||||
await axios.post(buildUrl(), {
|
||||
...ownershipParameters,
|
||||
type: resourceType,
|
||||
resourceId,
|
||||
});
|
||||
} catch (error) {
|
||||
throw parseAxiosError(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(id?: ResourceControlId) {
|
||||
let url = '/resource_controls';
|
||||
|
||||
if (id) {
|
||||
url += `/${id}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import angular from 'angular';
|
||||
|
||||
import { AccessControlPanelAngular } from './AccessControlPanel/AccessControlPanel';
|
||||
|
||||
export const accessControlModule = angular
|
||||
.module('portainer.access-control', [])
|
||||
.component('accessControlPanel', AccessControlPanelAngular).name;
|
|
@ -0,0 +1,60 @@
|
|||
import {
|
||||
ResourceControlId,
|
||||
ResourceControlOwnership,
|
||||
ResourceControlResponse,
|
||||
ResourceControlType,
|
||||
ResourceId,
|
||||
TeamResourceAccess,
|
||||
UserResourceAccess,
|
||||
} from '../types';
|
||||
|
||||
export class ResourceControlViewModel {
|
||||
Id: ResourceControlId;
|
||||
|
||||
Type: ResourceControlType;
|
||||
|
||||
ResourceId: ResourceId;
|
||||
|
||||
UserAccesses: UserResourceAccess[];
|
||||
|
||||
TeamAccesses: TeamResourceAccess[];
|
||||
|
||||
Public: boolean;
|
||||
|
||||
System: boolean;
|
||||
|
||||
Ownership: ResourceControlOwnership;
|
||||
|
||||
constructor(data: ResourceControlResponse) {
|
||||
this.Id = data.Id;
|
||||
this.Type = data.Type;
|
||||
this.ResourceId = data.ResourceId;
|
||||
this.UserAccesses = data.UserAccesses;
|
||||
this.TeamAccesses = data.TeamAccesses;
|
||||
this.Public = data.Public;
|
||||
this.System = data.System;
|
||||
this.Ownership = determineOwnership(this);
|
||||
}
|
||||
}
|
||||
|
||||
function determineOwnership(resourceControl: ResourceControlViewModel) {
|
||||
if (resourceControl.Public) {
|
||||
return ResourceControlOwnership.PUBLIC;
|
||||
}
|
||||
|
||||
if (
|
||||
resourceControl.UserAccesses.length === 1 &&
|
||||
resourceControl.TeamAccesses.length === 0
|
||||
) {
|
||||
return ResourceControlOwnership.PRIVATE;
|
||||
}
|
||||
|
||||
if (
|
||||
resourceControl.UserAccesses.length > 1 ||
|
||||
resourceControl.TeamAccesses.length > 0
|
||||
) {
|
||||
return ResourceControlOwnership.RESTRICTED;
|
||||
}
|
||||
|
||||
return ResourceControlOwnership.ADMINISTRATORS;
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
import { TeamId } from '@/portainer/teams/types';
|
||||
import { UserId } from '@/portainer/users/types';
|
||||
|
||||
export type ResourceControlId = number;
|
||||
|
||||
export type ResourceId = number | string;
|
||||
|
||||
export enum ResourceControlOwnership {
|
||||
PUBLIC = 'public',
|
||||
PRIVATE = 'private',
|
||||
RESTRICTED = 'restricted',
|
||||
ADMINISTRATORS = 'administrators',
|
||||
}
|
||||
|
||||
/**
|
||||
* Transient type from view data to payload
|
||||
*/
|
||||
export interface OwnershipParameters {
|
||||
administratorsOnly: boolean;
|
||||
public: boolean;
|
||||
users: UserId[];
|
||||
teams: TeamId[];
|
||||
subResourcesIds: ResourceId[];
|
||||
}
|
||||
|
||||
export enum ResourceControlType {
|
||||
// Container represents a resource control associated to a Docker container
|
||||
Container = 1,
|
||||
// Service represents a resource control associated to a Docker service
|
||||
Service,
|
||||
// Volume represents a resource control associated to a Docker volume
|
||||
Volume,
|
||||
// Network represents a resource control associated to a Docker network
|
||||
Network,
|
||||
// Secret represents a resource control associated to a Docker secret
|
||||
Secret,
|
||||
// Stack represents a resource control associated to a stack composed of Docker services
|
||||
Stack,
|
||||
// Config represents a resource control associated to a Docker config
|
||||
Config,
|
||||
// CustomTemplate represents a resource control associated to a custom template
|
||||
CustomTemplate,
|
||||
// ContainerGroup represents a resource control associated to an Azure container group
|
||||
ContainerGroup,
|
||||
}
|
||||
|
||||
enum ResourceAccessLevel {
|
||||
ReadWriteAccessLevel = 1,
|
||||
}
|
||||
|
||||
export interface UserResourceAccess {
|
||||
UserId: UserId;
|
||||
AccessLevel: ResourceAccessLevel;
|
||||
}
|
||||
|
||||
export interface TeamResourceAccess {
|
||||
TeamId: TeamId;
|
||||
AccessLevel: ResourceAccessLevel;
|
||||
}
|
||||
|
||||
export interface ResourceControlResponse {
|
||||
Id: number;
|
||||
Type: ResourceControlType;
|
||||
ResourceId: ResourceId;
|
||||
UserAccesses: UserResourceAccess[];
|
||||
TeamAccesses: TeamResourceAccess[];
|
||||
Public: boolean;
|
||||
AdministratorsOnly: boolean;
|
||||
System: boolean;
|
||||
}
|
||||
|
||||
export interface AccessControlFormData {
|
||||
ownership: ResourceControlOwnership;
|
||||
authorizedUsers: UserId[];
|
||||
authorizedTeams: TeamId[];
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
import { ResourceControlViewModel } from './models/ResourceControlViewModel';
|
||||
import { ResourceControlOwnership, ResourceControlType } from './types';
|
||||
import { parseAccessControlFormData } from './utils';
|
||||
|
||||
describe('parseAccessControlFormData', () => {
|
||||
[
|
||||
ResourceControlOwnership.ADMINISTRATORS,
|
||||
ResourceControlOwnership.RESTRICTED,
|
||||
ResourceControlOwnership.PUBLIC,
|
||||
ResourceControlOwnership.PRIVATE,
|
||||
].forEach((ownership) => {
|
||||
test(`when resource control supplied, if user is not admin, will change ownership to rc ownership (${ownership})`, () => {
|
||||
const resourceControl = buildResourceControl(ownership);
|
||||
|
||||
const actual = parseAccessControlFormData(false, resourceControl);
|
||||
expect(actual.ownership).toBe(resourceControl.Ownership);
|
||||
});
|
||||
});
|
||||
|
||||
[
|
||||
ResourceControlOwnership.ADMINISTRATORS,
|
||||
ResourceControlOwnership.RESTRICTED,
|
||||
ResourceControlOwnership.PUBLIC,
|
||||
].forEach((ownership) => {
|
||||
test(`when resource control supplied and user is admin, if resource ownership is ${ownership} , will change ownership to rc ownership`, () => {
|
||||
const resourceControl = buildResourceControl(ownership);
|
||||
|
||||
const actual = parseAccessControlFormData(true, resourceControl);
|
||||
expect(actual.ownership).toBe(resourceControl.Ownership);
|
||||
});
|
||||
});
|
||||
|
||||
test('when isAdmin and resource control not supplied, ownership should be set to Administrator', () => {
|
||||
const actual = parseAccessControlFormData(true);
|
||||
|
||||
expect(actual.ownership).toBe(ResourceControlOwnership.ADMINISTRATORS);
|
||||
});
|
||||
|
||||
test('when resource control supplied, if user is admin and resource ownership is private, will change ownership to restricted', () => {
|
||||
const resourceControl = buildResourceControl(
|
||||
ResourceControlOwnership.PRIVATE
|
||||
);
|
||||
|
||||
const actual = parseAccessControlFormData(true, resourceControl);
|
||||
expect(actual.ownership).toBe(ResourceControlOwnership.RESTRICTED);
|
||||
});
|
||||
|
||||
function buildResourceControl(
|
||||
ownership: ResourceControlOwnership
|
||||
): ResourceControlViewModel {
|
||||
return {
|
||||
UserAccesses: [],
|
||||
TeamAccesses: [],
|
||||
Ownership: ownership,
|
||||
Id: 1,
|
||||
Public: false,
|
||||
ResourceId: 1,
|
||||
System: false,
|
||||
Type: ResourceControlType.Config,
|
||||
};
|
||||
}
|
||||
});
|
|
@ -0,0 +1,76 @@
|
|||
import { TeamId } from '../teams/types';
|
||||
import { UserId } from '../users/types';
|
||||
|
||||
import {
|
||||
AccessControlFormData,
|
||||
OwnershipParameters,
|
||||
ResourceControlOwnership,
|
||||
ResourceId,
|
||||
} from './types';
|
||||
import { ResourceControlViewModel } from './models/ResourceControlViewModel';
|
||||
|
||||
/**
|
||||
* Transform AccessControlFormData to ResourceControlOwnershipParameters
|
||||
* @param {int} userId ID of user performing the operation
|
||||
* @param {AccessControlFormData} formValues Form data (generated by AccessControlForm)
|
||||
* @param {int[]} subResources Sub Resources restricted by the ResourceControl
|
||||
*/
|
||||
export function parseOwnershipParameters(
|
||||
formValues: AccessControlFormData,
|
||||
subResourcesIds: ResourceId[] = []
|
||||
): OwnershipParameters {
|
||||
const { ownership, authorizedTeams, authorizedUsers } = formValues;
|
||||
|
||||
const adminOnly = ownership === ResourceControlOwnership.ADMINISTRATORS;
|
||||
const publicOnly = ownership === ResourceControlOwnership.PUBLIC;
|
||||
|
||||
let users = authorizedUsers;
|
||||
let teams = authorizedTeams;
|
||||
if (
|
||||
[
|
||||
ResourceControlOwnership.ADMINISTRATORS,
|
||||
ResourceControlOwnership.PUBLIC,
|
||||
].includes(ownership)
|
||||
) {
|
||||
users = [];
|
||||
teams = [];
|
||||
}
|
||||
|
||||
return {
|
||||
administratorsOnly: adminOnly,
|
||||
public: publicOnly,
|
||||
users,
|
||||
teams,
|
||||
subResourcesIds,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAccessControlFormData(
|
||||
isAdmin: boolean,
|
||||
resourceControl?: ResourceControlViewModel
|
||||
): AccessControlFormData {
|
||||
let ownership =
|
||||
resourceControl?.Ownership || ResourceControlOwnership.PRIVATE;
|
||||
if (isAdmin) {
|
||||
if (!resourceControl) {
|
||||
ownership = ResourceControlOwnership.ADMINISTRATORS;
|
||||
} else if (ownership === ResourceControlOwnership.PRIVATE) {
|
||||
ownership = ResourceControlOwnership.RESTRICTED;
|
||||
}
|
||||
}
|
||||
|
||||
let authorizedTeams: TeamId[] = [];
|
||||
let authorizedUsers: UserId[] = [];
|
||||
if (
|
||||
resourceControl &&
|
||||
[
|
||||
ResourceControlOwnership.PRIVATE,
|
||||
ResourceControlOwnership.RESTRICTED,
|
||||
].includes(ownership)
|
||||
) {
|
||||
authorizedTeams = resourceControl.TeamAccesses.map((ra) => ra.TeamId);
|
||||
authorizedUsers = resourceControl.UserAccesses.map((ra) => ra.UserId);
|
||||
}
|
||||
|
||||
return { ownership, authorizedUsers, authorizedTeams };
|
||||
}
|
|
@ -85,10 +85,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.box-selector-item-description {
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.box-selector-item.limited.business {
|
||||
--selected-item-color: var(--BE-only);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import Select from 'react-select';
|
||||
|
||||
import { Select } from '@/portainer/components/form-components/ReactSelect';
|
||||
import { Team, TeamId } from '@/portainer/teams/types';
|
||||
|
||||
interface Props {
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
.selector__control {
|
||||
border: 1px solid var(--border-multiselect) !important;
|
||||
background-color: var(--bg-multiselect-color) !important;
|
||||
}
|
||||
|
||||
.selector__multi-value {
|
||||
background-color: var(--bg-multiselect-checkboxcontainer) !important;
|
||||
}
|
||||
|
||||
.selector__multi-value__label {
|
||||
color: var(--text-multiselect-item-color) !important;
|
||||
}
|
||||
|
||||
.selector__menu {
|
||||
background-color: var(--bg-multiselect-color) !important;
|
||||
border: 1px solid var(--border-multiselect) !important;
|
||||
}
|
||||
|
||||
.selector__option {
|
||||
background-color: var(--bg-multiselect-color) !important;
|
||||
border: 1px solid var(--border-multiselect) !important;
|
||||
border: 1px solid red;
|
||||
}
|
|
@ -1,14 +1,11 @@
|
|||
import Select from 'react-select';
|
||||
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { UserId } from '@/portainer/users/types';
|
||||
import './UsersSelector.css';
|
||||
import { User, UserId } from '@/portainer/users/types';
|
||||
import { Select } from '@/portainer/components/form-components/ReactSelect';
|
||||
|
||||
interface Props {
|
||||
name?: string;
|
||||
value: UserId[];
|
||||
onChange(value: UserId[]): void;
|
||||
users: UserViewModel[];
|
||||
users: User[];
|
||||
dataCy?: string;
|
||||
inputId?: string;
|
||||
placeholder?: string;
|
||||
|
@ -28,9 +25,8 @@ export function UsersSelector({
|
|||
isMulti
|
||||
name={name}
|
||||
getOptionLabel={(user) => user.Username}
|
||||
getOptionValue={(user) => user.Id}
|
||||
getOptionValue={(user) => `${user.Id}`}
|
||||
options={users}
|
||||
classNamePrefix="selector"
|
||||
value={users.filter((user) => value.includes(user.Id))}
|
||||
closeMenuOnSelect={false}
|
||||
onChange={(selectedUsers) =>
|
||||
|
|
|
@ -1,181 +0,0 @@
|
|||
import _ from 'lodash';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { ResourceControlOwnership as RCO } from '@/portainer/models/resourceControl/resourceControlOwnership';
|
||||
import { BoxSelector, buildOption } from '@/portainer/components/BoxSelector';
|
||||
import { ownershipIcon } from '@/portainer/filters/filters';
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
import { Team } from '@/portainer/teams/types';
|
||||
import { BoxSelectorOption } from '@/portainer/components/BoxSelector/types';
|
||||
import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
|
||||
import { SwitchField } from '@/portainer/components/form-components/SwitchField';
|
||||
|
||||
import { FormError } from '../form-components/FormError';
|
||||
|
||||
import { AccessControlFormData } from './model';
|
||||
import { UsersField } from './UsersField';
|
||||
import { TeamsField } from './TeamsField';
|
||||
import { useLoadState } from './useLoadState';
|
||||
|
||||
export interface Props {
|
||||
values: AccessControlFormData;
|
||||
onChange(values: AccessControlFormData): void;
|
||||
hideTitle?: boolean;
|
||||
errors?: FormikErrors<AccessControlFormData>;
|
||||
formNamespace?: string;
|
||||
}
|
||||
|
||||
export function AccessControlForm({
|
||||
values,
|
||||
onChange,
|
||||
hideTitle,
|
||||
errors,
|
||||
formNamespace,
|
||||
}: Props) {
|
||||
const { users, teams, isLoading } = useLoadState();
|
||||
|
||||
const { user } = useUser();
|
||||
const isAdmin = user?.Role === 1;
|
||||
|
||||
const options = useOptions(isAdmin, teams);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(partialValues: Partial<typeof values>) => {
|
||||
onChange({ ...values, ...partialValues });
|
||||
},
|
||||
|
||||
[values, onChange]
|
||||
);
|
||||
|
||||
if (isLoading || !teams || !users) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!hideTitle && <FormSectionTitle>Access control</FormSectionTitle>}
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
dataCy="portainer-accessMgmtToggle"
|
||||
checked={values.accessControlEnabled}
|
||||
name={withNamespace('accessControlEnabled')}
|
||||
label="Enable access control"
|
||||
tooltip="When enabled, you can restrict the access and management of this resource."
|
||||
onChange={(accessControlEnabled) =>
|
||||
handleChange({ accessControlEnabled })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{values.accessControlEnabled && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<BoxSelector
|
||||
radioName={withNamespace('ownership')}
|
||||
value={values.ownership}
|
||||
options={options}
|
||||
onChange={(ownership) => handleChange({ ownership })}
|
||||
/>
|
||||
</div>
|
||||
{values.ownership === RCO.RESTRICTED && (
|
||||
<div aria-label="extra-options">
|
||||
{isAdmin && (
|
||||
<UsersField
|
||||
name={withNamespace('authorizedUsers')}
|
||||
users={users}
|
||||
onChange={(authorizedUsers) =>
|
||||
handleChange({ authorizedUsers })
|
||||
}
|
||||
value={values.authorizedUsers}
|
||||
errors={errors?.authorizedUsers}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(isAdmin || teams.length > 1) && (
|
||||
<TeamsField
|
||||
name={withNamespace('authorizedTeams')}
|
||||
teams={teams}
|
||||
overrideTooltip={
|
||||
!isAdmin && teams.length > 1
|
||||
? 'As you are a member of multiple teams, you can select which teams(s) will be able to manage this resource.'
|
||||
: undefined
|
||||
}
|
||||
onChange={(authorizedTeams) =>
|
||||
handleChange({ authorizedTeams })
|
||||
}
|
||||
value={values.authorizedTeams}
|
||||
errors={errors?.authorizedTeams}
|
||||
/>
|
||||
)}
|
||||
|
||||
{typeof errors === 'string' && (
|
||||
<div className="form-group col-md-12">
|
||||
<FormError>{errors}</FormError>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
function withNamespace(name: string) {
|
||||
return formNamespace ? `${formNamespace}.${name}` : name;
|
||||
}
|
||||
}
|
||||
|
||||
function useOptions(isAdmin: boolean, teams?: Team[]) {
|
||||
const [options, setOptions] = useState<Array<BoxSelectorOption<RCO>>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setOptions(isAdmin ? adminOptions() : nonAdminOptions(teams));
|
||||
}, [isAdmin, teams]);
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function adminOptions() {
|
||||
return [
|
||||
buildOption(
|
||||
'access_administrators',
|
||||
ownershipIcon('administrators'),
|
||||
'Administrators',
|
||||
'I want to restrict the management of this resource to administrators only',
|
||||
RCO.ADMINISTRATORS
|
||||
),
|
||||
buildOption(
|
||||
'access_restricted',
|
||||
ownershipIcon('restricted'),
|
||||
'Restricted',
|
||||
'I want to restrict the management of this resource to a set of users and/or teams',
|
||||
RCO.RESTRICTED
|
||||
),
|
||||
];
|
||||
}
|
||||
function nonAdminOptions(teams?: Team[]) {
|
||||
return _.compact([
|
||||
buildOption(
|
||||
'access_private',
|
||||
ownershipIcon('private'),
|
||||
'Private',
|
||||
'I want to this resource to be manageable by myself only',
|
||||
RCO.PRIVATE
|
||||
),
|
||||
teams &&
|
||||
teams.length > 0 &&
|
||||
buildOption(
|
||||
'access_restricted',
|
||||
ownershipIcon('restricted'),
|
||||
'Restricted',
|
||||
teams.length === 1
|
||||
? `I want any member of my team (${teams[0].Name}) to be able to manage this resource`
|
||||
: 'I want to restrict the management of this resource to one or more of my teams',
|
||||
RCO.RESTRICTED
|
||||
),
|
||||
]);
|
||||
}
|
|
@ -1,118 +0,0 @@
|
|||
import { ResourceControlOwnership } from '@/portainer/models/resourceControl/resourceControlOwnership';
|
||||
|
||||
import { validationSchema } from './AccessControlForm.validation';
|
||||
|
||||
test('when access control is disabled, should be valid', async () => {
|
||||
const schema = validationSchema(true);
|
||||
const object = { accessControlEnabled: false };
|
||||
|
||||
await expect(
|
||||
schema.validate(object, { strict: true })
|
||||
).resolves.toStrictEqual(object);
|
||||
});
|
||||
|
||||
test('when only access control is enabled, should be invalid', async () => {
|
||||
const schema = validationSchema(true);
|
||||
|
||||
await expect(
|
||||
schema.validate({ accessControlEnabled: true }, { strict: true })
|
||||
).rejects.toThrowErrorMatchingSnapshot();
|
||||
});
|
||||
|
||||
test('when access control is enabled and ownership not restricted, should be valid', async () => {
|
||||
const schema = validationSchema(true);
|
||||
[
|
||||
ResourceControlOwnership.ADMINISTRATORS,
|
||||
ResourceControlOwnership.PRIVATE,
|
||||
ResourceControlOwnership.PUBLIC,
|
||||
].forEach(async (ownership) => {
|
||||
const object = { accessControlEnabled: false, ownership };
|
||||
|
||||
await expect(
|
||||
schema.validate(object, { strict: true })
|
||||
).resolves.toStrictEqual(object);
|
||||
});
|
||||
});
|
||||
|
||||
test('when access control is enabled, ownership is restricted and no teams or users, should be invalid', async () => {
|
||||
[true, false].forEach(async (isAdmin) => {
|
||||
const schema = validationSchema(isAdmin);
|
||||
|
||||
await expect(
|
||||
schema.validate(
|
||||
{
|
||||
accessControlEnabled: true,
|
||||
ownership: ResourceControlOwnership.RESTRICTED,
|
||||
authorizedTeams: [],
|
||||
authorizedUsers: [],
|
||||
},
|
||||
{ strict: true }
|
||||
)
|
||||
).rejects.toThrowErrorMatchingSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
test('when access control is enabled, ownership is restricted, user is admin should have either teams or users', async () => {
|
||||
const schema = validationSchema(true);
|
||||
const teams = {
|
||||
accessControlEnabled: true,
|
||||
ownership: ResourceControlOwnership.RESTRICTED,
|
||||
authorizedTeams: [1],
|
||||
authorizedUsers: [],
|
||||
};
|
||||
|
||||
await expect(schema.validate(teams, { strict: true })).resolves.toStrictEqual(
|
||||
teams
|
||||
);
|
||||
|
||||
const users = {
|
||||
accessControlEnabled: true,
|
||||
ownership: ResourceControlOwnership.RESTRICTED,
|
||||
authorizedTeams: [],
|
||||
authorizedUsers: [1],
|
||||
};
|
||||
|
||||
await expect(schema.validate(users, { strict: true })).resolves.toStrictEqual(
|
||||
users
|
||||
);
|
||||
|
||||
const both = {
|
||||
accessControlEnabled: true,
|
||||
ownership: ResourceControlOwnership.RESTRICTED,
|
||||
authorizedTeams: [1],
|
||||
authorizedUsers: [2],
|
||||
};
|
||||
|
||||
await expect(schema.validate(both, { strict: true })).resolves.toStrictEqual(
|
||||
both
|
||||
);
|
||||
});
|
||||
|
||||
test('when access control is enabled, ownership is restricted, user is admin with teams and users, should be valid', async () => {
|
||||
const schema = validationSchema(false);
|
||||
|
||||
const object = {
|
||||
accessControlEnabled: true,
|
||||
ownership: ResourceControlOwnership.RESTRICTED,
|
||||
authorizedTeams: [1],
|
||||
authorizedUsers: [1],
|
||||
};
|
||||
|
||||
await expect(
|
||||
schema.validate(object, { strict: true })
|
||||
).resolves.toStrictEqual(object);
|
||||
});
|
||||
|
||||
test('when access control is enabled, ownership is restricted, user is not admin with teams, should be valid', async () => {
|
||||
const schema = validationSchema(false);
|
||||
|
||||
const object = {
|
||||
accessControlEnabled: true,
|
||||
ownership: ResourceControlOwnership.RESTRICTED,
|
||||
authorizedTeams: [1],
|
||||
};
|
||||
|
||||
await expect(
|
||||
schema.validate(object, { strict: true })
|
||||
).resolves.toStrictEqual(object);
|
||||
});
|
|
@ -1,7 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`when access control is enabled, ownership is restricted and no teams or users, should be invalid 1`] = `"You must specify at least one team or user."`;
|
||||
|
||||
exports[`when access control is enabled, ownership is restricted and no teams or users, should be invalid 2`] = `"You must specify at least one team."`;
|
||||
|
||||
exports[`when only access control is enabled, should be invalid 1`] = `"ownership is a required field"`;
|
|
@ -1,61 +0,0 @@
|
|||
import { ResourceControlOwnership as RCO } from '@/portainer/models/resourceControl/resourceControlOwnership';
|
||||
import {
|
||||
ResourceControlType,
|
||||
ResourceControlViewModel,
|
||||
} from '@/portainer/models/resourceControl/resourceControl';
|
||||
|
||||
import { parseFromResourceControl } from './model';
|
||||
|
||||
test('when resource control supplied, if user is not admin, will change ownership to rc ownership', () => {
|
||||
[RCO.ADMINISTRATORS, RCO.RESTRICTED, RCO.PUBLIC, RCO.PRIVATE].forEach(
|
||||
(ownership) => {
|
||||
const resourceControl = buildResourceControl(ownership);
|
||||
|
||||
const actual = parseFromResourceControl(false, resourceControl.Ownership);
|
||||
expect(actual.ownership).toBe(resourceControl.Ownership);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('when resource control supplied and user is admin, if resource ownership is not private , will change ownership to rc ownership', () => {
|
||||
[RCO.ADMINISTRATORS, RCO.RESTRICTED, RCO.PUBLIC].forEach((ownership) => {
|
||||
const resourceControl = buildResourceControl(ownership);
|
||||
|
||||
const actual = parseFromResourceControl(true, resourceControl.Ownership);
|
||||
expect(actual.ownership).toBe(resourceControl.Ownership);
|
||||
});
|
||||
});
|
||||
|
||||
test('when resource control supplied, if ownership is public, will disabled access control', () => {
|
||||
const resourceControl = buildResourceControl(RCO.PUBLIC);
|
||||
|
||||
const actual = parseFromResourceControl(false, resourceControl.Ownership);
|
||||
|
||||
expect(actual.accessControlEnabled).toBe(false);
|
||||
});
|
||||
|
||||
test('when isAdmin and resource control not supplied, ownership should be set to Administrator', () => {
|
||||
const actual = parseFromResourceControl(true);
|
||||
|
||||
expect(actual.ownership).toBe(RCO.ADMINISTRATORS);
|
||||
});
|
||||
|
||||
test('when resource control supplied, if user is admin and resource ownership is private, will change ownership to restricted', () => {
|
||||
const resourceControl = buildResourceControl(RCO.PRIVATE);
|
||||
|
||||
const actual = parseFromResourceControl(true, resourceControl.Ownership);
|
||||
expect(actual.ownership).toBe(RCO.RESTRICTED);
|
||||
});
|
||||
|
||||
function buildResourceControl(ownership: RCO): ResourceControlViewModel {
|
||||
return {
|
||||
UserAccesses: [],
|
||||
TeamAccesses: [],
|
||||
Ownership: ownership,
|
||||
Id: 1,
|
||||
Public: false,
|
||||
ResourceId: 1,
|
||||
System: false,
|
||||
Type: ResourceControlType.Config,
|
||||
};
|
||||
}
|
|
@ -1,40 +0,0 @@
|
|||
import { ResourceControlOwnership } from 'Portainer/models/resourceControl/resourceControlOwnership';
|
||||
|
||||
import { TeamId } from '@/portainer/teams/types';
|
||||
import { UserId } from '@/portainer/users/types';
|
||||
|
||||
export class AccessControlFormData {
|
||||
accessControlEnabled = true;
|
||||
|
||||
ownership = ResourceControlOwnership.PRIVATE;
|
||||
|
||||
authorizedUsers: UserId[] = [];
|
||||
|
||||
authorizedTeams: TeamId[] = [];
|
||||
}
|
||||
|
||||
export function parseFromResourceControl(
|
||||
isAdmin: boolean,
|
||||
resourceControlOwnership?: ResourceControlOwnership
|
||||
): AccessControlFormData {
|
||||
const formData = new AccessControlFormData();
|
||||
|
||||
if (resourceControlOwnership) {
|
||||
let ownership = resourceControlOwnership;
|
||||
if (isAdmin && ownership === ResourceControlOwnership.PRIVATE) {
|
||||
ownership = ResourceControlOwnership.RESTRICTED;
|
||||
}
|
||||
|
||||
let accessControl = formData.accessControlEnabled;
|
||||
if (ownership === ResourceControlOwnership.PUBLIC) {
|
||||
accessControl = false;
|
||||
}
|
||||
|
||||
formData.ownership = ownership;
|
||||
formData.accessControlEnabled = accessControl;
|
||||
} else if (isAdmin) {
|
||||
formData.ownership = ResourceControlOwnership.ADMINISTRATORS;
|
||||
}
|
||||
|
||||
return formData;
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import _ from 'lodash-es';
|
||||
import { ResourceControlOwnership as RCO } from 'Portainer/models/resourceControl/resourceControlOwnership';
|
||||
import { ResourceControlOwnership as RCO } from '@/portainer/access-control/types';
|
||||
|
||||
angular.module('portainer.app').controller('porAccessControlFormController', [
|
||||
'$q',
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { ResourceControlOwnership as RCO } from 'Portainer/models/resourceControl/resourceControlOwnership';
|
||||
import { ResourceControlOwnership as RCO } from '@/portainer/access-control/types';
|
||||
|
||||
/**
|
||||
* @deprecated use only for angularjs components. For react components use ./model.ts
|
||||
|
|
|
@ -1,45 +0,0 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
|
||||
import { getTeams } from '@/portainer/teams/teams.service';
|
||||
import * as notifications from '@/portainer/services/notifications';
|
||||
import { getUsers } from '@/portainer/services/api/userService';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
|
||||
export function useLoadState() {
|
||||
const { teams, isLoading: isLoadingTeams } = useTeams();
|
||||
|
||||
const { users, isLoading: isLoadingUsers } = useUsers();
|
||||
|
||||
return { teams, users, isLoading: isLoadingTeams || isLoadingUsers };
|
||||
}
|
||||
|
||||
function useTeams() {
|
||||
const { isError, error, isLoading, data } = useQuery('teams', () =>
|
||||
getTeams()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isError) {
|
||||
notifications.error('Failure', error as Error, 'Failed retrieving teams');
|
||||
}
|
||||
}, [isError, error]);
|
||||
|
||||
return { isLoading, teams: data };
|
||||
}
|
||||
|
||||
function useUsers() {
|
||||
const { isError, error, isLoading, data } = useQuery<
|
||||
unknown,
|
||||
unknown,
|
||||
UserViewModel[]
|
||||
>('users', () => getUsers());
|
||||
|
||||
useEffect(() => {
|
||||
if (isError) {
|
||||
notifications.error('Failure', error as Error, 'Failed retrieving users');
|
||||
}
|
||||
}, [isError, error]);
|
||||
|
||||
return { isLoading, users: data };
|
||||
}
|
|
@ -1,16 +0,0 @@
|
|||
angular.module('portainer.app').component('porAccessControlPanel', {
|
||||
templateUrl: './porAccessControlPanel.html',
|
||||
controller: 'porAccessControlPanelController',
|
||||
bindings: {
|
||||
// The component will use this identifier when updating the resource control object.
|
||||
resourceId: '<',
|
||||
// The component will display information about this resource control object.
|
||||
resourceControl: '=',
|
||||
// This component is usually displayed inside a resource-details view.
|
||||
// This variable specifies the type of the associated resource.
|
||||
// Accepted values: 'container', 'service' or 'volume'.
|
||||
resourceType: '<',
|
||||
// Allow to disable the Ownership edition based on non resource control data
|
||||
disableOwnershipChange: '<',
|
||||
},
|
||||
});
|
|
@ -1,215 +0,0 @@
|
|||
<div class="row">
|
||||
<div class="col-sm-12" ng-if="$ctrl.state.displayAccessControlPanel">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-eye" title-text="Access control"></rd-widget-header>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<!-- ownership -->
|
||||
<tr data-cy="access-ownership">
|
||||
<td>Ownership</td>
|
||||
<td>
|
||||
<i ng-class="$ctrl.resourceControl.Ownership | ownershipicon" aria-hidden="true" style="margin-right: 2px"></i>
|
||||
<span ng-if="!$ctrl.resourceControl">
|
||||
administrators
|
||||
<portainer-tooltip message="This resource can only be managed by administrators." position="bottom" style="margin-left: -3px"></portainer-tooltip>
|
||||
</span>
|
||||
<span ng-if="$ctrl.resourceControl">
|
||||
{{ $ctrl.resourceControl.Ownership }}
|
||||
<portainer-tooltip
|
||||
ng-if="$ctrl.resourceControl.Ownership === $ctrl.RCO.PUBLIC"
|
||||
message="This resource can be managed by any user with access to this environment."
|
||||
position="bottom"
|
||||
style="margin-left: -3px"
|
||||
></portainer-tooltip>
|
||||
<portainer-tooltip
|
||||
ng-if="$ctrl.resourceControl.Ownership === $ctrl.RCO.PRIVATE"
|
||||
message="Management of this resource is restricted to a single user."
|
||||
position="bottom"
|
||||
style="margin-left: -3px"
|
||||
></portainer-tooltip>
|
||||
<portainer-tooltip
|
||||
ng-if="$ctrl.resourceControl.Ownership === $ctrl.RCO.RESTRICTED"
|
||||
message="This resource can be managed by a restricted set of users and/or teams."
|
||||
position="bottom"
|
||||
style="margin-left: -3px"
|
||||
></portainer-tooltip>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- !ownership -->
|
||||
<tr ng-if="$ctrl.resourceControl.Type === $ctrl.RCTI.SERVICE && $ctrl.resourceType === $ctrl.RCTS.CONTAINER">
|
||||
<td colspan="2">
|
||||
<i class="fa fa-info-circle" aria-hidden="true" style="margin-right: 2px"></i>
|
||||
Access control on this resource is inherited from the following service:
|
||||
<a ui-sref="docker.services.service({ id: $ctrl.resourceControl.ResourceId })">{{ $ctrl.resourceControl.ResourceId | truncate }}</a>
|
||||
<portainer-tooltip
|
||||
message="Access control applied on a service is also applied on each container of that service."
|
||||
position="bottom"
|
||||
style="margin-left: 2px"
|
||||
></portainer-tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-if="$ctrl.resourceControl.Type === $ctrl.RCTI.CONTAINER && $ctrl.resourceType === $ctrl.RCTS.VOLUME">
|
||||
<td colspan="2">
|
||||
<i class="fa fa-info-circle" aria-hidden="true" style="margin-right: 2px"></i>
|
||||
Access control on this resource is inherited from the following container:
|
||||
<a ui-sref="docker.containers.container({ id: $ctrl.resourceControl.ResourceId })">{{ $ctrl.resourceControl.ResourceId | truncate }}</a>
|
||||
<portainer-tooltip
|
||||
message="Access control applied on a container created using a template is also applied on each volume associated to the container."
|
||||
position="bottom"
|
||||
style="margin-left: 2px"
|
||||
></portainer-tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-if="$ctrl.resourceControl.Type === $ctrl.RCTI.STACK && $ctrl.resourceType !== $ctrl.RCTS.STACK">
|
||||
<td colspan="2">
|
||||
<i class="fa fa-info-circle" aria-hidden="true" style="margin-right: 2px"></i>
|
||||
Access control on this resource is inherited from the following stack: {{ $ctrl.removeEndpointId($ctrl.resourceControl.ResourceId) }}
|
||||
<portainer-tooltip
|
||||
message="Access control applied on a stack is also applied on each resource in the stack."
|
||||
position="bottom"
|
||||
style="margin-left: 2px"
|
||||
></portainer-tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- authorized-users -->
|
||||
<tr ng-if="$ctrl.resourceControl.UserAccesses.length > 0" data-cy="access-authorisedUsers">
|
||||
<td>Authorized users</td>
|
||||
<td>
|
||||
<span ng-repeat="user in $ctrl.authorizedUsers">{{ user.Username }}{{ $last ? '' : ', ' }} </span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- !authorized-users -->
|
||||
<!-- authorized-teams -->
|
||||
<tr ng-if="$ctrl.resourceControl.TeamAccesses.length > 0" data-cy="access-authorisedTeams">
|
||||
<td>Authorized teams</td>
|
||||
<td>
|
||||
<span ng-repeat="team in $ctrl.authorizedTeams">{{ team.Name }}{{ $last ? '' : ', ' }} </span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- !authorized-teams -->
|
||||
<!-- edit-ownership -->
|
||||
<tr ng-if="$ctrl.canEditOwnership();">
|
||||
<td colspan="2">
|
||||
<a ng-click="$ctrl.state.editOwnership = true"><i class="fa fa-edit space-right" aria-hidden="true"></i>Change ownership</a>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- !edit-ownership -->
|
||||
<!-- edit-ownership-choices -->
|
||||
<tr ng-if="$ctrl.state.editOwnership">
|
||||
<td colspan="2" style="white-space: inherit">
|
||||
<div class="boxselector_wrapper">
|
||||
<div ng-if="$ctrl.isAdmin">
|
||||
<input type="radio" id="access_administrators" ng-model="$ctrl.formValues.Ownership" ng-value="$ctrl.RCO.ADMINISTRATORS" />
|
||||
<label for="access_administrators">
|
||||
<div class="boxselector_header">
|
||||
<i ng-class="'administrators' | ownershipicon" aria-hidden="true" style="margin-right: 2px"></i>
|
||||
Administrators
|
||||
</div>
|
||||
<p>I want to restrict the management of this resource to administrators only</p>
|
||||
</label>
|
||||
</div>
|
||||
<div ng-if="$ctrl.isAdmin">
|
||||
<input type="radio" id="access_restricted" ng-model="$ctrl.formValues.Ownership" ng-value="$ctrl.RCO.RESTRICTED" />
|
||||
<label for="access_restricted">
|
||||
<div class="boxselector_header">
|
||||
<i ng-class="'restricted' | ownershipicon" aria-hidden="true" style="margin-right: 2px"></i>
|
||||
Restricted
|
||||
</div>
|
||||
<p> I want to restrict the management of this resource to a set of users and/or teams </p>
|
||||
</label>
|
||||
</div>
|
||||
<div ng-if="!$ctrl.isAdmin && $ctrl.state.canChangeOwnershipToTeam && $ctrl.availableTeams.length > 0">
|
||||
<input type="radio" id="access_restricted" ng-model="$ctrl.formValues.Ownership" ng-value="$ctrl.RCO.RESTRICTED" />
|
||||
<label for="access_restricted">
|
||||
<div class="boxselector_header">
|
||||
<i ng-class="'restricted' | ownershipicon" aria-hidden="true" style="margin-right: 2px"></i>
|
||||
Restricted
|
||||
</div>
|
||||
<p ng-if="$ctrl.availableTeams.length === 1">
|
||||
I want any member of my team (<b>{{ $ctrl.availableTeams[0].Name }}</b
|
||||
>) to be able to manage this resource
|
||||
</p>
|
||||
<p ng-if="$ctrl.availableTeams.length > 1"> I want to restrict the management of this resource to one or more of my teams </p>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="access_public" ng-model="$ctrl.formValues.Ownership" ng-value="$ctrl.RCO.PUBLIC" />
|
||||
<label for="access_public">
|
||||
<div class="boxselector_header">
|
||||
<i ng-class="'public' | ownershipicon" aria-hidden="true" style="margin-right: 2px"></i>
|
||||
Public
|
||||
</div>
|
||||
<p>I want any user with access to this environment to be able to manage this resource</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- edit-ownership-choices -->
|
||||
<!-- select-teams -->
|
||||
<tr ng-if="$ctrl.state.editOwnership && $ctrl.formValues.Ownership === $ctrl.RCO.RESTRICTED && ($ctrl.isAdmin || (!$ctrl.isAdmin && $ctrl.availableTeams.length > 1))">
|
||||
<td colspan="2">
|
||||
<span>Teams</span>
|
||||
<span ng-if="$ctrl.isAdmin && $ctrl.availableTeams.length === 0" class="small text-muted" style="margin-left: 10px">
|
||||
You have not yet created any teams. Head over to the <a ui-sref="portainer.teams">Teams view</a> to manage teams.
|
||||
</span>
|
||||
<span
|
||||
isteven-multi-select
|
||||
ng-if="($ctrl.isAdmin && $ctrl.availableTeams.length > 0) || (!$ctrl.isAdmin && $ctrl.availableTeams.length > 1)"
|
||||
input-model="$ctrl.availableTeams"
|
||||
output-model="$ctrl.formValues.Ownership_Teams"
|
||||
button-label="Name"
|
||||
item-label="Name"
|
||||
tick-property="selected"
|
||||
helper-elements="filter"
|
||||
search-property="Name"
|
||||
max-labels="3"
|
||||
translation="{nothingSelected: 'Select one or more teams', search: 'Search...'}"
|
||||
>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- !select-teams -->
|
||||
<!-- select-users -->
|
||||
<tr ng-if="$ctrl.isAdmin && $ctrl.state.editOwnership && $ctrl.formValues.Ownership === $ctrl.RCO.RESTRICTED">
|
||||
<td colspan="2">
|
||||
<span>Users</span>
|
||||
<span ng-if="$ctrl.availableUsers.length === 0" class="small text-muted" style="margin-left: 10px">
|
||||
You have not yet created any users. Head over to the <a ui-sref="portainer.users">Users view</a> to manage users.
|
||||
</span>
|
||||
<span
|
||||
isteven-multi-select
|
||||
ng-if="$ctrl.availableUsers.length > 0"
|
||||
input-model="$ctrl.availableUsers"
|
||||
output-model="$ctrl.formValues.Ownership_Users"
|
||||
button-label="Username"
|
||||
item-label="Username"
|
||||
tick-property="selected"
|
||||
helper-elements="filter"
|
||||
search-property="Username"
|
||||
max-labels="3"
|
||||
translation="{nothingSelected: 'Select one or more users', search: 'Search...'}"
|
||||
>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- !select-users -->
|
||||
<!-- ownership-actions -->
|
||||
<tr ng-if="$ctrl.state.editOwnership">
|
||||
<td colspan="2">
|
||||
<div>
|
||||
<a type="button" class="btn btn-default btn-sm" ng-click="$ctrl.state.editOwnership = false">Cancel</a>
|
||||
<a type="button" class="btn btn-primary btn-sm" ng-click="$ctrl.confirmUpdateOwnership()">Update ownership</a>
|
||||
<span class="text-danger" ng-if="$ctrl.state.formValidationError" style="margin-left: 5px">{{ $ctrl.state.formValidationError }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- !ownership-actions -->
|
||||
</tbody>
|
||||
</table>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
|
@ -1,154 +0,0 @@
|
|||
import _ from 'lodash-es';
|
||||
import { ResourceControlOwnership as RCO } from 'Portainer/models/resourceControl/resourceControlOwnership';
|
||||
import { ResourceControlTypeInt as RCTI, ResourceControlTypeString as RCTS } from 'Portainer/models/resourceControl/resourceControlTypes';
|
||||
import { AccessControlPanelData } from './porAccessControlPanelModel';
|
||||
|
||||
angular.module('portainer.app').controller('porAccessControlPanelController', [
|
||||
'$q',
|
||||
'$state',
|
||||
'UserService',
|
||||
'TeamService',
|
||||
'ResourceControlHelper',
|
||||
'ResourceControlService',
|
||||
'Notifications',
|
||||
'Authentication',
|
||||
'ModalService',
|
||||
'FormValidator',
|
||||
function ($q, $state, UserService, TeamService, ResourceControlHelper, ResourceControlService, Notifications, Authentication, ModalService, FormValidator) {
|
||||
var ctrl = this;
|
||||
|
||||
ctrl.RCO = RCO;
|
||||
ctrl.RCTS = RCTS;
|
||||
ctrl.RCTI = RCTI;
|
||||
ctrl.state = {
|
||||
displayAccessControlPanel: false,
|
||||
canEditOwnership: false,
|
||||
editOwnership: false,
|
||||
formValidationError: '',
|
||||
};
|
||||
|
||||
ctrl.formValues = new AccessControlPanelData();
|
||||
|
||||
ctrl.authorizedUsers = [];
|
||||
ctrl.availableUsers = [];
|
||||
ctrl.authorizedTeams = [];
|
||||
ctrl.availableTeams = [];
|
||||
|
||||
ctrl.canEditOwnership = function () {
|
||||
const hasRC = ctrl.resourceControl;
|
||||
const inheritedVolume = hasRC && ctrl.resourceControl.Type === RCTI.CONTAINER && ctrl.resourceType === RCTS.VOLUME;
|
||||
const inheritedContainer = hasRC && ctrl.resourceControl.Type === RCTI.SERVICE && ctrl.resourceType === RCTS.CONTAINER;
|
||||
const inheritedFromStack = hasRC && ctrl.resourceControl.Type === RCTI.STACK && ctrl.resourceType !== RCTS.STACK;
|
||||
const hasSpecialDisable = ctrl.disableOwnershipChange;
|
||||
|
||||
return !inheritedVolume && !inheritedContainer && !inheritedFromStack && !hasSpecialDisable && !ctrl.state.editOwnership && (ctrl.isAdmin || ctrl.state.canEditOwnership);
|
||||
};
|
||||
|
||||
ctrl.confirmUpdateOwnership = function () {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
ModalService.confirmAccessControlUpdate(function (confirmed) {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
updateOwnership();
|
||||
});
|
||||
};
|
||||
|
||||
ctrl.removeEndpointId = function (stackName) {
|
||||
if (!stackName) {
|
||||
return stackName;
|
||||
}
|
||||
const firstUnderlineIndex = stackName.indexOf('_');
|
||||
if (firstUnderlineIndex < 0) {
|
||||
return stackName;
|
||||
}
|
||||
return stackName.substring(firstUnderlineIndex + 1);
|
||||
};
|
||||
|
||||
function validateForm() {
|
||||
ctrl.state.formValidationError = '';
|
||||
var error = '';
|
||||
|
||||
var accessControlData = {
|
||||
AccessControlEnabled: ctrl.formValues.Ownership === RCO.PUBLIC ? false : true,
|
||||
Ownership: ctrl.formValues.Ownership,
|
||||
AuthorizedUsers: ctrl.formValues.Ownership_Users,
|
||||
AuthorizedTeams: ctrl.formValues.Ownership_Teams,
|
||||
};
|
||||
var isAdmin = ctrl.isAdmin;
|
||||
error = FormValidator.validateAccessControl(accessControlData, isAdmin);
|
||||
if (error) {
|
||||
ctrl.state.formValidationError = error;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateOwnership() {
|
||||
ResourceControlService.applyResourceControlChange(ctrl.resourceType, ctrl.resourceId, ctrl.resourceControl, ctrl.formValues)
|
||||
.then(function success() {
|
||||
Notifications.success('Access control successfully updated');
|
||||
$state.reload();
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to update access control');
|
||||
});
|
||||
}
|
||||
|
||||
this.$onInit = $onInit;
|
||||
function $onInit() {
|
||||
var userDetails = Authentication.getUserDetails();
|
||||
var isAdmin = Authentication.isAdmin();
|
||||
var userId = userDetails.ID;
|
||||
ctrl.isAdmin = isAdmin;
|
||||
var resourceControl = ctrl.resourceControl;
|
||||
|
||||
if (isAdmin && resourceControl) {
|
||||
ctrl.formValues.Ownership = resourceControl.Ownership === RCO.PRIVATE ? RCO.RESTRICTED : resourceControl.Ownership;
|
||||
} else {
|
||||
ctrl.formValues.Ownership = RCO.ADMINISTRATORS;
|
||||
}
|
||||
|
||||
ResourceControlService.retrieveOwnershipDetails(resourceControl)
|
||||
.then(function success(data) {
|
||||
ctrl.authorizedUsers = data.authorizedUsers;
|
||||
ctrl.authorizedTeams = data.authorizedTeams;
|
||||
return ResourceControlService.retrieveUserPermissionsOnResource(userId, isAdmin, resourceControl);
|
||||
})
|
||||
.then(function success(data) {
|
||||
ctrl.state.canEditOwnership = data.isPartOfRestrictedUsers || data.isLeaderOfAnyRestrictedTeams;
|
||||
ctrl.state.canChangeOwnershipToTeam = data.isPartOfRestrictedUsers;
|
||||
|
||||
return $q.all({
|
||||
availableUsers: isAdmin ? UserService.users(false) : [],
|
||||
availableTeams: isAdmin || data.isPartOfRestrictedUsers ? TeamService.teams() : [],
|
||||
});
|
||||
})
|
||||
.then(function success(data) {
|
||||
ctrl.availableUsers = _.orderBy(data.availableUsers, 'Username', 'asc');
|
||||
angular.forEach(ctrl.availableUsers, function (user) {
|
||||
var found = _.find(ctrl.authorizedUsers, { Id: user.Id });
|
||||
if (found) {
|
||||
user.selected = true;
|
||||
}
|
||||
});
|
||||
ctrl.availableTeams = _.orderBy(data.availableTeams, 'Name', 'asc');
|
||||
angular.forEach(data.availableTeams, function (team) {
|
||||
var found = _.find(ctrl.authorizedTeams, { Id: team.Id });
|
||||
if (found) {
|
||||
team.selected = true;
|
||||
}
|
||||
});
|
||||
if (data.availableTeams.length === 1) {
|
||||
ctrl.formValues.Ownership_Teams.push(data.availableTeams[0]);
|
||||
}
|
||||
ctrl.state.displayAccessControlPanel = true;
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve access control information');
|
||||
});
|
||||
}
|
||||
},
|
||||
]);
|
|
@ -1,7 +0,0 @@
|
|||
import { ResourceControlOwnership } from 'Portainer/models/resourceControl/resourceControlOwnership';
|
||||
|
||||
export function AccessControlPanelData() {
|
||||
this.Ownership = ResourceControlOwnership.ADMINISTRATORS;
|
||||
this.Ownership_Users = [];
|
||||
this.Ownership_Teams = [];
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import _ from 'lodash-es';
|
||||
import './datatable.css';
|
||||
import { ResourceControlOwnership as RCO } from 'Portainer/models/resourceControl/resourceControlOwnership';
|
||||
import { ResourceControlOwnership as RCO } from '@/portainer/access-control/types';
|
||||
|
||||
function isBetween(value, a, b) {
|
||||
return (value >= a && value <= b) || (value >= b && value <= a);
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
.root :global .selector__control {
|
||||
border: 1px solid var(--border-multiselect);
|
||||
background-color: var(--bg-multiselect-color);
|
||||
}
|
||||
|
||||
.root :global .selector__multi-value {
|
||||
background-color: var(--grey-51);
|
||||
}
|
||||
|
||||
:global :root[theme='dark'] :local .root :global .selector__multi-value {
|
||||
background-color: var(--grey-3);
|
||||
}
|
||||
|
||||
:global :root[theme='highcontrast'] :local .root :global .selector__multi-value {
|
||||
background-color: var(--grey-3);
|
||||
}
|
||||
|
||||
.root :global .selector__multi-value__label {
|
||||
color: var(--black-color);
|
||||
}
|
||||
|
||||
:global :root[theme='dark'] :local .root :global .selector__multi-value__label {
|
||||
color: var(--white-color);
|
||||
}
|
||||
|
||||
:global :root[theme='highcontrast'] :local .root :global .selector__multi-value__label {
|
||||
color: var(--white-color);
|
||||
}
|
||||
|
||||
.root :global .selector__menu {
|
||||
background-color: var(--bg-multiselect-color);
|
||||
border: 1px solid var(--border-multiselect);
|
||||
}
|
||||
|
||||
.root :global .selector__option {
|
||||
background-color: var(--bg-multiselect-color);
|
||||
border: 1px solid var(--border-multiselect);
|
||||
}
|
||||
|
||||
.root :global .selector__option:active,
|
||||
.root :global .selector__option--is-focused {
|
||||
background-color: var(--blue-8);
|
||||
}
|
||||
|
||||
:global :root[theme='dark'] :local .root :global .selector__option:active,
|
||||
:global :root[theme='dark'] :local .root :global .selector__option--is-focused {
|
||||
background-color: var(--blue-2);
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import ReactSelect, { GroupBase, Props as SelectProps } from 'react-select';
|
||||
import clsx from 'clsx';
|
||||
import { RefAttributes } from 'react';
|
||||
import ReactSelectType from 'react-select/dist/declarations/src/Select';
|
||||
|
||||
import styles from './ReactSelect.module.css';
|
||||
|
||||
export function Select<
|
||||
Option = unknown,
|
||||
IsMulti extends boolean = false,
|
||||
Group extends GroupBase<Option> = GroupBase<Option>
|
||||
>({
|
||||
className,
|
||||
...props
|
||||
}: SelectProps<Option, IsMulti, Group> &
|
||||
RefAttributes<ReactSelectType<Option, IsMulti, Group>>) {
|
||||
return (
|
||||
<ReactSelect
|
||||
className={clsx(styles.root, className)}
|
||||
classNamePrefix="selector"
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -45,7 +45,7 @@ export function WidgetTitle({
|
|||
<div className="row">
|
||||
<span className={clsx('pull-left', className)}>
|
||||
{typeof icon === 'string' ? <i className={clsx('fa', icon)} /> : icon}
|
||||
{title}
|
||||
<span>{title}</span>
|
||||
</span>
|
||||
<span className={clsx('pull-right', className)}>{children}</span>
|
||||
</div>
|
||||
|
|
|
@ -1,13 +1,19 @@
|
|||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { TeamId } from '@/portainer/teams/types';
|
||||
import { UserId } from '@/portainer/users/types';
|
||||
|
||||
import {
|
||||
EnvironmentId,
|
||||
TeamAccessPolicies,
|
||||
UserAccessPolicies,
|
||||
} from '../types';
|
||||
import { EnvironmentId } from '../types';
|
||||
|
||||
import { buildUrl } from './utils';
|
||||
|
||||
export type RoleId = number;
|
||||
interface AccessPolicy {
|
||||
RoleId: RoleId;
|
||||
}
|
||||
|
||||
type UserAccessPolicies = Record<UserId, AccessPolicy>; // map[UserID]AccessPolicy
|
||||
type TeamAccessPolicies = Record<TeamId, AccessPolicy>;
|
||||
|
||||
export type RegistryId = number;
|
||||
export interface Registry {
|
||||
Id: RegistryId;
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import { TagId } from '@/portainer/tags/types';
|
||||
import { EnvironmentGroupId } from '@/portainer/environment-groups/types';
|
||||
import { UserId } from '@/portainer/users/types';
|
||||
import { TeamId } from '@/portainer/teams/types';
|
||||
|
||||
export type EnvironmentId = number;
|
||||
|
||||
|
@ -113,10 +111,3 @@ export interface EnvironmentSettings {
|
|||
// Whether host management features are enabled
|
||||
enableHostManagementFeatures: boolean;
|
||||
}
|
||||
|
||||
export type RoleId = number;
|
||||
interface AccessPolicy {
|
||||
RoleId: RoleId;
|
||||
}
|
||||
export type UserAccessPolicies = Record<UserId, AccessPolicy>;
|
||||
export type TeamAccessPolicies = Record<TeamId, AccessPolicy>;
|
||||
|
|
|
@ -2,7 +2,7 @@ import moment from 'moment';
|
|||
import _ from 'lodash-es';
|
||||
import filesize from 'filesize';
|
||||
|
||||
import { ResourceControlOwnership as RCO } from 'Portainer/models/resourceControl/resourceControlOwnership';
|
||||
import { ResourceControlOwnership as RCO } from '@/portainer/access-control/types';
|
||||
|
||||
export function truncateLeftRight(text, max, left, right) {
|
||||
max = isNaN(max) ? 50 : max;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import _ from 'lodash-es';
|
||||
import angular from 'angular';
|
||||
import { ResourceControlOwnership as RCO } from 'Portainer/models/resourceControl/resourceControlOwnership';
|
||||
import { ResourceControlOwnership as RCO } from '@/portainer/access-control/types';
|
||||
import { ResourceControlOwnershipParameters } from '../models/resourceControl/resourceControlOwnershipParameters';
|
||||
|
||||
class ResourceControlHelper {
|
||||
|
@ -68,19 +68,6 @@ class ResourceControlHelper {
|
|||
return new ResourceControlOwnershipParameters(adminOnly, publicOnly, users, teams, subResources);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform AccessControlPanelData to ResourceControlOwnershipParameters
|
||||
* @param {AccessControlPanelData} formValues Form data (generated by AccessControlPanel)
|
||||
*/
|
||||
RCPanelDataToOwnershipParameters(formValues) {
|
||||
const adminOnly = formValues.Ownership === RCO.ADMINISTRATORS;
|
||||
const publicOnly = formValues.Ownership === RCO.PUBLIC;
|
||||
const userIds = publicOnly || adminOnly ? [] : _.map(formValues.Ownership_Users, (user) => user.Id);
|
||||
const teamIds = publicOnly || adminOnly ? [] : _.map(formValues.Ownership_Teams, (team) => team.Id);
|
||||
|
||||
return new ResourceControlOwnershipParameters(adminOnly, publicOnly, userIds, teamIds);
|
||||
}
|
||||
|
||||
retrieveAuthorizedUsers(resourceControl, users) {
|
||||
const authorizedUsers = [];
|
||||
_.forEach(resourceControl.UserAccesses, (access) => {
|
||||
|
@ -102,18 +89,6 @@ class ResourceControlHelper {
|
|||
});
|
||||
return authorizedTeams;
|
||||
}
|
||||
|
||||
isLeaderOfAnyRestrictedTeams(userMemberships, resourceControl) {
|
||||
let isTeamLeader = false;
|
||||
_.forEach(userMemberships, (membership) => {
|
||||
const found = _.find(resourceControl.TeamAccesses, { TeamId: membership.TeamId });
|
||||
if (found && membership.Role === 1) {
|
||||
isTeamLeader = true;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return isTeamLeader;
|
||||
}
|
||||
}
|
||||
|
||||
export default ResourceControlHelper;
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
import { UserViewModel } from '../models/user';
|
||||
|
||||
export function filterNonAdministratorUsers(users: UserViewModel[]) {
|
||||
return users.filter((user) => user.Role !== 1);
|
||||
}
|
|
@ -9,14 +9,13 @@ import {
|
|||
useMemo,
|
||||
} from 'react';
|
||||
|
||||
import { getUser } from '@/portainer/services/api/userService';
|
||||
|
||||
import { UserViewModel } from '../models/user';
|
||||
import { getUser } from '../users/user.service';
|
||||
import { User, UserId } from '../users/types';
|
||||
|
||||
import { useLocalStorage } from './useLocalStorage';
|
||||
|
||||
interface State {
|
||||
user?: UserViewModel | null;
|
||||
user?: User;
|
||||
}
|
||||
|
||||
export const UserContext = createContext<State | null>(null);
|
||||
|
@ -28,7 +27,10 @@ export function useUser() {
|
|||
throw new Error('should be nested under UserProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
return useMemo(
|
||||
() => ({ user: context.user, isAdmin: isAdmin(context.user) }),
|
||||
[context.user]
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuthorizations(
|
||||
|
@ -41,6 +43,10 @@ export function useAuthorizations(
|
|||
const { user } = useUser();
|
||||
const { params } = useCurrentStateAndParams();
|
||||
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (process.env.PORTAINER_EDITION === 'CE') {
|
||||
return !adminOnlyCE || isAdmin(user);
|
||||
}
|
||||
|
@ -50,10 +56,6 @@ export function useAuthorizations(
|
|||
return false;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isAdmin(user)) {
|
||||
return true;
|
||||
}
|
||||
|
@ -88,7 +90,7 @@ interface UserProviderProps {
|
|||
|
||||
export function UserProvider({ children }: UserProviderProps) {
|
||||
const [jwt] = useLocalStorage('JWT', '');
|
||||
const [user, setUser] = useState<UserViewModel | null>(null);
|
||||
const [user, setUser] = useState<User | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (jwt !== '') {
|
||||
|
@ -114,13 +116,13 @@ export function UserProvider({ children }: UserProviderProps) {
|
|||
</UserContext.Provider>
|
||||
);
|
||||
|
||||
async function loadUser(id: number) {
|
||||
async function loadUser(id: UserId) {
|
||||
const user = await getUser(id);
|
||||
setUser(user);
|
||||
}
|
||||
}
|
||||
|
||||
export function isAdmin(user?: UserViewModel | null): boolean {
|
||||
function isAdmin(user?: User): boolean {
|
||||
return !!user && user.Role === 1;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,84 +0,0 @@
|
|||
import { ResourceControlOwnership as RCO } from './resourceControlOwnership';
|
||||
|
||||
export enum ResourceControlType {
|
||||
// Container represents a resource control associated to a Docker container
|
||||
Container = 1,
|
||||
// Service represents a resource control associated to a Docker service
|
||||
Service,
|
||||
// Volume represents a resource control associated to a Docker volume
|
||||
Volume,
|
||||
// Network represents a resource control associated to a Docker network
|
||||
Network,
|
||||
// Secret represents a resource control associated to a Docker secret
|
||||
Secret,
|
||||
// Stack represents a resource control associated to a stack composed of Docker services
|
||||
Stack,
|
||||
// Config represents a resource control associated to a Docker config
|
||||
Config,
|
||||
// CustomTemplate represents a resource control associated to a custom template
|
||||
CustomTemplate,
|
||||
// ContainerGroup represents a resource control associated to an Azure container group
|
||||
ContainerGroup,
|
||||
}
|
||||
|
||||
export interface ResourceControlResponse {
|
||||
Id: number;
|
||||
Type: ResourceControlType;
|
||||
ResourceId: string | number;
|
||||
UserAccesses: unknown[];
|
||||
TeamAccesses: unknown[];
|
||||
Public: boolean;
|
||||
AdministratorsOnly: boolean;
|
||||
System: boolean;
|
||||
}
|
||||
|
||||
export class ResourceControlViewModel {
|
||||
Id: number;
|
||||
|
||||
Type: ResourceControlType;
|
||||
|
||||
ResourceId: string | number;
|
||||
|
||||
UserAccesses: unknown[];
|
||||
|
||||
TeamAccesses: unknown[];
|
||||
|
||||
Public: boolean;
|
||||
|
||||
System: boolean;
|
||||
|
||||
Ownership: RCO;
|
||||
|
||||
constructor(data: ResourceControlResponse) {
|
||||
this.Id = data.Id;
|
||||
this.Type = data.Type;
|
||||
this.ResourceId = data.ResourceId;
|
||||
this.UserAccesses = data.UserAccesses;
|
||||
this.TeamAccesses = data.TeamAccesses;
|
||||
this.Public = data.Public;
|
||||
this.System = data.System;
|
||||
this.Ownership = determineOwnership(this);
|
||||
}
|
||||
}
|
||||
|
||||
function determineOwnership(resourceControl: ResourceControlViewModel) {
|
||||
if (resourceControl.Public) {
|
||||
return RCO.PUBLIC;
|
||||
}
|
||||
|
||||
if (
|
||||
resourceControl.UserAccesses.length === 1 &&
|
||||
resourceControl.TeamAccesses.length === 0
|
||||
) {
|
||||
return RCO.PRIVATE;
|
||||
}
|
||||
|
||||
if (
|
||||
resourceControl.UserAccesses.length > 1 ||
|
||||
resourceControl.TeamAccesses.length > 0
|
||||
) {
|
||||
return RCO.RESTRICTED;
|
||||
}
|
||||
|
||||
return RCO.ADMINISTRATORS;
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
export enum ResourceControlOwnership {
|
||||
PUBLIC = 'public',
|
||||
PRIVATE = 'private',
|
||||
RESTRICTED = 'restricted',
|
||||
ADMINISTRATORS = 'administrators',
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
export const ResourceControlTypeString = Object.freeze({
|
||||
CONFIG: 'config',
|
||||
CONTAINER: 'container',
|
||||
NETWORK: 'network',
|
||||
SECRET: 'secret',
|
||||
SERVICE: 'service',
|
||||
STACK: 'stack',
|
||||
VOLUME: 'volume',
|
||||
CUSTOM_TEMPLATE: 'custom-template',
|
||||
CONTAINER_GROUP: 'container-group',
|
||||
});
|
||||
|
||||
/**
|
||||
* ResourceType int defined in portainer.go as ResourceControlType
|
||||
*/
|
||||
export const ResourceControlTypeInt = Object.freeze({
|
||||
CONTAINER: 1,
|
||||
SERVICE: 2,
|
||||
VOLUME: 3,
|
||||
NETWORK: 4,
|
||||
SECRET: 5,
|
||||
STACK: 6,
|
||||
CONFIG: 7,
|
||||
CUSTOM_TEMPLATE: 8,
|
||||
CONTAINER_GROUP: 9,
|
||||
});
|
|
@ -1,10 +0,0 @@
|
|||
/**
|
||||
* Payload for resourceControlUpdate operation
|
||||
* @param {ResourceControlOwnershipParameters} data
|
||||
*/
|
||||
export function ResourceControlUpdatePayload(data) {
|
||||
this.Public = data.Public;
|
||||
this.AdministratorsOnly = data.AdministratorsOnly;
|
||||
this.Users = data.Users;
|
||||
this.Teams = data.Teams;
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { ResourceControlViewModel } from 'Portainer/models/resourceControl/resourceControl';
|
||||
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
|
||||
|
||||
export function StackViewModel(data) {
|
||||
this.Id = data.Id;
|
||||
|
|
|
@ -1,51 +0,0 @@
|
|||
import { ResourceControlOwnership } from 'Portainer/models/resourceControl/resourceControlOwnership';
|
||||
|
||||
import { AccessControlFormData } from '../components/accessControlForm/model';
|
||||
import { TeamId } from '../teams/types';
|
||||
import { UserId } from '../users/types';
|
||||
|
||||
import { OwnershipParameters } from './types';
|
||||
|
||||
/**
|
||||
* Transform AccessControlFormData to ResourceControlOwnershipParameters
|
||||
* @param {int} userId ID of user performing the operation
|
||||
* @param {AccessControlFormData} formValues Form data (generated by AccessControlForm)
|
||||
* @param {int[]} subResources Sub Resources restricted by the ResourceControl
|
||||
*/
|
||||
export function parseOwnershipParameters(
|
||||
userId: UserId,
|
||||
formValues: AccessControlFormData,
|
||||
subResources: (number | string)[] = []
|
||||
): OwnershipParameters {
|
||||
let { ownership } = formValues;
|
||||
if (!formValues.accessControlEnabled) {
|
||||
ownership = ResourceControlOwnership.PUBLIC;
|
||||
}
|
||||
|
||||
let adminOnly = false;
|
||||
let publicOnly = false;
|
||||
let users: UserId[] = [];
|
||||
let teams: TeamId[] = [];
|
||||
switch (ownership) {
|
||||
case ResourceControlOwnership.PUBLIC:
|
||||
publicOnly = true;
|
||||
break;
|
||||
case ResourceControlOwnership.PRIVATE:
|
||||
users.push(userId);
|
||||
break;
|
||||
case ResourceControlOwnership.RESTRICTED:
|
||||
users = formValues.authorizedUsers;
|
||||
teams = formValues.authorizedTeams;
|
||||
break;
|
||||
default:
|
||||
adminOnly = true;
|
||||
break;
|
||||
}
|
||||
return {
|
||||
administratorsOnly: adminOnly,
|
||||
public: publicOnly,
|
||||
users,
|
||||
teams,
|
||||
subResources,
|
||||
};
|
||||
}
|
|
@ -1,48 +0,0 @@
|
|||
import { UserId } from '@/portainer/users/types';
|
||||
import { AccessControlFormData } from '@/portainer/components/accessControlForm/model';
|
||||
import { ResourceControlResponse } from '@/portainer/models/resourceControl/resourceControl';
|
||||
|
||||
import axios, { parseAxiosError } from '../services/axios';
|
||||
|
||||
import { parseOwnershipParameters } from './helper';
|
||||
import { OwnershipParameters } from './types';
|
||||
|
||||
/**
|
||||
* Apply a ResourceControl after Resource creation
|
||||
* @param userId ID of User performing the action
|
||||
* @param accessControlData ResourceControl to apply
|
||||
* @param resourceControl ResourceControl to update
|
||||
* @param subResources SubResources managed by the ResourceControl
|
||||
*/
|
||||
export function applyResourceControl(
|
||||
userId: UserId,
|
||||
accessControlData: AccessControlFormData,
|
||||
resourceControl: ResourceControlResponse,
|
||||
subResources: (number | string)[] = []
|
||||
) {
|
||||
const ownershipParameters = parseOwnershipParameters(
|
||||
userId,
|
||||
accessControlData,
|
||||
subResources
|
||||
);
|
||||
return updateResourceControl(resourceControl.Id, ownershipParameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a ResourceControl
|
||||
* @param resourceControlId ID of involved resource
|
||||
* @param ownershipParameters Transient type from view data to payload
|
||||
*/
|
||||
async function updateResourceControl(
|
||||
resourceControlId: string | number,
|
||||
ownershipParameters: OwnershipParameters
|
||||
) {
|
||||
try {
|
||||
await axios.put(
|
||||
`/resource_controls/${resourceControlId}`,
|
||||
ownershipParameters
|
||||
);
|
||||
} catch (error) {
|
||||
throw parseAxiosError(error as Error);
|
||||
}
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
import { TeamId } from '@/portainer/teams/types';
|
||||
import { UserId } from '@/portainer/users/types';
|
||||
|
||||
/**
|
||||
* Transient type from view data to payload
|
||||
*/
|
||||
export interface OwnershipParameters {
|
||||
administratorsOnly: boolean;
|
||||
public: boolean;
|
||||
users: UserId[];
|
||||
teams: TeamId[];
|
||||
subResources: (number | string)[];
|
||||
}
|
|
@ -7,10 +7,7 @@ angular.module('portainer.app').factory('ResourceControl', [
|
|||
API_ENDPOINT_RESOURCE_CONTROLS + '/:id',
|
||||
{},
|
||||
{
|
||||
create: { method: 'POST', ignoreLoadingBar: true },
|
||||
get: { method: 'GET', params: { id: '@id' } },
|
||||
update: { method: 'PUT', params: { id: '@id' } },
|
||||
remove: { method: 'DELETE', params: { id: '@id' } },
|
||||
}
|
||||
);
|
||||
},
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import _ from 'lodash-es';
|
||||
|
||||
angular.module('portainer.app').factory('ResourceControlService', [
|
||||
'$q',
|
||||
'ResourceControl',
|
||||
|
@ -11,34 +9,13 @@ angular.module('portainer.app').factory('ResourceControlService', [
|
|||
const service = {};
|
||||
|
||||
service.duplicateResourceControl = duplicateResourceControl;
|
||||
service.applyResourceControlChange = applyResourceControlChange;
|
||||
service.applyResourceControl = applyResourceControl;
|
||||
service.retrieveOwnershipDetails = retrieveOwnershipDetails;
|
||||
service.retrieveUserPermissionsOnResource = retrieveUserPermissionsOnResource;
|
||||
|
||||
/**
|
||||
* PRIVATE SECTION
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a ResourceControl
|
||||
* @param {ResourceControlTypeString} rcType Type of ResourceControl
|
||||
* @param {string} rcID ID of involved resource
|
||||
* @param {ResourceControlOwnershipParameters} ownershipParameters Transcient type from view data to payload
|
||||
*/
|
||||
function createResourceControl(rcType, resourceID, ownershipParameters) {
|
||||
var payload = {
|
||||
Type: rcType,
|
||||
Public: ownershipParameters.Public,
|
||||
AdministratorsOnly: ownershipParameters.AdministratorsOnly,
|
||||
ResourceID: resourceID,
|
||||
Users: ownershipParameters.Users,
|
||||
Teams: ownershipParameters.Teams,
|
||||
SubResourceIds: ownershipParameters.SubResourceIDs,
|
||||
};
|
||||
return ResourceControl.create({}, payload).$promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a ResourceControl
|
||||
* @param {String} rcID ID of involved resource
|
||||
|
@ -86,22 +63,6 @@ angular.module('portainer.app').factory('ResourceControlService', [
|
|||
return updateResourceControl(newResourceControl.Id, ownershipParameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing ResourceControl or create a new one on existing resource without RC
|
||||
* @param {ResourceControlTypeString} rcType Type of ResourceControl
|
||||
* @param {String} resourceId ID of involved Resource
|
||||
* @param {ResourceControlViewModel} resourceControl Previous ResourceControl (can be undefined)
|
||||
* @param {AccessControlPanelData} formValues View data generated by AccessControlPanel
|
||||
*/
|
||||
function applyResourceControlChange(rcType, resourceId, resourceControl, formValues) {
|
||||
const ownershipParameters = ResourceControlHelper.RCPanelDataToOwnershipParameters(formValues);
|
||||
if (resourceControl) {
|
||||
return updateResourceControl(resourceControl.Id, ownershipParameters);
|
||||
} else {
|
||||
return createResourceControl(rcType, resourceId, ownershipParameters);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve users and team details for ResourceControlViewModel
|
||||
* @param {ResourceControlViewModel} resourceControl ResourceControl view model
|
||||
|
@ -130,33 +91,6 @@ angular.module('portainer.app').factory('ResourceControlService', [
|
|||
return deferred.promise;
|
||||
}
|
||||
|
||||
function retrieveUserPermissionsOnResource(userID, isAdministrator, resourceControl) {
|
||||
var deferred = $q.defer();
|
||||
|
||||
if (!resourceControl || isAdministrator) {
|
||||
deferred.resolve({ isPartOfRestrictedUsers: false, isLeaderOfAnyRestrictedTeams: false });
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
var found = _.find(resourceControl.UserAccesses, { UserId: userID });
|
||||
if (found) {
|
||||
deferred.resolve({ isPartOfRestrictedUsers: true, isLeaderOfAnyRestrictedTeams: false });
|
||||
} else {
|
||||
var isTeamLeader = false;
|
||||
UserService.userMemberships(userID)
|
||||
.then(function success(data) {
|
||||
var memberships = data;
|
||||
isTeamLeader = ResourceControlHelper.isLeaderOfAnyRestrictedTeams(memberships, resourceControl);
|
||||
deferred.resolve({ isPartOfRestrictedUsers: false, isLeaderOfAnyRestrictedTeams: isTeamLeader });
|
||||
})
|
||||
.catch(function error(err) {
|
||||
deferred.reject({ msg: 'Unable to retrieve user memberships', err: err });
|
||||
});
|
||||
}
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* END PUBLIC SECTION
|
||||
*/
|
||||
|
|
|
@ -1,47 +1,25 @@
|
|||
import _ from 'lodash-es';
|
||||
import { UserTokenModel, UserViewModel } from '@/portainer/models/user';
|
||||
import { getUser, getUsers } from '@/portainer/users/user.service';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
const BASE_URL = '/users';
|
||||
|
||||
import { filterNonAdministratorUsers } from '@/portainer/helpers/userHelper';
|
||||
import { UserViewModel, UserTokenModel } from '../../models/user';
|
||||
import { TeamMembershipModel } from '../../models/teamMembership';
|
||||
|
||||
export async function getUsers(includeAdministrators) {
|
||||
try {
|
||||
let { data } = await axios.get(BASE_URL);
|
||||
|
||||
const users = data.map((user) => new UserViewModel(user));
|
||||
|
||||
if (includeAdministrators) {
|
||||
return users;
|
||||
}
|
||||
|
||||
return filterNonAdministratorUsers(users);
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e, 'Unable to retrieve users');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUser(id) {
|
||||
try {
|
||||
const { data: user } = await axios.get(`${BASE_URL}/${id}`);
|
||||
|
||||
return new UserViewModel(user);
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e, 'Unable to retrieve user details');
|
||||
}
|
||||
}
|
||||
|
||||
/* @ngInject */
|
||||
export function UserService($q, Users, TeamService, TeamMembershipService) {
|
||||
'use strict';
|
||||
var service = {};
|
||||
|
||||
service.users = getUsers;
|
||||
service.users = async function (includeAdministrators) {
|
||||
const users = await getUsers(includeAdministrators);
|
||||
|
||||
service.user = getUser;
|
||||
return users.map((u) => new UserViewModel(u));
|
||||
};
|
||||
|
||||
service.user = async function (includeAdministrators) {
|
||||
const user = await getUser(includeAdministrators);
|
||||
|
||||
return new UserViewModel(user);
|
||||
};
|
||||
|
||||
service.createUser = function (username, password, role, teamIds) {
|
||||
var deferred = $q.defer();
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { ResourceControlOwnership as RCO } from 'Portainer/models/resourceControl/resourceControlOwnership';
|
||||
import { ResourceControlOwnership as RCO } from '@/portainer/access-control/types';
|
||||
|
||||
angular.module('portainer.app').factory('FormValidator', [
|
||||
function FormValidatorFactory() {
|
||||
|
|
|
@ -55,21 +55,6 @@ export function confirm(options: ConfirmOptions) {
|
|||
applyBoxCSS(box);
|
||||
}
|
||||
|
||||
export function confirmAccessControlUpdate(callback: ConfirmCallback) {
|
||||
confirm({
|
||||
title: 'Are you sure ?',
|
||||
message:
|
||||
'Changing the ownership of this resource will potentially restrict its management to some users.',
|
||||
buttons: {
|
||||
confirm: {
|
||||
label: 'Change ownership',
|
||||
className: 'btn-primary',
|
||||
},
|
||||
},
|
||||
callback,
|
||||
});
|
||||
}
|
||||
|
||||
export function confirmImageForceRemoval(callback: ConfirmCallback) {
|
||||
confirm({
|
||||
title: 'Are you sure?',
|
||||
|
|
|
@ -3,7 +3,6 @@ import bootbox from 'bootbox';
|
|||
|
||||
import {
|
||||
cancelRegistryRepositoryAction,
|
||||
confirmAccessControlUpdate,
|
||||
confirmAsync,
|
||||
confirmDeassociate,
|
||||
confirmDeletion,
|
||||
|
@ -43,7 +42,6 @@ export function ModalServiceAngular() {
|
|||
confirmWebEditorDiscard,
|
||||
confirmAsync,
|
||||
confirm,
|
||||
confirmAccessControlUpdate,
|
||||
confirmImageForceRemoval,
|
||||
cancelRegistryRepositoryAction,
|
||||
confirmDeletion,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import toastr from 'toastr';
|
||||
|
||||
import { error, success, warning } from './notifications';
|
||||
import { notifyError, notifySuccess, notifyWarning } from './notifications';
|
||||
|
||||
jest.mock('toastr');
|
||||
|
||||
|
@ -12,7 +12,7 @@ it('calling success should show success message', () => {
|
|||
const title = 'title';
|
||||
const text = 'text';
|
||||
|
||||
success(title, text);
|
||||
notifySuccess(title, text);
|
||||
|
||||
expect(toastr.success).toHaveBeenCalledWith(text, title);
|
||||
});
|
||||
|
@ -25,7 +25,7 @@ it('calling error with Error should show error message', () => {
|
|||
const errorMessage = 'message';
|
||||
const fallback = 'fallback';
|
||||
|
||||
error(title, new Error(errorMessage), fallback);
|
||||
notifyError(title, new Error(errorMessage), fallback);
|
||||
|
||||
expect(toastr.error).toHaveBeenCalledWith(
|
||||
errorMessage,
|
||||
|
@ -44,7 +44,7 @@ it('calling error without Error should show fallback message', () => {
|
|||
|
||||
const fallback = 'fallback';
|
||||
|
||||
error(title, undefined, fallback);
|
||||
notifyError(title, undefined, fallback);
|
||||
|
||||
expect(toastr.error).toHaveBeenCalledWith(fallback, title, expect.anything());
|
||||
consoleErrorFn.mockRestore();
|
||||
|
@ -54,7 +54,7 @@ it('calling warning should show warning message', () => {
|
|||
const title = 'title';
|
||||
const text = 'text';
|
||||
|
||||
warning(title, text);
|
||||
notifyWarning(title, text);
|
||||
|
||||
expect(toastr.warning).toHaveBeenCalledWith(text, title, expect.anything());
|
||||
});
|
||||
|
|
|
@ -9,15 +9,15 @@ toastr.options = {
|
|||
tapToDismiss: false,
|
||||
};
|
||||
|
||||
export function success(title: string, text: string) {
|
||||
export function notifySuccess(title: string, text?: string) {
|
||||
toastr.success(sanitize(_.escape(text)), sanitize(title));
|
||||
}
|
||||
|
||||
export function warning(title: string, text: string) {
|
||||
export function notifyWarning(title: string, text: string) {
|
||||
toastr.warning(sanitize(_.escape(text)), sanitize(title), { timeOut: 6000 });
|
||||
}
|
||||
|
||||
export function error(title: string, e?: Error, fallbackText = '') {
|
||||
export function notifyError(title: string, e?: Error, fallbackText = '') {
|
||||
const msg = pickErrorMsg(e) || fallbackText;
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
|
@ -28,12 +28,16 @@ export function error(title: string, e?: Error, fallbackText = '') {
|
|||
}
|
||||
}
|
||||
|
||||
export const success = notifySuccess;
|
||||
export const error = notifyError;
|
||||
export const warning = notifyWarning;
|
||||
|
||||
/* @ngInject */
|
||||
export function Notifications() {
|
||||
return {
|
||||
success,
|
||||
warning,
|
||||
error,
|
||||
success: notifySuccess,
|
||||
warning: notifyWarning,
|
||||
error: notifyError,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import { getTeams } from './teams.service';
|
||||
import { Team } from './types';
|
||||
|
||||
export function useTeams<T = Team[]>(
|
||||
enabled = true,
|
||||
select: (data: Team[]) => T = (data) => data as unknown as T
|
||||
) {
|
||||
const teams = useQuery(['teams'], () => getTeams(), {
|
||||
meta: {
|
||||
error: { title: 'Failure', message: 'Unable to load teams' },
|
||||
},
|
||||
enabled,
|
||||
select,
|
||||
});
|
||||
|
||||
return teams;
|
||||
}
|
|
@ -1,6 +1,20 @@
|
|||
import { UserId } from '../users/types';
|
||||
|
||||
export type TeamId = number;
|
||||
|
||||
export enum Role {
|
||||
TeamLeader = 1,
|
||||
TeamMember,
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
Id: TeamId;
|
||||
Name: string;
|
||||
}
|
||||
|
||||
export interface TeamMembership {
|
||||
Id: number;
|
||||
UserID: UserId;
|
||||
TeamID: TeamId;
|
||||
Role: Role;
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue