From 9c279e7fae0e1f72d46bde3d6cc88ac4e95b5993 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Fri, 24 Sep 2021 14:02:10 +0300 Subject: [PATCH 01/18] fix(k8s/ns): validate ingress ctrl host pattern (#5662) * fix(k8s/ns): validate ingress ctrl host pattern * feat(kube/ns): validate ingress hostname --- .../views/resource-pools/create/createResourcePool.html | 5 +++++ .../resource-pools/create/createResourcePoolController.js | 2 +- app/kubernetes/views/resource-pools/edit/resourcePool.html | 6 ++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/kubernetes/views/resource-pools/create/createResourcePool.html b/app/kubernetes/views/resource-pools/create/createResourcePool.html index 8529a0395..611c2bc45 100644 --- a/app/kubernetes/views/resource-pools/create/createResourcePool.html +++ b/app/kubernetes/views/resource-pools/create/createResourcePool.html @@ -269,6 +269,7 @@ ng-model="item.Host" ng-change="$ctrl.onChangeIngressHostname()" placeholder="foo" + pattern="[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*" required /> @@ -288,6 +289,10 @@ >

Hostname is required.

+

+ + This field must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g. 'example.com'). +

This hostname is already used. diff --git a/app/kubernetes/views/resource-pools/create/createResourcePoolController.js b/app/kubernetes/views/resource-pools/create/createResourcePoolController.js index 5cdc8accc..92149f4f9 100644 --- a/app/kubernetes/views/resource-pools/create/createResourcePoolController.js +++ b/app/kubernetes/views/resource-pools/create/createResourcePoolController.js @@ -34,7 +34,7 @@ class KubernetesCreateResourcePoolController { onChangeIngressHostname() { const state = this.state.duplicates.ingressHosts; const hosts = _.flatMap(this.formValues.IngressClasses, 'Hosts'); - const hostnames = _.map(hosts, 'Host'); + const hostnames = _.compact(hosts.map((h) => h.Host)); const hostnamesWithoutRemoved = _.filter(hostnames, (h) => !h.NeedsDeletion); const allHosts = _.flatMap(this.allIngresses, 'Hosts'); const formDuplicates = KubernetesFormValidationHelper.getDuplicates(hostnamesWithoutRemoved); diff --git a/app/kubernetes/views/resource-pools/edit/resourcePool.html b/app/kubernetes/views/resource-pools/edit/resourcePool.html index 29d39b3a4..d847527e0 100644 --- a/app/kubernetes/views/resource-pools/edit/resourcePool.html +++ b/app/kubernetes/views/resource-pools/edit/resourcePool.html @@ -221,6 +221,7 @@ ng-model="item.Host" ng-change="ctrl.onChangeIngressHostname()" placeholder="foo" + pattern="[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*" required /> @@ -240,6 +241,11 @@ >

Hostname is required.

+

+ + This field must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g. + 'example.com'). +

From 885ae16278d152016b166c4e30f1394086fee46e Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Fri, 1 Oct 2021 08:27:31 +0300 Subject: [PATCH 02/18] fix(db): warn on missing docker id when migrating to db 31 (#5782) * fix(db): warn on missing docker id when migrating to db 31 * fix(db): guard against nil exception --- api/bolt/migrator/migrate_dbversion31.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/api/bolt/migrator/migrate_dbversion31.go b/api/bolt/migrator/migrate_dbversion31.go index 8086dfc40..55555096d 100644 --- a/api/bolt/migrator/migrate_dbversion31.go +++ b/api/bolt/migrator/migrate_dbversion31.go @@ -176,7 +176,8 @@ func (m *Migrator) updateVolumeResourceControlToDB32() error { endpointDockerID, err := snapshotutils.FetchDockerID(snapshot) if err != nil { - return fmt.Errorf("failed fetching environment docker id: %w", err) + log.Printf("[WARN] [bolt,migrator,v31] [message: failed fetching environment docker id] [err: %s]", err) + continue } if volumesData, done := snapshot.SnapshotRaw.Volumes.(map[string]interface{}); done { @@ -213,7 +214,11 @@ func findResourcesToUpdateForDB32(dockerID string, volumesData map[string]interf volumes := volumesData["Volumes"].([]interface{}) for _, volumeMeta := range volumes { volume := volumeMeta.(map[string]interface{}) - volumeName := volume["Name"].(string) + volumeName, nameExist := volume["Name"].(string) + if !nameExist { + continue + } + oldResourceID := fmt.Sprintf("%s%s", volumeName, volume["CreatedAt"].(string)) resourceControl, ok := volumeResourceControls[oldResourceID] From 8096c5e8bc6be65ea9ec517e23f039e691125543 Mon Sep 17 00:00:00 2001 From: Matt Hook Date: Thu, 7 Oct 2021 08:07:00 +1300 Subject: [PATCH 03/18] remove default value for compose path (#5832) Co-authored-by: cheloRydel --- app/kubernetes/views/deploy/deployController.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/kubernetes/views/deploy/deployController.js b/app/kubernetes/views/deploy/deployController.js index 8a8eab073..d5d1855a3 100644 --- a/app/kubernetes/views/deploy/deployController.js +++ b/app/kubernetes/views/deploy/deployController.js @@ -63,7 +63,7 @@ class KubernetesDeployController { RepositoryUsername: '', RepositoryPassword: '', AdditionalFiles: [], - ComposeFilePathInRepository: 'deployment.yml', + ComposeFilePathInRepository: '', RepositoryAutomaticUpdates: true, RepositoryMechanism: RepositoryMechanismTypes.INTERVAL, RepositoryFetchInterval: '5m', From b7841e7fc362d432b53382d4c77642976789f128 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Thu, 7 Oct 2021 01:59:53 +0300 Subject: [PATCH 04/18] feat(app): highlight be provided value [EE-882] (#5703) (#5835) --- api/http/handler/handler.go | 4 + api/http/handler/ldap/handler.go | 53 +++ .../ldap_check.go} | 29 +- api/http/handler/settings/handler.go | 4 +- api/http/server.go | 7 + api/ldap/ldap.go | 212 +++++++-- api/portainer.go | 8 + app/assets/css/app.css | 30 +- app/assets/css/theme.css | 5 + app/assets/css/vendor-override.css | 11 + app/kubernetes/views/configure/configure.html | 16 +- .../views/configure/configureController.js | 3 +- .../create/createResourcePool.html | 31 +- .../create/createResourcePoolController.js | 4 + .../resource-pools/edit/resourcePool.html | 31 +- .../edit/resourcePoolController.js | 4 + app/portainer/__module.js | 28 +- .../por-access-management-users-selector.html | 4 +- .../accessManagement/por-access-management.js | 1 + .../accessManagement/porAccessManagement.html | 30 +- .../porAccessManagementController.js | 43 +- .../be-feature-indicator.controller.js | 18 + .../be-feature-indicator.css | 26 ++ .../be-feature-indicator.html | 5 + .../components/be-feature-indicator/index.js | 15 + .../box-selector-item.controller.js | 23 + .../box-selector-item/box-selector-item.css | 117 +++++ .../box-selector-item/box-selector-item.html | 7 +- .../box-selector/box-selector-item/index.js | 8 + .../box-selector/box-selector.controller.js | 4 +- .../components/box-selector/box-selector.css | 86 ---- .../components/box-selector/index.js | 4 +- .../accessViewerDatatable.html | 26 -- .../components/datatables/datatable.css | 4 + .../filter/datatable-filter.controller.js | 19 + .../datatables/filter/datatable-filter.html | 32 ++ .../components/datatables/filter/index.js | 13 + .../datatables/genericDatatableController.js | 6 +- app/portainer/components/datatables/index.js | 16 + .../components/datatables/pagination/index.js | 9 + .../datatables/pagination/pagination.html | 15 + .../registriesDatatable.html | 13 +- .../registriesDatatableController.js | 3 +- .../roles-datatable/rolesDatatable.html | 64 --- .../searchbar/datatable-searchbar.html | 4 + .../components/datatables/searchbar/index.js | 7 + .../datatable-sort-icon.controller.js | 5 + .../sort-icon/datatable-sort-icon.html | 9 + .../components/datatables/sort-icon/index.js | 11 + .../titlebar/datatable-titlebar.html | 7 + .../components/datatables/titlebar/index.js | 8 + .../por-switch-field/por-switch-field.html | 2 + .../por-switch-field/por-switch-field.js | 4 +- .../forms/por-switch/por-switch.controller.js | 14 + .../por-switch.css} | 15 + .../forms/por-switch/por-switch.html | 14 +- .../components/forms/por-switch/por-switch.js | 6 + app/portainer/components/widget-header.js | 17 +- app/portainer/feature-flags/enums.js | 10 + app/portainer/feature-flags/feature-flags.css | 26 ++ .../feature-flags/feature-flags.service.js | 57 +++ app/portainer/feature-flags/feature-ids.js | 11 + app/portainer/feature-flags/index.js | 7 + .../limited-feature.directive.js | 41 ++ .../{oauth-providers-selector.js => index.js} | 9 +- .../oauth-provider-selector-controller.js | 39 -- .../oauth-provider-selector.controller.js | 14 + .../oauth-providers-selector.html | 16 +- .../{oauth-settings.js => index.js} | 5 +- .../oauth-settings-controller.js | 23 - .../oauth-settings.controller.js | 109 +++++ .../oauth-settings/oauth-settings.html | 430 +++++++++++++----- .../components/oauth-settings/providers.js | 43 ++ .../access-viewer-datatable.html | 73 +++ .../access-viewer-datatable/index.js} | 6 +- .../access-viewer/access-viewer.controller.js | 128 ++++++ .../access-viewer/access-viewer.html | 43 ++ .../rbac/components/access-viewer/index.js | 6 + .../rbac/components/roles-datatable/index.js | 15 + .../roles-datatable.controller.js | 15 + .../roles-datatable/roles-datatable.css | 7 + .../roles-datatable/roles-datatable.html | 85 ++++ app/portainer/rbac/index.js | 33 ++ app/portainer/rbac/models/access.js | 16 + app/portainer/rbac/models/role.js | 14 + app/portainer/rbac/services/role.rest.js | 14 + app/portainer/rbac/services/role.service.js | 19 + app/portainer/rbac/views/roles/index.js | 6 + .../rbac/views/roles/roles.controller.js | 20 + app/portainer/rbac/views/roles/roles.html | 18 + .../authentication/auth-method-constants.js | 11 + .../authentication/auth-type-constants.js | 11 + .../auto-user-provision-toggle.html | 14 + .../auto-user-provision-toggle/index.js | 9 + .../settings/authentication/index.js | 10 + .../ad-settings/ad-settings.controller.js | 62 +++ .../ldap/ad-settings/ad-settings.html | 157 +++++++ .../authentication/ldap/ad-settings/index.js | 12 + .../settings/authentication/ldap/index.js | 44 ++ .../ldap/ldap-connectivity-check/index.js | 9 + .../ldap-connectivity-check.html | 21 + .../ldap/ldap-custom-group-search/index.js | 11 + .../ldap-custom-group-search.controller.js | 34 ++ .../ldap-custom-group-search.html | 117 +++++ .../ldap/ldap-custom-user-search/index.js | 11 + .../ldap-custom-user-search.controller.js | 33 ++ .../ldap-custom-user-search.html | 117 +++++ .../ldap/ldap-group-search-item/index.js | 15 + .../ldap-group-search-item.controller.js | 51 +++ .../ldap-group-search-item.html | 93 ++++ .../ldap/ldap-group-search/index.js | 14 + .../ldap-group-search.controller.js | 36 ++ .../ldap-group-search/ldap-group-search.html | 39 ++ .../ldap/ldap-groups-datatable/index.js} | 6 +- .../ldap-groups-datatable.html | 77 ++++ .../ldap/ldap-settings-custom/index.js | 15 + .../ldap-settings-custom.controller.js | 15 + .../ldap-settings-custom.html | 119 +++++ .../ldap/ldap-settings-dn-builder/index.js | 16 + .../ldap-settings-dn-builder.controller.js | 84 ++++ .../ldap-settings-dn-builder.html | 69 +++ .../ldap-settings-group-dn-builder/index.js | 18 + ...ap-settings-group-dn-builder.controller.js | 55 +++ .../ldap-settings-group-dn-builder.html | 37 ++ .../ldap/ldap-settings-openldap/index.js | 16 + .../ldap-settings-openldap.controller.js | 45 ++ .../ldap-settings-openldap.html | 187 ++++++++ .../ldap/ldap-settings-security/index.js | 11 + .../ldap-settings-security.html | 72 +++ .../ldap/ldap-settings-test-login/index.js | 11 + .../ldap-settings-test-login.controller.js | 31 ++ .../ldap-settings-test-login.html | 45 ++ .../ldap/ldap-settings.model.js | 54 +++ .../ldap/ldap-settings/index.js | 11 + .../ldap-settings/ldap-settings.controller.js | 67 +++ .../ldap/ldap-settings/ldap-settings.html | 41 ++ .../ldap/ldap-user-search-item/index.js | 15 + .../ldap-user-search-item.controller.js | 67 +++ .../ldap-user-search-item.html | 106 +++++ .../ldap/ldap-user-search/index.js | 15 + .../ldap-user-search.controller.js | 38 ++ .../ldap-user-search/ldap-user-search.html | 40 ++ .../ldap/ldap-users-datatable/index.js | 12 + .../ldap-users-datatable.html | 71 +++ .../settings/authentication/ldap/ldap.rest.js | 15 + .../authentication/ldap/ldap.service.js | 29 ++ app/portainer/settings/index.js | 3 +- .../activity-logs-datatable.controller.js | 38 ++ .../activity-logs-datatable.css | 3 + .../activity-logs-datatable.html | 66 +++ .../activity-logs-datatable/index.js | 24 + .../activity-logs-view.controller.js | 93 ++++ .../activity-logs-view.html | 48 ++ .../activity-logs-view/activity-logs-view.js | 6 + .../user-activity/activity-logs-view/index.js | 9 + .../auth-logs-datatable.controller.js | 68 +++ .../auth-logs-datatable.html | 64 +++ .../auth-logs-datatable/index.js | 25 + .../auth-logs-view.controller.js | 103 +++++ .../auth-logs-view/auth-logs-view.html | 51 +++ .../auth-logs-view/auth-logs-view.js | 6 + .../user-activity/auth-logs-view/index.js | 6 + app/portainer/user-activity/index.js | 29 ++ .../endpoints/access/endpointAccess.html | 1 + .../access/endpointAccessController.js | 4 + .../views/groups/access/groupAccess.html | 1 + .../groups/access/groupAccessController.js | 4 + app/portainer/views/roles/roles.html | 56 --- .../settingsAuthentication.html | 401 ++-------------- .../settingsAuthenticationController.js | 395 +++++++++------- app/portainer/views/settings/settings.html | 255 ++++++++--- .../views/settings/settingsController.js | 20 + app/portainer/views/sidebar/sidebar.html | 4 + package.json | 4 +- yarn.lock | 13 +- 175 files changed, 5612 insertions(+), 1201 deletions(-) create mode 100644 api/http/handler/ldap/handler.go rename api/http/handler/{settings/settings_ldap_check.go => ldap/ldap_check.go} (50%) create mode 100644 app/portainer/components/be-feature-indicator/be-feature-indicator.controller.js create mode 100644 app/portainer/components/be-feature-indicator/be-feature-indicator.css create mode 100644 app/portainer/components/be-feature-indicator/be-feature-indicator.html create mode 100644 app/portainer/components/be-feature-indicator/index.js create mode 100644 app/portainer/components/box-selector/box-selector-item/box-selector-item.controller.js create mode 100644 app/portainer/components/box-selector/box-selector-item/box-selector-item.css delete mode 100644 app/portainer/components/datatables/access-viewer-datatable/accessViewerDatatable.html create mode 100644 app/portainer/components/datatables/filter/datatable-filter.controller.js create mode 100644 app/portainer/components/datatables/filter/datatable-filter.html create mode 100644 app/portainer/components/datatables/filter/index.js create mode 100644 app/portainer/components/datatables/index.js create mode 100644 app/portainer/components/datatables/pagination/index.js create mode 100644 app/portainer/components/datatables/pagination/pagination.html delete mode 100644 app/portainer/components/datatables/roles-datatable/rolesDatatable.html create mode 100644 app/portainer/components/datatables/searchbar/datatable-searchbar.html create mode 100644 app/portainer/components/datatables/searchbar/index.js create mode 100644 app/portainer/components/datatables/sort-icon/datatable-sort-icon.controller.js create mode 100644 app/portainer/components/datatables/sort-icon/datatable-sort-icon.html create mode 100644 app/portainer/components/datatables/sort-icon/index.js create mode 100644 app/portainer/components/datatables/titlebar/datatable-titlebar.html create mode 100644 app/portainer/components/datatables/titlebar/index.js create mode 100644 app/portainer/components/forms/por-switch/por-switch.controller.js rename app/portainer/components/forms/{por-switch-field/por-switch-field.css => por-switch/por-switch.css} (82%) create mode 100644 app/portainer/feature-flags/enums.js create mode 100644 app/portainer/feature-flags/feature-flags.css create mode 100644 app/portainer/feature-flags/feature-flags.service.js create mode 100644 app/portainer/feature-flags/feature-ids.js create mode 100644 app/portainer/feature-flags/index.js create mode 100644 app/portainer/feature-flags/limited-feature.directive.js rename app/portainer/oauth/components/oauth-providers-selector/{oauth-providers-selector.js => index.js} (50%) delete mode 100644 app/portainer/oauth/components/oauth-providers-selector/oauth-provider-selector-controller.js create mode 100644 app/portainer/oauth/components/oauth-providers-selector/oauth-provider-selector.controller.js rename app/portainer/oauth/components/oauth-settings/{oauth-settings.js => index.js} (61%) delete mode 100644 app/portainer/oauth/components/oauth-settings/oauth-settings-controller.js create mode 100644 app/portainer/oauth/components/oauth-settings/oauth-settings.controller.js create mode 100644 app/portainer/oauth/components/oauth-settings/providers.js create mode 100644 app/portainer/rbac/components/access-viewer/access-viewer-datatable/access-viewer-datatable.html rename app/portainer/{components/datatables/access-viewer-datatable/accessViewerDatatable.js => rbac/components/access-viewer/access-viewer-datatable/index.js} (56%) create mode 100644 app/portainer/rbac/components/access-viewer/access-viewer.controller.js create mode 100644 app/portainer/rbac/components/access-viewer/access-viewer.html create mode 100644 app/portainer/rbac/components/access-viewer/index.js create mode 100644 app/portainer/rbac/components/roles-datatable/index.js create mode 100644 app/portainer/rbac/components/roles-datatable/roles-datatable.controller.js create mode 100644 app/portainer/rbac/components/roles-datatable/roles-datatable.css create mode 100644 app/portainer/rbac/components/roles-datatable/roles-datatable.html create mode 100644 app/portainer/rbac/index.js create mode 100644 app/portainer/rbac/models/access.js create mode 100644 app/portainer/rbac/models/role.js create mode 100644 app/portainer/rbac/services/role.rest.js create mode 100644 app/portainer/rbac/services/role.service.js create mode 100644 app/portainer/rbac/views/roles/index.js create mode 100644 app/portainer/rbac/views/roles/roles.controller.js create mode 100644 app/portainer/rbac/views/roles/roles.html create mode 100644 app/portainer/settings/authentication/auth-method-constants.js create mode 100644 app/portainer/settings/authentication/auth-type-constants.js create mode 100644 app/portainer/settings/authentication/auto-user-provision-toggle/auto-user-provision-toggle.html create mode 100644 app/portainer/settings/authentication/auto-user-provision-toggle/index.js create mode 100644 app/portainer/settings/authentication/index.js create mode 100644 app/portainer/settings/authentication/ldap/ad-settings/ad-settings.controller.js create mode 100644 app/portainer/settings/authentication/ldap/ad-settings/ad-settings.html create mode 100644 app/portainer/settings/authentication/ldap/ad-settings/index.js create mode 100644 app/portainer/settings/authentication/ldap/index.js create mode 100644 app/portainer/settings/authentication/ldap/ldap-connectivity-check/index.js create mode 100644 app/portainer/settings/authentication/ldap/ldap-connectivity-check/ldap-connectivity-check.html create mode 100644 app/portainer/settings/authentication/ldap/ldap-custom-group-search/index.js create mode 100644 app/portainer/settings/authentication/ldap/ldap-custom-group-search/ldap-custom-group-search.controller.js create mode 100644 app/portainer/settings/authentication/ldap/ldap-custom-group-search/ldap-custom-group-search.html create mode 100644 app/portainer/settings/authentication/ldap/ldap-custom-user-search/index.js create mode 100644 app/portainer/settings/authentication/ldap/ldap-custom-user-search/ldap-custom-user-search.controller.js create mode 100644 app/portainer/settings/authentication/ldap/ldap-custom-user-search/ldap-custom-user-search.html create mode 100644 app/portainer/settings/authentication/ldap/ldap-group-search-item/index.js create mode 100644 app/portainer/settings/authentication/ldap/ldap-group-search-item/ldap-group-search-item.controller.js create mode 100644 app/portainer/settings/authentication/ldap/ldap-group-search-item/ldap-group-search-item.html create mode 100644 app/portainer/settings/authentication/ldap/ldap-group-search/index.js create mode 100644 app/portainer/settings/authentication/ldap/ldap-group-search/ldap-group-search.controller.js create mode 100644 app/portainer/settings/authentication/ldap/ldap-group-search/ldap-group-search.html rename app/portainer/{components/datatables/roles-datatable/rolesDatatable.js => settings/authentication/ldap/ldap-groups-datatable/index.js} (63%) create mode 100644 app/portainer/settings/authentication/ldap/ldap-groups-datatable/ldap-groups-datatable.html create mode 100644 app/portainer/settings/authentication/ldap/ldap-settings-custom/index.js create mode 100644 app/portainer/settings/authentication/ldap/ldap-settings-custom/ldap-settings-custom.controller.js create mode 100644 app/portainer/settings/authentication/ldap/ldap-settings-custom/ldap-settings-custom.html create mode 100644 app/portainer/settings/authentication/ldap/ldap-settings-dn-builder/index.js create mode 100644 app/portainer/settings/authentication/ldap/ldap-settings-dn-builder/ldap-settings-dn-builder.controller.js create mode 100644 app/portainer/settings/authentication/ldap/ldap-settings-dn-builder/ldap-settings-dn-builder.html create mode 100644 app/portainer/settings/authentication/ldap/ldap-settings-group-dn-builder/index.js create mode 100644 app/portainer/settings/authentication/ldap/ldap-settings-group-dn-builder/ldap-settings-group-dn-builder.controller.js create mode 100644 app/portainer/settings/authentication/ldap/ldap-settings-group-dn-builder/ldap-settings-group-dn-builder.html create mode 100644 app/portainer/settings/authentication/ldap/ldap-settings-openldap/index.js create mode 100644 app/portainer/settings/authentication/ldap/ldap-settings-openldap/ldap-settings-openldap.controller.js create mode 100644 app/portainer/settings/authentication/ldap/ldap-settings-openldap/ldap-settings-openldap.html create mode 100644 app/portainer/settings/authentication/ldap/ldap-settings-security/index.js create mode 100644 app/portainer/settings/authentication/ldap/ldap-settings-security/ldap-settings-security.html create mode 100644 app/portainer/settings/authentication/ldap/ldap-settings-test-login/index.js create mode 100644 app/portainer/settings/authentication/ldap/ldap-settings-test-login/ldap-settings-test-login.controller.js create mode 100644 app/portainer/settings/authentication/ldap/ldap-settings-test-login/ldap-settings-test-login.html create mode 100644 app/portainer/settings/authentication/ldap/ldap-settings.model.js create mode 100644 app/portainer/settings/authentication/ldap/ldap-settings/index.js create mode 100644 app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.controller.js create mode 100644 app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.html create mode 100644 app/portainer/settings/authentication/ldap/ldap-user-search-item/index.js create mode 100644 app/portainer/settings/authentication/ldap/ldap-user-search-item/ldap-user-search-item.controller.js create mode 100644 app/portainer/settings/authentication/ldap/ldap-user-search-item/ldap-user-search-item.html create mode 100644 app/portainer/settings/authentication/ldap/ldap-user-search/index.js create mode 100644 app/portainer/settings/authentication/ldap/ldap-user-search/ldap-user-search.controller.js create mode 100644 app/portainer/settings/authentication/ldap/ldap-user-search/ldap-user-search.html create mode 100644 app/portainer/settings/authentication/ldap/ldap-users-datatable/index.js create mode 100644 app/portainer/settings/authentication/ldap/ldap-users-datatable/ldap-users-datatable.html create mode 100644 app/portainer/settings/authentication/ldap/ldap.rest.js create mode 100644 app/portainer/settings/authentication/ldap/ldap.service.js create mode 100644 app/portainer/user-activity/activity-logs-view/activity-logs-datatable/activity-logs-datatable.controller.js create mode 100644 app/portainer/user-activity/activity-logs-view/activity-logs-datatable/activity-logs-datatable.css create mode 100644 app/portainer/user-activity/activity-logs-view/activity-logs-datatable/activity-logs-datatable.html create mode 100644 app/portainer/user-activity/activity-logs-view/activity-logs-datatable/index.js create mode 100644 app/portainer/user-activity/activity-logs-view/activity-logs-view.controller.js create mode 100644 app/portainer/user-activity/activity-logs-view/activity-logs-view.html create mode 100644 app/portainer/user-activity/activity-logs-view/activity-logs-view.js create mode 100644 app/portainer/user-activity/activity-logs-view/index.js create mode 100644 app/portainer/user-activity/auth-logs-view/auth-logs-datatable/auth-logs-datatable.controller.js create mode 100644 app/portainer/user-activity/auth-logs-view/auth-logs-datatable/auth-logs-datatable.html create mode 100644 app/portainer/user-activity/auth-logs-view/auth-logs-datatable/index.js create mode 100644 app/portainer/user-activity/auth-logs-view/auth-logs-view.controller.js create mode 100644 app/portainer/user-activity/auth-logs-view/auth-logs-view.html create mode 100644 app/portainer/user-activity/auth-logs-view/auth-logs-view.js create mode 100644 app/portainer/user-activity/auth-logs-view/index.js create mode 100644 app/portainer/user-activity/index.js delete mode 100644 app/portainer/views/roles/roles.html diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 8ea0e3792..df6333f6a 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -18,6 +18,7 @@ import ( "github.com/portainer/portainer/api/http/handler/file" "github.com/portainer/portainer/api/http/handler/helm" "github.com/portainer/portainer/api/http/handler/kubernetes" + "github.com/portainer/portainer/api/http/handler/ldap" "github.com/portainer/portainer/api/http/handler/motd" "github.com/portainer/portainer/api/http/handler/registries" "github.com/portainer/portainer/api/http/handler/resourcecontrols" @@ -53,6 +54,7 @@ type Handler struct { HelmTemplatesHandler *helm.Handler KubernetesHandler *kubernetes.Handler FileHandler *file.Handler + LDAPHandler *ldap.Handler MOTDHandler *motd.Handler RegistryHandler *registries.Handler ResourceControlHandler *resourcecontrols.Handler @@ -189,6 +191,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { default: http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r) } + case strings.HasPrefix(r.URL.Path, "/api/ldap"): + http.StripPrefix("/api", h.LDAPHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/motd"): http.StripPrefix("/api", h.MOTDHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/registries"): diff --git a/api/http/handler/ldap/handler.go b/api/http/handler/ldap/handler.go new file mode 100644 index 000000000..aac809cca --- /dev/null +++ b/api/http/handler/ldap/handler.go @@ -0,0 +1,53 @@ +package ldap + +import ( + "net/http" + + "github.com/gorilla/mux" + httperror "github.com/portainer/libhttp/error" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/api/http/security" +) + +// Handler is the HTTP handler used to handle LDAP search Operations +type Handler struct { + *mux.Router + DataStore portainer.DataStore + FileService portainer.FileService + LDAPService portainer.LDAPService +} + +// NewHandler returns a new Handler +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + + h.Handle("/ldap/check", + bouncer.AdminAccess(httperror.LoggerHandler(h.ldapCheck))).Methods(http.MethodPost) + + return h +} + +func (handler *Handler) prefillSettings(ldapSettings *portainer.LDAPSettings) error { + if !ldapSettings.AnonymousMode && ldapSettings.Password == "" { + settings, err := handler.DataStore.Settings().Settings() + if err != nil { + return err + } + + ldapSettings.Password = settings.LDAPSettings.Password + } + + if (ldapSettings.TLSConfig.TLS || ldapSettings.StartTLS) && !ldapSettings.TLSConfig.TLSSkipVerify { + caCertPath, err := handler.FileService.GetPathForTLSFile(filesystem.LDAPStorePath, portainer.TLSFileCA) + if err != nil { + return err + } + + ldapSettings.TLSConfig.TLSCACertPath = caCertPath + } + + return nil +} diff --git a/api/http/handler/settings/settings_ldap_check.go b/api/http/handler/ldap/ldap_check.go similarity index 50% rename from api/http/handler/settings/settings_ldap_check.go rename to api/http/handler/ldap/ldap_check.go index 2ef1fb4d2..36c25c407 100644 --- a/api/http/handler/settings/settings_ldap_check.go +++ b/api/http/handler/ldap/ldap_check.go @@ -1,4 +1,4 @@ -package settings +package ldap import ( "net/http" @@ -7,42 +7,43 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/filesystem" ) -type settingsLDAPCheckPayload struct { +type checkPayload struct { LDAPSettings portainer.LDAPSettings } -func (payload *settingsLDAPCheckPayload) Validate(r *http.Request) error { +func (payload *checkPayload) Validate(r *http.Request) error { return nil } -// @id SettingsLDAPCheck +// @id LDAPCheck // @summary Test LDAP connectivity // @description Test LDAP connectivity using LDAP details // @description **Access policy**: administrator -// @tags settings +// @tags ldap // @security jwt // @accept json -// @param body body settingsLDAPCheckPayload true "details" +// @param body body checkPayload true "details" // @success 204 "Success" // @failure 400 "Invalid request" // @failure 500 "Server error" -// @router /settings/ldap/check [put] -func (handler *Handler) settingsLDAPCheck(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - var payload settingsLDAPCheckPayload +// @router /ldap/check [post] +func (handler *Handler) ldapCheck(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload checkPayload err := request.DecodeAndValidateJSONPayload(r, &payload) if err != nil { return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - if (payload.LDAPSettings.TLSConfig.TLS || payload.LDAPSettings.StartTLS) && !payload.LDAPSettings.TLSConfig.TLSSkipVerify { - caCertPath, _ := handler.FileService.GetPathForTLSFile(filesystem.LDAPStorePath, portainer.TLSFileCA) - payload.LDAPSettings.TLSConfig.TLSCACertPath = caCertPath + settings := &payload.LDAPSettings + + err = handler.prefillSettings(settings) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to fetch default settings", err} } - err = handler.LDAPService.TestConnectivity(&payload.LDAPSettings) + err = handler.LDAPService.TestConnectivity(settings) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to connect to LDAP server", err} } diff --git a/api/http/handler/settings/handler.go b/api/http/handler/settings/handler.go index 9fa17b842..cbb159fca 100644 --- a/api/http/handler/settings/handler.go +++ b/api/http/handler/settings/handler.go @@ -5,7 +5,7 @@ import ( "github.com/gorilla/mux" httperror "github.com/portainer/libhttp/error" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" ) @@ -35,8 +35,6 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { bouncer.AdminAccess(httperror.LoggerHandler(h.settingsUpdate))).Methods(http.MethodPut) h.Handle("/settings/public", bouncer.PublicAccess(httperror.LoggerHandler(h.settingsPublic))).Methods(http.MethodGet) - h.Handle("/settings/authentication/checkLDAP", - bouncer.AdminAccess(httperror.LoggerHandler(h.settingsLDAPCheck))).Methods(http.MethodPut) return h } diff --git a/api/http/server.go b/api/http/server.go index ac6f1d79b..3dfe047f3 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -29,6 +29,7 @@ import ( "github.com/portainer/portainer/api/http/handler/file" "github.com/portainer/portainer/api/http/handler/helm" kubehandler "github.com/portainer/portainer/api/http/handler/kubernetes" + "github.com/portainer/portainer/api/http/handler/ldap" "github.com/portainer/portainer/api/http/handler/motd" "github.com/portainer/portainer/api/http/handler/registries" "github.com/portainer/portainer/api/http/handler/resourcecontrols" @@ -175,6 +176,11 @@ func (server *Server) Start() error { var helmTemplatesHandler = helm.NewTemplateHandler(requestBouncer, server.HelmPackageManager) + var ldapHandler = ldap.NewHandler(requestBouncer) + ldapHandler.DataStore = server.DataStore + ldapHandler.FileService = server.FileService + ldapHandler.LDAPService = server.LDAPService + var motdHandler = motd.NewHandler(requestBouncer) var registryHandler = registries.NewHandler(requestBouncer) @@ -255,6 +261,7 @@ func (server *Server) Start() error { EndpointEdgeHandler: endpointEdgeHandler, EndpointProxyHandler: endpointProxyHandler, FileHandler: fileHandler, + LDAPHandler: ldapHandler, HelmTemplatesHandler: helmTemplatesHandler, KubernetesHandler: kubernetesHandler, MOTDHandler: motdHandler, diff --git a/api/ldap/ldap.go b/api/ldap/ldap.go index 89c08ec61..21358816c 100644 --- a/api/ldap/ldap.go +++ b/api/ldap/ldap.go @@ -1,11 +1,11 @@ package ldap import ( - "errors" "fmt" "strings" ldap "github.com/go-ldap/ldap/v3" + "github.com/pkg/errors" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/crypto" httperrors "github.com/portainer/portainer/api/http/errors" @@ -20,55 +20,28 @@ var ( // Service represents a service used to authenticate users against a LDAP/AD. type Service struct{} -func searchUser(username string, conn *ldap.Conn, settings []portainer.LDAPSearchSettings) (string, error) { - var userDN string - found := false - usernameEscaped := ldap.EscapeFilter(username) - - for _, searchSettings := range settings { - searchRequest := ldap.NewSearchRequest( - searchSettings.BaseDN, - ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - fmt.Sprintf("(&%s(%s=%s))", searchSettings.Filter, searchSettings.UserNameAttribute, usernameEscaped), - []string{"dn"}, - nil, - ) - - // Deliberately skip errors on the search request so that we can jump to other search settings - // if any issue arise with the current one. - sr, err := conn.Search(searchRequest) - if err != nil { - continue - } - - if len(sr.Entries) == 1 { - found = true - userDN = sr.Entries[0].DN - break - } - } - - if !found { - return "", errUserNotFound +func createConnection(settings *portainer.LDAPSettings) (*ldap.Conn, error) { + conn, err := createConnectionForURL(settings.URL, settings) + if err != nil { + return nil, errors.Wrap(err, "failed creating LDAP connection") } - return userDN, nil + return conn, nil } -func createConnection(settings *portainer.LDAPSettings) (*ldap.Conn, error) { - +func createConnectionForURL(url string, settings *portainer.LDAPSettings) (*ldap.Conn, error) { if settings.TLSConfig.TLS || settings.StartTLS { config, err := crypto.CreateTLSConfigurationFromDisk(settings.TLSConfig.TLSCACertPath, settings.TLSConfig.TLSCertPath, settings.TLSConfig.TLSKeyPath, settings.TLSConfig.TLSSkipVerify) if err != nil { return nil, err } - config.ServerName = strings.Split(settings.URL, ":")[0] + config.ServerName = strings.Split(url, ":")[0] if settings.TLSConfig.TLS { - return ldap.DialTLS("tcp", settings.URL, config) + return ldap.DialTLS("tcp", url, config) } - conn, err := ldap.Dial("tcp", settings.URL) + conn, err := ldap.Dial("tcp", url) if err != nil { return nil, err } @@ -81,7 +54,7 @@ func createConnection(settings *portainer.LDAPSettings) (*ldap.Conn, error) { return conn, nil } - return ldap.Dial("tcp", settings.URL) + return ldap.Dial("tcp", url) } // AuthenticateUser is used to authenticate a user against a LDAP/AD. @@ -133,13 +106,157 @@ func (*Service) GetUserGroups(username string, settings *portainer.LDAPSettings) return nil, err } - userGroups := getGroups(userDN, connection, settings.GroupSearchSettings) + userGroups := getGroupsByUser(userDN, connection, settings.GroupSearchSettings) return userGroups, nil } +// SearchUsers searches for users with the specified settings +func (*Service) SearchUsers(settings *portainer.LDAPSettings) ([]string, error) { + connection, err := createConnection(settings) + if err != nil { + return nil, err + } + defer connection.Close() + + if !settings.AnonymousMode { + err = connection.Bind(settings.ReaderDN, settings.Password) + if err != nil { + return nil, err + } + } + + users := map[string]bool{} + + for _, searchSettings := range settings.SearchSettings { + searchRequest := ldap.NewSearchRequest( + searchSettings.BaseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + searchSettings.Filter, + []string{"dn", searchSettings.UserNameAttribute}, + nil, + ) + + sr, err := connection.Search(searchRequest) + if err != nil { + return nil, err + } + + for _, user := range sr.Entries { + username := user.GetAttributeValue(searchSettings.UserNameAttribute) + if username != "" { + users[username] = true + } + } + } + + usersList := []string{} + for user := range users { + usersList = append(usersList, user) + } + + return usersList, nil +} + +// SearchGroups searches for groups with the specified settings +func (*Service) SearchGroups(settings *portainer.LDAPSettings) ([]portainer.LDAPUser, error) { + type groupSet map[string]bool + + connection, err := createConnection(settings) + if err != nil { + return nil, err + } + defer connection.Close() + + if !settings.AnonymousMode { + err = connection.Bind(settings.ReaderDN, settings.Password) + if err != nil { + return nil, err + } + } + + userGroups := map[string]groupSet{} + + for _, searchSettings := range settings.GroupSearchSettings { + searchRequest := ldap.NewSearchRequest( + searchSettings.GroupBaseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + searchSettings.GroupFilter, + []string{"cn", searchSettings.GroupAttribute}, + nil, + ) + + sr, err := connection.Search(searchRequest) + if err != nil { + return nil, err + } + + for _, entry := range sr.Entries { + members := entry.GetAttributeValues(searchSettings.GroupAttribute) + for _, username := range members { + _, ok := userGroups[username] + if !ok { + userGroups[username] = groupSet{} + } + userGroups[username][entry.GetAttributeValue("cn")] = true + } + } + } + + users := []portainer.LDAPUser{} + + for username, groups := range userGroups { + groupList := []string{} + for group := range groups { + groupList = append(groupList, group) + } + user := portainer.LDAPUser{ + Name: username, + Groups: groupList, + } + users = append(users, user) + } + + return users, nil +} + +func searchUser(username string, conn *ldap.Conn, settings []portainer.LDAPSearchSettings) (string, error) { + var userDN string + found := false + usernameEscaped := ldap.EscapeFilter(username) + + for _, searchSettings := range settings { + searchRequest := ldap.NewSearchRequest( + searchSettings.BaseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + fmt.Sprintf("(&%s(%s=%s))", searchSettings.Filter, searchSettings.UserNameAttribute, usernameEscaped), + []string{"dn"}, + nil, + ) + + // Deliberately skip errors on the search request so that we can jump to other search settings + // if any issue arise with the current one. + sr, err := conn.Search(searchRequest) + if err != nil { + continue + } + + if len(sr.Entries) == 1 { + found = true + userDN = sr.Entries[0].DN + break + } + } + + if !found { + return "", errUserNotFound + } + + return userDN, nil +} + // Get a list of group names for specified user from LDAP/AD -func getGroups(userDN string, conn *ldap.Conn, settings []portainer.LDAPGroupSearchSettings) []string { +func getGroupsByUser(userDN string, conn *ldap.Conn, settings []portainer.LDAPGroupSearchSettings) []string { groups := make([]string, 0) userDNEscaped := ldap.EscapeFilter(userDN) @@ -179,9 +296,18 @@ func (*Service) TestConnectivity(settings *portainer.LDAPSettings) error { } defer connection.Close() - err = connection.Bind(settings.ReaderDN, settings.Password) - if err != nil { - return err + if !settings.AnonymousMode { + err = connection.Bind(settings.ReaderDN, settings.Password) + if err != nil { + return err + } + + } else { + err = connection.UnauthenticatedBind("") + if err != nil { + return err + } } + return nil } diff --git a/api/portainer.go b/api/portainer.go index 4fa7430aa..50f2d1ac0 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -513,6 +513,12 @@ type ( AutoCreateUsers bool `json:"AutoCreateUsers" example:"true"` } + // LDAPUser represents a LDAP user + LDAPUser struct { + Name string + Groups []string + } + // LicenseInformation represents information about an extension license LicenseInformation struct { LicenseKey string `json:"LicenseKey,omitempty"` @@ -1295,6 +1301,8 @@ type ( AuthenticateUser(username, password string, settings *LDAPSettings) error TestConnectivity(settings *LDAPSettings) error GetUserGroups(username string, settings *LDAPSettings) ([]string, error) + SearchGroups(settings *LDAPSettings) ([]LDAPUser, error) + SearchUsers(settings *LDAPSettings) ([]string, error) } // OAuthService represents a service used to authenticate users using OAuth diff --git a/app/assets/css/app.css b/app/assets/css/app.css index c75fcd32d..c9b2de5a9 100644 --- a/app/assets/css/app.css +++ b/app/assets/css/app.css @@ -816,10 +816,6 @@ json-tree .branch-preview { } /* !spinkit override */ -.w-full { - width: 100%; -} - /* uib-typeahead override */ #scrollable-dropdown-menu .dropdown-menu { max-height: 300px; @@ -827,17 +823,33 @@ json-tree .branch-preview { } /* !uib-typeahead override */ -.my-8 { - margin-top: 2rem; - margin-bottom: 2rem; -} - .kubectl-shell { display: block; text-align: center; padding-bottom: 5px; } +.w-full { + width: 100%; +} + +.flex { + display: flex; +} + +.block { + display: block; +} + +.items-center { + align-items: center; +} + +.my-8 { + margin-top: 2rem; + margin-bottom: 2rem; +} + .text-wrap { word-break: break-all; white-space: normal; diff --git a/app/assets/css/theme.css b/app/assets/css/theme.css index 902fbd2c8..17e94309b 100644 --- a/app/assets/css/theme.css +++ b/app/assets/css/theme.css @@ -89,8 +89,13 @@ html { --green-1: #164; --green-2: #1ec863; --green-3: #23ae89; + + --orange-1: #e86925; + + --BE-only: var(--orange-1); } +/* Default Theme */ :root { --bg-card-color: var(--grey-10); --bg-main-color: var(--white-color); diff --git a/app/assets/css/vendor-override.css b/app/assets/css/vendor-override.css index 5b0152acc..ddcd04356 100644 --- a/app/assets/css/vendor-override.css +++ b/app/assets/css/vendor-override.css @@ -401,3 +401,14 @@ input:-webkit-autofill { color: var(--white-color); } /* Overide Vendor CSS */ + +.btn.disabled, +.btn[disabled], +fieldset[disabled] .btn { + pointer-events: none; + touch-action: none; +} + +.multiSelect.inlineBlock button { + margin: 0; +} diff --git a/app/kubernetes/views/configure/configure.html b/app/kubernetes/views/configure/configure.html index 0a02767d4..b40814156 100644 --- a/app/kubernetes/views/configure/configure.html +++ b/app/kubernetes/views/configure/configure.html @@ -178,14 +178,14 @@

- - - - - This feature is available in Portainer Business Edition. - +
diff --git a/app/kubernetes/views/configure/configureController.js b/app/kubernetes/views/configure/configureController.js index 4e38cd27d..ac9468e2b 100644 --- a/app/kubernetes/views/configure/configureController.js +++ b/app/kubernetes/views/configure/configureController.js @@ -6,7 +6,7 @@ import { KubernetesIngressClass } from 'Kubernetes/ingress/models'; import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper'; import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants'; import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; - +import { K8S_SETUP_DEFAULT } from '@/portainer/feature-flags/feature-ids'; class KubernetesConfigureController { /* #region CONSTRUCTOR */ @@ -38,6 +38,7 @@ class KubernetesConfigureController { this.onInit = this.onInit.bind(this); this.configureAsync = this.configureAsync.bind(this); + this.limitedFeature = K8S_SETUP_DEFAULT; } /* #endregion */ diff --git a/app/kubernetes/views/resource-pools/create/createResourcePool.html b/app/kubernetes/views/resource-pools/create/createResourcePool.html index 6a26f36f2..4ab2b8c84 100644 --- a/app/kubernetes/views/resource-pools/create/createResourcePool.html +++ b/app/kubernetes/views/resource-pools/create/createResourcePool.html @@ -162,14 +162,13 @@
- - - - - This feature is available in Portainer Business Edition. - +
@@ -192,15 +191,13 @@
- - - - - This feature is available in - Portainer Business Edition. - +
diff --git a/app/kubernetes/views/resource-pools/create/createResourcePoolController.js b/app/kubernetes/views/resource-pools/create/createResourcePoolController.js index 92149f4f9..a352b0a9a 100644 --- a/app/kubernetes/views/resource-pools/create/createResourcePoolController.js +++ b/app/kubernetes/views/resource-pools/create/createResourcePoolController.js @@ -12,6 +12,8 @@ import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHel import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues'; import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants'; +import { K8S_RESOURCE_POOL_LB_QUOTA, K8S_RESOURCE_POOL_STORAGE_QUOTA } from '@/portainer/feature-flags/feature-ids'; + class KubernetesCreateResourcePoolController { /* #region CONSTRUCTOR */ /* @ngInject */ @@ -28,6 +30,8 @@ class KubernetesCreateResourcePoolController { }); this.IngressClassTypes = KubernetesIngressClassTypes; + this.LBQuotaFeatureId = K8S_RESOURCE_POOL_LB_QUOTA; + this.StorageQuotaFeatureId = K8S_RESOURCE_POOL_STORAGE_QUOTA; } /* #endregion */ diff --git a/app/kubernetes/views/resource-pools/edit/resourcePool.html b/app/kubernetes/views/resource-pools/edit/resourcePool.html index d847527e0..45963b455 100644 --- a/app/kubernetes/views/resource-pools/edit/resourcePool.html +++ b/app/kubernetes/views/resource-pools/edit/resourcePool.html @@ -146,14 +146,13 @@
- - - - - This feature is available in Portainer Business Edition. - +
@@ -389,15 +388,13 @@
- - - - - This feature is available in - Portainer Business Edition. - +
diff --git a/app/kubernetes/views/resource-pools/edit/resourcePoolController.js b/app/kubernetes/views/resource-pools/edit/resourcePoolController.js index d35600c32..b2aefdc93 100644 --- a/app/kubernetes/views/resource-pools/edit/resourcePoolController.js +++ b/app/kubernetes/views/resource-pools/edit/resourcePoolController.js @@ -16,6 +16,7 @@ import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHel import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants'; import KubernetesResourceQuotaConverter from 'Kubernetes/converters/resourceQuota'; import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; +import { K8S_RESOURCE_POOL_LB_QUOTA, K8S_RESOURCE_POOL_STORAGE_QUOTA } from '@/portainer/feature-flags/feature-ids'; class KubernetesResourcePoolController { /* #region CONSTRUCTOR */ @@ -60,6 +61,9 @@ class KubernetesResourcePoolController { this.IngressClassTypes = KubernetesIngressClassTypes; this.ResourceQuotaDefaults = KubernetesResourceQuotaDefaults; + this.LBQuotaFeatureId = K8S_RESOURCE_POOL_LB_QUOTA; + this.StorageQuotaFeatureId = K8S_RESOURCE_POOL_STORAGE_QUOTA; + this.updateResourcePoolAsync = this.updateResourcePoolAsync.bind(this); this.getEvents = this.getEvents.bind(this); } diff --git a/app/portainer/__module.js b/app/portainer/__module.js index 3ee842a5a..7124cf7b0 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -1,7 +1,10 @@ import _ from 'lodash-es'; +import './rbac'; import componentsModule from './components'; import settingsModule from './settings'; +import featureFlagModule from './feature-flags'; +import userActivityModule from './user-activity'; async function initAuthentication(authManager, Authentication, $rootScope, $state) { authManager.checkAuthOnRefresh(); @@ -18,7 +21,7 @@ async function initAuthentication(authManager, Authentication, $rootScope, $stat return await Authentication.init(); } -angular.module('portainer.app', ['portainer.oauth', componentsModule, settingsModule]).config([ +angular.module('portainer.app', ['portainer.oauth', 'portainer.rbac', componentsModule, settingsModule, featureFlagModule, userActivityModule, 'portainer.shared.datatable']).config([ '$stateRegistryProvider', function ($stateRegistryProvider) { 'use strict'; @@ -51,6 +54,18 @@ angular.module('portainer.app', ['portainer.oauth', componentsModule, settingsMo controller: 'SidebarController', }, }, + resolve: { + featuresServiceInitialized: /* @ngInject */ function featuresServiceInitialized($async, featureService, Notifications) { + return $async(async () => { + try { + await featureService.init(); + } catch (e) { + Notifications.error('Failed initializing features service', e); + throw e; + } + }); + }, + }, }; var endpointRoot = { @@ -403,16 +418,6 @@ angular.module('portainer.app', ['portainer.oauth', componentsModule, settingsMo }, }; - var roles = { - name: 'portainer.roles', - url: '/roles', - views: { - 'content@': { - templateUrl: './views/roles/roles.html', - }, - }, - }; - $stateRegistryProvider.register(root); $stateRegistryProvider.register(endpointRoot); $stateRegistryProvider.register(portainer); @@ -444,7 +449,6 @@ angular.module('portainer.app', ['portainer.oauth', componentsModule, settingsMo $stateRegistryProvider.register(user); $stateRegistryProvider.register(teams); $stateRegistryProvider.register(team); - $stateRegistryProvider.register(roles); }, ]); diff --git a/app/portainer/components/accessManagement/por-access-management-users-selector/por-access-management-users-selector.html b/app/portainer/components/accessManagement/por-access-management-users-selector/por-access-management-users-selector.html index 14471129b..5b1ad6e66 100644 --- a/app/portainer/components/accessManagement/por-access-management-users-selector/por-access-management-users-selector.html +++ b/app/portainer/components/accessManagement/por-access-management-users-selector/por-access-management-users-selector.html @@ -11,8 +11,8 @@ ng-if="$ctrl.options.length > 0" input-model="$ctrl.options" output-model="$ctrl.value" - button-label="icon '-' Name" - item-label="icon '-' Name" + button-label="icon Name" + item-label="icon Name" tick-property="ticked" helper-elements="filter" search-property="Name" diff --git a/app/portainer/components/accessManagement/por-access-management.js b/app/portainer/components/accessManagement/por-access-management.js index 7c8cf34ab..0ab867014 100644 --- a/app/portainer/components/accessManagement/por-access-management.js +++ b/app/portainer/components/accessManagement/por-access-management.js @@ -9,5 +9,6 @@ export const porAccessManagement = { updateAccess: '<', actionInProgress: '<', filterUsers: '<', + limitedFeature: '<', }, }; diff --git a/app/portainer/components/accessManagement/porAccessManagement.html b/app/portainer/components/accessManagement/porAccessManagement.html index 077df83d7..b8670486e 100644 --- a/app/portainer/components/accessManagement/porAccessManagement.html +++ b/app/portainer/components/accessManagement/porAccessManagement.html @@ -4,17 +4,31 @@
+
+ +

+ + Adding user access will require the affected user(s) to logout and login for the changes to be taken into account. +

+
+
+ -
+
-
- - - This feature is available in Portainer Business Edition. - +
+
+ + +
@@ -48,6 +62,10 @@ title-icon="fa-user-lock" table-key="{{ 'access_' + ctrl.entityType }}" order-by="Name" + show-warning="ctrl.entityType !== 'registry'" + is-update-enabled="ctrl.entityType !== 'registry'" + show-roles="ctrl.entityType !== 'registry'" + roles="ctrl.roles" inherit-from="ctrl.inheritFrom" dataset="ctrl.authorizedUsersAndTeams" update-action="ctrl.updateAction" diff --git a/app/portainer/components/accessManagement/porAccessManagementController.js b/app/portainer/components/accessManagement/porAccessManagementController.js index 8c1e5d524..2b4c8bc82 100644 --- a/app/portainer/components/accessManagement/porAccessManagementController.js +++ b/app/portainer/components/accessManagement/porAccessManagementController.js @@ -1,12 +1,14 @@ import _ from 'lodash-es'; - import angular from 'angular'; +import { RoleTypes } from '@/portainer/rbac/models/role'; + class PorAccessManagementController { /* @ngInject */ - constructor(Notifications, AccessService) { - this.Notifications = Notifications; - this.AccessService = AccessService; + constructor(Notifications, AccessService, RoleService, featureService) { + Object.assign(this, { Notifications, AccessService, RoleService, featureService }); + + this.limitedToBE = false; this.unauthorizeAccess = this.unauthorizeAccess.bind(this); this.updateAction = this.updateAction.bind(this); @@ -29,10 +31,11 @@ class PorAccessManagementController { const entity = this.accessControlledEntity; const oldUserAccessPolicies = entity.UserAccessPolicies; const oldTeamAccessPolicies = entity.TeamAccessPolicies; + const selectedRoleId = this.formValues.selectedRole.Id; const selectedUserAccesses = _.filter(this.formValues.multiselectOutput, (access) => access.Type === 'user'); const selectedTeamAccesses = _.filter(this.formValues.multiselectOutput, (access) => access.Type === 'team'); - const accessPolicies = this.AccessService.generateAccessPolicies(oldUserAccessPolicies, oldTeamAccessPolicies, selectedUserAccesses, selectedTeamAccesses, 0); + const accessPolicies = this.AccessService.generateAccessPolicies(oldUserAccessPolicies, oldTeamAccessPolicies, selectedUserAccesses, selectedTeamAccesses, selectedRoleId); this.accessControlledEntity.UserAccessPolicies = accessPolicies.userAccessPolicies; this.accessControlledEntity.TeamAccessPolicies = accessPolicies.teamAccessPolicies; this.updateAccess(); @@ -50,11 +53,41 @@ class PorAccessManagementController { this.updateAccess(); } + isRoleLimitedToBE(role) { + if (!this.limitedToBE) { + return false; + } + + return role.ID !== RoleTypes.STANDARD; + } + + roleLabel(role) { + if (!this.limitedToBE) { + return role.Name; + } + + if (this.isRoleLimitedToBE(role)) { + return `${role.Name} (Business Edition Feature)`; + } + + return `${role.Name} (Default)`; + } + async $onInit() { try { + if (this.limitedFeature) { + this.limitedToBE = this.featureService.isLimitedToBE(this.limitedFeature); + } + const entity = this.accessControlledEntity; const parent = this.inheritFrom; + const roles = await this.RoleService.roles(); + this.roles = _.orderBy(roles, 'Priority', 'asc'); + this.formValues = { + selectedRole: this.roles.find((role) => !this.isRoleLimitedToBE(role)), + }; + const data = await this.AccessService.accesses(entity, parent, this.roles); if (this.filterUsers) { diff --git a/app/portainer/components/be-feature-indicator/be-feature-indicator.controller.js b/app/portainer/components/be-feature-indicator/be-feature-indicator.controller.js new file mode 100644 index 000000000..5d7a71cbf --- /dev/null +++ b/app/portainer/components/be-feature-indicator/be-feature-indicator.controller.js @@ -0,0 +1,18 @@ +const BE_URL = 'https://www.portainer.io/business-upsell?from='; + +export default class BeIndicatorController { + /* @ngInject */ + constructor(featureService) { + Object.assign(this, { featureService }); + + this.limitedToBE = false; + } + + $onInit() { + if (this.feature) { + this.url = `${BE_URL}${this.feature}`; + + this.limitedToBE = this.featureService.isLimitedToBE(this.feature); + } + } +} diff --git a/app/portainer/components/be-feature-indicator/be-feature-indicator.css b/app/portainer/components/be-feature-indicator/be-feature-indicator.css new file mode 100644 index 000000000..33e4eb1da --- /dev/null +++ b/app/portainer/components/be-feature-indicator/be-feature-indicator.css @@ -0,0 +1,26 @@ +.be-indicator { + border: solid 1px var(--BE-only); + border-radius: 15px; + padding: 5px 10px; + font-weight: 400; + touch-action: all; + pointer-events: all; + white-space: nowrap; +} + +.be-indicator .be-indicator-icon { + color: #000000; +} + +.be-indicator:hover { + text-decoration: none; +} + +.be-indicator:hover .be-indicator-label { + text-decoration: underline; +} + +.be-indicator-container { + border: solid 1px var(--BE-only); + margin: 15px; +} diff --git a/app/portainer/components/be-feature-indicator/be-feature-indicator.html b/app/portainer/components/be-feature-indicator/be-feature-indicator.html new file mode 100644 index 000000000..89261049f --- /dev/null +++ b/app/portainer/components/be-feature-indicator/be-feature-indicator.html @@ -0,0 +1,5 @@ + + + + Business Edition Feature + diff --git a/app/portainer/components/be-feature-indicator/index.js b/app/portainer/components/be-feature-indicator/index.js new file mode 100644 index 000000000..6d214f2d2 --- /dev/null +++ b/app/portainer/components/be-feature-indicator/index.js @@ -0,0 +1,15 @@ +import angular from 'angular'; +import controller from './be-feature-indicator.controller.js'; + +import './be-feature-indicator.css'; + +export const beFeatureIndicator = { + templateUrl: './be-feature-indicator.html', + controller, + bindings: { + feature: '<', + }, + transclude: true, +}; + +angular.module('portainer.app').component('beFeatureIndicator', beFeatureIndicator); diff --git a/app/portainer/components/box-selector/box-selector-item/box-selector-item.controller.js b/app/portainer/components/box-selector/box-selector-item/box-selector-item.controller.js new file mode 100644 index 000000000..720f67afe --- /dev/null +++ b/app/portainer/components/box-selector/box-selector-item/box-selector-item.controller.js @@ -0,0 +1,23 @@ +export default class BoxSelectorItemController { + /* @ngInject */ + constructor(featureService) { + Object.assign(this, { featureService }); + + this.limitedToBE = false; + } + + handleChange(value) { + this.formCtrl.$setValidity(this.radioName, !this.limitedToBE, this.formCtrl); + this.onChange(value); + } + + $onInit() { + if (this.option.feature) { + this.limitedToBE = this.featureService.isLimitedToBE(this.option.feature); + } + } + + $onDestroy() { + this.formCtrl.$setValidity(this.radioName, true, this.formCtrl); + } +} diff --git a/app/portainer/components/box-selector/box-selector-item/box-selector-item.css b/app/portainer/components/box-selector/box-selector-item/box-selector-item.css new file mode 100644 index 000000000..936879064 --- /dev/null +++ b/app/portainer/components/box-selector/box-selector-item/box-selector-item.css @@ -0,0 +1,117 @@ +.boxselector_wrapper > div, +.boxselector_wrapper box-selector-item { + --selected-item-color: var(--blue-2); + flex: 1; + padding: 0.5rem; +} + +.boxselector_wrapper .boxselector_header { + font-size: 14px; + margin-bottom: 5px; + font-weight: bold; + user-select: none; +} + +.boxselector_header .fa, +.fab { + font-weight: normal; +} + +.boxselector_wrapper input[type='radio'] { + display: none; +} + +.boxselector_wrapper input[type='radio']:not(:disabled) ~ label { + cursor: pointer; + background-color: var(--bg-boxselector-wrapper-disabled-color); +} + +.boxselector_wrapper input[type='radio']:not(:disabled):hover ~ label:hover { + cursor: pointer; +} + +.boxselector_wrapper label { + font-weight: normal; + font-size: 12px; + display: block; + background: var(--bg-boxselector-color); + border: 1px solid var(--border-boxselector-color); + border-radius: 2px; + padding: 10px 10px 0 10px; + text-align: center; + box-shadow: var(--shadow-boxselector-color); + position: relative; +} + +.box-selector-item input:disabled + label, +.boxselector_wrapper label.boxselector_disabled { + background: var(--bg-boxselector-disabled-color) !important; + border-color: #787878; + color: #787878; + cursor: not-allowed; + pointer-events: none; +} + +.boxselector_wrapper input[type='radio']:checked + label { + background: var(--selected-item-color); + color: white; + padding-top: 2rem; + border-color: var(--selected-item-color); +} + +.boxselector_wrapper input[type='radio']:checked + label::after { + color: var(--selected-item-color); + font-family: 'Font Awesome 5 Free'; + border: 2px solid var(--selected-item-color); + content: '\f00c'; + font-size: 16px; + font-weight: bold; + position: absolute; + top: -15px; + left: 50%; + transform: translateX(-50%); + height: 30px; + width: 30px; + line-height: 26px; + text-align: center; + border-radius: 50%; + background: white; + box-shadow: 0 2px 5px -2px rgba(0, 0, 0, 0.25); +} + +@media only screen and (max-width: 700px) { + .boxselector_wrapper { + flex-direction: column; + } +} + +.box-selector-item-description { + height: 1em; +} + +.box-selector-item.limited.business { + --selected-item-color: var(--BE-only); +} + +.box-selector-item.limited.business label { + border-color: var(--BE-only); + border-width: 2px; +} + +.box-selector-item .limited-icon { + position: absolute; + left: 1em; + top: calc(50% - 0.5em); + height: 1em; +} + +@media (min-width: 992px) { + .box-selector-item .limited-icon { + left: 2em; + } +} + +.box-selector-item.limited.business :checked + label { + background-color: initial; + color: initial; +} diff --git a/app/portainer/components/box-selector/box-selector-item/box-selector-item.html b/app/portainer/components/box-selector/box-selector-item/box-selector-item.html index c0aec093a..3fa7c8107 100644 --- a/app/portainer/components/box-selector/box-selector-item/box-selector-item.html +++ b/app/portainer/components/box-selector/box-selector-item/box-selector-item.html @@ -1,5 +1,6 @@
-
diff --git a/app/portainer/components/box-selector/box-selector-item/index.js b/app/portainer/components/box-selector/box-selector-item/index.js index 91756c5ad..f43c0a439 100644 --- a/app/portainer/components/box-selector/box-selector-item/index.js +++ b/app/portainer/components/box-selector/box-selector-item/index.js @@ -1,7 +1,15 @@ import angular from 'angular'; +import './box-selector-item.css'; + +import controller from './box-selector-item.controller'; + angular.module('portainer.app').component('boxSelectorItem', { templateUrl: './box-selector-item.html', + controller, + require: { + formCtrl: '^^form', + }, bindings: { radioName: '@', isChecked: '<', diff --git a/app/portainer/components/box-selector/box-selector.controller.js b/app/portainer/components/box-selector/box-selector.controller.js index 3b3c60d49..8d7d65fff 100644 --- a/app/portainer/components/box-selector/box-selector.controller.js +++ b/app/portainer/components/box-selector/box-selector.controller.js @@ -4,10 +4,10 @@ export default class BoxSelectorController { this.change = this.change.bind(this); } - change(value) { + change(value, limited) { this.ngModel = value; if (this.onChange) { - this.onChange(value); + this.onChange(value, limited); } } diff --git a/app/portainer/components/box-selector/box-selector.css b/app/portainer/components/box-selector/box-selector.css index 2fded1581..58b64f790 100644 --- a/app/portainer/components/box-selector/box-selector.css +++ b/app/portainer/components/box-selector/box-selector.css @@ -3,89 +3,3 @@ flex-flow: row wrap; margin: 0.5rem; } - -.boxselector_wrapper > div, -.boxselector_wrapper box-selector-item { - flex: 1; - padding: 0.5rem; -} - -.boxselector_wrapper .boxselector_header { - font-size: 14px; - margin-bottom: 5px; - font-weight: bold; - user-select: none; -} - -.boxselector_header .fa, -.fab { - font-weight: normal; -} - -.boxselector_wrapper input[type='radio'] { - display: none; -} - -.boxselector_wrapper input[type='radio']:not(:disabled) ~ label { - cursor: pointer; - background-color: var(--bg-boxselector-wrapper-disabled-color); -} - -.boxselector_wrapper input[type='radio']:not(:disabled):hover ~ label:hover { - cursor: pointer; -} - -.boxselector_wrapper label { - font-weight: normal; - font-size: 12px; - display: block; - background: var(--bg-boxselector-color); - border: 1px solid var(--border-boxselector-color); - border-radius: 2px; - padding: 10px 10px 0 10px; - text-align: center; - box-shadow: var(--shadow-boxselector-color); - position: relative; -} - -.box-selector-item input:disabled + label, -.boxselector_wrapper label.boxselector_disabled { - background: var(--bg-boxselector-disabled-color) !important; - border-color: #787878; - color: #787878; - cursor: not-allowed; - pointer-events: none; -} - -.boxselector_wrapper input[type='radio']:checked + label { - background: #337ab7; - color: white; - padding-top: 2rem; - border-color: #337ab7; -} - -.boxselector_wrapper input[type='radio']:checked + label::after { - color: #337ab7; - font-family: 'Font Awesome 5 Free'; - border: 2px solid #337ab7; - content: '\f00c'; - font-size: 16px; - font-weight: bold; - position: absolute; - top: -15px; - left: 50%; - transform: translateX(-50%); - height: 30px; - width: 30px; - line-height: 26px; - text-align: center; - border-radius: 50%; - background: white; - box-shadow: 0 2px 5px -2px rgba(0, 0, 0, 0.25); -} - -@media only screen and (max-width: 700px) { - .boxselector_wrapper { - flex-direction: column; - } -} diff --git a/app/portainer/components/box-selector/index.js b/app/portainer/components/box-selector/index.js index 70ae7d768..555b6191f 100644 --- a/app/portainer/components/box-selector/index.js +++ b/app/portainer/components/box-selector/index.js @@ -15,6 +15,6 @@ angular.module('portainer.app').component('boxSelector', { }, }); -export function buildOption(id, icon, label, description, value) { - return { id, icon, label, description, value }; +export function buildOption(id, icon, label, description, value, feature) { + return { id, icon, label, description, value, feature }; } diff --git a/app/portainer/components/datatables/access-viewer-datatable/accessViewerDatatable.html b/app/portainer/components/datatables/access-viewer-datatable/accessViewerDatatable.html deleted file mode 100644 index 9a725651a..000000000 --- a/app/portainer/components/datatables/access-viewer-datatable/accessViewerDatatable.html +++ /dev/null @@ -1,26 +0,0 @@ -
- -
- - - - - - - - - - - - - -
- Environment - - Role - Access origin
Select a user to show associated access and role
-
-
diff --git a/app/portainer/components/datatables/datatable.css b/app/portainer/components/datatables/datatable.css index c7f17b4c5..c6ae521da 100644 --- a/app/portainer/components/datatables/datatable.css +++ b/app/portainer/components/datatables/datatable.css @@ -92,6 +92,10 @@ float: none; } +.datatable.datatable-empty .table > tbody > tr > td { + padding: 15px 0; +} + .tableMenu { color: #767676; padding: 10px; diff --git a/app/portainer/components/datatables/filter/datatable-filter.controller.js b/app/portainer/components/datatables/filter/datatable-filter.controller.js new file mode 100644 index 000000000..32c6de64d --- /dev/null +++ b/app/portainer/components/datatables/filter/datatable-filter.controller.js @@ -0,0 +1,19 @@ +export default class DatatableFilterController { + isEnabled() { + return 0 < this.state.length && this.state.length < this.labels.length; + } + + onChangeItem(filterValue) { + if (this.isChecked(filterValue)) { + return this.onChange( + this.filterKey, + this.state.filter((v) => v !== filterValue) + ); + } + return this.onChange(this.filterKey, [...this.state, filterValue]); + } + + isChecked(filterValue) { + return this.state.includes(filterValue); + } +} diff --git a/app/portainer/components/datatables/filter/datatable-filter.html b/app/portainer/components/datatables/filter/datatable-filter.html new file mode 100644 index 000000000..f824447ff --- /dev/null +++ b/app/portainer/components/datatables/filter/datatable-filter.html @@ -0,0 +1,32 @@ +
+ +
+ + Filter + + +
+ +
diff --git a/app/portainer/components/datatables/filter/index.js b/app/portainer/components/datatables/filter/index.js new file mode 100644 index 000000000..87ea3f18a --- /dev/null +++ b/app/portainer/components/datatables/filter/index.js @@ -0,0 +1,13 @@ +import controller from './datatable-filter.controller'; + +export const datatableFilter = { + bindings: { + labels: '<', // [{label, value}] + state: '<', // [filterValue] + filterKey: '@', + onChange: '<', + }, + controller, + templateUrl: './datatable-filter.html', + transclude: true, +}; diff --git a/app/portainer/components/datatables/genericDatatableController.js b/app/portainer/components/datatables/genericDatatableController.js index 30f02dd44..bc7f93de7 100644 --- a/app/portainer/components/datatables/genericDatatableController.js +++ b/app/portainer/components/datatables/genericDatatableController.js @@ -128,7 +128,11 @@ angular.module('portainer.app').controller('GenericDatatableController', [ * https://github.com/portainer/portainer/pull/2877#issuecomment-503333425 * https://github.com/portainer/portainer/pull/2877#issuecomment-503537249 */ - this.$onInit = function () { + this.$onInit = function $onInit() { + this.$onInitGeneric(); + }; + + this.$onInitGeneric = function $onInitGeneric() { this.setDefaults(); this.prepareTableFromDataset(); diff --git a/app/portainer/components/datatables/index.js b/app/portainer/components/datatables/index.js new file mode 100644 index 000000000..d8bb89dbc --- /dev/null +++ b/app/portainer/components/datatables/index.js @@ -0,0 +1,16 @@ +import angular from 'angular'; +import 'angular-utils-pagination'; + +import { datatableTitlebar } from './titlebar'; +import { datatableSearchbar } from './searchbar'; +import { datatableSortIcon } from './sort-icon'; +import { datatablePagination } from './pagination'; +import { datatableFilter } from './filter'; + +export default angular + .module('portainer.shared.datatable', ['angularUtils.directives.dirPagination']) + .component('datatableTitlebar', datatableTitlebar) + .component('datatableSearchbar', datatableSearchbar) + .component('datatableSortIcon', datatableSortIcon) + .component('datatablePagination', datatablePagination) + .component('datatableFilter', datatableFilter).name; diff --git a/app/portainer/components/datatables/pagination/index.js b/app/portainer/components/datatables/pagination/index.js new file mode 100644 index 000000000..2f299755f --- /dev/null +++ b/app/portainer/components/datatables/pagination/index.js @@ -0,0 +1,9 @@ +export const datatablePagination = { + bindings: { + onChangeLimit: '<', + limit: '<', + enableNoLimit: '<', + onChangePage: '<', + }, + templateUrl: './pagination.html', +}; diff --git a/app/portainer/components/datatables/pagination/pagination.html b/app/portainer/components/datatables/pagination/pagination.html new file mode 100644 index 000000000..930b047c3 --- /dev/null +++ b/app/portainer/components/datatables/pagination/pagination.html @@ -0,0 +1,15 @@ +
+ + + Items per page + + + + +
diff --git a/app/portainer/components/datatables/registries-datatable/registriesDatatable.html b/app/portainer/components/datatables/registries-datatable/registriesDatatable.html index 684d3e0b9..1930a37de 100644 --- a/app/portainer/components/datatables/registries-datatable/registriesDatatable.html +++ b/app/portainer/components/datatables/registries-datatable/registriesDatatable.html @@ -87,15 +87,10 @@ Manage access - - Browse + + Browse + + - diff --git a/app/portainer/components/datatables/registries-datatable/registriesDatatableController.js b/app/portainer/components/datatables/registries-datatable/registriesDatatableController.js index 1b2f86e35..67e46352c 100644 --- a/app/portainer/components/datatables/registries-datatable/registriesDatatableController.js +++ b/app/portainer/components/datatables/registries-datatable/registriesDatatableController.js @@ -1,5 +1,5 @@ import { PortainerEndpointTypes } from 'Portainer/models/endpoint/models'; - +import { REGISTRY_MANAGEMENT } from '@/portainer/feature-flags/feature-ids'; angular.module('portainer.docker').controller('RegistriesDatatableController', RegistriesDatatableController); /* @ngInject */ @@ -45,6 +45,7 @@ function RegistriesDatatableController($scope, $controller, $state, Authenticati }; this.$onInit = function () { + this.limitedFeature = REGISTRY_MANAGEMENT; this.isAdmin = Authentication.isAdmin(); this.setDefaults(); this.prepareTableFromDataset(); diff --git a/app/portainer/components/datatables/roles-datatable/rolesDatatable.html b/app/portainer/components/datatables/roles-datatable/rolesDatatable.html deleted file mode 100644 index 9ea5b1500..000000000 --- a/app/portainer/components/datatables/roles-datatable/rolesDatatable.html +++ /dev/null @@ -1,64 +0,0 @@ -
- - -
-
{{ $ctrl.titleText }}
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - -
- Name - - Description -
Environment administratorFull control of all resources in an environment
HelpdeskRead-only access of all resources in an environment
Read-only userRead-only access of assigned resources in an environment
Standard userFull control of assigned resources in an environment
- -
-
-
-
diff --git a/app/portainer/components/datatables/searchbar/datatable-searchbar.html b/app/portainer/components/datatables/searchbar/datatable-searchbar.html new file mode 100644 index 000000000..a2d0333b4 --- /dev/null +++ b/app/portainer/components/datatables/searchbar/datatable-searchbar.html @@ -0,0 +1,4 @@ + diff --git a/app/portainer/components/datatables/searchbar/index.js b/app/portainer/components/datatables/searchbar/index.js new file mode 100644 index 000000000..6a8da2b4a --- /dev/null +++ b/app/portainer/components/datatables/searchbar/index.js @@ -0,0 +1,7 @@ +export const datatableSearchbar = { + bindings: { + onChange: '<', + ngModel: '<', + }, + templateUrl: './datatable-searchbar.html', +}; diff --git a/app/portainer/components/datatables/sort-icon/datatable-sort-icon.controller.js b/app/portainer/components/datatables/sort-icon/datatable-sort-icon.controller.js new file mode 100644 index 000000000..5a0b2e436 --- /dev/null +++ b/app/portainer/components/datatables/sort-icon/datatable-sort-icon.controller.js @@ -0,0 +1,5 @@ +export default class datatableSortIconController { + isCurrentSortOrder() { + return this.selectedSortKey === this.key; + } +} diff --git a/app/portainer/components/datatables/sort-icon/datatable-sort-icon.html b/app/portainer/components/datatables/sort-icon/datatable-sort-icon.html new file mode 100644 index 000000000..11335c671 --- /dev/null +++ b/app/portainer/components/datatables/sort-icon/datatable-sort-icon.html @@ -0,0 +1,9 @@ + diff --git a/app/portainer/components/datatables/sort-icon/index.js b/app/portainer/components/datatables/sort-icon/index.js new file mode 100644 index 000000000..b7f22bf6a --- /dev/null +++ b/app/portainer/components/datatables/sort-icon/index.js @@ -0,0 +1,11 @@ +import controller from './datatable-sort-icon.controller'; + +export const datatableSortIcon = { + bindings: { + key: '@', + selectedSortKey: '@', + reverseOrder: '<', + }, + controller, + templateUrl: './datatable-sort-icon.html', +}; diff --git a/app/portainer/components/datatables/titlebar/datatable-titlebar.html b/app/portainer/components/datatables/titlebar/datatable-titlebar.html new file mode 100644 index 000000000..add8e9fff --- /dev/null +++ b/app/portainer/components/datatables/titlebar/datatable-titlebar.html @@ -0,0 +1,7 @@ +
+
+ + {{ $ctrl.title }} + +
+
diff --git a/app/portainer/components/datatables/titlebar/index.js b/app/portainer/components/datatables/titlebar/index.js new file mode 100644 index 000000000..43dd588f7 --- /dev/null +++ b/app/portainer/components/datatables/titlebar/index.js @@ -0,0 +1,8 @@ +export const datatableTitlebar = { + bindings: { + icon: '@', + title: '@', + feature: '@', + }, + templateUrl: './datatable-titlebar.html', +}; diff --git a/app/portainer/components/forms/por-switch-field/por-switch-field.html b/app/portainer/components/forms/por-switch-field/por-switch-field.html index d20ae2776..19f458722 100644 --- a/app/portainer/components/forms/por-switch-field/por-switch-field.html +++ b/app/portainer/components/forms/por-switch-field/por-switch-field.html @@ -10,5 +10,7 @@ ng-model="$ctrl.ngModel" disabled="$ctrl.disabled" on-change="($ctrl.onChange)" + feature="$ctrl.feature" + ng-data-cy="{{::$ctrl.ngDataCy}}" > diff --git a/app/portainer/components/forms/por-switch-field/por-switch-field.js b/app/portainer/components/forms/por-switch-field/por-switch-field.js index 800604209..0a38e0483 100644 --- a/app/portainer/components/forms/por-switch-field/por-switch-field.js +++ b/app/portainer/components/forms/por-switch-field/por-switch-field.js @@ -1,7 +1,5 @@ import angular from 'angular'; -import './por-switch-field.css'; - export const porSwitchField = { templateUrl: './por-switch-field.html', bindings: { @@ -10,8 +8,10 @@ export const porSwitchField = { label: '@', name: '@', labelClass: '@', + ngDataCy: '@', disabled: '<', onChange: '<', + feature: '<', // feature id }, }; diff --git a/app/portainer/components/forms/por-switch/por-switch.controller.js b/app/portainer/components/forms/por-switch/por-switch.controller.js new file mode 100644 index 000000000..0f7ed0532 --- /dev/null +++ b/app/portainer/components/forms/por-switch/por-switch.controller.js @@ -0,0 +1,14 @@ +export default class PorSwitchController { + /* @ngInject */ + constructor(featureService) { + Object.assign(this, { featureService }); + + this.limitedToBE = false; + } + + $onInit() { + if (this.feature) { + this.limitedToBE = this.featureService.isLimitedToBE(this.feature); + } + } +} diff --git a/app/portainer/components/forms/por-switch-field/por-switch-field.css b/app/portainer/components/forms/por-switch/por-switch.css similarity index 82% rename from app/portainer/components/forms/por-switch-field/por-switch-field.css rename to app/portainer/components/forms/por-switch/por-switch.css index 0bf57f6b0..c09420285 100644 --- a/app/portainer/components/forms/por-switch-field/por-switch-field.css +++ b/app/portainer/components/forms/por-switch/por-switch.css @@ -51,3 +51,18 @@ opacity: 0.5; cursor: not-allowed; } + +.switch.limited { + pointer-events: none; + touch-action: none; +} + +.switch.limited i { + opacity: 1; + cursor: not-allowed; +} + +.switch.business i { + background-color: var(--BE-only); + box-shadow: inset 0 0 1px rgb(0 0 0 / 50%), inset 0 0 40px var(--BE-only); +} diff --git a/app/portainer/components/forms/por-switch/por-switch.html b/app/portainer/components/forms/por-switch/por-switch.html index 3710e4077..acacc784c 100644 --- a/app/portainer/components/forms/por-switch/por-switch.html +++ b/app/portainer/components/forms/por-switch/por-switch.html @@ -1,4 +1,12 @@ -
-
-
-
-
- - -
-
-
+ diff --git a/app/portainer/oauth/components/oauth-settings/oauth-settings.js b/app/portainer/oauth/components/oauth-settings/index.js similarity index 61% rename from app/portainer/oauth/components/oauth-settings/oauth-settings.js rename to app/portainer/oauth/components/oauth-settings/index.js index ffe6b1739..c7fc2712f 100644 --- a/app/portainer/oauth/components/oauth-settings/oauth-settings.js +++ b/app/portainer/oauth/components/oauth-settings/index.js @@ -1,8 +1,11 @@ +import angular from 'angular'; +import controller from './oauth-settings.controller'; + angular.module('portainer.oauth').component('oauthSettings', { templateUrl: './oauth-settings.html', bindings: { settings: '=', teams: '<', }, - controller: 'OAuthSettingsController', + controller, }); diff --git a/app/portainer/oauth/components/oauth-settings/oauth-settings-controller.js b/app/portainer/oauth/components/oauth-settings/oauth-settings-controller.js deleted file mode 100644 index ce7f6ec47..000000000 --- a/app/portainer/oauth/components/oauth-settings/oauth-settings-controller.js +++ /dev/null @@ -1,23 +0,0 @@ -angular.module('portainer.oauth').controller('OAuthSettingsController', function OAuthSettingsController() { - var ctrl = this; - - this.state = { - provider: {}, - }; - - this.$onInit = $onInit; - - function $onInit() { - if (ctrl.settings.RedirectURI === '') { - ctrl.settings.RedirectURI = window.location.origin; - } - - if (ctrl.settings.AuthorizationURI !== '') { - ctrl.state.provider.authUrl = ctrl.settings.AuthorizationURI; - } - - if (ctrl.settings.DefaultTeamID === 0) { - ctrl.settings.DefaultTeamID = null; - } - } -}); diff --git a/app/portainer/oauth/components/oauth-settings/oauth-settings.controller.js b/app/portainer/oauth/components/oauth-settings/oauth-settings.controller.js new file mode 100644 index 000000000..500560ad7 --- /dev/null +++ b/app/portainer/oauth/components/oauth-settings/oauth-settings.controller.js @@ -0,0 +1,109 @@ +import { HIDE_INTERNAL_AUTH } from '@/portainer/feature-flags/feature-ids'; + +import providers, { getProviderByUrl } from './providers'; + +export default class OAuthSettingsController { + /* @ngInject */ + constructor(featureService) { + this.featureService = featureService; + + this.limitedFeature = HIDE_INTERNAL_AUTH; + + this.state = { + provider: 'custom', + overrideConfiguration: false, + microsoftTenantID: '', + }; + + this.$onInit = this.$onInit.bind(this); + this.onSelectProvider = this.onSelectProvider.bind(this); + this.onMicrosoftTenantIDChange = this.onMicrosoftTenantIDChange.bind(this); + this.useDefaultProviderConfiguration = this.useDefaultProviderConfiguration.bind(this); + this.updateSSO = this.updateSSO.bind(this); + this.addTeamMembershipMapping = this.addTeamMembershipMapping.bind(this); + this.removeTeamMembership = this.removeTeamMembership.bind(this); + } + + onMicrosoftTenantIDChange() { + const tenantID = this.state.microsoftTenantID; + + this.settings.AuthorizationURI = `https://login.microsoftonline.com/${tenantID}/oauth2/authorize`; + this.settings.AccessTokenURI = `https://login.microsoftonline.com/${tenantID}/oauth2/token`; + this.settings.ResourceURI = `https://graph.windows.net/${tenantID}/me?api-version=2013-11-08`; + } + + useDefaultProviderConfiguration(providerId) { + const provider = providers[providerId]; + + this.state.overrideConfiguration = false; + + if (!this.isLimitedToBE || providerId === 'custom') { + this.settings.AuthorizationURI = provider.authUrl; + this.settings.AccessTokenURI = provider.accessTokenUrl; + this.settings.ResourceURI = provider.resourceUrl; + this.settings.LogoutURI = provider.logoutUrl; + this.settings.UserIdentifier = provider.userIdentifier; + this.settings.Scopes = provider.scopes; + + if (providerId === 'microsoft' && this.state.microsoftTenantID !== '') { + this.onMicrosoftTenantIDChange(); + } + } else { + this.settings.ClientID = ''; + this.settings.ClientSecret = ''; + } + } + + onSelectProvider(provider) { + this.state.provider = provider; + + this.useDefaultProviderConfiguration(provider); + } + + updateSSO() { + this.settings.HideInternalAuth = this.settings.SSO; + } + + addTeamMembershipMapping() { + this.settings.TeamMemberships.OAuthClaimMappings.push({ ClaimValRegex: '', Team: this.settings.DefaultTeamID }); + } + + removeTeamMembership(index) { + this.settings.TeamMemberships.OAuthClaimMappings.splice(index, 1); + } + + $onInit() { + this.isLimitedToBE = this.featureService.isLimitedToBE(this.limitedFeature); + + if (this.isLimitedToBE) { + return; + } + + if (this.settings.RedirectURI === '') { + this.settings.RedirectURI = window.location.origin; + } + + if (this.settings.AuthorizationURI) { + const authUrl = this.settings.AuthorizationURI; + + this.state.provider = getProviderByUrl(authUrl); + if (this.state.provider === 'microsoft') { + const tenantID = authUrl.match(/login.microsoftonline.com\/(.*?)\//)[1]; + this.state.microsoftTenantID = tenantID; + this.onMicrosoftTenantIDChange(); + } + } + + if (this.settings.DefaultTeamID === 0) { + this.settings.DefaultTeamID = null; + } + + if (this.settings.TeamMemberships == null) { + this.settings.TeamMemberships = {}; + } + + if (this.settings.TeamMemberships.OAuthClaimMappings === null) { + this.settings.TeamMemberships.OAuthClaimMappings = []; + } + } +} diff --git a/app/portainer/oauth/components/oauth-settings/oauth-settings.html b/app/portainer/oauth/components/oauth-settings/oauth-settings.html index 61ad0014c..4853aabc1 100644 --- a/app/portainer/oauth/components/oauth-settings/oauth-settings.html +++ b/app/portainer/oauth/components/oauth-settings/oauth-settings.html @@ -1,54 +1,50 @@ -
+ +
Single Sign-On
+
- -
- +
+
- -
- - - This feature is available in Portainer Business Edition. - +
+
-
- Automatic user provisioning -
-
- + + + With automatic user provisioning enabled, Portainer will create user(s) automatically with the standard user role. If disabled, users must be created beforehand in Portainer in order to login. - -
-
- - -
+ +

The users created by the automatic provisioning feature can be added to a default team on creation.

-

By assigning newly created users to a team, they will be able to access the environments associated to that team. This setting is optional and if not set, newly created - users won't be able to access any environments.

+

+ By assigning newly created users to a team, they will be able to access the environments associated to that team. This setting is optional and if not set, newly created + users won't be able to access any environments. +

@@ -56,13 +52,33 @@ You have not yet created any teams. Head over to the Teams view to manage teams. - -
- + +
+
+

+ + The default team option will be disabled when automatic team membership is enabled +

+
+
+ +
+
@@ -71,118 +87,296 @@ Team membership
- +
Automatic team membership synchronizes the team membership based on a custom claim in the token from the OAuth provider. - +
- - - This feature is available in Portainer Business Edition. - +
+ +
-
OAuth Configuration
+
+
+ +
+
+ +
+
+
-
- -
- +
+ +
+ + add team mapping + + +
+
+ claim value regex + +
+ maps to +
+ team + +
+ + +
+ Claim value regex is required. +
+
+
-
-
- -
- +
+
+ The default team will be assigned when the user does not belong to any other team +
+ + + You have not yet created any teams. Head over to the Teams view to manage teams. + + +
+
+ +
+
-
-