From b7841e7fc362d432b53382d4c77642976789f128 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Thu, 7 Oct 2021 01:59:53 +0300 Subject: [PATCH] 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 | 446 +++++++++++++----- .../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 | 407 +++++++++------- app/portainer/views/settings/settings.html | 257 +++++++--- .../views/settings/settingsController.js | 20 + app/portainer/views/sidebar/sidebar.html | 4 + package.json | 4 +- yarn.lock | 13 +- 175 files changed, 5627 insertions(+), 1216 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 - } +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") } - if !found { - return "", errUserNotFound - } - - 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. - +
+ +
+
+
+ +
+
+ +
+
+
+ +
+ +
+ + 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. + + +
+
+ +
+
+
+
+ + +
OAuth Configuration
+
+ +
+ +
+
+
- +
- +
-
- -
- +
+
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
-
- -
- + - -
- -
- -
-
- -
- -
- -
-
-
- -
- -
-
-
- -
- -
-
- -
- -
- -
-
-
+ diff --git a/app/portainer/oauth/components/oauth-settings/providers.js b/app/portainer/oauth/components/oauth-settings/providers.js new file mode 100644 index 000000000..fc04de927 --- /dev/null +++ b/app/portainer/oauth/components/oauth-settings/providers.js @@ -0,0 +1,43 @@ +export default { + microsoft: { + authUrl: 'https://login.microsoftonline.com/TENANT_ID/oauth2/authorize', + accessTokenUrl: 'https://login.microsoftonline.com/TENANT_ID/oauth2/token', + resourceUrl: 'https://graph.windows.net/TENANT_ID/me?api-version=2013-11-08', + logoutUrl: `https://login.microsoftonline.com/common/oauth2/v2.0/logout?post_logout_redirect_uri=${window.location.origin}/#!/auth`, + userIdentifier: 'userPrincipalName', + scopes: 'id,email,name', + }, + google: { + authUrl: 'https://accounts.google.com/o/oauth2/auth', + accessTokenUrl: 'https://accounts.google.com/o/oauth2/token', + resourceUrl: 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json', + logoutUrl: `https://www.google.com/accounts/Logout?continue=https://appengine.google.com/_ah/logout?continue=${window.location.origin}/#!/auth`, + userIdentifier: 'email', + scopes: 'profile email', + }, + github: { + authUrl: 'https://github.com/login/oauth/authorize', + accessTokenUrl: 'https://github.com/login/oauth/access_token', + resourceUrl: 'https://api.github.com/user', + logoutUrl: `https://github.com/logout`, + userIdentifier: 'login', + scopes: 'id email name', + }, + custom: { authUrl: '', accessTokenUrl: '', resourceUrl: '', logoutUrl: '', userIdentifier: '', scopes: '' }, +}; + +export function getProviderByUrl(providerAuthURL = '') { + if (providerAuthURL.includes('login.microsoftonline.com')) { + return 'microsoft'; + } + + if (providerAuthURL.includes('accounts.google.com')) { + return 'google'; + } + + if (providerAuthURL.includes('github.com')) { + return 'github'; + } + + return 'custom'; +} diff --git a/app/portainer/rbac/components/access-viewer/access-viewer-datatable/access-viewer-datatable.html b/app/portainer/rbac/components/access-viewer/access-viewer-datatable/access-viewer-datatable.html new file mode 100644 index 000000000..66a856a90 --- /dev/null +++ b/app/portainer/rbac/components/access-viewer/access-viewer-datatable/access-viewer-datatable.html @@ -0,0 +1,73 @@ +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ + Environment + + + + + + Role + + + + Access origin
{{ item.EndpointName }}{{ item.RoleName }}{{ item.TeamName ? 'Team' : 'User' }} {{ item.TeamName }} access defined on {{ item.AccessLocation }} + {{ item.GroupName }} + Manage access + + Manage access + +
Select a user to show associated access and role
The selected user does not have access to any environment(s)
+
+ +
diff --git a/app/portainer/components/datatables/access-viewer-datatable/accessViewerDatatable.js b/app/portainer/rbac/components/access-viewer/access-viewer-datatable/index.js similarity index 56% rename from app/portainer/components/datatables/access-viewer-datatable/accessViewerDatatable.js rename to app/portainer/rbac/components/access-viewer/access-viewer-datatable/index.js index 39bf39f70..e096ef51a 100644 --- a/app/portainer/components/datatables/access-viewer-datatable/accessViewerDatatable.js +++ b/app/portainer/rbac/components/access-viewer/access-viewer-datatable/index.js @@ -1,5 +1,5 @@ -angular.module('portainer.app').component('accessViewerDatatable', { - templateUrl: './accessViewerDatatable.html', +export const accessViewerDatatable = { + templateUrl: './access-viewer-datatable.html', controller: 'GenericDatatableController', bindings: { titleText: '@', @@ -8,4 +8,4 @@ angular.module('portainer.app').component('accessViewerDatatable', { orderBy: '@', dataset: '<', }, -}); +}; diff --git a/app/portainer/rbac/components/access-viewer/access-viewer.controller.js b/app/portainer/rbac/components/access-viewer/access-viewer.controller.js new file mode 100644 index 000000000..a4a03e483 --- /dev/null +++ b/app/portainer/rbac/components/access-viewer/access-viewer.controller.js @@ -0,0 +1,128 @@ +import _ from 'lodash-es'; + +import AccessViewerPolicyModel from '../../models/access'; + +export default class AccessViewerController { + /* @ngInject */ + constructor(featureService, Notifications, RoleService, UserService, EndpointService, GroupService, TeamService, TeamMembershipService) { + this.featureService = featureService; + this.Notifications = Notifications; + this.RoleService = RoleService; + this.UserService = UserService; + this.EndpointService = EndpointService; + this.GroupService = GroupService; + this.TeamService = TeamService; + this.TeamMembershipService = TeamMembershipService; + + this.limitedFeature = 'rbac-roles'; + this.users = []; + } + + onUserSelect() { + this.userRoles = []; + const userRoles = {}; + const user = this.selectedUser; + const userMemberships = _.filter(this.teamMemberships, { UserId: user.Id }); + + for (const [, endpoint] of _.entries(this.endpoints)) { + let role = this.getRoleFromUserEndpointPolicy(user, endpoint); + if (role) { + userRoles[endpoint.Id] = role; + continue; + } + + role = this.getRoleFromUserEndpointGroupPolicy(user, endpoint); + if (role) { + userRoles[endpoint.Id] = role; + continue; + } + + role = this.getRoleFromTeamEndpointPolicies(userMemberships, endpoint); + if (role) { + userRoles[endpoint.Id] = role; + continue; + } + + role = this.getRoleFromTeamEndpointGroupPolicies(userMemberships, endpoint); + if (role) { + userRoles[endpoint.Id] = role; + } + } + + this.userRoles = _.values(userRoles); + } + + findLowestRole(policies) { + return _.first(_.orderBy(policies, 'RolePriority', 'desc')); + } + + getRoleFromUserEndpointPolicy(user, endpoint) { + const policyRoles = []; + const policy = (endpoint.UserAccessPolicies || {})[user.Id]; + if (policy) { + const accessPolicy = new AccessViewerPolicyModel(policy, endpoint, this.roles, null, null); + policyRoles.push(accessPolicy); + } + return this.findLowestRole(policyRoles); + } + + getRoleFromUserEndpointGroupPolicy(user, endpoint) { + const policyRoles = []; + const policy = this.groupUserAccessPolicies[endpoint.GroupId][user.Id]; + if (policy) { + const accessPolicy = new AccessViewerPolicyModel(policy, endpoint, this.roles, this.groups[endpoint.GroupId], null); + policyRoles.push(accessPolicy); + } + return this.findLowestRole(policyRoles); + } + + getRoleFromTeamEndpointPolicies(memberships, endpoint) { + const policyRoles = []; + for (const membership of memberships) { + const policy = (endpoint.TeamAccessPolicies || {})[membership.TeamId]; + if (policy) { + const accessPolicy = new AccessViewerPolicyModel(policy, endpoint, this.roles, null, this.teams[membership.TeamId]); + policyRoles.push(accessPolicy); + } + } + return this.findLowestRole(policyRoles); + } + + getRoleFromTeamEndpointGroupPolicies(memberships, endpoint) { + const policyRoles = []; + for (const membership of memberships) { + const policy = this.groupTeamAccessPolicies[endpoint.GroupId][membership.TeamId]; + if (policy) { + const accessPolicy = new AccessViewerPolicyModel(policy, endpoint, this.roles, this.groups[endpoint.GroupId], this.teams[membership.TeamId]); + policyRoles.push(accessPolicy); + } + } + return this.findLowestRole(policyRoles); + } + + async $onInit() { + try { + const limitedToBE = this.featureService.isLimitedToBE(this.limitedFeature); + + if (limitedToBE) { + return; + } + + this.users = await this.UserService.users(); + this.endpoints = _.keyBy((await this.EndpointService.endpoints()).value, 'Id'); + const groups = await this.GroupService.groups(); + this.groupUserAccessPolicies = {}; + this.groupTeamAccessPolicies = {}; + _.forEach(groups, (group) => { + this.groupUserAccessPolicies[group.Id] = group.UserAccessPolicies; + this.groupTeamAccessPolicies[group.Id] = group.TeamAccessPolicies; + }); + this.groups = _.keyBy(groups, 'Id'); + this.roles = _.keyBy(await this.RoleService.roles(), 'Id'); + this.teams = _.keyBy(await this.TeamService.teams(), 'Id'); + this.teamMemberships = await this.TeamMembershipService.memberships(); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve accesses'); + } + } +} diff --git a/app/portainer/rbac/components/access-viewer/access-viewer.html b/app/portainer/rbac/components/access-viewer/access-viewer.html new file mode 100644 index 000000000..1f42912d1 --- /dev/null +++ b/app/portainer/rbac/components/access-viewer/access-viewer.html @@ -0,0 +1,43 @@ +
+ + + + Effective access viewer + + + + +
+
+ User +
+
+
+ + No user available + + + + {{ $select.selected.Username }} + + + {{ item.Username }} + + +
+
+ +
+ Access +
+
+
+ + Effective role for each environment will be displayed for the selected user +
+
+ +
+
+
+
diff --git a/app/portainer/rbac/components/access-viewer/index.js b/app/portainer/rbac/components/access-viewer/index.js new file mode 100644 index 000000000..334f0f0f7 --- /dev/null +++ b/app/portainer/rbac/components/access-viewer/index.js @@ -0,0 +1,6 @@ +import controller from './access-viewer.controller'; + +export const accessViewer = { + templateUrl: './access-viewer.html', + controller, +}; diff --git a/app/portainer/rbac/components/roles-datatable/index.js b/app/portainer/rbac/components/roles-datatable/index.js new file mode 100644 index 000000000..06af2dcf4 --- /dev/null +++ b/app/portainer/rbac/components/roles-datatable/index.js @@ -0,0 +1,15 @@ +import controller from './roles-datatable.controller'; +import './roles-datatable.css'; + +export const rolesDatatable = { + templateUrl: './roles-datatable.html', + controller, + bindings: { + titleText: '@', + titleIcon: '@', + dataset: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + }, +}; diff --git a/app/portainer/rbac/components/roles-datatable/roles-datatable.controller.js b/app/portainer/rbac/components/roles-datatable/roles-datatable.controller.js new file mode 100644 index 000000000..ff980bbac --- /dev/null +++ b/app/portainer/rbac/components/roles-datatable/roles-datatable.controller.js @@ -0,0 +1,15 @@ +import angular from 'angular'; +import { RoleTypes } from '../../models/role'; + +export default class RolesDatatableController { + /* @ngInject */ + constructor($controller, $scope) { + this.limitedFeature = 'rbac-roles'; + + angular.extend(this, $controller('GenericDatatableController', { $scope })); + } + + isDefaultItem(item) { + return item.ID === RoleTypes.STANDARD; + } +} diff --git a/app/portainer/rbac/components/roles-datatable/roles-datatable.css b/app/portainer/rbac/components/roles-datatable/roles-datatable.css new file mode 100644 index 000000000..14def6618 --- /dev/null +++ b/app/portainer/rbac/components/roles-datatable/roles-datatable.css @@ -0,0 +1,7 @@ +th.be-visual-indicator-col { + width: 300px; +} + +td.be-visual-indicator-col { + text-align: center; +} diff --git a/app/portainer/rbac/components/roles-datatable/roles-datatable.html b/app/portainer/rbac/components/roles-datatable/roles-datatable.html new file mode 100644 index 000000000..59c7e4003 --- /dev/null +++ b/app/portainer/rbac/components/roles-datatable/roles-datatable.html @@ -0,0 +1,85 @@ +
+ + +
+
{{ $ctrl.titleText }}
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ + Name + + + + + + Description + + + +
{{ item.Name }}{{ item.Description }} + + + + Default +
Loading...
No role available.
+
+ +
+
+
diff --git a/app/portainer/rbac/index.js b/app/portainer/rbac/index.js new file mode 100644 index 000000000..28647e586 --- /dev/null +++ b/app/portainer/rbac/index.js @@ -0,0 +1,33 @@ +import { rolesView } from './views/roles'; +import { accessViewer } from './components/access-viewer'; +import { accessViewerDatatable } from './components/access-viewer/access-viewer-datatable'; +import { rolesDatatable } from './components/roles-datatable'; + +import { RoleService } from './services/role.service'; +import { RolesFactory } from './services/role.rest'; + +angular + .module('portainer.rbac', ['ngResource']) + .constant('API_ENDPOINT_ROLES', 'api/roles') + .component('accessViewer', accessViewer) + .component('accessViewerDatatable', accessViewerDatatable) + .component('rolesDatatable', rolesDatatable) + .component('rolesView', rolesView) + .factory('RoleService', RoleService) + .factory('Roles', RolesFactory) + .config(config); + +/* @ngInject */ +function config($stateRegistryProvider) { + const roles = { + name: 'portainer.roles', + url: '/roles', + views: { + 'content@': { + component: 'rolesView', + }, + }, + }; + + $stateRegistryProvider.register(roles); +} diff --git a/app/portainer/rbac/models/access.js b/app/portainer/rbac/models/access.js new file mode 100644 index 000000000..18b7269fe --- /dev/null +++ b/app/portainer/rbac/models/access.js @@ -0,0 +1,16 @@ +export default function AccessViewerPolicyModel(policy, endpoint, roles, group, team) { + this.EndpointId = endpoint.Id; + this.EndpointName = endpoint.Name; + this.RoleId = policy.RoleId; + this.RoleName = roles[policy.RoleId].Name; + this.RolePriority = roles[policy.RoleId].Priority; + if (group) { + this.GroupId = group.Id; + this.GroupName = group.Name; + } + if (team) { + this.TeamId = team.Id; + this.TeamName = team.Name; + } + this.AccessLocation = group ? 'environment group' : 'environment'; +} diff --git a/app/portainer/rbac/models/role.js b/app/portainer/rbac/models/role.js new file mode 100644 index 000000000..44cb4a377 --- /dev/null +++ b/app/portainer/rbac/models/role.js @@ -0,0 +1,14 @@ +export function RoleViewModel(id, name, description, authorizations) { + this.ID = id; + this.Name = name; + this.Description = description; + this.Authorizations = authorizations; +} + +export const RoleTypes = Object.freeze({ + ENDPOINT_ADMIN: 1, + HELPDESK: 2, + STANDARD: 3, + READ_ONLY: 4, + OPERATOR: 5, +}); diff --git a/app/portainer/rbac/services/role.rest.js b/app/portainer/rbac/services/role.rest.js new file mode 100644 index 000000000..121360957 --- /dev/null +++ b/app/portainer/rbac/services/role.rest.js @@ -0,0 +1,14 @@ +/* @ngInject */ +export function RolesFactory($resource, API_ENDPOINT_ROLES) { + return $resource( + API_ENDPOINT_ROLES + '/:id', + {}, + { + create: { method: 'POST', ignoreLoadingBar: true }, + query: { method: 'GET', isArray: true }, + get: { method: 'GET', params: { id: '@id' } }, + update: { method: 'PUT', params: { id: '@id' } }, + remove: { method: 'DELETE', params: { id: '@id' } }, + } + ); +} diff --git a/app/portainer/rbac/services/role.service.js b/app/portainer/rbac/services/role.service.js new file mode 100644 index 000000000..14b763b31 --- /dev/null +++ b/app/portainer/rbac/services/role.service.js @@ -0,0 +1,19 @@ +import { RoleViewModel, RoleTypes } from '../models/role'; + +export function RoleService() { + const rolesData = [ + new RoleViewModel(RoleTypes.ENDPOINT_ADMIN, 'Environment administrator', 'Full control of all resources in an environment', []), + new RoleViewModel(RoleTypes.OPERATOR, 'Operator', 'Operational Control of all existing resources in an environment', []), + new RoleViewModel(RoleTypes.HELPDESK, 'Helpdesk', 'Read-only access of all resources in an environment', []), + new RoleViewModel(RoleTypes.READ_ONLY, 'Read-only user', 'Read-only access of assigned resources in an environment', []), + new RoleViewModel(RoleTypes.STANDARD, 'Standard user', 'Full control of assigned resources in an environment', []), + ]; + + return { + roles, + }; + + function roles() { + return rolesData; + } +} diff --git a/app/portainer/rbac/views/roles/index.js b/app/portainer/rbac/views/roles/index.js new file mode 100644 index 000000000..c1e91cf9f --- /dev/null +++ b/app/portainer/rbac/views/roles/index.js @@ -0,0 +1,6 @@ +import controller from './roles.controller'; + +export const rolesView = { + templateUrl: './roles.html', + controller, +}; diff --git a/app/portainer/rbac/views/roles/roles.controller.js b/app/portainer/rbac/views/roles/roles.controller.js new file mode 100644 index 000000000..742c243c3 --- /dev/null +++ b/app/portainer/rbac/views/roles/roles.controller.js @@ -0,0 +1,20 @@ +import _ from 'lodash-es'; + +export default class RolesController { + /* @ngInject */ + constructor(Notifications, RoleService) { + this.Notifications = Notifications; + this.RoleService = RoleService; + } + + async $onInit() { + this.roles = []; + + try { + this.roles = await this.RoleService.roles(); + this.roles = _.orderBy(this.roles, 'Priority', 'asc'); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve roles'); + } + } +} diff --git a/app/portainer/rbac/views/roles/roles.html b/app/portainer/rbac/views/roles/roles.html new file mode 100644 index 000000000..85ee70459 --- /dev/null +++ b/app/portainer/rbac/views/roles/roles.html @@ -0,0 +1,18 @@ + + + + + + + Role management + + +
+
+ +
+
+ +
+ +
diff --git a/app/portainer/settings/authentication/auth-method-constants.js b/app/portainer/settings/authentication/auth-method-constants.js new file mode 100644 index 000000000..dc9bf0a0c --- /dev/null +++ b/app/portainer/settings/authentication/auth-method-constants.js @@ -0,0 +1,11 @@ +export const authenticationMethodTypesMap = { + INTERNAL: 1, + LDAP: 2, + OAUTH: 3, +}; + +export const authenticationMethodTypesLabels = { + [authenticationMethodTypesMap.INTERNAL]: 'Internal', + [authenticationMethodTypesMap.LDAP]: 'LDAP', + [authenticationMethodTypesMap.OAUTH]: 'OAuth', +}; diff --git a/app/portainer/settings/authentication/auth-type-constants.js b/app/portainer/settings/authentication/auth-type-constants.js new file mode 100644 index 000000000..84de1d959 --- /dev/null +++ b/app/portainer/settings/authentication/auth-type-constants.js @@ -0,0 +1,11 @@ +export const authenticationActivityTypesMap = { + AuthSuccess: 1, + AuthFailure: 2, + Logout: 3, +}; + +export const authenticationActivityTypesLabels = { + [authenticationActivityTypesMap.AuthSuccess]: 'Authentication success', + [authenticationActivityTypesMap.AuthFailure]: 'Authentication failure', + [authenticationActivityTypesMap.Logout]: 'Logout', +}; diff --git a/app/portainer/settings/authentication/auto-user-provision-toggle/auto-user-provision-toggle.html b/app/portainer/settings/authentication/auto-user-provision-toggle/auto-user-provision-toggle.html new file mode 100644 index 000000000..aac43263a --- /dev/null +++ b/app/portainer/settings/authentication/auto-user-provision-toggle/auto-user-provision-toggle.html @@ -0,0 +1,14 @@ +
+ Automatic user provisioning +
+
+ +
+
+
+ + +
+
diff --git a/app/portainer/settings/authentication/auto-user-provision-toggle/index.js b/app/portainer/settings/authentication/auto-user-provision-toggle/index.js new file mode 100644 index 000000000..68c7b95d1 --- /dev/null +++ b/app/portainer/settings/authentication/auto-user-provision-toggle/index.js @@ -0,0 +1,9 @@ +export const autoUserProvisionToggle = { + templateUrl: './auto-user-provision-toggle.html', + transclude: { + description: 'fieldDescription', + }, + bindings: { + ngModel: '=', + }, +}; diff --git a/app/portainer/settings/authentication/index.js b/app/portainer/settings/authentication/index.js new file mode 100644 index 000000000..897e48042 --- /dev/null +++ b/app/portainer/settings/authentication/index.js @@ -0,0 +1,10 @@ +import angular from 'angular'; + +import ldapModule from './ldap'; + +import { autoUserProvisionToggle } from './auto-user-provision-toggle'; + +export default angular + .module('portainer.settings.authentication', [ldapModule]) + + .component('autoUserProvisionToggle', autoUserProvisionToggle).name; diff --git a/app/portainer/settings/authentication/ldap/ad-settings/ad-settings.controller.js b/app/portainer/settings/authentication/ldap/ad-settings/ad-settings.controller.js new file mode 100644 index 000000000..41d29e0ef --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ad-settings/ad-settings.controller.js @@ -0,0 +1,62 @@ +import _ from 'lodash-es'; +import { HIDE_INTERNAL_AUTH } from '@/portainer/feature-flags/feature-ids'; + +export default class AdSettingsController { + /* @ngInject */ + constructor(LDAPService) { + this.LDAPService = LDAPService; + + this.domainSuffix = ''; + this.limitedFeatureId = HIDE_INTERNAL_AUTH; + this.onTlscaCertChange = this.onTlscaCertChange.bind(this); + this.searchUsers = this.searchUsers.bind(this); + this.searchGroups = this.searchGroups.bind(this); + this.parseDomainName = this.parseDomainName.bind(this); + this.onAccountChange = this.onAccountChange.bind(this); + } + + parseDomainName(account) { + this.domainName = ''; + + if (!account || !account.includes('@')) { + return; + } + + const [, domainName] = account.split('@'); + if (!domainName) { + return; + } + + const parts = _.compact(domainName.split('.')); + this.domainSuffix = parts.map((part) => `dc=${part}`).join(','); + } + + onAccountChange(account) { + this.parseDomainName(account); + } + + searchUsers() { + return this.LDAPService.users(this.settings); + } + + searchGroups() { + return this.LDAPService.groups(this.settings); + } + + onTlscaCertChange(file) { + this.tlscaCert = file; + } + + addLDAPUrl() { + this.settings.URLs.push(''); + } + + removeLDAPUrl(index) { + this.settings.URLs.splice(index, 1); + } + + $onInit() { + this.tlscaCert = this.settings.TLSCACert; + this.parseDomainName(this.settings.ReaderDN); + } +} diff --git a/app/portainer/settings/authentication/ldap/ad-settings/ad-settings.html b/app/portainer/settings/authentication/ldap/ad-settings/ad-settings.html new file mode 100644 index 000000000..ffe36a0f4 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ad-settings/ad-settings.html @@ -0,0 +1,157 @@ + + + + + + With automatic user provisioning enabled, Portainer will create user(s) automatically with standard user role and assign them to team(s) which matches to LDAP group name(s). + If disabled, users must be created in Portainer beforehand. + + + +
+
+ Information +
+
+ When using Microsoft AD authentication, Portainer will delegate user authentication to the Domain Controller(s) configured below; if there is no connectivity, Portainer will + fallback to internal authentication. +
+
+ +
+ AD configuration +
+ +
+
+

+ + You can configure multiple AD Controllers for authentication fallback. Make sure all servers are using the same configuration (i.e. if TLS is enabled, they should all use + the same certificates). +

+
+
+ +
+ +
+
+ + +
+
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ + + + + + + + + + + + +
diff --git a/app/portainer/settings/authentication/ldap/ad-settings/index.js b/app/portainer/settings/authentication/ldap/ad-settings/index.js new file mode 100644 index 000000000..59a474097 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ad-settings/index.js @@ -0,0 +1,12 @@ +import controller from './ad-settings.controller'; + +export const adSettings = { + templateUrl: './ad-settings.html', + controller, + bindings: { + settings: '=', + tlscaCert: '=', + state: '=', + connectivityCheck: '<', + }, +}; diff --git a/app/portainer/settings/authentication/ldap/index.js b/app/portainer/settings/authentication/ldap/index.js new file mode 100644 index 000000000..2b3612be8 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/index.js @@ -0,0 +1,44 @@ +import angular from 'angular'; + +import { adSettings } from './ad-settings'; +import { ldapSettings } from './ldap-settings'; +import { ldapSettingsCustom } from './ldap-settings-custom'; +import { ldapSettingsOpenLdap } from './ldap-settings-openldap'; + +import { ldapConnectivityCheck } from './ldap-connectivity-check'; +import { ldapGroupsDatatable } from './ldap-groups-datatable'; +import { ldapGroupSearch } from './ldap-group-search'; +import { ldapGroupSearchItem } from './ldap-group-search-item'; +import { ldapUserSearch } from './ldap-user-search'; +import { ldapUserSearchItem } from './ldap-user-search-item'; +import { ldapSettingsDnBuilder } from './ldap-settings-dn-builder'; +import { ldapSettingsGroupDnBuilder } from './ldap-settings-group-dn-builder'; +import { ldapCustomGroupSearch } from './ldap-custom-group-search'; +import { ldapSettingsSecurity } from './ldap-settings-security'; +import { ldapSettingsTestLogin } from './ldap-settings-test-login'; +import { ldapCustomUserSearch } from './ldap-custom-user-search'; +import { ldapUsersDatatable } from './ldap-users-datatable'; +import { LDAPService } from './ldap.service'; +import { LDAP } from './ldap.rest'; + +export default angular + .module('portainer.settings.authentication.ldap', []) + .service('LDAPService', LDAPService) + .service('LDAP', LDAP) + .component('ldapConnectivityCheck', ldapConnectivityCheck) + .component('ldapGroupsDatatable', ldapGroupsDatatable) + .component('ldapSettings', ldapSettings) + .component('adSettings', adSettings) + .component('ldapGroupSearch', ldapGroupSearch) + .component('ldapGroupSearchItem', ldapGroupSearchItem) + .component('ldapUserSearch', ldapUserSearch) + .component('ldapUserSearchItem', ldapUserSearchItem) + .component('ldapSettingsCustom', ldapSettingsCustom) + .component('ldapSettingsDnBuilder', ldapSettingsDnBuilder) + .component('ldapSettingsGroupDnBuilder', ldapSettingsGroupDnBuilder) + .component('ldapCustomGroupSearch', ldapCustomGroupSearch) + .component('ldapSettingsOpenLdap', ldapSettingsOpenLdap) + .component('ldapSettingsSecurity', ldapSettingsSecurity) + .component('ldapSettingsTestLogin', ldapSettingsTestLogin) + .component('ldapCustomUserSearch', ldapCustomUserSearch) + .component('ldapUsersDatatable', ldapUsersDatatable).name; diff --git a/app/portainer/settings/authentication/ldap/ldap-connectivity-check/index.js b/app/portainer/settings/authentication/ldap/ldap-connectivity-check/index.js new file mode 100644 index 000000000..93784cb42 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-connectivity-check/index.js @@ -0,0 +1,9 @@ +export const ldapConnectivityCheck = { + templateUrl: './ldap-connectivity-check.html', + bindings: { + settings: '<', + state: '<', + connectivityCheck: '<', + limitedFeatureId: '<', + }, +}; diff --git a/app/portainer/settings/authentication/ldap/ldap-connectivity-check/ldap-connectivity-check.html b/app/portainer/settings/authentication/ldap/ldap-connectivity-check/ldap-connectivity-check.html new file mode 100644 index 000000000..0d88ade53 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-connectivity-check/ldap-connectivity-check.html @@ -0,0 +1,21 @@ +
+ +
+ +
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-custom-group-search/index.js b/app/portainer/settings/authentication/ldap/ldap-custom-group-search/index.js new file mode 100644 index 000000000..1f51f32f8 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-custom-group-search/index.js @@ -0,0 +1,11 @@ +import controller from './ldap-custom-group-search.controller'; + +export const ldapCustomGroupSearch = { + templateUrl: './ldap-custom-group-search.html', + controller, + bindings: { + settings: '=', + onSearchClick: '<', + limitedFeatureId: '<', + }, +}; diff --git a/app/portainer/settings/authentication/ldap/ldap-custom-group-search/ldap-custom-group-search.controller.js b/app/portainer/settings/authentication/ldap/ldap-custom-group-search/ldap-custom-group-search.controller.js new file mode 100644 index 000000000..4c746f50a --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-custom-group-search/ldap-custom-group-search.controller.js @@ -0,0 +1,34 @@ +export default class LdapCustomGroupSearchController { + /* @ngInject */ + constructor($async, Notifications) { + Object.assign(this, { $async, Notifications }); + + this.groups = null; + this.showTable = false; + + this.onRemoveClick = this.onRemoveClick.bind(this); + this.onAddClick = this.onAddClick.bind(this); + this.search = this.search.bind(this); + } + + onAddClick() { + this.settings.push({ GroupBaseDN: '', GroupAttribute: '', GroupFilter: '' }); + } + + onRemoveClick(index) { + this.settings.splice(index, 1); + } + + search() { + return this.$async(async () => { + try { + this.groups = null; + this.showTable = true; + this.groups = await this.onSearchClick(); + } catch (error) { + this.showTable = false; + this.Notifications.error('Failure', error, 'Failed to search users'); + } + }); + } +} diff --git a/app/portainer/settings/authentication/ldap/ldap-custom-group-search/ldap-custom-group-search.html b/app/portainer/settings/authentication/ldap/ldap-custom-group-search/ldap-custom-group-search.html new file mode 100644 index 000000000..e7db2ed5b --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-custom-group-search/ldap-custom-group-search.html @@ -0,0 +1,117 @@ +
+ Teams auto-population configurations +
+ + + +
+ + Extra search configuration + +
+ +
+ +
+ +
+ + +
+ +
+
+
+ +
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-custom-user-search/index.js b/app/portainer/settings/authentication/ldap/ldap-custom-user-search/index.js new file mode 100644 index 000000000..06fdedd24 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-custom-user-search/index.js @@ -0,0 +1,11 @@ +import controller from './ldap-custom-user-search.controller'; + +export const ldapCustomUserSearch = { + templateUrl: './ldap-custom-user-search.html', + controller, + bindings: { + settings: '=', + onSearchClick: '<', + limitedFeatureId: '<', + }, +}; diff --git a/app/portainer/settings/authentication/ldap/ldap-custom-user-search/ldap-custom-user-search.controller.js b/app/portainer/settings/authentication/ldap/ldap-custom-user-search/ldap-custom-user-search.controller.js new file mode 100644 index 000000000..e672e9ed4 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-custom-user-search/ldap-custom-user-search.controller.js @@ -0,0 +1,33 @@ +export default class LdapCustomUserSearchController { + /* @ngInject */ + constructor($async, Notifications) { + Object.assign(this, { $async, Notifications }); + + this.users = null; + + this.onRemoveClick = this.onRemoveClick.bind(this); + this.onAddClick = this.onAddClick.bind(this); + this.search = this.search.bind(this); + } + + onAddClick() { + this.settings.push({ BaseDN: '', UserNameAttribute: '', Filter: '' }); + } + + onRemoveClick(index) { + this.settings.splice(index, 1); + } + + search() { + return this.$async(async () => { + try { + this.users = null; + this.showTable = true; + this.users = await this.onSearchClick(); + } catch (error) { + this.showTable = false; + this.Notifications.error('Failure', error, 'Failed to search users'); + } + }); + } +} diff --git a/app/portainer/settings/authentication/ldap/ldap-custom-user-search/ldap-custom-user-search.html b/app/portainer/settings/authentication/ldap/ldap-custom-user-search/ldap-custom-user-search.html new file mode 100644 index 000000000..331d8e8ab --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-custom-user-search/ldap-custom-user-search.html @@ -0,0 +1,117 @@ +
+ User search configurations +
+ + + +
+ + Extra search configuration + +
+ +
+ +
+ +
+ + +
+ +
+
+
+ +
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-group-search-item/index.js b/app/portainer/settings/authentication/ldap/ldap-group-search-item/index.js new file mode 100644 index 000000000..62fcd5b1f --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-group-search-item/index.js @@ -0,0 +1,15 @@ +import controller from './ldap-group-search-item.controller'; + +export const ldapGroupSearchItem = { + templateUrl: './ldap-group-search-item.html', + controller, + bindings: { + config: '=', + index: '<', + domainSuffix: '@', + baseFilter: '@', + + onRemoveClick: '<', + limitedFeatureId: '<', + }, +}; diff --git a/app/portainer/settings/authentication/ldap/ldap-group-search-item/ldap-group-search-item.controller.js b/app/portainer/settings/authentication/ldap/ldap-group-search-item/ldap-group-search-item.controller.js new file mode 100644 index 000000000..95a1cc31a --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-group-search-item/ldap-group-search-item.controller.js @@ -0,0 +1,51 @@ +export default class LdapSettingsAdGroupSearchItemController { + /* @ngInject */ + constructor(Notifications) { + Object.assign(this, { Notifications }); + + this.groups = []; + + this.onChangeBaseDN = this.onChangeBaseDN.bind(this); + } + + onChangeBaseDN(baseDN) { + this.config.GroupBaseDN = baseDN; + } + + addGroup() { + this.groups.push({ type: 'ou', value: '' }); + } + + removeGroup($index) { + this.groups.splice($index, 1); + this.onGroupsChange(); + } + + onGroupsChange() { + const groupsFilter = this.groups.map(({ type, value }) => `(${type}=${value})`).join(''); + this.onFilterChange(groupsFilter ? `(&${this.baseFilter}(|${groupsFilter}))` : `${this.baseFilter}`); + } + + onFilterChange(filter) { + this.config.GroupFilter = filter; + } + + parseGroupFilter() { + const match = this.config.GroupFilter.match(/^\(&\(objectClass=(\w+)\)\(\|((\(\w+=.+\))+)\)\)$/); + if (!match) { + return; + } + + const [, , groupFilter = ''] = match; + + this.groups = groupFilter + .slice(1, -1) + .split(')(') + .map((str) => str.split('=')) + .map(([type, value]) => ({ type, value })); + } + + $onInit() { + this.parseGroupFilter(); + } +} diff --git a/app/portainer/settings/authentication/ldap/ldap-group-search-item/ldap-group-search-item.html b/app/portainer/settings/authentication/ldap/ldap-group-search-item/ldap-group-search-item.html new file mode 100644 index 000000000..cb3791e28 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-group-search-item/ldap-group-search-item.html @@ -0,0 +1,93 @@ + + +
+ + Extra search configuration + + +
+ + + +
+ +
+ {{ $ctrl.config.GroupBaseDN }} +
+
+ +
+
+ + + add another group + +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+ +
+ +
+ {{ $ctrl.config.GroupFilter }} +
+
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-group-search/index.js b/app/portainer/settings/authentication/ldap/ldap-group-search/index.js new file mode 100644 index 000000000..8b610ddcb --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-group-search/index.js @@ -0,0 +1,14 @@ +import controller from './ldap-group-search.controller'; + +export const ldapGroupSearch = { + templateUrl: './ldap-group-search.html', + controller, + bindings: { + settings: '=', + domainSuffix: '@', + baseFilter: '@', + + onSearchClick: '<', + limitedFeatureId: '<', + }, +}; diff --git a/app/portainer/settings/authentication/ldap/ldap-group-search/ldap-group-search.controller.js b/app/portainer/settings/authentication/ldap/ldap-group-search/ldap-group-search.controller.js new file mode 100644 index 000000000..c431bb230 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-group-search/ldap-group-search.controller.js @@ -0,0 +1,36 @@ +import _ from 'lodash-es'; + +export default class LdapGroupSearchController { + /* @ngInject */ + constructor($async, Notifications) { + Object.assign(this, { $async, Notifications }); + + this.groups = null; + + this.onRemoveClick = this.onRemoveClick.bind(this); + this.onAddClick = this.onAddClick.bind(this); + this.search = this.search.bind(this); + } + + onAddClick() { + const lastSetting = _.last(this.settings); + this.settings.push({ GroupBaseDN: this.domainSuffix, GroupAttribute: lastSetting.GroupAttribute, GroupFilter: this.baseFilter }); + } + + onRemoveClick(index) { + this.settings.splice(index, 1); + } + + search() { + return this.$async(async () => { + try { + this.groups = null; + this.showTable = true; + this.groups = await this.onSearchClick(); + } catch (error) { + this.showTable = false; + this.Notifications.error('Failure', error, 'Failed to search users'); + } + }); + } +} diff --git a/app/portainer/settings/authentication/ldap/ldap-group-search/ldap-group-search.html b/app/portainer/settings/authentication/ldap/ldap-group-search/ldap-group-search.html new file mode 100644 index 000000000..473aade4b --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-group-search/ldap-group-search.html @@ -0,0 +1,39 @@ +
+ Teams auto-population configurations +
+ +
+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
diff --git a/app/portainer/components/datatables/roles-datatable/rolesDatatable.js b/app/portainer/settings/authentication/ldap/ldap-groups-datatable/index.js similarity index 63% rename from app/portainer/components/datatables/roles-datatable/rolesDatatable.js rename to app/portainer/settings/authentication/ldap/ldap-groups-datatable/index.js index 3cb7cf24a..28cacef0c 100644 --- a/app/portainer/components/datatables/roles-datatable/rolesDatatable.js +++ b/app/portainer/settings/authentication/ldap/ldap-groups-datatable/index.js @@ -1,5 +1,5 @@ -angular.module('portainer.app').component('rolesDatatable', { - templateUrl: './rolesDatatable.html', +export const ldapGroupsDatatable = { + templateUrl: './ldap-groups-datatable.html', controller: 'GenericDatatableController', bindings: { titleText: '@', @@ -9,4 +9,4 @@ angular.module('portainer.app').component('rolesDatatable', { orderBy: '@', reverseOrder: '<', }, -}); +}; diff --git a/app/portainer/settings/authentication/ldap/ldap-groups-datatable/ldap-groups-datatable.html b/app/portainer/settings/authentication/ldap/ldap-groups-datatable/ldap-groups-datatable.html new file mode 100644 index 000000000..061448f70 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-groups-datatable/ldap-groups-datatable.html @@ -0,0 +1,77 @@ +
+ + +
+
{{ $ctrl.titleText }}
+
+ +
+ + + + + + + + + + + + + + + + + + + +
+ + User Name + + + + + Groups +
+ {{ item.Name }} + +

{{ group }}

+
Loading...
No groups found.
+
+ +
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-custom/index.js b/app/portainer/settings/authentication/ldap/ldap-settings-custom/index.js new file mode 100644 index 000000000..321223717 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings-custom/index.js @@ -0,0 +1,15 @@ +import controller from './ldap-settings-custom.controller'; + +export const ldapSettingsCustom = { + templateUrl: './ldap-settings-custom.html', + controller, + bindings: { + settings: '=', + tlscaCert: '=', + state: '=', + onTlscaCertChange: '<', + connectivityCheck: '<', + onSearchUsersClick: '<', + onSearchGroupsClick: '<', + }, +}; diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-custom/ldap-settings-custom.controller.js b/app/portainer/settings/authentication/ldap/ldap-settings-custom/ldap-settings-custom.controller.js new file mode 100644 index 000000000..b4c3de4d5 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings-custom/ldap-settings-custom.controller.js @@ -0,0 +1,15 @@ +import { EXTERNAL_AUTH_LDAP } from '@/portainer/feature-flags/feature-ids'; + +export default class LdapSettingsCustomController { + constructor() { + this.limitedFeatureId = EXTERNAL_AUTH_LDAP; + } + + addLDAPUrl() { + this.settings.URLs.push(''); + } + + removeLDAPUrl(index) { + this.settings.URLs.splice(index, 1); + } +} diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-custom/ldap-settings-custom.html b/app/portainer/settings/authentication/ldap/ldap-settings-custom/ldap-settings-custom.html new file mode 100644 index 000000000..00690e750 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings-custom/ldap-settings-custom.html @@ -0,0 +1,119 @@ +
+
+ Information +
+
+ When using LDAP authentication, Portainer will delegate user authentication to a LDAP server and fallback to internal authentication if LDAP authentication fails. +
+
+ +
+ LDAP configuration +
+ +
+
+

+ + You can configure multiple LDAP Servers for authentication fallback. Make sure all servers are using the same configuration (i.e. if TLS is enabled, they should all use the + same certificates). +

+
+
+ +
+ +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+ +
+ +
+ +
+
+
+ + + + + + + + + + +
+ +
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-dn-builder/index.js b/app/portainer/settings/authentication/ldap/ldap-settings-dn-builder/index.js new file mode 100644 index 000000000..32ab9fe29 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings-dn-builder/index.js @@ -0,0 +1,16 @@ +import controller from './ldap-settings-dn-builder.controller'; + +export const ldapSettingsDnBuilder = { + templateUrl: './ldap-settings-dn-builder.html', + controller, + bindings: { + // ngModel: string (dc=,cn=,) + ngModel: '<', + // onChange(string) => void + onChange: '<', + // suffix: string (dc=,dc=,) + suffix: '@', + label: '@', + limitedFeatureId: '<', + }, +}; diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-dn-builder/ldap-settings-dn-builder.controller.js b/app/portainer/settings/authentication/ldap/ldap-settings-dn-builder/ldap-settings-dn-builder.controller.js new file mode 100644 index 000000000..4b829967a --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings-dn-builder/ldap-settings-dn-builder.controller.js @@ -0,0 +1,84 @@ +export default class LdapSettingsBaseDnBuilderController { + /* @ngInject */ + constructor() { + this.entries = []; + } + + addEntry() { + this.entries.push({ type: 'ou', value: '' }); + } + + removeEntry($index) { + this.entries.splice($index, 1); + this.onEntriesChange(); + } + + moveUp($index) { + if ($index <= 0) { + return; + } + arrayMove(this.entries, $index, $index - 1); + this.onEntriesChange(); + } + + moveDown($index) { + if ($index >= this.entries.length - 1) { + return; + } + arrayMove(this.entries, $index, $index + 1); + this.onEntriesChange(); + } + + onEntriesChange() { + const dn = this.entries + .filter(({ value }) => value) + .map(({ type, value }) => `${type}=${value}`) + .concat(this.suffix) + .filter((value) => value) + .join(','); + + this.onChange(dn); + } + + getOUValues(dn, domainSuffix = '') { + const regex = /(\w+)=(\w*),?/; + let ouValues = []; + let left = dn; + let match = left.match(regex); + while (match && left !== domainSuffix) { + const [, type, value] = match; + ouValues.push({ type, value }); + left = left.replace(regex, ''); + match = left.match(/(\w+)=(\w+),?/); + } + return ouValues; + } + + parseBaseDN() { + this.entries = this.getOUValues(this.ngModel, this.suffix); + } + + $onChanges({ suffix, ngModel }) { + if ((!suffix && !ngModel) || (suffix && suffix.isFirstChange())) { + return; + } + this.onEntriesChange(); + } + + $onInit() { + this.parseBaseDN(); + } +} + +function arrayMove(array, fromIndex, toIndex) { + if (!checkValidIndex(array, fromIndex) || !checkValidIndex(array, toIndex)) { + throw new Error('index is out of bounds'); + } + const [item] = array.splice(fromIndex, 1); + + array.splice(toIndex, 0, item); + + function checkValidIndex(array, index) { + return index >= 0 && index <= array.length; + } +} diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-dn-builder/ldap-settings-dn-builder.html b/app/portainer/settings/authentication/ldap/ldap-settings-dn-builder/ldap-settings-dn-builder.html new file mode 100644 index 000000000..ab1902981 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings-dn-builder/ldap-settings-dn-builder.html @@ -0,0 +1,69 @@ +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ + + +
+
+
+
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-group-dn-builder/index.js b/app/portainer/settings/authentication/ldap/ldap-settings-group-dn-builder/index.js new file mode 100644 index 000000000..21a2a8625 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings-group-dn-builder/index.js @@ -0,0 +1,18 @@ +import controller from './ldap-settings-group-dn-builder.controller'; + +export const ldapSettingsGroupDnBuilder = { + templateUrl: './ldap-settings-group-dn-builder.html', + controller, + bindings: { + // ngModel: string (dc=,cn=,) + ngModel: '<', + // onChange(string) => void + onChange: '<', + // suffix: string (dc=,dc=,) + suffix: '@', + // index: int >= 0 + index: '<', + onRemoveClick: '<', + limitedFeatureId: '<', + }, +}; diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-group-dn-builder/ldap-settings-group-dn-builder.controller.js b/app/portainer/settings/authentication/ldap/ldap-settings-group-dn-builder/ldap-settings-group-dn-builder.controller.js new file mode 100644 index 000000000..32ee7f3ee --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings-group-dn-builder/ldap-settings-group-dn-builder.controller.js @@ -0,0 +1,55 @@ +export default class LdapSettingsGroupDnBuilderController { + /* @ngInject */ + constructor() { + this.groupName = ''; + this.entries = ''; + + this.onEntriesChange = this.onEntriesChange.bind(this); + this.onGroupNameChange = this.onGroupNameChange.bind(this); + this.onGroupChange = this.onGroupChange.bind(this); + this.removeGroup = this.removeGroup.bind(this); + } + + onEntriesChange(entries) { + this.onGroupChange(this.groupName, entries); + } + + onGroupNameChange() { + this.onGroupChange(this.groupName, this.entries); + } + + onGroupChange(groupName, entries) { + if (!groupName) { + return; + } + const groupNameEntry = `cn=${groupName}`; + this.onChange(this.index, entries || this.suffix ? `${groupNameEntry},${entries || this.suffix}` : groupNameEntry); + } + + removeGroup() { + this.onRemoveClick(this.index); + } + + parseEntries(value, suffix) { + if (value === suffix) { + this.groupName = ''; + this.entries = suffix; + return; + } + + const [groupName, entries] = this.ngModel.split(/,(.+)/); + this.groupName = groupName.replace('cn=', ''); + this.entries = entries || ''; + } + + $onChange({ ngModel, suffix }) { + if ((!suffix || suffix.isFirstChange()) && !ngModel) { + return; + } + this.parseEntries(ngModel.value, suffix.value); + } + + $onInit() { + this.parseEntries(this.ngModel, this.suffix); + } +} diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-group-dn-builder/ldap-settings-group-dn-builder.html b/app/portainer/settings/authentication/ldap/ldap-settings-group-dn-builder/ldap-settings-group-dn-builder.html new file mode 100644 index 000000000..b00f63c47 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings-group-dn-builder/ldap-settings-group-dn-builder.html @@ -0,0 +1,37 @@ +
+ +
+ +
+
+ +
+
+ + diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-openldap/index.js b/app/portainer/settings/authentication/ldap/ldap-settings-openldap/index.js new file mode 100644 index 000000000..b88f8008c --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings-openldap/index.js @@ -0,0 +1,16 @@ +import controller from './ldap-settings-openldap.controller'; + +export const ldapSettingsOpenLdap = { + templateUrl: './ldap-settings-openldap.html', + controller, + bindings: { + settings: '=', + tlscaCert: '=', + state: '=', + connectivityCheck: '<', + + onTlscaCertChange: '<', + onSearchUsersClick: '<', + onSearchGroupsClick: '<', + }, +}; diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-openldap/ldap-settings-openldap.controller.js b/app/portainer/settings/authentication/ldap/ldap-settings-openldap/ldap-settings-openldap.controller.js new file mode 100644 index 000000000..5115548d1 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings-openldap/ldap-settings-openldap.controller.js @@ -0,0 +1,45 @@ +import { EXTERNAL_AUTH_LDAP } from '@/portainer/feature-flags/feature-ids'; + +export default class LdapSettingsOpenLDAPController { + /* @ngInject */ + constructor() { + this.domainSuffix = ''; + this.limitedFeatureId = EXTERNAL_AUTH_LDAP; + + this.findDomainSuffix = this.findDomainSuffix.bind(this); + this.parseDomainSuffix = this.parseDomainSuffix.bind(this); + this.onAccountChange = this.onAccountChange.bind(this); + } + + findDomainSuffix() { + const serviceAccount = this.settings.ReaderDN; + let domainSuffix = this.parseDomainSuffix(serviceAccount); + if (!domainSuffix && this.settings.SearchSettings.length > 0) { + const searchSettings = this.settings.SearchSettings[0]; + domainSuffix = this.parseDomainSuffix(searchSettings.BaseDN); + } + + this.domainSuffix = domainSuffix; + } + + parseDomainSuffix(string = '') { + const index = string.toLowerCase().indexOf('dc='); + return index !== -1 ? string.substring(index) : ''; + } + + onAccountChange(serviceAccount) { + this.domainSuffix = this.parseDomainSuffix(serviceAccount); + } + + addLDAPUrl() { + this.settings.URLs.push(''); + } + + removeLDAPUrl(index) { + this.settings.URLs.splice(index, 1); + } + + $onInit() { + this.findDomainSuffix(); + } +} diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-openldap/ldap-settings-openldap.html b/app/portainer/settings/authentication/ldap/ldap-settings-openldap/ldap-settings-openldap.html new file mode 100644 index 000000000..b6893961b --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings-openldap/ldap-settings-openldap.html @@ -0,0 +1,187 @@ + + + +
+
+ Information +
+
+ When using LDAP authentication, Portainer will delegate user authentication to a LDAP server and fallback to internal authentication if LDAP authentication fails. +
+
+ +
+ LDAP configuration +
+ +
+
+

+ + You can configure multiple LDAP Servers for authentication fallback. Make sure all servers are using the same configuration (i.e. if TLS is enabled, they should all use the + same certificates). +

+
+
+ +
+ +
+
+ + +
+
+
+ + +
+ +
+ +
+
+ + +
+
+ +
+ +
+
+ +
+ +
+ +
+
+
+ +
+ +
+ +
+
+ + + + + + + + + + + + +
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-security/index.js b/app/portainer/settings/authentication/ldap/ldap-settings-security/index.js new file mode 100644 index 000000000..75f9fce5e --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings-security/index.js @@ -0,0 +1,11 @@ +export const ldapSettingsSecurity = { + templateUrl: './ldap-settings-security.html', + bindings: { + settings: '=', + tlscaCert: '<', + onTlscaCertChange: '<', + uploadInProgress: '<', + title: '@', + limitedFeatureId: '<', + }, +}; diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-security/ldap-settings-security.html b/app/portainer/settings/authentication/ldap/ldap-settings-security/ldap-settings-security.html new file mode 100644 index 000000000..6bab54bff --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings-security/ldap-settings-security.html @@ -0,0 +1,72 @@ +
+ {{ $ctrl.title || 'LDAP security' }} +
+ + +
+ +
+ +
+
+ + + +
+ +
+ +
+
+ + + +
+ +
+ +
+
+ + + +
+ +
+ + + {{ $ctrl.tlscaCert.name }} + + + + +
+
+ diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-test-login/index.js b/app/portainer/settings/authentication/ldap/ldap-settings-test-login/index.js new file mode 100644 index 000000000..b5298616c --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings-test-login/index.js @@ -0,0 +1,11 @@ +import controller from './ldap-settings-test-login.controller'; + +export const ldapSettingsTestLogin = { + templateUrl: './ldap-settings-test-login.html', + controller, + bindings: { + settings: '=', + limitedFeatureId: '<', + showBeIndicatorIfNeeded: '<', + }, +}; diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-test-login/ldap-settings-test-login.controller.js b/app/portainer/settings/authentication/ldap/ldap-settings-test-login/ldap-settings-test-login.controller.js new file mode 100644 index 000000000..811f70aa9 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings-test-login/ldap-settings-test-login.controller.js @@ -0,0 +1,31 @@ +const TEST_STATUS = { + LOADING: 'LOADING', + SUCCESS: 'SUCCESS', + FAILURE: 'FAILURE', +}; + +export default class LdapSettingsTestLogin { + /* @ngInject */ + constructor($async, LDAPService, Notifications) { + Object.assign(this, { $async, LDAPService, Notifications }); + + this.TEST_STATUS = TEST_STATUS; + + this.state = { + testStatus: '', + }; + } + + async testLogin(username, password) { + return this.$async(async () => { + this.state.testStatus = TEST_STATUS.LOADING; + try { + const response = await this.LDAPService.testLogin(this.settings, username, password); + this.state.testStatus = response.valid ? TEST_STATUS.SUCCESS : TEST_STATUS.FAILURE; + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to test login'); + this.state.testStatus = TEST_STATUS.FAILURE; + } + }); + } +} diff --git a/app/portainer/settings/authentication/ldap/ldap-settings-test-login/ldap-settings-test-login.html b/app/portainer/settings/authentication/ldap/ldap-settings-test-login/ldap-settings-test-login.html new file mode 100644 index 000000000..5a0871a61 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings-test-login/ldap-settings-test-login.html @@ -0,0 +1,45 @@ +
+ Test login +
+
+
+ + +
+ +
+ + +
+ +
+ + + +
+ + +
diff --git a/app/portainer/settings/authentication/ldap/ldap-settings.model.js b/app/portainer/settings/authentication/ldap/ldap-settings.model.js new file mode 100644 index 000000000..d14711eec --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings.model.js @@ -0,0 +1,54 @@ +export function buildLdapSettingsModel() { + return { + AnonymousMode: true, + ReaderDN: '', + URLs: [''], + ServerType: 0, + TLSConfig: { + TLS: false, + TLSSkipVerify: false, + }, + StartTLS: false, + SearchSettings: [ + { + BaseDN: '', + Filter: '', + UserNameAttribute: '', + }, + ], + GroupSearchSettings: [ + { + GroupBaseDN: '', + GroupFilter: '', + GroupAttribute: '', + }, + ], + AutoCreateUsers: true, + }; +} + +export function buildAdSettingsModel() { + const settings = buildLdapSettingsModel(); + + settings.ServerType = 2; + settings.AnonymousMode = false; + settings.SearchSettings[0].UserNameAttribute = 'sAMAccountName'; + settings.SearchSettings[0].Filter = '(objectClass=user)'; + settings.GroupSearchSettings[0].GroupAttribute = 'member'; + settings.GroupSearchSettings[0].GroupFilter = '(objectClass=group)'; + + return settings; +} + +export function buildOpenLDAPSettingsModel() { + const settings = buildLdapSettingsModel(); + + settings.ServerType = 1; + settings.AnonymousMode = false; + settings.SearchSettings[0].UserNameAttribute = 'uid'; + settings.SearchSettings[0].Filter = '(objectClass=inetOrgPerson)'; + settings.GroupSearchSettings[0].GroupAttribute = 'member'; + settings.GroupSearchSettings[0].GroupFilter = '(objectClass=groupOfNames)'; + + return settings; +} diff --git a/app/portainer/settings/authentication/ldap/ldap-settings/index.js b/app/portainer/settings/authentication/ldap/ldap-settings/index.js new file mode 100644 index 000000000..90e86951e --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings/index.js @@ -0,0 +1,11 @@ +import controller from './ldap-settings.controller'; + +export const ldapSettings = { + templateUrl: './ldap-settings.html', + controller, + bindings: { + settings: '=', + state: '<', + connectivityCheck: '<', + }, +}; diff --git a/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.controller.js b/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.controller.js new file mode 100644 index 000000000..b989393ab --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.controller.js @@ -0,0 +1,67 @@ +const SERVER_TYPES = { + CUSTOM: 0, + OPEN_LDAP: 1, + AD: 2, +}; + +import { buildOpenLDAPSettingsModel } from '@/portainer/settings/authentication/ldap/ldap-settings.model'; +import { EXTERNAL_AUTH_LDAP } from '@/portainer/feature-flags/feature-ids'; + +const DEFAULT_GROUP_FILTER = '(objectClass=groupOfNames)'; +const DEFAULT_USER_FILTER = '(objectClass=inetOrgPerson)'; + +export default class LdapSettingsController { + /* @ngInject */ + constructor(LDAPService) { + Object.assign(this, { LDAPService, SERVER_TYPES }); + + this.tlscaCert = null; + + this.boxSelectorOptions = [ + { id: 'ldap_custom', value: SERVER_TYPES.CUSTOM, label: 'Custom', icon: 'fa fa-server' }, + { id: 'ldap_openldap', value: SERVER_TYPES.OPEN_LDAP, label: 'OpenLDAP', icon: 'fa fa-server', feature: EXTERNAL_AUTH_LDAP }, + ]; + + this.onTlscaCertChange = this.onTlscaCertChange.bind(this); + this.searchUsers = this.searchUsers.bind(this); + this.searchGroups = this.searchGroups.bind(this); + this.onChangeServerType = this.onChangeServerType.bind(this); + } + + onTlscaCertChange(file) { + this.tlscaCert = file; + } + + $onInit() { + this.tlscaCert = this.settings.TLSCACert; + } + + onChangeServerType(serverType) { + switch (serverType) { + case SERVER_TYPES.OPEN_LDAP: + return this.onChangeToOpenLDAP(); + default: + break; + } + } + + onChangeToOpenLDAP() { + this.settings = buildOpenLDAPSettingsModel(); + } + + searchUsers() { + const settings = { + ...this.settings, + SearchSettings: this.settings.SearchSettings.map((search) => ({ ...search, Filter: search.Filter || DEFAULT_USER_FILTER })), + }; + return this.LDAPService.users(settings); + } + + searchGroups() { + const settings = { + ...this.settings, + GroupSearchSettings: this.settings.GroupSearchSettings.map((search) => ({ ...search, GroupFilter: search.GroupFilter || DEFAULT_GROUP_FILTER })), + }; + return this.LDAPService.groups(settings); + } +} diff --git a/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.html b/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.html new file mode 100644 index 000000000..ccad9ca6b --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.html @@ -0,0 +1,41 @@ +
+ + + With automatic user provisioning enabled, Portainer will create user(s) automatically with standard user role and assign them to team(s) which matches to LDAP group name(s). + If disabled, users must be created in Portainer beforehand. + + + +
+ Server Type +
+ + + + + +
diff --git a/app/portainer/settings/authentication/ldap/ldap-user-search-item/index.js b/app/portainer/settings/authentication/ldap/ldap-user-search-item/index.js new file mode 100644 index 000000000..32dd36786 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-user-search-item/index.js @@ -0,0 +1,15 @@ +import controller from './ldap-user-search-item.controller'; + +export const ldapUserSearchItem = { + templateUrl: './ldap-user-search-item.html', + controller, + bindings: { + config: '=', + index: '<', + showUsernameFormat: '<', + domainSuffix: '@', + baseFilter: '@', + onRemoveClick: '<', + limitedFeatureId: '<', + }, +}; diff --git a/app/portainer/settings/authentication/ldap/ldap-user-search-item/ldap-user-search-item.controller.js b/app/portainer/settings/authentication/ldap/ldap-user-search-item/ldap-user-search-item.controller.js new file mode 100644 index 000000000..a42ffdc72 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-user-search-item/ldap-user-search-item.controller.js @@ -0,0 +1,67 @@ +export default class LdapUserSearchItemController { + /* @ngInject */ + constructor() { + this.groups = []; + + this.onBaseDNChange = this.onBaseDNChange.bind(this); + this.onGroupChange = this.onGroupChange.bind(this); + this.onGroupsChange = this.onGroupsChange.bind(this); + this.removeGroup = this.removeGroup.bind(this); + } + + onBaseDNChange(baseDN) { + this.config.BaseDN = baseDN; + } + + onGroupChange(index, group) { + this.groups[index] = group; + this.onGroupsChange(this.groups); + } + + onGroupsChange(groups) { + this.config.Filter = this.generateUserFilter(groups); + } + + removeGroup(index) { + this.groups.splice(index, 1); + this.onGroupsChange(this.groups); + } + + addGroup() { + this.groups.push(this.domainSuffix ? `cn=,${this.domainSuffix}` : 'cn='); + } + + generateUserFilter(groups) { + const filteredGroups = groups.filter((group) => group !== this.domainSuffix); + + if (!filteredGroups.length) { + return this.baseFilter; + } + + const groupsFilter = filteredGroups.map((group) => `(memberOf=${group})`); + + return `(&${this.baseFilter}${groupsFilter.length > 1 ? `(|${groupsFilter.join('')})` : groupsFilter[0]})`; + } + + parseFilter() { + const filter = this.config.Filter; + if (filter === this.baseFilter) { + return; + } + + if (!filter.includes('|')) { + const index = filter.indexOf('memberOf='); + if (index > -1) { + this.groups = [filter.slice(index + 9, -2)]; + } + return; + } + + const members = filter.slice(filter.indexOf('|') + 2, -3); + this.groups = members.split(')(').map((member) => member.replace('memberOf=', '')); + } + + $onInit() { + this.parseFilter(); + } +} diff --git a/app/portainer/settings/authentication/ldap/ldap-user-search-item/ldap-user-search-item.html b/app/portainer/settings/authentication/ldap/ldap-user-search-item/ldap-user-search-item.html new file mode 100644 index 000000000..4e75783ff --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-user-search-item/ldap-user-search-item.html @@ -0,0 +1,106 @@ + + +
+ + Extra search configuration + + +
+ +
+
+ +
+
+
+
+ + +
+
+
+
+ +
+ +
+ {{ $ctrl.config.BaseDN }} +
+
+ + + +
+
+ + +
+
+
+ + + + + +
+
+
+ +
+ +
+ {{ $ctrl.config.Filter }} +
+
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-user-search/index.js b/app/portainer/settings/authentication/ldap/ldap-user-search/index.js new file mode 100644 index 000000000..380ddcfd3 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-user-search/index.js @@ -0,0 +1,15 @@ +import controller from './ldap-user-search.controller'; + +export const ldapUserSearch = { + templateUrl: './ldap-user-search.html', + controller, + bindings: { + settings: '=', + domainSuffix: '@', + showUsernameFormat: '<', + baseFilter: '@', + limitedFeatureId: '<', + + onSearchClick: '<', + }, +}; diff --git a/app/portainer/settings/authentication/ldap/ldap-user-search/ldap-user-search.controller.js b/app/portainer/settings/authentication/ldap/ldap-user-search/ldap-user-search.controller.js new file mode 100644 index 000000000..6d5ff11eb --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-user-search/ldap-user-search.controller.js @@ -0,0 +1,38 @@ +import _ from 'lodash'; + +export default class LdapUserSearchController { + /* @ngInject */ + constructor($async, Notifications) { + Object.assign(this, { $async, Notifications }); + + this.users = null; + this.showTable = false; + + this.onRemoveClick = this.onRemoveClick.bind(this); + this.onAddClick = this.onAddClick.bind(this); + this.search = this.search.bind(this); + } + + onAddClick() { + const lastSetting = _.last(this.settings); + this.settings.push({ BaseDN: this.domainSuffix, UserNameAttribute: lastSetting.UserNameAttribute, Filter: this.baseFilter }); + } + + onRemoveClick(index) { + this.settings.splice(index, 1); + } + + search() { + return this.$async(async () => { + try { + this.users = null; + this.showTable = true; + const users = await this.onSearchClick(); + this.users = _.compact(users); + } catch (error) { + this.Notifications.error('Failure', error, 'Failed to search users'); + this.showTable = false; + } + }); + } +} diff --git a/app/portainer/settings/authentication/ldap/ldap-user-search/ldap-user-search.html b/app/portainer/settings/authentication/ldap/ldap-user-search/ldap-user-search.html new file mode 100644 index 000000000..ae8741fce --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-user-search/ldap-user-search.html @@ -0,0 +1,40 @@ +
+ User search configurations +
+ +
+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
diff --git a/app/portainer/settings/authentication/ldap/ldap-users-datatable/index.js b/app/portainer/settings/authentication/ldap/ldap-users-datatable/index.js new file mode 100644 index 000000000..4c80771d4 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-users-datatable/index.js @@ -0,0 +1,12 @@ +export const ldapUsersDatatable = { + templateUrl: './ldap-users-datatable.html', + controller: 'GenericDatatableController', + bindings: { + titleText: '@', + titleIcon: '@', + dataset: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + }, +}; diff --git a/app/portainer/settings/authentication/ldap/ldap-users-datatable/ldap-users-datatable.html b/app/portainer/settings/authentication/ldap/ldap-users-datatable/ldap-users-datatable.html new file mode 100644 index 000000000..9817654f2 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap-users-datatable/ldap-users-datatable.html @@ -0,0 +1,71 @@ +
+ + +
+
{{ $ctrl.titleText }}
+
+ +
+ + + + + + + + + + + + + + + + + +
+ + Name + + + +
+ {{ item }} +
Loading...
No users found.
+
+ +
+
+
diff --git a/app/portainer/settings/authentication/ldap/ldap.rest.js b/app/portainer/settings/authentication/ldap/ldap.rest.js new file mode 100644 index 000000000..e93d5277c --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap.rest.js @@ -0,0 +1,15 @@ +const API_ENDPOINT_LDAP = 'api/ldap'; + +/* @ngInject */ +export function LDAP($resource) { + return $resource( + `${API_ENDPOINT_LDAP}/:action`, + {}, + { + check: { method: 'POST', params: { action: 'check' } }, + users: { method: 'POST', isArray: true, params: { action: 'users' } }, + groups: { method: 'POST', isArray: true, params: { action: 'groups' } }, + testLogin: { method: 'POST', params: { action: 'test' } }, + } + ); +} diff --git a/app/portainer/settings/authentication/ldap/ldap.service.js b/app/portainer/settings/authentication/ldap/ldap.service.js new file mode 100644 index 000000000..875f83a02 --- /dev/null +++ b/app/portainer/settings/authentication/ldap/ldap.service.js @@ -0,0 +1,29 @@ +/* @ngInject */ +export function LDAPService(LDAP) { + return { users, groups, check, testLogin }; + + function users(ldapSettings) { + return LDAP.users({ ldapSettings }).$promise; + } + + async function groups(ldapSettings) { + const userGroups = await LDAP.groups({ ldapSettings }).$promise; + return userGroups.map(({ Name, Groups }) => { + let name = Name; + if (Name.includes(',') && Name.includes('=')) { + const [cnName] = Name.split(','); + const split = cnName.split('='); + name = split[1]; + } + return { Groups, Name: name }; + }); + } + + function check(ldapSettings) { + return LDAP.check({ ldapSettings }).$promise; + } + + function testLogin(ldapSettings, username, password) { + return LDAP.testLogin({ ldapSettings, username, password }).$promise; + } +} diff --git a/app/portainer/settings/index.js b/app/portainer/settings/index.js index 42e4e25ac..629f9b5ed 100644 --- a/app/portainer/settings/index.js +++ b/app/portainer/settings/index.js @@ -1,5 +1,6 @@ import angular from 'angular'; +import authenticationModule from './authentication'; import generalModule from './general'; -export default angular.module('portainer.settings', [generalModule]).name; +export default angular.module('portainer.settings', [authenticationModule, generalModule]).name; diff --git a/app/portainer/user-activity/activity-logs-view/activity-logs-datatable/activity-logs-datatable.controller.js b/app/portainer/user-activity/activity-logs-view/activity-logs-datatable/activity-logs-datatable.controller.js new file mode 100644 index 000000000..aa3f0c854 --- /dev/null +++ b/app/portainer/user-activity/activity-logs-view/activity-logs-datatable/activity-logs-datatable.controller.js @@ -0,0 +1,38 @@ +export default class ActivityLogsDatatableController { + /* @ngInject */ + constructor($controller, $scope, PaginationService) { + this.PaginationService = PaginationService; + + this.tableKey = 'authLogs'; + + const $onInit = this.$onInit; + angular.extend(this, $controller('GenericDatatableController', { $scope })); + + this.changeSort = this.changeSort.bind(this); + this.handleChangeLimit = this.handleChangeLimit.bind(this); + this.$onInit = $onInit.bind(this); + } + + changeSort(key) { + let desc = false; + if (key === this.sort.key) { + desc = !this.sort.desc; + } + + this.onChangeSort({ key, desc }); + } + + handleChangeLimit(limit) { + this.PaginationService.setPaginationLimit(this.tableKey, limit); + this.onChangeLimit(limit); + } + + $onInit() { + this.$onInitGeneric(); + + const limit = this.PaginationService.getPaginationLimit(this.tableKey); + if (limit) { + this.onChangeLimit(+limit); + } + } +} diff --git a/app/portainer/user-activity/activity-logs-view/activity-logs-datatable/activity-logs-datatable.css b/app/portainer/user-activity/activity-logs-view/activity-logs-datatable/activity-logs-datatable.css new file mode 100644 index 000000000..42eb3b401 --- /dev/null +++ b/app/portainer/user-activity/activity-logs-view/activity-logs-datatable/activity-logs-datatable.css @@ -0,0 +1,3 @@ +.activity-logs-datatable .small-column { + width: 150px; +} diff --git a/app/portainer/user-activity/activity-logs-view/activity-logs-datatable/activity-logs-datatable.html b/app/portainer/user-activity/activity-logs-view/activity-logs-datatable/activity-logs-datatable.html new file mode 100644 index 000000000..ac2f1bbaf --- /dev/null +++ b/app/portainer/user-activity/activity-logs-view/activity-logs-datatable/activity-logs-datatable.html @@ -0,0 +1,66 @@ +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Time + + + + + User + + + + + Environment + + + + Action + + Payload +
+ +
Loading...
+ No logs available. +
+
+ +
+
+
diff --git a/app/portainer/user-activity/activity-logs-view/activity-logs-datatable/index.js b/app/portainer/user-activity/activity-logs-view/activity-logs-datatable/index.js new file mode 100644 index 000000000..9550fb36e --- /dev/null +++ b/app/portainer/user-activity/activity-logs-view/activity-logs-datatable/index.js @@ -0,0 +1,24 @@ +import './activity-logs-datatable.css'; + +import controller from './activity-logs-datatable.controller.js'; + +export const activityLogsDatatable = { + templateUrl: './activity-logs-datatable.html', + controller, + bindings: { + logs: '<', + keyword: '<', + sort: '<', + limit: '<', + totalItems: '<', + currentPage: '<', + feature: '@', + + onChangeContextFilter: '<', + onChangeKeyword: '<', + onChangeSort: '<', + + onChangeLimit: '<', + onChangePage: '<', + }, +}; diff --git a/app/portainer/user-activity/activity-logs-view/activity-logs-view.controller.js b/app/portainer/user-activity/activity-logs-view/activity-logs-view.controller.js new file mode 100644 index 000000000..77957cb59 --- /dev/null +++ b/app/portainer/user-activity/activity-logs-view/activity-logs-view.controller.js @@ -0,0 +1,93 @@ +import moment from 'moment'; +import { ACTIVITY_AUDIT } from '@/portainer/feature-flags/feature-ids'; +export default class ActivityLogsViewController { + /* @ngInject */ + constructor($async, Notifications) { + this.$async = $async; + this.Notifications = Notifications; + this.limitedFeature = ACTIVITY_AUDIT; + this.state = { + keyword: '', + date: { + from: 0, + to: 0, + }, + sort: { + key: 'Timestamp', + desc: true, + }, + page: 1, + limit: 10, + totalItems: 0, + logs: null, + }; + + this.today = moment().endOf('day'); + this.minValidDate = moment().subtract(7, 'd').startOf('day'); + + this.onChangeDate = this.onChangeDate.bind(this); + this.onChangeKeyword = this.onChangeKeyword.bind(this); + this.onChangeSort = this.onChangeSort.bind(this); + this.loadLogs = this.loadLogs.bind(this); + this.onChangePage = this.onChangePage.bind(this); + this.onChangeLimit = this.onChangeLimit.bind(this); + } + + onChangePage(page) { + this.state.page = page; + this.loadLogs(); + } + + onChangeLimit(limit) { + this.state.page = 1; + this.state.limit = limit; + this.loadLogs(); + } + + onChangeSort(sort) { + this.state.page = 1; + this.state.sort = sort; + this.loadLogs(); + } + + onChangeKeyword(keyword) { + this.state.page = 1; + this.state.keyword = keyword; + this.loadLogs(); + } + + onChangeDate({ startDate, endDate }) { + this.state.page = 1; + this.state.date = { to: endDate, from: startDate }; + this.loadLogs(); + } + + async export() { + return this.$async(async () => { + try { + await this.UserActivityService.saveLogsAsCSV(this.state.sort, this.state.keyword, this.state.date, this.state.contextFilter); + } catch (err) { + this.Notifications.error('Failure', err, 'Failed loading user activity logs csv'); + } + }); + } + + async loadLogs() { + return this.$async(async () => { + this.state.logs = null; + try { + const { logs, totalCount } = { logs: [{}, {}, {}, {}, {}], totalCount: 5 }; + this.state.logs = logs; + this.state.totalItems = totalCount; + } catch (err) { + this.Notifications.error('Failure', err, 'Failed loading user activity logs'); + } + }); + } + + $onInit() { + return this.$async(async () => { + this.loadLogs(); + }); + } +} diff --git a/app/portainer/user-activity/activity-logs-view/activity-logs-view.html b/app/portainer/user-activity/activity-logs-view/activity-logs-view.html new file mode 100644 index 000000000..b33b728e7 --- /dev/null +++ b/app/portainer/user-activity/activity-logs-view/activity-logs-view.html @@ -0,0 +1,48 @@ + + + + + + + Activity Logs + + +
+ + +
+
+ +
+ +
+
+
+ +

+ + Portainer user activity logs have a maximum retention of 7 days. +

+ +
+ + +
+
+
+ + +
diff --git a/app/portainer/user-activity/activity-logs-view/activity-logs-view.js b/app/portainer/user-activity/activity-logs-view/activity-logs-view.js new file mode 100644 index 000000000..52082d363 --- /dev/null +++ b/app/portainer/user-activity/activity-logs-view/activity-logs-view.js @@ -0,0 +1,6 @@ +import controller from './activity-logs-view.controller.js'; + +export const activityLogsView = { + templateUrl: './activity-logs-view.html', + controller, +}; diff --git a/app/portainer/user-activity/activity-logs-view/index.js b/app/portainer/user-activity/activity-logs-view/index.js new file mode 100644 index 000000000..da8c69cb4 --- /dev/null +++ b/app/portainer/user-activity/activity-logs-view/index.js @@ -0,0 +1,9 @@ +import angular from 'angular'; + +import { activityLogsView } from './activity-logs-view'; +import { activityLogsDatatable } from './activity-logs-datatable'; + +export default angular + .module('portainer.app.user-activity.activity-logs-view', []) + .component('activityLogsDatatable', activityLogsDatatable) + .component('activityLogsView', activityLogsView).name; diff --git a/app/portainer/user-activity/auth-logs-view/auth-logs-datatable/auth-logs-datatable.controller.js b/app/portainer/user-activity/auth-logs-view/auth-logs-datatable/auth-logs-datatable.controller.js new file mode 100644 index 000000000..cd053f5c7 --- /dev/null +++ b/app/portainer/user-activity/auth-logs-view/auth-logs-datatable/auth-logs-datatable.controller.js @@ -0,0 +1,68 @@ +import { authenticationMethodTypesMap, authenticationMethodTypesLabels } from '@/portainer/settings/authentication/auth-method-constants'; +import { authenticationActivityTypesMap, authenticationActivityTypesLabels } from '@/portainer/settings/authentication/auth-type-constants'; + +class ActivityLogsDatatableController { + /* @ngInject */ + constructor($controller, $scope, PaginationService) { + this.PaginationService = PaginationService; + + this.tableKey = 'authLogs'; + + this.contextFilterLabels = Object.values(authenticationMethodTypesMap).map((value) => ({ value, label: authenticationMethodTypesLabels[value] })); + this.typeFilterLabels = Object.values(authenticationActivityTypesMap).map((value) => ({ value, label: authenticationActivityTypesLabels[value] })); + + const $onInit = this.$onInit; + angular.extend(this, $controller('GenericDatatableController', { $scope })); + this.$onInit = $onInit.bind(this); + + this.changeSort = this.changeSort.bind(this); + this.handleChangeLimit = this.handleChangeLimit.bind(this); + } + + changeSort(key) { + let desc = false; + if (key === this.sort.key) { + desc = !this.sort.desc; + } + + this.onChangeSort({ key, desc }); + } + + contextType(context) { + if (!(context in authenticationMethodTypesLabels)) { + return ''; + } + return authenticationMethodTypesLabels[context]; + } + + activityType(type) { + if (!(type in authenticationActivityTypesLabels)) { + return ''; + } + return authenticationActivityTypesLabels[type]; + } + + isAuthSuccess(type) { + return type === authenticationActivityTypesMap.AuthSuccess; + } + + isAuthFailure(type) { + return type === authenticationActivityTypesMap.AuthFailure; + } + + handleChangeLimit(limit) { + this.PaginationService.setPaginationLimit(this.tableKey, limit); + this.onChangeLimit(limit); + } + + $onInit() { + this.$onInitGeneric(); + + const limit = this.PaginationService.getPaginationLimit(this.tableKey); + if (limit) { + this.handleChangeLimit(+limit); + } + } +} + +export default ActivityLogsDatatableController; diff --git a/app/portainer/user-activity/auth-logs-view/auth-logs-datatable/auth-logs-datatable.html b/app/portainer/user-activity/auth-logs-view/auth-logs-datatable/auth-logs-datatable.html new file mode 100644 index 000000000..07d687ee8 --- /dev/null +++ b/app/portainer/user-activity/auth-logs-view/auth-logs-datatable/auth-logs-datatable.html @@ -0,0 +1,64 @@ +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Time + + + + + Origin + + + + + Context + + + + + + User + + + + Result +
Loading...
+ No logs available. +
+
+ +
+
+
diff --git a/app/portainer/user-activity/auth-logs-view/auth-logs-datatable/index.js b/app/portainer/user-activity/auth-logs-view/auth-logs-datatable/index.js new file mode 100644 index 000000000..6488f3549 --- /dev/null +++ b/app/portainer/user-activity/auth-logs-view/auth-logs-datatable/index.js @@ -0,0 +1,25 @@ +import controller from './auth-logs-datatable.controller'; + +export const authLogsDatatable = { + templateUrl: './auth-logs-datatable.html', + controller, + bindings: { + logs: '<', + keyword: '<', + sort: '<', + limit: '<', + totalItems: '<', + currentPage: '<', + contextFilter: '<', + typeFilter: '<', + feature: '@', + + onChangeContextFilter: '<', + onChangeTypeFilter: '<', + onChangeKeyword: '<', + onChangeSort: '<', + + onChangeLimit: '<', + onChangePage: '<', + }, +}; diff --git a/app/portainer/user-activity/auth-logs-view/auth-logs-view.controller.js b/app/portainer/user-activity/auth-logs-view/auth-logs-view.controller.js new file mode 100644 index 000000000..f4ddb81ff --- /dev/null +++ b/app/portainer/user-activity/auth-logs-view/auth-logs-view.controller.js @@ -0,0 +1,103 @@ +import moment from 'moment'; +import { ACTIVITY_AUDIT } from '@/portainer/feature-flags/feature-ids'; + +export default class AuthLogsViewController { + /* @ngInject */ + constructor($async, Notifications) { + this.$async = $async; + this.Notifications = Notifications; + + this.limitedFeature = ACTIVITY_AUDIT; + this.state = { + keyword: 'f', + date: { + from: 0, + to: 0, + }, + sort: { + key: 'Timestamp', + desc: true, + }, + contextFilter: [1, 2, 3], + typeFilter: [1, 2, 3], + page: 1, + limit: 10, + totalItems: 0, + logs: null, + }; + + this.today = moment().endOf('day'); + this.minValidDate = moment().subtract(7, 'd').startOf('day'); + + this.onChangeDate = this.onChangeDate.bind(this); + this.onChangeKeyword = this.onChangeKeyword.bind(this); + this.onChangeSort = this.onChangeSort.bind(this); + this.onChangeContextFilter = this.onChangeContextFilter.bind(this); + this.onChangeTypeFilter = this.onChangeTypeFilter.bind(this); + this.loadLogs = this.loadLogs.bind(this); + this.onChangePage = this.onChangePage.bind(this); + this.onChangeLimit = this.onChangeLimit.bind(this); + } + + onChangePage(page) { + this.state.page = page; + this.loadLogs(); + } + + onChangeLimit(limit) { + this.state.page = 1; + this.state.limit = limit; + this.loadLogs(); + } + + onChangeSort(sort) { + this.state.page = 1; + this.state.sort = sort; + this.loadLogs(); + } + + onChangeContextFilter(filterKey, filterState) { + this.state.contextFilter = filterState; + this.loadLogs(); + } + + onChangeTypeFilter(filterKey, filterState) { + this.state.typeFilter = filterState; + this.loadLogs(); + } + + onChangeKeyword(keyword) { + this.state.page = 1; + this.state.keyword = keyword; + this.loadLogs(); + } + + onChangeDate({ startDate, endDate }) { + this.state.page = 1; + this.state.date = { to: endDate, from: startDate }; + this.loadLogs(); + } + + async loadLogs() { + return this.$async(async () => { + this.state.logs = null; + try { + const { logs, totalCount } = { logs: [{}, {}, {}, {}, {}], totalCount: 5 }; + this.state.logs = decorateLogs(logs); + this.state.totalItems = totalCount; + } catch (err) { + this.Notifications.error('Failure', err, 'Failed loading auth activity logs'); + } + }); + } + + $onInit() { + return this.$async(async () => { + this.loadLogs(); + }); + } +} + +function decorateLogs(logs) { + return logs; +} diff --git a/app/portainer/user-activity/auth-logs-view/auth-logs-view.html b/app/portainer/user-activity/auth-logs-view/auth-logs-view.html new file mode 100644 index 000000000..6bc1030b9 --- /dev/null +++ b/app/portainer/user-activity/auth-logs-view/auth-logs-view.html @@ -0,0 +1,51 @@ + + + + + + + User authentication activity + + +
+ + +
+
+ +
+ +
+
+
+ +

+ + Portainer user authentication activity logs have a maximum retention of 7 days. +

+ +
+ + +
+
+
+ + +
diff --git a/app/portainer/user-activity/auth-logs-view/auth-logs-view.js b/app/portainer/user-activity/auth-logs-view/auth-logs-view.js new file mode 100644 index 000000000..c5cd52bb7 --- /dev/null +++ b/app/portainer/user-activity/auth-logs-view/auth-logs-view.js @@ -0,0 +1,6 @@ +import controller from './auth-logs-view.controller.js'; + +export const authLogsView = { + templateUrl: './auth-logs-view.html', + controller, +}; diff --git a/app/portainer/user-activity/auth-logs-view/index.js b/app/portainer/user-activity/auth-logs-view/index.js new file mode 100644 index 000000000..6aa7f7e85 --- /dev/null +++ b/app/portainer/user-activity/auth-logs-view/index.js @@ -0,0 +1,6 @@ +import angular from 'angular'; + +import { authLogsView } from './auth-logs-view'; +import { authLogsDatatable } from './auth-logs-datatable'; + +export default angular.module('portainer.app.user-activity.auth-logs-view', []).component('authLogsView', authLogsView).component('authLogsDatatable', authLogsDatatable).name; diff --git a/app/portainer/user-activity/index.js b/app/portainer/user-activity/index.js new file mode 100644 index 000000000..27056da2f --- /dev/null +++ b/app/portainer/user-activity/index.js @@ -0,0 +1,29 @@ +import angular from 'angular'; + +import authLogsViewModule from './auth-logs-view'; +import activityLogsViewModule from './activity-logs-view'; + +export default angular.module('portainer.app.user-activity', [authLogsViewModule, activityLogsViewModule]).config(config).name; + +/* @ngInject */ +function config($stateRegistryProvider) { + $stateRegistryProvider.register({ + name: 'portainer.authLogs', + url: '/auth-logs', + views: { + 'content@': { + component: 'authLogsView', + }, + }, + }); + + $stateRegistryProvider.register({ + name: 'portainer.activityLogs', + url: '/activity-logs', + views: { + 'content@': { + component: 'activityLogsView', + }, + }, + }); +} diff --git a/app/portainer/views/endpoints/access/endpointAccess.html b/app/portainer/views/endpoints/access/endpointAccess.html index f0c96ea86..1021e3b63 100644 --- a/app/portainer/views/endpoints/access/endpointAccess.html +++ b/app/portainer/views/endpoints/access/endpointAccess.html @@ -44,5 +44,6 @@ entity-type="endpoint" inherit-from="ctrl.group" update-access="ctrl.updateAccess" + limited-feature="ctrl.limitedFeature" > diff --git a/app/portainer/views/endpoints/access/endpointAccessController.js b/app/portainer/views/endpoints/access/endpointAccessController.js index 77f414a31..4600c1977 100644 --- a/app/portainer/views/endpoints/access/endpointAccessController.js +++ b/app/portainer/views/endpoints/access/endpointAccessController.js @@ -1,5 +1,7 @@ import angular from 'angular'; +import { RBAC_ROLES } from '@/portainer/feature-flags/feature-ids'; + class EndpointAccessController { /* @ngInject */ constructor($state, $transition$, Notifications, EndpointService, GroupService, $async) { @@ -10,6 +12,8 @@ class EndpointAccessController { this.GroupService = GroupService; this.$async = $async; + this.limitedFeature = RBAC_ROLES; + this.updateAccess = this.updateAccess.bind(this); this.updateAccessAsync = this.updateAccessAsync.bind(this); } diff --git a/app/portainer/views/groups/access/groupAccess.html b/app/portainer/views/groups/access/groupAccess.html index 595d37e6f..d9404eb4e 100644 --- a/app/portainer/views/groups/access/groupAccess.html +++ b/app/portainer/views/groups/access/groupAccess.html @@ -31,4 +31,5 @@ entity-type="group" action-in-progress="state.actionInProgress" update-access="updateAccess" + limited-feature="limitedFeature" > diff --git a/app/portainer/views/groups/access/groupAccessController.js b/app/portainer/views/groups/access/groupAccessController.js index 5dff41065..e6112be28 100644 --- a/app/portainer/views/groups/access/groupAccessController.js +++ b/app/portainer/views/groups/access/groupAccessController.js @@ -1,3 +1,5 @@ +import { RBAC_ROLES } from '@/portainer/feature-flags/feature-ids'; + angular.module('portainer.app').controller('GroupAccessController', [ '$scope', '$state', @@ -5,6 +7,8 @@ angular.module('portainer.app').controller('GroupAccessController', [ 'GroupService', 'Notifications', function ($scope, $state, $transition$, GroupService, Notifications) { + $scope.limitedFeature = RBAC_ROLES; + $scope.updateAccess = function () { $scope.state.actionInProgress = true; GroupService.updateGroup($scope.group, $scope.group.AssociatedEndpoints) diff --git a/app/portainer/views/roles/roles.html b/app/portainer/views/roles/roles.html deleted file mode 100644 index 9d73e610a..000000000 --- a/app/portainer/views/roles/roles.html +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - Role management - - - - -

- - This feature is available in Portainer Business Edition. -

-
-
- -
-
- -
-
- -
-
- - - -
-
- User -
-
-
- - No user available - -
-
- -
- Access -
-
-
- - Effective role for each environment will be displayed for the selected user -
-
- -
-
-
-
-
diff --git a/app/portainer/views/settings/authentication/settingsAuthentication.html b/app/portainer/views/settings/authentication/settingsAuthentication.html index 2d36bcd76..3520b5e2a 100644 --- a/app/portainer/views/settings/authentication/settingsAuthentication.html +++ b/app/portainer/views/settings/authentication/settingsAuthentication.html @@ -8,7 +8,7 @@ -
+
Configuration
@@ -34,43 +34,10 @@
Authentication method
-
-
-
-
- - -
-
- - -
-
- - -
-
-
-
+ + +
Information
@@ -79,345 +46,23 @@
-
-
-
- Information -
-
- When using LDAP authentication, Portainer will delegate user authentication to a LDAP server and fallback to internal authentication if LDAP authentication fails. -
-
+ -
- LDAP configuration -
+ -
- -
- -
-
- - -
-
- - -
-
- - -
-
- -
- -
-
- -
- -
- -
-
-
- -
- -
- -
-
- -
- LDAP security -
- - -
-
- - -
-
- - - -
-
- - -
-
- - - -
-
- - -
-
- - - -
- -
- -
- - - {{ formValues.TLSCACert.name }} - - - - -
-
- -
- - -
- -
- - -
-
- -
- Automatic user provisioning -
-
- - With automatic user provisioning enabled, Portainer will create user(s) automatically with standard user role and assign them to team(s) which matches to LDAP group - name(s). If disabled, users must be created in Portainer beforehand. - -
-
-
- - -
-
- -
- User search configurations -
- - -
-
- - Extra search configuration - -
- -
- -
- -
- - -
- -
-
-
- -
- -
-
- -
-
- -
- - add user search configuration - -
-
- - -
- Group search configurations -
- - -
-
- - Extra search configuration - -
- -
- -
- -
- - -
- -
-
-
- -
- -
-
- -
-
- -
- - add group search configuration - -
-
- -
- -
-
- Provider -
-
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
- - +
@@ -425,7 +70,13 @@
- diff --git a/app/portainer/views/settings/authentication/settingsAuthenticationController.js b/app/portainer/views/settings/authentication/settingsAuthenticationController.js index f03b88f46..820dae8d3 100644 --- a/app/portainer/views/settings/authentication/settingsAuthenticationController.js +++ b/app/portainer/views/settings/authentication/settingsAuthenticationController.js @@ -1,181 +1,246 @@ -angular.module('portainer.app').controller('SettingsAuthenticationController', [ - '$q', - '$scope', - '$state', - 'Notifications', - 'SettingsService', - 'FileUploadService', - 'TeamService', - function ($q, $scope, $state, Notifications, SettingsService, FileUploadService, TeamService) { - $scope.state = { - successfulConnectivityCheck: false, - failedConnectivityCheck: false, - uploadInProgress: false, - connectivityCheckInProgress: false, - actionInProgress: false, - availableUserSessionTimeoutOptions: [ - { - key: '1 hour', - value: '1h', - }, - { - key: '4 hours', - value: '4h', - }, - { - key: '8 hours', - value: '8h', - }, - { - key: '24 hours', - value: '24h', - }, - { key: '1 week', value: `${24 * 7}h` }, - { key: '1 month', value: `${24 * 30}h` }, - { key: '6 months', value: `${24 * 30 * 6}h` }, - { key: '1 year', value: `${24 * 30 * 12}h` }, - ], - }; +import angular from 'angular'; +import _ from 'lodash-es'; - $scope.formValues = { - UserSessionTimeout: $scope.state.availableUserSessionTimeoutOptions[0], - TLSCACert: '', - LDAPSettings: { - AnonymousMode: true, - ReaderDN: '', - URL: '', - TLSConfig: { - TLS: false, - TLSSkipVerify: false, - }, - StartTLS: false, - SearchSettings: [ - { - BaseDN: '', - Filter: '', - UserNameAttribute: '', - }, - ], - GroupSearchSettings: [ - { - GroupBaseDN: '', - GroupFilter: '', - GroupAttribute: '', - }, - ], - AutoCreateUsers: true, +import { HIDE_INTERNAL_AUTH } from '@/portainer/feature-flags/feature-ids'; +import { buildLdapSettingsModel, buildAdSettingsModel } from '@/portainer/settings/authentication/ldap/ldap-settings.model'; + +angular.module('portainer.app').controller('SettingsAuthenticationController', SettingsAuthenticationController); + +function SettingsAuthenticationController($q, $scope, $state, Notifications, SettingsService, FileUploadService, TeamService, LDAPService) { + $scope.state = { + uploadInProgress: false, + actionInProgress: false, + availableUserSessionTimeoutOptions: [ + { + key: '1 hour', + value: '1h', }, - }; + { + key: '4 hours', + value: '4h', + }, + { + key: '8 hours', + value: '8h', + }, + { + key: '24 hours', + value: '24h', + }, + { key: '1 week', value: `${24 * 7}h` }, + { key: '1 month', value: `${24 * 30}h` }, + { key: '6 months', value: `${24 * 30 * 6}h` }, + { key: '1 year', value: `${24 * 30 * 12}h` }, + ], + }; - $scope.isOauthEnabled = function isOauthEnabled() { - return $scope.settings && $scope.settings.AuthenticationMethod === 3; - }; + $scope.formValues = { + UserSessionTimeout: $scope.state.availableUserSessionTimeoutOptions[0], + TLSCACert: '', + ldap: { + serverType: 0, + adSettings: buildAdSettingsModel(), + ldapSettings: buildLdapSettingsModel(), + }, + }; - $scope.addSearchConfiguration = function () { - $scope.formValues.LDAPSettings.SearchSettings.push({ BaseDN: '', UserNameAttribute: '', Filter: '' }); - }; + $scope.authOptions = [ + { id: 'auth_internal', icon: 'fa fa-users', label: 'Internal', description: 'Internal authentication mechanism', value: 1 }, + { id: 'auth_ldap', icon: 'fa fa-users', label: 'LDAP', description: 'LDAP authentication', value: 2 }, + { id: 'auth_ad', icon: 'fab fa-microsoft', label: 'Microsoft Active Directory', description: 'AD authentication', value: 4, feature: HIDE_INTERNAL_AUTH }, + { id: 'auth_oauth', icon: 'fa fa-users', label: 'OAuth', description: 'OAuth authentication', value: 3 }, + ]; - $scope.removeSearchConfiguration = function (index) { - $scope.formValues.LDAPSettings.SearchSettings.splice(index, 1); - }; - - $scope.addGroupSearchConfiguration = function () { - $scope.formValues.LDAPSettings.GroupSearchSettings.push({ GroupBaseDN: '', GroupAttribute: '', GroupFilter: '' }); - }; - - $scope.removeGroupSearchConfiguration = function (index) { - $scope.formValues.LDAPSettings.GroupSearchSettings.splice(index, 1); - }; - - $scope.LDAPConnectivityCheck = function () { - var settings = angular.copy($scope.settings); - var TLSCAFile = $scope.formValues.TLSCACert !== settings.LDAPSettings.TLSConfig.TLSCACert ? $scope.formValues.TLSCACert : null; - - if ($scope.formValues.LDAPSettings.AnonymousMode) { - settings.LDAPSettings['ReaderDN'] = ''; - settings.LDAPSettings['Password'] = ''; - } - - var uploadRequired = ($scope.formValues.LDAPSettings.TLSConfig.TLS || $scope.formValues.LDAPSettings.StartTLS) && !$scope.formValues.LDAPSettings.TLSConfig.TLSSkipVerify; - $scope.state.uploadInProgress = uploadRequired; - - $scope.state.connectivityCheckInProgress = true; - $q.when(!uploadRequired || FileUploadService.uploadLDAPTLSFiles(TLSCAFile, null, null)) - .then(function success() { - addLDAPDefaultPort(settings, $scope.formValues.LDAPSettings.TLSConfig.TLS); - return SettingsService.checkLDAPConnectivity(settings); - }) - .then(function success() { - $scope.state.failedConnectivityCheck = false; - $scope.state.successfulConnectivityCheck = true; - Notifications.success('Connection to LDAP successful'); - }) - .catch(function error(err) { - $scope.state.failedConnectivityCheck = true; - $scope.state.successfulConnectivityCheck = false; - Notifications.error('Failure', err, 'Connection to LDAP failed'); - }) - .finally(function final() { - $scope.state.uploadInProgress = false; - $scope.state.connectivityCheckInProgress = false; - }); - }; - - $scope.saveSettings = function () { - var settings = angular.copy($scope.settings); - var TLSCAFile = $scope.formValues.TLSCACert !== settings.LDAPSettings.TLSConfig.TLSCACert ? $scope.formValues.TLSCACert : null; - - if ($scope.formValues.LDAPSettings.AnonymousMode) { - settings.LDAPSettings['ReaderDN'] = ''; - settings.LDAPSettings['Password'] = ''; - } - - var uploadRequired = ($scope.formValues.LDAPSettings.TLSConfig.TLS || $scope.formValues.LDAPSettings.StartTLS) && !$scope.formValues.LDAPSettings.TLSConfig.TLSSkipVerify; - $scope.state.uploadInProgress = uploadRequired; - - $scope.state.actionInProgress = true; - $q.when(!uploadRequired || FileUploadService.uploadLDAPTLSFiles(TLSCAFile, null, null)) - .then(function success() { - addLDAPDefaultPort(settings, $scope.formValues.LDAPSettings.TLSConfig.TLS); - return SettingsService.update(settings); - }) - .then(function success() { - Notifications.success('Authentication settings updated'); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to update authentication settings'); - }) - .finally(function final() { - $scope.state.uploadInProgress = false; - $scope.state.actionInProgress = false; - }); - }; - - // Add default port if :port is not defined in URL - function addLDAPDefaultPort(settings, tlsEnabled) { - if (settings.LDAPSettings.URL.indexOf(':') === -1) { - settings.LDAPSettings.URL += tlsEnabled ? ':636' : ':389'; - } + $scope.onChangeAuthMethod = function onChangeAuthMethod(value) { + if (value === 4) { + $scope.settings.AuthenticationMethod = 2; + $scope.formValues.ldap.serverType = 2; + return; } - function initView() { - $q.all({ - settings: SettingsService.settings(), - teams: TeamService.teams(), + if (value === 2) { + $scope.settings.AuthenticationMethod = 2; + $scope.formValues.ldap.serverType = $scope.formValues.ldap.ldapSettings.ServerType; + return; + } + + $scope.settings.AuthenticationMethod = value; + }; + + $scope.authenticationMethodSelected = function authenticationMethodSelected(value) { + if (!$scope.settings) { + return false; + } + + if (value === 4) { + return $scope.settings.AuthenticationMethod === 2 && $scope.formValues.ldap.serverType === 2; + } + + if (value === 2) { + return $scope.settings.AuthenticationMethod === 2 && $scope.formValues.ldap.serverType !== 2; + } + + return $scope.settings.AuthenticationMethod === value; + }; + + $scope.isOauthEnabled = function isOauthEnabled() { + return $scope.settings && $scope.settings.AuthenticationMethod === 3; + }; + + $scope.LDAPConnectivityCheck = LDAPConnectivityCheck; + function LDAPConnectivityCheck() { + const settings = angular.copy($scope.settings); + + const { settings: ldapSettings, uploadRequired, tlscaFile } = prepareLDAPSettings(); + settings.LDAPSettings = ldapSettings; + $scope.state.uploadInProgress = uploadRequired; + + $scope.state.connectivityCheckInProgress = true; + + $q.when(!uploadRequired || FileUploadService.uploadLDAPTLSFiles(tlscaFile, null, null)) + .then(function success() { + return LDAPService.check(settings.LDAPSettings); }) - .then(function success(data) { - var settings = data.settings; - $scope.teams = data.teams; - $scope.settings = settings; - $scope.formValues.LDAPSettings = settings.LDAPSettings; - $scope.OAuthSettings = settings.OAuthSettings; - $scope.formValues.TLSCACert = settings.LDAPSettings.TLSConfig.TLSCACert; - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to retrieve application settings'); - }); + .then(function success() { + $scope.state.failedConnectivityCheck = false; + $scope.state.successfulConnectivityCheck = true; + Notifications.success('Connection to LDAP successful'); + }) + .catch(function error(err) { + $scope.state.failedConnectivityCheck = true; + $scope.state.successfulConnectivityCheck = false; + Notifications.error('Failure', err, 'Connection to LDAP failed'); + }) + .finally(function final() { + $scope.state.uploadInProgress = false; + $scope.state.connectivityCheckInProgress = false; + }); + } + + $scope.saveSettings = function () { + const settings = angular.copy($scope.settings); + + const { settings: ldapSettings, uploadRequired, tlscaFile } = prepareLDAPSettings(); + settings.LDAPSettings = ldapSettings; + $scope.state.uploadInProgress = uploadRequired; + + $scope.state.actionInProgress = true; + + $q.when(!uploadRequired || FileUploadService.uploadLDAPTLSFiles(tlscaFile, null, null)) + .then(function success() { + return SettingsService.update(settings); + }) + .then(function success() { + Notifications.success('Authentication settings updated'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update authentication settings'); + }) + .finally(function final() { + $scope.state.uploadInProgress = false; + $scope.state.actionInProgress = false; + }); + }; + + function prepareLDAPSettings() { + const tlscaCert = $scope.formValues.TLSCACert; + + const tlscaFile = tlscaCert !== $scope.settings.LDAPSettings.TLSConfig.TLSCACert ? tlscaCert : null; + + const isADServer = $scope.formValues.ldap.serverType === 2; + + const settings = isADServer ? $scope.formValues.ldap.adSettings : $scope.formValues.ldap.ldapSettings; + + if (settings.AnonymousMode && !isADServer) { + settings.ReaderDN = ''; + settings.Password = ''; } - initView(); - }, -]); + if (isADServer) { + settings.AnonymousMode = false; + } + + settings.URLs = settings.URLs.map((url) => { + if (url.includes(':')) { + return url; + } + return url + (settings.TLSConfig.TLS ? ':636' : ':389'); + }); + + const uploadRequired = (settings.TLSConfig.TLS || settings.StartTLS) && !settings.TLSConfig.TLSSkipVerify; + + settings.URL = settings.URLs[0]; + + return { settings, uploadRequired, tlscaFile }; + } + + $scope.isLDAPFormValid = isLDAPFormValid; + function isLDAPFormValid() { + const ldapSettings = $scope.formValues.ldap.serverType === 2 ? $scope.formValues.ldap.adSettings : $scope.formValues.ldap.ldapSettings; + const isTLSMode = ldapSettings.TLSConfig.TLS || ldapSettings.StartTLS; + + return ( + _.compact(ldapSettings.URLs).length && + (ldapSettings.AnonymousMode || (ldapSettings.ReaderDN && ldapSettings.Password)) && + (!isTLSMode || $scope.formValues.TLSCACert || ldapSettings.TLSConfig.TLSSkipVerify) + ); + } + + $scope.isOAuthTeamMembershipFormValid = isOAuthTeamMembershipFormValid; + function isOAuthTeamMembershipFormValid() { + if ($scope.settings && $scope.settings.OAuthSettings.OAuthAutoMapTeamMemberships && $scope.settings.OAuthSettings.TeamMemberships) { + if (!$scope.settings.OAuthSettings.TeamMemberships.OAuthClaimName) { + return false; + } + + const hasInvalidMapping = $scope.settings.OAuthSettings.TeamMemberships.OAuthClaimMappings.some((m) => !(m.ClaimValRegex && m.Team)); + if (hasInvalidMapping) { + return false; + } + } + return true; + } + + function initView() { + $q.all({ + settings: SettingsService.settings(), + teams: TeamService.teams(), + }) + .then(function success(data) { + var settings = data.settings; + $scope.teams = data.teams; + $scope.settings = settings; + + $scope.OAuthSettings = settings.OAuthSettings; + $scope.authMethod = settings.AuthenticationMethod; + if (settings.AuthenticationMethod === 2 && settings.LDAPSettings.ServerType === 2) { + $scope.authMethod = 4; + } + + $scope.formValues.ldap.serverType = settings.LDAPSettings.ServerType; + if (settings.LDAPSettings.ServerType === 2) { + $scope.formValues.ldap.adSettings = settings.LDAPSettings; + } else { + $scope.formValues.ldap.ldapSettings = settings.LDAPSettings; + } + + if (settings.LDAPSettings.URL) { + settings.LDAPSettings.URLs = [settings.LDAPSettings.URL]; + } + if (!settings.LDAPSettings.URLs) { + settings.LDAPSettings.URLs = []; + } + if (!settings.LDAPSettings.URLs.length) { + settings.LDAPSettings.URLs.push(''); + } + if (!settings.LDAPSettings.ServerType) { + settings.LDAPSettings.ServerType = 0; + } + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve application settings'); + }); + } + + initView(); +} diff --git a/app/portainer/views/settings/settings.html b/app/portainer/views/settings/settings.html index b9a083450..c6e13d2b6 100644 --- a/app/portainer/views/settings/settings.html +++ b/app/portainer/views/settings/settings.html @@ -252,72 +252,217 @@
Backup configuration
-
-
-
-
- - + +
+ +
+
+
-
- -
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ +
+ Security settings +
+ +
+ +
+
-
-
- Security settings -
- -
- -
- + + +
+ +
+ +
-
- - - -
- -
- +
+
+
+

This field is required.

+
+
-
-
-
-
-

This field is required.

+ +
+
+ +
+
+
+
+
+
- +
+
+ Security settings +
+ +
+ +
+ +
+
+ - -
-
- + +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + + +
+
+ +
diff --git a/app/portainer/views/settings/settingsController.js b/app/portainer/views/settings/settingsController.js index 8d7c48b3b..c7861e74e 100644 --- a/app/portainer/views/settings/settingsController.js +++ b/app/portainer/views/settings/settingsController.js @@ -1,3 +1,8 @@ +import angular from 'angular'; + +import { buildOption } from '@/portainer/components/box-selector'; +import { S3_BACKUP_SETTING } from '@/portainer/feature-flags/feature-ids'; + angular.module('portainer.app').controller('SettingsController', [ '$scope', '$state', @@ -8,6 +13,12 @@ angular.module('portainer.app').controller('SettingsController', [ 'FileSaver', 'Blob', function ($scope, $state, Notifications, SettingsService, StateManager, BackupService, FileSaver) { + $scope.s3BackupFeatureId = S3_BACKUP_SETTING; + $scope.backupOptions = [ + buildOption('backup_file', 'fa fa-download', 'Download backup file', '', 'file'), + buildOption('backup_s3', 'fa fa-upload', 'Store in S3', 'Define a cron schedule', 's3', S3_BACKUP_SETTING), + ]; + $scope.state = { actionInProgress: false, availableEdgeAgentCheckinOptions: [ @@ -47,8 +58,11 @@ angular.module('portainer.app').controller('SettingsController', [ }, ], backupInProgress: false, + featureLimited: false, }; + $scope.BACKUP_FORM_TYPES = { S3: 's3', FILE: 'file' }; + $scope.formValues = { customLogo: false, labelName: '', @@ -57,6 +71,12 @@ angular.module('portainer.app').controller('SettingsController', [ enableTelemetry: false, passwordProtect: false, password: '', + backupFormType: $scope.BACKUP_FORM_TYPES.FILE, + }; + + $scope.onBackupOptionsChange = function (type, limited) { + $scope.formValues.backupFormType = type; + $scope.state.featureLimited = limited; }; $scope.removeFilteredContainerLabel = function (index) { diff --git a/app/portainer/views/sidebar/sidebar.html b/app/portainer/views/sidebar/sidebar.html index fe83ac39b..3ed7eb3ff 100644 --- a/app/portainer/views/sidebar/sidebar.html +++ b/app/portainer/views/sidebar/sidebar.html @@ -93,6 +93,10 @@ Registries + + Activity Logs + + Authentication diff --git a/package.json b/package.json index dd095ea7a..4e93f797c 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,7 @@ "jquery": "^3.5.1", "js-base64": "^3.6.0", "js-yaml": "^3.14.0", - "lodash-es": "^4.17.15", + "lodash-es": "^4.17.21", "moment": "^2.21.0", "ng-file-upload": "~12.2.13", "parse-duration": "^1.0.0", @@ -162,7 +162,7 @@ "resolutions": { "jquery": "^3.5.1", "decompress": "^4.2.1", - "lodash": "^4.17.15", + "lodash": "^4.17.21", "js-yaml": "^3.14.0", "minimist": "^1.2.5", "http-proxy": "^1.18.1" diff --git a/yarn.lock b/yarn.lock index 6af13cde5..6141c7c5c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3222,7 +3222,7 @@ cssnano-util-same-parent@^4.0.0: resolved "https://registry.yarnpkg.com/cssnano-util-same-parent/-/cssnano-util-same-parent-4.0.1.tgz#574082fb2859d2db433855835d9a8456ea18bbf3" integrity sha512-WcKx5OY+KoSIAxBW6UBBRay1U6vkYheCdjyVNDm85zt5K9mHoGOfsOsqIszfAqrQQFIIKgjh2+FDgIj/zsl21Q== -cssnano@4: +cssnano@^4.1.10: version "4.1.11" resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-4.1.11.tgz#c7b5f5b81da269cb1fd982cb960c1200910c9a99" integrity sha512-6gZm2htn7xIPJOHY824ERgj8cNPgPxyCSnkXc4v7YvNW+TdVfzgngHcEhy/8D11kUWRUMbke+tC+AUcUsnMz2g== @@ -6994,6 +6994,11 @@ lodash-es@^4.17.15: resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.15.tgz#21bd96839354412f23d7a10340e5eac6ee455d78" integrity sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ== +lodash-es@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" + integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== + lodash-webpack-plugin@^0.11.5: version "0.11.5" resolved "https://registry.yarnpkg.com/lodash-webpack-plugin/-/lodash-webpack-plugin-0.11.5.tgz#c4bd064b4f561c3f823fa5982bdeb12c475390b9" @@ -7042,9 +7047,9 @@ lodash.uniq@^4.5.0: integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= lodash@^3.10.0, lodash@^3.6.0, lodash@^4.0.0, lodash@^4.11.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.21, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.3.0, lodash@^4.7.0, lodash@~2.4.1, lodash@~4.17.10, lodash@~4.17.15, lodash@~4.17.5: - version "4.17.15" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" - integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== log-symbols@^1.0.2: version "1.0.2"