refactor(access-control): create access-control-panel component [EE-2345] (#6486)

pull/6596/merge
Chaim Lev-Ari 2022-03-16 08:35:32 +02:00 committed by GitHub
parent 07294c19bb
commit f63b07bbb9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
109 changed files with 2053 additions and 1518 deletions

View File

@ -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,

View File

@ -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

View File

@ -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';

View File

@ -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']);
},
}

View File

@ -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 {

View File

@ -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 = '';

View File

@ -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';

View File

@ -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>

View File

@ -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() {

View File

@ -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() {

View File

@ -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',

View File

@ -1,4 +1,4 @@
import { ResourceControlViewModel } from '@/portainer/models/resourceControl/resourceControl';
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
export type DockerContainerStatus =
| 'paused'

View File

@ -1,4 +1,4 @@
import { ResourceControlViewModel } from 'Portainer/models/resourceControl/resourceControl';
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
function b64DecodeUnicode(str) {
try {

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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">

View File

@ -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() {

View File

@ -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">

View File

@ -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);

View File

@ -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)">

View File

@ -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() {

View File

@ -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 -->

View File

@ -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() {

View File

@ -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">

View File

@ -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 = {

View File

@ -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)">

View File

@ -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) {

View File

@ -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 */

View File

@ -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',

View File

@ -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>
);

View File

@ -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 };
}

View File

@ -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 });
}
}

View File

@ -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);
});

View File

@ -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;
}

View File

@ -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."`;

View File

@ -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,
};
}

View File

@ -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',
]);

View File

@ -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;
})
);
});
}

View File

@ -0,0 +1,3 @@
.form {
padding: 0 20px;
}

View File

@ -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',
},
},
});
}

View File

@ -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 });
}
}

View File

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

View File

@ -0,0 +1 @@
export { EditDetails } from './EditDetails';

View File

@ -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,
};
}

View File

@ -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
),
]);
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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;
}

View File

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

View File

@ -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,
};
}
});

View File

@ -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 };
}

View File

@ -85,10 +85,6 @@
}
}
.box-selector-item-description {
height: 1em;
}
.box-selector-item.limited.business {
--selected-item-color: var(--BE-only);
}

View File

@ -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 {

View File

@ -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;
}

View File

@ -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) =>

View File

@ -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
),
]);
}

View File

@ -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);
});

View File

@ -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"`;

View File

@ -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,
};
}

View File

@ -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;
}

View File

@ -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',

View File

@ -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

View File

@ -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 };
}

View File

@ -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: '<',
},
});

View File

@ -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>

View File

@ -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');
});
}
},
]);

View File

@ -1,7 +0,0 @@
import { ResourceControlOwnership } from 'Portainer/models/resourceControl/resourceControlOwnership';
export function AccessControlPanelData() {
this.Ownership = ResourceControlOwnership.ADMINISTRATORS;
this.Ownership_Users = [];
this.Ownership_Teams = [];
}

View File

@ -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);

View File

@ -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);
}

View File

@ -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}
/>
);
}

View File

@ -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>

View File

@ -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;

View File

@ -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>;

View File

@ -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;

View File

@ -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;

View File

@ -1,5 +0,0 @@
import { UserViewModel } from '../models/user';
export function filterNonAdministratorUsers(users: UserViewModel[]) {
return users.filter((user) => user.Role !== 1);
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -1,6 +0,0 @@
export enum ResourceControlOwnership {
PUBLIC = 'public',
PRIVATE = 'private',
RESTRICTED = 'restricted',
ADMINISTRATORS = 'administrators',
}

View File

@ -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,
});

View File

@ -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;
}

View File

@ -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;

View File

@ -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,
};
}

View File

@ -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);
}
}

View File

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

View File

@ -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' } },
}
);
},

View File

@ -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
*/

View File

@ -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();

View File

@ -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() {

View File

@ -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?',

View File

@ -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,

View File

@ -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());
});

View File

@ -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,
};
}

View File

@ -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;
}

View File

@ -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