feat(app): highlight be provided value [EE-882] (#5703) (#5835)

pull/5851/head
Chaim Lev-Ari 2021-10-07 01:59:53 +03:00 committed by GitHub
parent 8096c5e8bc
commit b7841e7fc3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
175 changed files with 5627 additions and 1216 deletions

View File

@ -18,6 +18,7 @@ import (
"github.com/portainer/portainer/api/http/handler/file" "github.com/portainer/portainer/api/http/handler/file"
"github.com/portainer/portainer/api/http/handler/helm" "github.com/portainer/portainer/api/http/handler/helm"
"github.com/portainer/portainer/api/http/handler/kubernetes" "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/motd"
"github.com/portainer/portainer/api/http/handler/registries" "github.com/portainer/portainer/api/http/handler/registries"
"github.com/portainer/portainer/api/http/handler/resourcecontrols" "github.com/portainer/portainer/api/http/handler/resourcecontrols"
@ -53,6 +54,7 @@ type Handler struct {
HelmTemplatesHandler *helm.Handler HelmTemplatesHandler *helm.Handler
KubernetesHandler *kubernetes.Handler KubernetesHandler *kubernetes.Handler
FileHandler *file.Handler FileHandler *file.Handler
LDAPHandler *ldap.Handler
MOTDHandler *motd.Handler MOTDHandler *motd.Handler
RegistryHandler *registries.Handler RegistryHandler *registries.Handler
ResourceControlHandler *resourcecontrols.Handler ResourceControlHandler *resourcecontrols.Handler
@ -189,6 +191,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
default: default:
http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r) 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"): case strings.HasPrefix(r.URL.Path, "/api/motd"):
http.StripPrefix("/api", h.MOTDHandler).ServeHTTP(w, r) http.StripPrefix("/api", h.MOTDHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/registries"): case strings.HasPrefix(r.URL.Path, "/api/registries"):

View File

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

View File

@ -1,4 +1,4 @@
package settings package ldap
import ( import (
"net/http" "net/http"
@ -7,42 +7,43 @@ import (
"github.com/portainer/libhttp/request" "github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response" "github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
) )
type settingsLDAPCheckPayload struct { type checkPayload struct {
LDAPSettings portainer.LDAPSettings LDAPSettings portainer.LDAPSettings
} }
func (payload *settingsLDAPCheckPayload) Validate(r *http.Request) error { func (payload *checkPayload) Validate(r *http.Request) error {
return nil return nil
} }
// @id SettingsLDAPCheck // @id LDAPCheck
// @summary Test LDAP connectivity // @summary Test LDAP connectivity
// @description Test LDAP connectivity using LDAP details // @description Test LDAP connectivity using LDAP details
// @description **Access policy**: administrator // @description **Access policy**: administrator
// @tags settings // @tags ldap
// @security jwt // @security jwt
// @accept json // @accept json
// @param body body settingsLDAPCheckPayload true "details" // @param body body checkPayload true "details"
// @success 204 "Success" // @success 204 "Success"
// @failure 400 "Invalid request" // @failure 400 "Invalid request"
// @failure 500 "Server error" // @failure 500 "Server error"
// @router /settings/ldap/check [put] // @router /ldap/check [post]
func (handler *Handler) settingsLDAPCheck(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) ldapCheck(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload settingsLDAPCheckPayload var payload checkPayload
err := request.DecodeAndValidateJSONPayload(r, &payload) err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
} }
if (payload.LDAPSettings.TLSConfig.TLS || payload.LDAPSettings.StartTLS) && !payload.LDAPSettings.TLSConfig.TLSSkipVerify { settings := &payload.LDAPSettings
caCertPath, _ := handler.FileService.GetPathForTLSFile(filesystem.LDAPStorePath, portainer.TLSFileCA)
payload.LDAPSettings.TLSConfig.TLSCACertPath = caCertPath 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 { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to connect to LDAP server", err} return &httperror.HandlerError{http.StatusInternalServerError, "Unable to connect to LDAP server", err}
} }

View File

@ -5,7 +5,7 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
"github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security" "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) bouncer.AdminAccess(httperror.LoggerHandler(h.settingsUpdate))).Methods(http.MethodPut)
h.Handle("/settings/public", h.Handle("/settings/public",
bouncer.PublicAccess(httperror.LoggerHandler(h.settingsPublic))).Methods(http.MethodGet) 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 return h
} }

View File

@ -29,6 +29,7 @@ import (
"github.com/portainer/portainer/api/http/handler/file" "github.com/portainer/portainer/api/http/handler/file"
"github.com/portainer/portainer/api/http/handler/helm" "github.com/portainer/portainer/api/http/handler/helm"
kubehandler "github.com/portainer/portainer/api/http/handler/kubernetes" 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/motd"
"github.com/portainer/portainer/api/http/handler/registries" "github.com/portainer/portainer/api/http/handler/registries"
"github.com/portainer/portainer/api/http/handler/resourcecontrols" "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 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 motdHandler = motd.NewHandler(requestBouncer)
var registryHandler = registries.NewHandler(requestBouncer) var registryHandler = registries.NewHandler(requestBouncer)
@ -255,6 +261,7 @@ func (server *Server) Start() error {
EndpointEdgeHandler: endpointEdgeHandler, EndpointEdgeHandler: endpointEdgeHandler,
EndpointProxyHandler: endpointProxyHandler, EndpointProxyHandler: endpointProxyHandler,
FileHandler: fileHandler, FileHandler: fileHandler,
LDAPHandler: ldapHandler,
HelmTemplatesHandler: helmTemplatesHandler, HelmTemplatesHandler: helmTemplatesHandler,
KubernetesHandler: kubernetesHandler, KubernetesHandler: kubernetesHandler,
MOTDHandler: motdHandler, MOTDHandler: motdHandler,

View File

@ -1,11 +1,11 @@
package ldap package ldap
import ( import (
"errors"
"fmt" "fmt"
"strings" "strings"
ldap "github.com/go-ldap/ldap/v3" ldap "github.com/go-ldap/ldap/v3"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/crypto" "github.com/portainer/portainer/api/crypto"
httperrors "github.com/portainer/portainer/api/http/errors" 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. // Service represents a service used to authenticate users against a LDAP/AD.
type Service struct{} type Service struct{}
func searchUser(username string, conn *ldap.Conn, settings []portainer.LDAPSearchSettings) (string, error) { func createConnection(settings *portainer.LDAPSettings) (*ldap.Conn, error) {
var userDN string conn, err := createConnectionForURL(settings.URL, settings)
found := false if err != nil {
usernameEscaped := ldap.EscapeFilter(username) return nil, errors.Wrap(err, "failed creating LDAP connection")
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 conn, nil
return "", errUserNotFound
}
return userDN, 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 { if settings.TLSConfig.TLS || settings.StartTLS {
config, err := crypto.CreateTLSConfigurationFromDisk(settings.TLSConfig.TLSCACertPath, settings.TLSConfig.TLSCertPath, settings.TLSConfig.TLSKeyPath, settings.TLSConfig.TLSSkipVerify) config, err := crypto.CreateTLSConfigurationFromDisk(settings.TLSConfig.TLSCACertPath, settings.TLSConfig.TLSCertPath, settings.TLSConfig.TLSKeyPath, settings.TLSConfig.TLSSkipVerify)
if err != nil { if err != nil {
return nil, err return nil, err
} }
config.ServerName = strings.Split(settings.URL, ":")[0] config.ServerName = strings.Split(url, ":")[0]
if settings.TLSConfig.TLS { 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 { if err != nil {
return nil, err return nil, err
} }
@ -81,7 +54,7 @@ func createConnection(settings *portainer.LDAPSettings) (*ldap.Conn, error) {
return conn, nil 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. // 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 return nil, err
} }
userGroups := getGroups(userDN, connection, settings.GroupSearchSettings) userGroups := getGroupsByUser(userDN, connection, settings.GroupSearchSettings)
return userGroups, nil 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 // 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) groups := make([]string, 0)
userDNEscaped := ldap.EscapeFilter(userDN) userDNEscaped := ldap.EscapeFilter(userDN)
@ -179,9 +296,18 @@ func (*Service) TestConnectivity(settings *portainer.LDAPSettings) error {
} }
defer connection.Close() defer connection.Close()
err = connection.Bind(settings.ReaderDN, settings.Password) if !settings.AnonymousMode {
if err != nil { err = connection.Bind(settings.ReaderDN, settings.Password)
return err if err != nil {
return err
}
} else {
err = connection.UnauthenticatedBind("")
if err != nil {
return err
}
} }
return nil return nil
} }

View File

@ -513,6 +513,12 @@ type (
AutoCreateUsers bool `json:"AutoCreateUsers" example:"true"` 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 represents information about an extension license
LicenseInformation struct { LicenseInformation struct {
LicenseKey string `json:"LicenseKey,omitempty"` LicenseKey string `json:"LicenseKey,omitempty"`
@ -1295,6 +1301,8 @@ type (
AuthenticateUser(username, password string, settings *LDAPSettings) error AuthenticateUser(username, password string, settings *LDAPSettings) error
TestConnectivity(settings *LDAPSettings) error TestConnectivity(settings *LDAPSettings) error
GetUserGroups(username string, settings *LDAPSettings) ([]string, 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 // OAuthService represents a service used to authenticate users using OAuth

View File

@ -816,10 +816,6 @@ json-tree .branch-preview {
} }
/* !spinkit override */ /* !spinkit override */
.w-full {
width: 100%;
}
/* uib-typeahead override */ /* uib-typeahead override */
#scrollable-dropdown-menu .dropdown-menu { #scrollable-dropdown-menu .dropdown-menu {
max-height: 300px; max-height: 300px;
@ -827,17 +823,33 @@ json-tree .branch-preview {
} }
/* !uib-typeahead override */ /* !uib-typeahead override */
.my-8 {
margin-top: 2rem;
margin-bottom: 2rem;
}
.kubectl-shell { .kubectl-shell {
display: block; display: block;
text-align: center; text-align: center;
padding-bottom: 5px; 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 { .text-wrap {
word-break: break-all; word-break: break-all;
white-space: normal; white-space: normal;

View File

@ -89,8 +89,13 @@ html {
--green-1: #164; --green-1: #164;
--green-2: #1ec863; --green-2: #1ec863;
--green-3: #23ae89; --green-3: #23ae89;
--orange-1: #e86925;
--BE-only: var(--orange-1);
} }
/* Default Theme */
:root { :root {
--bg-card-color: var(--grey-10); --bg-card-color: var(--grey-10);
--bg-main-color: var(--white-color); --bg-main-color: var(--white-color);

View File

@ -401,3 +401,14 @@ input:-webkit-autofill {
color: var(--white-color); color: var(--white-color);
} }
/* Overide Vendor CSS */ /* Overide Vendor CSS */
.btn.disabled,
.btn[disabled],
fieldset[disabled] .btn {
pointer-events: none;
touch-action: none;
}
.multiSelect.inlineBlock button {
margin: 0;
}

View File

@ -178,14 +178,14 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="col-sm-12"> <div class="col-sm-12">
<label class="control-label text-left"> <por-switch-field
Allow resource over-commit label="Allow resource over-commit"
</label> name="resource-over-commit-switch"
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" checked disabled /><i data-cy="kubeSetup-resourceOverCommitToggle"></i> </label> feature="ctrl.limitedFeature"
<span class="text-muted small" style="margin-left: 15px;"> ng-model="ctrl.formValues.EnableResourceOverCommit"
<i class="fa fa-user" aria-hidden="true"></i> ng-change="ctrl.onChangeEnableResourceOverCommit()"
This feature is available in <a href="https://www.portainer.io/business-upsell?from=k8s-setup-overcommit" target="_blank"> Portainer Business Edition</a>. ng-data-cy="kubeSetup-resourceOverCommitToggle"
</span> ></por-switch-field>
</div> </div>
</div> </div>

View File

@ -6,7 +6,7 @@ import { KubernetesIngressClass } from 'Kubernetes/ingress/models';
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper'; import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants'; import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
import { K8S_SETUP_DEFAULT } from '@/portainer/feature-flags/feature-ids';
class KubernetesConfigureController { class KubernetesConfigureController {
/* #region CONSTRUCTOR */ /* #region CONSTRUCTOR */
@ -38,6 +38,7 @@ class KubernetesConfigureController {
this.onInit = this.onInit.bind(this); this.onInit = this.onInit.bind(this);
this.configureAsync = this.configureAsync.bind(this); this.configureAsync = this.configureAsync.bind(this);
this.limitedFeature = K8S_SETUP_DEFAULT;
} }
/* #endregion */ /* #endregion */

View File

@ -162,14 +162,13 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="col-sm-12"> <div class="col-sm-12">
<label class="control-label text-left"> <por-switch-field
Load Balancer quota ng-data-cy="k8sNamespaceCreate-loadBalancerQuotaToggle"
</label> label="Load Balancer quota"
<label class="switch" style="margin-left: 20px;" data-cy="k8sNamespaceCreate-loadBalancerQuotaToggle"> <input type="checkbox" disabled /><i></i> </label> name="k8s-resourcepool-Ibquota"
<span class="text-muted small" style="margin-left: 15px;"> feature="$ctrl.LBQuotaFeatureId"
<i class="fa fa-user" aria-hidden="true"></i> ng-model="lbquota"
This feature is available in <a href="https://www.portainer.io/business-upsell?from=k8s-resourcepool-lbquota" target="_blank"> Portainer Business Edition</a>. ></por-switch-field>
</span>
</div> </div>
</div> </div>
<!-- #endregion --> <!-- #endregion -->
@ -192,15 +191,13 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="col-sm-12"> <div class="col-sm-12">
<label class="control-label text-left"> <por-switch-field
Enable quota ng-data-cy="k8sNamespaceCreate-enableQuotaToggle"
</label> label="Enable quota"
<label class="switch" style="margin-left: 20px;" data-cy="k8sNamespaceCreate-enableQuotaToggle"> <input type="checkbox" disabled /><i></i> </label> name="k8s-resourcepool-storagequota"
<span class="text-muted small" style="margin-left: 15px;"> feature="$ctrl.StorageQuotaFeatureId"
<i class="fa fa-user" aria-hidden="true"></i> ng-model="storagequota"
This feature is available in ></por-switch-field>
<a href="https://www.portainer.io/business-upsell?from=k8s-resourcepool-storagequota" target="_blank"> Portainer Business Edition</a>.
</span>
</div> </div>
</div> </div>
<!-- #endregion --> <!-- #endregion -->

View File

@ -12,6 +12,8 @@ import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHel
import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues'; import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants'; 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 { class KubernetesCreateResourcePoolController {
/* #region CONSTRUCTOR */ /* #region CONSTRUCTOR */
/* @ngInject */ /* @ngInject */
@ -28,6 +30,8 @@ class KubernetesCreateResourcePoolController {
}); });
this.IngressClassTypes = KubernetesIngressClassTypes; this.IngressClassTypes = KubernetesIngressClassTypes;
this.LBQuotaFeatureId = K8S_RESOURCE_POOL_LB_QUOTA;
this.StorageQuotaFeatureId = K8S_RESOURCE_POOL_STORAGE_QUOTA;
} }
/* #endregion */ /* #endregion */

View File

@ -146,14 +146,13 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="col-sm-12"> <div class="col-sm-12">
<label class="control-label text-left"> <por-switch-field
Load Balancer quota ng-data-cy="k8sNamespaceCreate-loadBalancerQuotaToggle"
</label> label="Load Balancer quota"
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" disabled /><i></i> </label> name="k8s-resourcepool-Lbquota"
<span class="text-muted small" style="margin-left: 15px;"> feature="ctrl.LBQuotaFeatureId"
<i class="fa fa-user" aria-hidden="true"></i> ng-model="lbquota"
This feature is available in <a href="https://www.portainer.io/business-upsell?from=k8s-resourcepool-lbquota" target="_blank"> Portainer Business Edition</a>. ></por-switch-field>
</span>
</div> </div>
</div> </div>
<!-- #endregion --> <!-- #endregion -->
@ -389,15 +388,13 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="col-sm-12"> <div class="col-sm-12">
<label class="control-label text-left"> <por-switch-field
Enable quota ng-data-cy="k8sNamespaceCreate-enableQuotaToggle"
</label> label="Enable quota"
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" disabled /><i></i> </label> name="k8s-resourcepool-storagequota"
<span class="text-muted small" style="margin-left: 15px;"> feature="ctrl.StorageQuotaFeatureId"
<i class="fa fa-user" aria-hidden="true"></i> ng-model="storagequota"
This feature is available in ></por-switch-field>
<a href="https://www.portainer.io/business-upsell?from=k8s-resourcepool-storagequota" target="_blank"> Portainer Business Edition</a>.
</span>
</div> </div>
</div> </div>
<!-- #endregion --> <!-- #endregion -->

View File

@ -16,6 +16,7 @@ import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHel
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants'; import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
import KubernetesResourceQuotaConverter from 'Kubernetes/converters/resourceQuota'; import KubernetesResourceQuotaConverter from 'Kubernetes/converters/resourceQuota';
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; 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 { class KubernetesResourcePoolController {
/* #region CONSTRUCTOR */ /* #region CONSTRUCTOR */
@ -60,6 +61,9 @@ class KubernetesResourcePoolController {
this.IngressClassTypes = KubernetesIngressClassTypes; this.IngressClassTypes = KubernetesIngressClassTypes;
this.ResourceQuotaDefaults = KubernetesResourceQuotaDefaults; this.ResourceQuotaDefaults = KubernetesResourceQuotaDefaults;
this.LBQuotaFeatureId = K8S_RESOURCE_POOL_LB_QUOTA;
this.StorageQuotaFeatureId = K8S_RESOURCE_POOL_STORAGE_QUOTA;
this.updateResourcePoolAsync = this.updateResourcePoolAsync.bind(this); this.updateResourcePoolAsync = this.updateResourcePoolAsync.bind(this);
this.getEvents = this.getEvents.bind(this); this.getEvents = this.getEvents.bind(this);
} }

View File

@ -1,7 +1,10 @@
import _ from 'lodash-es'; import _ from 'lodash-es';
import './rbac';
import componentsModule from './components'; import componentsModule from './components';
import settingsModule from './settings'; import settingsModule from './settings';
import featureFlagModule from './feature-flags';
import userActivityModule from './user-activity';
async function initAuthentication(authManager, Authentication, $rootScope, $state) { async function initAuthentication(authManager, Authentication, $rootScope, $state) {
authManager.checkAuthOnRefresh(); authManager.checkAuthOnRefresh();
@ -18,7 +21,7 @@ async function initAuthentication(authManager, Authentication, $rootScope, $stat
return await Authentication.init(); 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', '$stateRegistryProvider',
function ($stateRegistryProvider) { function ($stateRegistryProvider) {
'use strict'; 'use strict';
@ -51,6 +54,18 @@ angular.module('portainer.app', ['portainer.oauth', componentsModule, settingsMo
controller: 'SidebarController', 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 = { 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(root);
$stateRegistryProvider.register(endpointRoot); $stateRegistryProvider.register(endpointRoot);
$stateRegistryProvider.register(portainer); $stateRegistryProvider.register(portainer);
@ -444,7 +449,6 @@ angular.module('portainer.app', ['portainer.oauth', componentsModule, settingsMo
$stateRegistryProvider.register(user); $stateRegistryProvider.register(user);
$stateRegistryProvider.register(teams); $stateRegistryProvider.register(teams);
$stateRegistryProvider.register(team); $stateRegistryProvider.register(team);
$stateRegistryProvider.register(roles);
}, },
]); ]);

View File

@ -11,8 +11,8 @@
ng-if="$ctrl.options.length > 0" ng-if="$ctrl.options.length > 0"
input-model="$ctrl.options" input-model="$ctrl.options"
output-model="$ctrl.value" output-model="$ctrl.value"
button-label="icon '-' Name" button-label="icon Name"
item-label="icon '-' Name" item-label="icon Name"
tick-property="ticked" tick-property="ticked"
helper-elements="filter" helper-elements="filter"
search-property="Name" search-property="Name"

View File

@ -9,5 +9,6 @@ export const porAccessManagement = {
updateAccess: '<', updateAccess: '<',
actionInProgress: '<', actionInProgress: '<',
filterUsers: '<', filterUsers: '<',
limitedFeature: '<',
}, },
}; };

View File

@ -4,17 +4,31 @@
<rd-widget-header icon="fa-user-lock" title-text="Create access"></rd-widget-header> <rd-widget-header icon="fa-user-lock" title-text="Create access"></rd-widget-header>
<rd-widget-body> <rd-widget-body>
<form class="form-horizontal"> <form class="form-horizontal">
<div ng-if="ctrl.entityType !== 'registry'" class="form-group">
<span class="col-sm-12 small text-warning">
<p>
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
Adding user access will require the affected user(s) to logout and login for the changes to be taken into account.
</p>
</span>
</div>
<por-access-management-users-selector options="ctrl.availableUsersAndTeams" value="ctrl.formValues.multiselectOutput"></por-access-management-users-selector> <por-access-management-users-selector options="ctrl.availableUsersAndTeams" value="ctrl.formValues.multiselectOutput"></por-access-management-users-selector>
<div class="form-group" ng-if="ctrl.entityType != 'registry'"> <div class="form-group" ng-if="ctrl.entityType !== 'registry'">
<label class="col-sm-3 col-lg-2 control-label text-left"> <label class="col-sm-3 col-lg-2 control-label text-left">
Role Role
</label> </label>
<div class="col-sm-9 col-lg-4"> <div class="col-sm-9 col-lg-6">
<span class="text-muted small"> <div class="flex items-center">
<i class="fa fa-user" aria-hidden="true"></i> <select
This feature is available in <a href="https://www.portainer.io/business-upsell?from=k8s-rbac-access" target="_blank"> Portainer Business Edition</a>. class="form-control"
</span> ng-model="ctrl.formValues.selectedRole"
ng-options="role as ctrl.roleLabel(role) disable when ctrl.isRoleLimitedToBE(role) for role in ctrl.roles"
>
</select>
<be-feature-indicator feature="ctrl.limitedFeature" class="space-left"></be-feature-indicator>
</div>
</div> </div>
</div> </div>
@ -48,6 +62,10 @@
title-icon="fa-user-lock" title-icon="fa-user-lock"
table-key="{{ 'access_' + ctrl.entityType }}" table-key="{{ 'access_' + ctrl.entityType }}"
order-by="Name" 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" inherit-from="ctrl.inheritFrom"
dataset="ctrl.authorizedUsersAndTeams" dataset="ctrl.authorizedUsersAndTeams"
update-action="ctrl.updateAction" update-action="ctrl.updateAction"

View File

@ -1,12 +1,14 @@
import _ from 'lodash-es'; import _ from 'lodash-es';
import angular from 'angular'; import angular from 'angular';
import { RoleTypes } from '@/portainer/rbac/models/role';
class PorAccessManagementController { class PorAccessManagementController {
/* @ngInject */ /* @ngInject */
constructor(Notifications, AccessService) { constructor(Notifications, AccessService, RoleService, featureService) {
this.Notifications = Notifications; Object.assign(this, { Notifications, AccessService, RoleService, featureService });
this.AccessService = AccessService;
this.limitedToBE = false;
this.unauthorizeAccess = this.unauthorizeAccess.bind(this); this.unauthorizeAccess = this.unauthorizeAccess.bind(this);
this.updateAction = this.updateAction.bind(this); this.updateAction = this.updateAction.bind(this);
@ -29,10 +31,11 @@ class PorAccessManagementController {
const entity = this.accessControlledEntity; const entity = this.accessControlledEntity;
const oldUserAccessPolicies = entity.UserAccessPolicies; const oldUserAccessPolicies = entity.UserAccessPolicies;
const oldTeamAccessPolicies = entity.TeamAccessPolicies; const oldTeamAccessPolicies = entity.TeamAccessPolicies;
const selectedRoleId = this.formValues.selectedRole.Id;
const selectedUserAccesses = _.filter(this.formValues.multiselectOutput, (access) => access.Type === 'user'); const selectedUserAccesses = _.filter(this.formValues.multiselectOutput, (access) => access.Type === 'user');
const selectedTeamAccesses = _.filter(this.formValues.multiselectOutput, (access) => access.Type === 'team'); 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.UserAccessPolicies = accessPolicies.userAccessPolicies;
this.accessControlledEntity.TeamAccessPolicies = accessPolicies.teamAccessPolicies; this.accessControlledEntity.TeamAccessPolicies = accessPolicies.teamAccessPolicies;
this.updateAccess(); this.updateAccess();
@ -50,11 +53,41 @@ class PorAccessManagementController {
this.updateAccess(); 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() { async $onInit() {
try { try {
if (this.limitedFeature) {
this.limitedToBE = this.featureService.isLimitedToBE(this.limitedFeature);
}
const entity = this.accessControlledEntity; const entity = this.accessControlledEntity;
const parent = this.inheritFrom; 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); const data = await this.AccessService.accesses(entity, parent, this.roles);
if (this.filterUsers) { if (this.filterUsers) {

View File

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

View File

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

View File

@ -0,0 +1,5 @@
<a class="be-indicator" href="{{ $ctrl.url }}" target="_blank" rel="noopener" ng-if="$ctrl.limitedToBE">
<ng-transclude></ng-transclude>
<i class="be-indicator-icon fas fa-briefcase space-right"></i>
<span class="be-indicator-label">Business Edition Feature</span>
</a>

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
<div <div
class="box-selector-item" class="box-selector-item"
ng-class="{ business: $ctrl.limitedToBE, limited: $ctrl.limitedToBE }"
tooltip-append-to-body="true" tooltip-append-to-body="true"
tooltip-placement="bottom" tooltip-placement="bottom"
tooltip-class="portainer-tooltip" tooltip-class="portainer-tooltip"
@ -14,11 +15,13 @@
ng-value="$ctrl.option.value" ng-value="$ctrl.option.value"
ng-disabled="$ctrl.disabled" ng-disabled="$ctrl.disabled"
/> />
<label for="{{ $ctrl.option.id }}" ng-click="$ctrl.onChange($ctrl.option.value)" t data-cy="edgeStackCreate-deploymentSelector_{{ $ctrl.option.value }}"> <label for="{{ $ctrl.option.id }}" ng-click="$ctrl.handleChange($ctrl.option.value)">
<i class="fas fa-briefcase limited-icon" ng-if="$ctrl.limitedToBE"></i>
<div class="boxselector_header"> <div class="boxselector_header">
<i ng-class="$ctrl.option.icon" aria-hidden="true" style="margin-right: 2px;"></i> <i ng-class="$ctrl.option.icon" aria-hidden="true" style="margin-right: 2px;"></i>
{{ $ctrl.option.label }} {{ $ctrl.option.label }}
</div> </div>
<p ng-if="$ctrl.option.description">{{ $ctrl.option.description }}</p> <p class="box-selector-item-description">{{ $ctrl.option.description }}</p>
</label> </label>
</div> </div>

View File

@ -1,7 +1,15 @@
import angular from 'angular'; import angular from 'angular';
import './box-selector-item.css';
import controller from './box-selector-item.controller';
angular.module('portainer.app').component('boxSelectorItem', { angular.module('portainer.app').component('boxSelectorItem', {
templateUrl: './box-selector-item.html', templateUrl: './box-selector-item.html',
controller,
require: {
formCtrl: '^^form',
},
bindings: { bindings: {
radioName: '@', radioName: '@',
isChecked: '<', isChecked: '<',

View File

@ -4,10 +4,10 @@ export default class BoxSelectorController {
this.change = this.change.bind(this); this.change = this.change.bind(this);
} }
change(value) { change(value, limited) {
this.ngModel = value; this.ngModel = value;
if (this.onChange) { if (this.onChange) {
this.onChange(value); this.onChange(value, limited);
} }
} }

View File

@ -3,89 +3,3 @@
flex-flow: row wrap; flex-flow: row wrap;
margin: 0.5rem; 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;
}
}

View File

@ -15,6 +15,6 @@ angular.module('portainer.app').component('boxSelector', {
}, },
}); });
export function buildOption(id, icon, label, description, value) { export function buildOption(id, icon, label, description, value, feature) {
return { id, icon, label, description, value }; return { id, icon, label, description, value, feature };
} }

View File

@ -1,26 +0,0 @@
<div class="datatable">
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input type="text" class="searchInput" placeholder="Search..." ng-model-options="{ debounce: 300 }" />
</div>
<div class="table-responsive">
<table class="table table-hover nowrap-cells">
<thead>
<tr>
<th>
Environment
</th>
<th>
Role
</th>
<th>Access origin</th>
</tr>
</thead>
<tbody>
<tr ng-if="!$ctrl.dataset">
<td colspan="3" class="text-center text-muted">Select a user to show associated access and role</td>
</tr>
</tbody>
</table>
</div>
</div>

View File

@ -92,6 +92,10 @@
float: none; float: none;
} }
.datatable.datatable-empty .table > tbody > tr > td {
padding: 15px 0;
}
.tableMenu { .tableMenu {
color: #767676; color: #767676;
padding: 10px; padding: 10px;

View File

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

View File

@ -0,0 +1,32 @@
<div uib-dropdown dropdown-append-to-body auto-close="outsideClick" is-open="$ctrl.isOpen">
<span ng-transclude></span>
<div class="filter-button">
<span uib-dropdown-toggle class="table-filter" ng-class="{ 'filter-active': $ctrl.isEnabled() }">
Filter
<i class="fa" ng-class="[$ctrl.isEnabled() ? 'fa-check' : 'fa-filter']" aria-hidden="true"></i>
</span>
</div>
<div class="dropdown-menu" style="min-width: 0;" uib-dropdown-menu>
<div class="tableMenu">
<div class="menuContent">
<div class="md-checkbox" ng-repeat="filter in $ctrl.labels track by filter.value">
<input
id="filter_{{ $ctrl.filterKey }}_{{ $index }}"
type="checkbox"
ng-value="filter.value"
ng-checked="$ctrl.state.includes(filter.value)"
ng-click="$ctrl.onChangeItem(filter.value)"
/>
<label for="filter_{{ $ctrl.filterKey }}_{{ $index }}">
{{ filter.label }}
</label>
</div>
</div>
<div>
<a class="btn btn-default btn-sm" ng-click="$ctrl.isOpen = false;">
Close
</a>
</div>
</div>
</div>
</div>

View File

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

View File

@ -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-503333425
* https://github.com/portainer/portainer/pull/2877#issuecomment-503537249 * 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.setDefaults();
this.prepareTableFromDataset(); this.prepareTableFromDataset();

View File

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

View File

@ -0,0 +1,9 @@
export const datatablePagination = {
bindings: {
onChangeLimit: '<',
limit: '<',
enableNoLimit: '<',
onChangePage: '<',
},
templateUrl: './pagination.html',
};

View File

@ -0,0 +1,15 @@
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;"> Items per page </span>
<select class="form-control" ng-model="$ctrl.limit" ng-change="$ctrl.onChangeLimit($ctrl.limit)">
<option ng-if="$ctrl.enableNoLimit" ng-value="0">All</option>
<option ng-value="10">10</option>
<option ng-value="25">25</option>
<option ng-value="50">50</option>
<option ng-value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5" on-page-change="$ctrl.onChangePage(newPageNumber)"> </dir-pagination-controls>
</form>
</div>

View File

@ -87,15 +87,10 @@
</td> </td>
<td> <td>
<a ng-if="$ctrl.canManageAccess(item)" ng-click="$ctrl.redirectToManageAccess(item)"> <i class="fa fa-users" aria-hidden="true"></i> Manage access </a> <a ng-if="$ctrl.canManageAccess(item)" ng-click="$ctrl.redirectToManageAccess(item)"> <i class="fa fa-users" aria-hidden="true"></i> Manage access </a>
<span <be-feature-indicator feature="$ctrl.limitedFeature" ng-if="$ctrl.canBrowse(item)">
ng-if="$ctrl.canBrowse(item)" <span class="text-muted space-left" style="padding-right: 5px;"> <i class="fa fa-search" aria-hidden="true"></i> Browse</span>
class="text-muted space-left" </be-feature-indicator>
style="cursor: pointer;"
data-toggle="tooltip"
title="This feature is available in Portainer Business Edition"
>
<i class="fa fa-search" aria-hidden="true"></i> Browse</span
>
<span ng-if="!$ctrl.canBrowse(item) && !$ctrl.canManageAccess(item)"> - </span> <span ng-if="!$ctrl.canBrowse(item) && !$ctrl.canManageAccess(item)"> - </span>
</td> </td>
</tr> </tr>

View File

@ -1,5 +1,5 @@
import { PortainerEndpointTypes } from 'Portainer/models/endpoint/models'; import { PortainerEndpointTypes } from 'Portainer/models/endpoint/models';
import { REGISTRY_MANAGEMENT } from '@/portainer/feature-flags/feature-ids';
angular.module('portainer.docker').controller('RegistriesDatatableController', RegistriesDatatableController); angular.module('portainer.docker').controller('RegistriesDatatableController', RegistriesDatatableController);
/* @ngInject */ /* @ngInject */
@ -45,6 +45,7 @@ function RegistriesDatatableController($scope, $controller, $state, Authenticati
}; };
this.$onInit = function () { this.$onInit = function () {
this.limitedFeature = REGISTRY_MANAGEMENT;
this.isAdmin = Authentication.isAdmin(); this.isAdmin = Authentication.isAdmin();
this.setDefaults(); this.setDefaults();
this.prepareTableFromDataset(); this.prepareTableFromDataset();

View File

@ -1,64 +0,0 @@
<div class="datatable">
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }} </div>
</div>
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input type="text" class="searchInput" placeholder="Search..." auto-focus ng-model-options="{ debounce: 300 }" />
</div>
<div class="table-responsive">
<table class="table table-hover nowrap-cells">
<thead>
<tr>
<th>
Name
</th>
<th>
Description
</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-muted">Environment administrator</td>
<td class="text-muted">Full control of all resources in an environment</td>
</tr>
<tr>
<td class="text-muted">Helpdesk</td>
<td class="text-muted">Read-only access of all resources in an environment</td>
</tr>
<tr>
<td class="text-muted">Read-only user</td>
<td class="text-muted">Read-only access of assigned resources in an environment</td>
</tr>
<tr>
<td class="text-muted">Standard user</td>
<td class="text-muted">Full control of assigned resources in an environment</td>
</tr>
</tbody>
</table>
<div class="footer">
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;">
Items per page
</span>
<select class="form-control">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5"></dir-pagination-controls>
</form>
</div>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>

View File

@ -0,0 +1,4 @@
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input type="text" class="searchInput" ng-model="$ctrl.filter" ng-change="$ctrl.onChange($ctrl.filter)" placeholder="Search..." ng-model-options="{ debounce: 300 }" />
</div>

View File

@ -0,0 +1,7 @@
export const datatableSearchbar = {
bindings: {
onChange: '<',
ngModel: '<',
},
templateUrl: './datatable-searchbar.html',
};

View File

@ -0,0 +1,5 @@
export default class datatableSortIconController {
isCurrentSortOrder() {
return this.selectedSortKey === this.key;
}
}

View File

@ -0,0 +1,9 @@
<i
class="fa fa-sort-alpha-down"
ng-class="{
'fa-sort-alpha-down': !$ctrl.reverseOrder,
'fa-sort-alpha-up': $ctrl.reverseOrder,
}"
aria-hidden="true"
ng-if="$ctrl.isCurrentSortOrder()"
></i>

View File

@ -0,0 +1,11 @@
import controller from './datatable-sort-icon.controller';
export const datatableSortIcon = {
bindings: {
key: '@',
selectedSortKey: '@',
reverseOrder: '<',
},
controller,
templateUrl: './datatable-sort-icon.html',
};

View File

@ -0,0 +1,7 @@
<div class="toolBar">
<div class="toolBarTitle">
<i class="fa" ng-class="$ctrl.icon" aria-hidden="true" style="margin-right: 2px;"></i>
<span style="margin-right: 10px;">{{ $ctrl.title }}</span>
<be-feature-indicator feature="$ctrl.feature"></be-feature-indicator>
</div>
</div>

View File

@ -0,0 +1,8 @@
export const datatableTitlebar = {
bindings: {
icon: '@',
title: '@',
feature: '@',
},
templateUrl: './datatable-titlebar.html',
};

View File

@ -10,5 +10,7 @@
ng-model="$ctrl.ngModel" ng-model="$ctrl.ngModel"
disabled="$ctrl.disabled" disabled="$ctrl.disabled"
on-change="($ctrl.onChange)" on-change="($ctrl.onChange)"
feature="$ctrl.feature"
ng-data-cy="{{::$ctrl.ngDataCy}}"
></por-switch> ></por-switch>
</label> </label>

View File

@ -1,7 +1,5 @@
import angular from 'angular'; import angular from 'angular';
import './por-switch-field.css';
export const porSwitchField = { export const porSwitchField = {
templateUrl: './por-switch-field.html', templateUrl: './por-switch-field.html',
bindings: { bindings: {
@ -10,8 +8,10 @@ export const porSwitchField = {
label: '@', label: '@',
name: '@', name: '@',
labelClass: '@', labelClass: '@',
ngDataCy: '@',
disabled: '<', disabled: '<',
onChange: '<', onChange: '<',
feature: '<', // feature id
}, },
}; };

View File

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

View File

@ -51,3 +51,18 @@
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; 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);
}

View File

@ -1,4 +1,12 @@
<label class="switch" ng-class="$ctrl.className" style="margin-bottom: 0;"> <label class="switch" ng-class="[$ctrl.className, { business: $ctrl.limitedToBE, limited: $ctrl.limitedToBE }]" style="margin-bottom: 0;">
<input type="checkbox" name="{{::$ctrl.name}}" id="{{::$ctrl.id}}" ng-model="$ctrl.ngModel" ng-disabled="$ctrl.disabled" ng-change="$ctrl.onChange($ctrl.ngModel)" /> <input
<i></i> type="checkbox"
name="{{::$ctrl.name}}"
id="{{::$ctrl.id}}"
ng-model="$ctrl.ngModel"
ng-disabled="$ctrl.disabled || $ctrl.limitedToBE"
ng-change="$ctrl.onChange($ctrl.ngModel)"
/>
<i data-cy="{{::$ctrl.ngDataCy}}"></i>
</label> </label>
<be-feature-indicator ng-if="$ctrl.limitedToBE" feature="$ctrl.feature"></be-feature-indicator>

View File

@ -1,14 +1,20 @@
import angular from 'angular'; import angular from 'angular';
import controller from './por-switch.controller';
import './por-switch.css';
const porSwitch = { const porSwitch = {
templateUrl: './por-switch.html', templateUrl: './por-switch.html',
controller,
bindings: { bindings: {
ngModel: '=', ngModel: '=',
id: '@', id: '@',
className: '@', className: '@',
name: '@', name: '@',
ngDataCy: '@',
disabled: '<', disabled: '<',
onChange: '<', onChange: '<',
feature: '<', // feature id
}, },
}; };

View File

@ -6,9 +6,20 @@ angular.module('portainer.app').directive('rdWidgetHeader', function rdWidgetTit
icon: '@', icon: '@',
classes: '@?', classes: '@?',
}, },
transclude: true, transclude: {
template: title: '?headerTitle',
'<div class="widget-header"><div class="row"><span ng-class="classes" class="pull-left"><i class="fa" ng-class="icon"></i> {{titleText}} </span><span ng-class="classes" class="pull-right" ng-transclude></span></div></div>', },
template: `
<div class="widget-header">
<div class="row">
<span ng-class="classes" class="pull-left">
<i class="fa" ng-class="icon"></i>
<span ng-transclude="title">{{ titleText }}</span>
</span>
<span ng-class="classes" class="pull-right" ng-transclude></span>
</div>
</div>
`,
restrict: 'E', restrict: 'E',
}; };
return directive; return directive;

View File

@ -0,0 +1,10 @@
export const EDITIONS = {
CE: 0,
BE: 1,
};
export const STATES = {
HIDDEN: 0,
VISIBLE: 1,
LIMITED_BE: 2,
};

View File

@ -0,0 +1,26 @@
.form-control.limited-be {
border-color: var(--BE-only);
}
.form-control.limited-be.no-border {
border-color: var(--border-form-control-color);
}
button.limited-be {
background-color: var(--BE-only);
border-color: var(--BE-only);
}
ng-form.limited-be,
form.limited-be,
div.limited-be {
border: solid 1px var(--BE-only);
padding: 10px;
pointer-events: none;
touch-action: none;
display: block;
}
.form-control.limited-be[disabled] {
background-color: transparent !important;
}

View File

@ -0,0 +1,57 @@
import { EDITIONS, STATES } from './enums';
import * as FEATURE_IDS from './feature-ids';
export function featureService() {
const state = {
currentEdition: undefined,
features: {},
};
return {
selectShow,
init,
isLimitedToBE,
};
async function init() {
// will be loaded on runtime
const currentEdition = EDITIONS.CE;
const features = {
[FEATURE_IDS.K8S_RESOURCE_POOL_LB_QUOTA]: EDITIONS.BE,
[FEATURE_IDS.K8S_RESOURCE_POOL_STORAGE_QUOTA]: EDITIONS.BE,
[FEATURE_IDS.ACTIVITY_AUDIT]: EDITIONS.BE,
[FEATURE_IDS.EXTERNAL_AUTH_LDAP]: EDITIONS.BE,
[FEATURE_IDS.HIDE_INTERNAL_AUTH]: EDITIONS.BE,
[FEATURE_IDS.HIDE_INTERNAL_AUTHENTICATION_PROMPT]: EDITIONS.BE,
[FEATURE_IDS.K8S_SETUP_DEFAULT]: EDITIONS.BE,
[FEATURE_IDS.RBAC_ROLES]: EDITIONS.BE,
[FEATURE_IDS.REGISTRY_MANAGEMENT]: EDITIONS.BE,
[FEATURE_IDS.S3_BACKUP_SETTING]: EDITIONS.BE,
[FEATURE_IDS.TEAM_MEMBERSHIP]: EDITIONS.BE,
};
state.currentEdition = currentEdition;
state.features = features;
}
function selectShow(featureId) {
if (!state.features[featureId]) {
return STATES.HIDDEN;
}
if (state.features[featureId] <= state.currentEdition) {
return STATES.VISIBLE;
}
if (state.features[featureId] === EDITIONS.BE) {
return STATES.LIMITED_BE;
}
return STATES.HIDDEN;
}
function isLimitedToBE(featureId) {
return selectShow(featureId) === STATES.LIMITED_BE;
}
}

View File

@ -0,0 +1,11 @@
export const K8S_RESOURCE_POOL_LB_QUOTA = 'k8s-resourcepool-Ibquota';
export const K8S_RESOURCE_POOL_STORAGE_QUOTA = 'k8s-resourcepool-storagequota';
export const RBAC_ROLES = 'rbac-roles';
export const REGISTRY_MANAGEMENT = 'registry-management';
export const K8S_SETUP_DEFAULT = 'k8s-setup-default';
export const S3_BACKUP_SETTING = 's3-backup-setting';
export const HIDE_INTERNAL_AUTHENTICATION_PROMPT = 'hide-internal-authentication-prompt';
export const TEAM_MEMBERSHIP = 'team-membership';
export const HIDE_INTERNAL_AUTH = 'hide-internal-auth';
export const EXTERNAL_AUTH_LDAP = 'external-auth-ldap';
export const ACTIVITY_AUDIT = 'activity-audit';

View File

@ -0,0 +1,7 @@
import angular from 'angular';
import { limitedFeatureDirective } from './limited-feature.directive';
import { featureService } from './feature-flags.service';
import './feature-flags.css';
export default angular.module('portainer.feature-flags', []).directive('limitedFeatureDir', limitedFeatureDirective).factory('featureService', featureService).name;

View File

@ -0,0 +1,41 @@
import _ from 'lodash-es';
import { STATES } from '@/portainer/feature-flags/enums';
const BASENAME = 'limitedFeature';
/* @ngInject */
export function limitedFeatureDirective(featureService) {
return {
restrict: 'A',
link,
};
function link(scope, elem, attrs) {
const { limitedFeatureDir: featureId } = attrs;
if (!featureId) {
return;
}
const limitedFeatureAttrs = Object.keys(attrs)
.filter((attr) => attr.startsWith(BASENAME) && attr !== `${BASENAME}Dir`)
.map((attr) => [_.kebabCase(attr.replace(BASENAME, '')), attrs[attr]]);
const state = featureService.selectShow(featureId);
if (state === STATES.HIDDEN) {
elem.hide();
return;
}
if (state === STATES.VISIBLE) {
return;
}
limitedFeatureAttrs.forEach(([attr, value = attr]) => {
const currentValue = elem.attr(attr) || '';
elem.attr(attr, `${currentValue} ${value}`.trim());
});
}
}

View File

@ -1,8 +1,11 @@
import angular from 'angular';
import controller from './oauth-provider-selector.controller';
angular.module('portainer.oauth').component('oauthProvidersSelector', { angular.module('portainer.oauth').component('oauthProvidersSelector', {
templateUrl: './oauth-providers-selector.html', templateUrl: './oauth-providers-selector.html',
bindings: { bindings: {
onSelect: '<', onChange: '<',
provider: '=', value: '<',
}, },
controller: 'OAuthProviderSelectorController', controller,
}); });

View File

@ -1,39 +0,0 @@
angular.module('portainer.oauth').controller('OAuthProviderSelectorController', function OAuthProviderSelectorController() {
var ctrl = this;
this.providers = [
{
authUrl: '',
accessTokenUrl: '',
resourceUrl: '',
userIdentifier: '',
scopes: '',
name: 'custom',
label: 'Custom',
description: 'Custom OAuth provider',
icon: 'fa fa-user-check',
},
];
this.$onInit = onInit;
function onInit() {
if (ctrl.provider.authUrl) {
ctrl.provider = getProviderByURL(ctrl.provider.authUrl);
} else {
ctrl.provider = ctrl.providers[0];
}
ctrl.onSelect(ctrl.provider, false);
}
function getProviderByURL(providerAuthURL) {
if (providerAuthURL.indexOf('login.microsoftonline.com') !== -1) {
return ctrl.providers[0];
} else if (providerAuthURL.indexOf('accounts.google.com') !== -1) {
return ctrl.providers[1];
} else if (providerAuthURL.indexOf('github.com') !== -1) {
return ctrl.providers[2];
}
return ctrl.providers[3];
}
});

View File

@ -0,0 +1,14 @@
import { HIDE_INTERNAL_AUTH } from '@/portainer/feature-flags/feature-ids';
import { buildOption } from '@/portainer/components/box-selector';
export default class OAuthProviderSelectorController {
constructor() {
this.options = [
buildOption('microsoft', 'fab fa-microsoft', 'Microsoft', 'Microsoft OAuth provider', 'microsoft', HIDE_INTERNAL_AUTH),
buildOption('google', 'fab fa-google', 'Google', 'Google OAuth provider', 'google', HIDE_INTERNAL_AUTH),
buildOption('github', 'fab fa-github', 'Github', 'Github OAuth provider', 'github', HIDE_INTERNAL_AUTH),
buildOption('custom', 'fa fa-user-check', 'Custom', 'Custom OAuth provider', 'custom'),
];
}
}

View File

@ -2,18 +2,4 @@
Provider Provider
</div> </div>
<div class="form-group"></div> <box-selector ng-model="$ctrl.value" options="$ctrl.options" on-change="($ctrl.onChange)" radio-name="oauth_provider"></box-selector>
<div class="form-group" style="margin-bottom: 0;">
<div class="boxselector_wrapper">
<div ng-repeat="provider in $ctrl.providers" ng-click="$ctrl.onSelect(provider, true)">
<input type="radio" id="{{ 'oauth_provider_' + provider.name }}" ng-model="$ctrl.provider" ng-value="provider" />
<label for="{{ 'oauth_provider_' + provider.name }}">
<div class="boxselector_header">
<i ng-class="provider.icon" aria-hidden="true" style="margin-right: 2px;"></i>
{{ provider.label }}
</div>
<p>{{ provider.description }}</p>
</label>
</div>
</div>
</div>

View File

@ -1,8 +1,11 @@
import angular from 'angular';
import controller from './oauth-settings.controller';
angular.module('portainer.oauth').component('oauthSettings', { angular.module('portainer.oauth').component('oauthSettings', {
templateUrl: './oauth-settings.html', templateUrl: './oauth-settings.html',
bindings: { bindings: {
settings: '=', settings: '=',
teams: '<', teams: '<',
}, },
controller: 'OAuthSettingsController', controller,
}); });

View File

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

View File

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

View File

@ -1,54 +1,50 @@
<div <ng-form name="$ctrl.oauthSettingsForm">
><div class="col-sm-12 form-section-title"> <div class="col-sm-12 form-section-title">
Single Sign-On Single Sign-On
</div> </div>
<!-- SSO --> <!-- SSO -->
<div class="form-group"> <div class="form-group">
<label for="use-sso" class="control-label col-sm-2 text-left" style="padding-top: 0;"> <div class="col-sm-12">
Use SSO <por-switch-field
<portainer-tooltip position="bottom" message="When using SSO the OAuth provider is not forced to prompt for credentials."></portainer-tooltip> label="Use SSO"
</label> tooltip="When using SSO the OAuth provider is not forced to prompt for credentials."
<div class="col-sm-9"> name="use-sso"
<label class="switch"> <input id="use-sso" type="checkbox" ng-model="$ctrl.settings.SSO" /><i></i> </label> ng-model="$ctrl.settings.SSO"
on-change="$ctrl.updateSSO()"
></por-switch-field>
</div> </div>
</div> </div>
<!-- !SSO --> <!-- !SSO -->
<!-- HideInternalAuth --> <!-- HideInternalAuth -->
<div class="form-group" ng-if="$ctrl.settings.SSO"> <div class="form-group" ng-if="$ctrl.settings.SSO">
<label for="hide-internal-auth" class="control-label col-sm-2 text-left" style="padding-top: 0;"> <div class="col-sm-12">
Hide internal authentication prompt <por-switch-field
</label> label="Hide internal authentication prompt"
<div class="col-sm-9"> name="hide-internal-auth"
<label class="switch"> <input id="hide-internal-auth" type="checkbox" disabled /><i></i> </label> feature="$ctrl.limitedFeature"
<span class="text-muted small" style="margin-left: 15px;"> ng-model="$ctrl.settings.HideInternalAuth"
This feature is available in <a href="https://www.portainer.io/business-upsell?from=hide-internal-auth" target="_blank"> Portainer Business Edition</a>. ></por-switch-field>
</span>
</div> </div>
</div> </div>
<!-- !HideInternalAuth --> <!-- !HideInternalAuth -->
<div class="col-sm-12 form-section-title">
Automatic user provisioning <auto-user-provision-toggle ng-model="$ctrl.settings.OAuthAutoCreateUsers">
</div> <field-description>
<div class="form-group">
<span class="col-sm-12 text-muted small">
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 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. in order to login.
</span> </field-description>
</div> </auto-user-provision-toggle>
<div class="form-group">
<label class="col-sm-3 col-lg-2 control-label text-left">Automatic user provisioning</label>
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="$ctrl.settings.OAuthAutoCreateUsers" /><i></i> </label>
</div>
<div ng-if="$ctrl.settings.OAuthAutoCreateUsers"> <div ng-if="$ctrl.settings.OAuthAutoCreateUsers">
<div class="form-group"> <div class="form-group">
<span class="col-sm-12 text-muted small"> <span class="col-sm-12 text-muted small">
<p>The users created by the automatic provisioning feature can be added to a default team on creation.</p> <p>The users created by the automatic provisioning feature can be added to a default team on creation.</p>
<p <p>
>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 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.</p users won't be able to access any environments.
> </p>
</span> </span>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -56,13 +52,33 @@
<span class="small text-muted" style="margin-left: 20px;" ng-if="$ctrl.teams.length === 0"> <span class="small text-muted" style="margin-left: 20px;" ng-if="$ctrl.teams.length === 0">
You have not yet created any teams. Head over to the <a ui-sref="portainer.teams">Teams view</a> to manage teams. You have not yet created any teams. Head over to the <a ui-sref="portainer.teams">Teams view</a> to manage teams.
</span> </span>
<button type="button" class="btn btn-sm btn-danger" ng-click="$ctrl.settings.DefaultTeamID = null" ng-disabled="!$ctrl.settings.DefaultTeamID" ng-if="$ctrl.teams.length > 0"
><i class="fa fa-times" aria-hidden="true"></i <div class="col-sm-9" ng-if="$ctrl.teams.length > 0">
></button> <div class="col-sm-12 small text-muted">
<div class="col-sm-9 col-lg-9" ng-if="$ctrl.teams.length > 0"> <p>
<select class="form-control" ng-model="$ctrl.settings.DefaultTeamID" ng-options="team.Id as team.Name for team in $ctrl.teams"> <i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
<option value="">No team</option> The default team option will be disabled when automatic team membership is enabled
</select> </p>
</div>
<div class="col-xs-11">
<select
class="form-control"
ng-disabled="$ctrl.settings.OAuthAutoMapTeamMemberships"
ng-model="$ctrl.settings.DefaultTeamID"
ng-options="team.Id as team.Name for team in $ctrl.teams"
>
<option value="">No team</option>
</select>
</div>
<button
type="button"
class="btn btn-sm btn-danger"
ng-click="$ctrl.settings.DefaultTeamID = null"
ng-disabled="!$ctrl.settings.DefaultTeamID || $ctrl.settings.OAuthAutoMapTeamMemberships"
ng-if="$ctrl.teams.length > 0"
>
<i class="fa fa-times" aria-hidden="true"> </i
></button>
</div> </div>
</div> </div>
</div> </div>
@ -71,118 +87,296 @@
Team membership Team membership
</div> </div>
<div class="form-group"> <div class="form-group">
<span class="col-sm-12 text-muted small"> <div class="col-sm-12 text-muted small">
Automatic team membership synchronizes the team membership based on a custom claim in the token from the OAuth provider. Automatic team membership synchronizes the team membership based on a custom claim in the token from the OAuth provider.
</span> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<span class="text-muted small" style="margin-left: 15px;"> <div class="col-sm-12">
<i class="fa fa-user" aria-hidden="true"></i> <por-switch-field label="Automatic team membership" name="tls" feature="$ctrl.limitedFeature" ng-model="$ctrl.settings.OAuthAutoMapTeamMemberships"></por-switch-field>
This feature is available in <a href="https://www.portainer.io/business-upsell?from=oauth-group-membership" target="_blank"> Portainer Business Edition</a>. </div>
</span>
</div> </div>
<div ng-if="$ctrl.settings.OAuthAutoMapTeamMemberships">
<div class="form-group">
<label class="col-sm-3 col-lg-2 control-label text-left">
Claim name
<portainer-tooltip position="bottom" message="The OpenID Connect UserInfo Claim name that contains the team identifier the user belongs to."></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<div class="col-xs-11 col-lg-10">
<input type="text" class="form-control" id="oauth_token_claim_name" ng-model="$ctrl.settings.TeamMemberships.OAuthClaimName" placeholder="groups" />
</div>
</div>
</div>
<div class="form-group">
<label class="col-sm-3 col-lg-2 control-label text-left">
Statically assigned teams
</label>
<div class="col-sm-9 col-lg-10">
<span class="label label-default interactive" style="margin-left: 1.4em;" ng-click="$ctrl.addTeamMembershipMapping()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add team mapping
</span>
<div class="col-sm-12 form-inline" ng-repeat="mapping in $ctrl.settings.TeamMemberships.OAuthClaimMappings" style="margin-top: 0.75em;">
<div class="input-group input-group-sm col-sm-5">
<span class="input-group-addon">claim value regex</span>
<input type="text" class="form-control" ng-model="mapping.ClaimValRegex" />
</div>
<span style="margin: 0px 0.5em;">maps to</span>
<div class="input-group input-group-sm col-sm-3 col-lg-4">
<span class="input-group-addon">team</span>
<select
class="form-control"
ng-init="mapping.Team = mapping.Team || $ctrl.settings.DefaultTeamID"
ng-model="mapping.Team"
ng-options="team.Id as team.Name for team in $ctrl.teams"
>
<option selected value="">Select a team</option>
</select>
</div>
<button type="button" class="btn btn-sm btn-danger" ng-click="$ctrl.removeTeamMembership($index)"> <i class="fa fa-trash" aria-hidden="true"> </i></button>
<div class="small text-warning" ng-show="!mapping.ClaimValRegex" style="margin-top: 0.4em;">
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Claim value regex is required.
</div>
</div>
</div>
</div>
<div class="form-group">
<div class="col-sm-12 text-muted small" style="margin-bottom: 0.5em;">
The default team will be assigned when the user does not belong to any other team
</div>
<label class="col-sm-3 col-lg-2 control-label text-left">Default team</label>
<span class="small text-muted" style="margin-left: 20px;" ng-if="$ctrl.teams.length === 0">
You have not yet created any teams. Head over to the <a ui-sref="portainer.teams">Teams view</a> to manage teams.
</span>
<div class="col-sm-9" ng-if="$ctrl.teams.length > 0">
<div class="col-xs-11">
<select class="form-control" ng-model="$ctrl.settings.DefaultTeamID" ng-options="team.Id as team.Name for team in $ctrl.teams">
<option value="">No team</option>
</select>
</div>
</div>
</div>
</div>
<oauth-providers-selector on-change="($ctrl.onSelectProvider)" value="$ctrl.state.provider"></oauth-providers-selector>
<div class="col-sm-12 form-section-title">OAuth Configuration</div> <div class="col-sm-12 form-section-title">OAuth Configuration</div>
<div class="form-group" ng-if="$ctrl.state.provider == 'microsoft'">
<label for="oauth_microsoft_tenant_id" class="col-sm-3 col-lg-2 control-label text-left">
Tenant ID
<portainer-tooltip position="bottom" message="ID of the Azure Directory you wish to authenticate against. Also known as the Directory ID"></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
class="form-control"
id="oauth_microsoft_tenant_id"
placeholder="xxxxxxxxxxxxxxxxxxxx"
ng-model="$ctrl.state.microsoftTenantID"
ng-change="$ctrl.onMicrosoftTenantIDChange()"
limited-feature-dir="{{::$ctrl.limitedFeature}}"
limited-feature-class="limited-be"
limited-feature-disabled
limited-feature-tabindex="-1"
required
/>
</div>
</div>
<div class="form-group"> <div class="form-group">
<label for="oauth_client_id" class="col-sm-3 col-lg-2 control-label text-left"> <label for="oauth_client_id" class="col-sm-3 col-lg-2 control-label text-left">
Client ID {{ $ctrl.state.provider == 'microsoft' ? 'Application ID' : 'Client ID' }}
<portainer-tooltip position="bottom" message="Public identifier of the OAuth application"></portainer-tooltip> <portainer-tooltip position="bottom" message="Public identifier of the OAuth application"></portainer-tooltip>
</label> </label>
<div class="col-sm-9 col-lg-10"> <div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="oauth_client_id" ng-model="$ctrl.settings.ClientID" placeholder="xxxxxxxxxxxxxxxxxxxx" /> <input
type="text"
id="oauth_client_id"
ng-model="$ctrl.settings.ClientID"
placeholder="xxxxxxxxxxxxxxxxxxxx"
ng-class="['form-control', { 'limited-be': $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' }]"
ng-disabled="$ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom'"
tabindex="{{ $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' ? -1 : 0 }}"
/>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="oauth_client_secret" class="col-sm-3 col-lg-2 control-label text-left"> <label for="oauth_client_secret" class="col-sm-3 col-lg-2 control-label text-left">
Client secret {{ $ctrl.state.provider == 'microsoft' ? 'Application key' : 'Client secret' }}
</label> </label>
<div class="col-sm-9 col-lg-10"> <div class="col-sm-9 col-lg-10">
<input type="password" class="form-control" id="oauth_client_secret" ng-model="$ctrl.settings.ClientSecret" placeholder="xxxxxxxxxxxxxxxxxxxx" autocomplete="new-password" /> <input
type="password"
class="form-control"
id="oauth_client_secret"
ng-model="$ctrl.settings.ClientSecret"
placeholder="xxxxxxxxxxxxxxxxxxxx"
autocomplete="new-password"
ng-class="['form-control', { 'limited-be': $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' }]"
ng-disabled="$ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom'"
tabindex="{{ $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' ? -1 : 0 }}"
/>
</div> </div>
</div> </div>
<div class="form-group"> <div ng-if="$ctrl.state.provider == 'custom' || $ctrl.state.overrideConfiguration">
<label for="oauth_authorization_uri" class="col-sm-3 col-lg-2 control-label text-left"> <div class="form-group">
Authorization URL <label for="oauth_authorization_uri" class="col-sm-3 col-lg-2 control-label text-left">
<portainer-tooltip Authorization URL
position="bottom" <portainer-tooltip
message="URL used to authenticate against the OAuth provider. Will redirect the user to the OAuth provider login view" position="bottom"
></portainer-tooltip> message="URL used to authenticate against the OAuth provider. Will redirect the user to the OAuth provider login view"
</label> ></portainer-tooltip>
<div class="col-sm-9 col-lg-10"> </label>
<input type="text" class="form-control" id="oauth_authorization_uri" ng-model="$ctrl.settings.AuthorizationURI" placeholder="https://example.com/oauth/authorize" /> <div class="col-sm-9 col-lg-10">
<input
type="text"
class="form-control"
id="oauth_authorization_uri"
ng-model="$ctrl.settings.AuthorizationURI"
placeholder="https://example.com/oauth/authorize"
ng-class="['form-control', { 'limited-be': $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' }]"
ng-disabled="$ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom'"
/>
</div>
</div>
<div class="form-group">
<label for="oauth_access_token_uri" class="col-sm-3 col-lg-2 control-label text-left">
Access token URL
<portainer-tooltip position="bottom" message="URL used by Portainer to exchange a valid OAuth authentication code for an access token"></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
class="form-control"
id="oauth_access_token_uri"
ng-model="$ctrl.settings.AccessTokenURI"
placeholder="https://example.com/oauth/token"
ng-class="['form-control', { 'limited-be': $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' }]"
ng-disabled="$ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom'"
/>
</div>
</div>
<div class="form-group">
<label for="oauth_resource_uri" class="col-sm-3 col-lg-2 control-label text-left">
Resource URL
<portainer-tooltip position="bottom" message="URL used by Portainer to retrieve information about the authenticated user"></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
class="form-control"
id="oauth_resource_uri"
ng-model="$ctrl.settings.ResourceURI"
placeholder="https://example.com/user"
ng-class="['form-control', { 'limited-be': $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' }]"
ng-disabled="$ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom'"
/>
</div>
</div>
<div class="form-group">
<label for="oauth_redirect_uri" class="col-sm-3 col-lg-2 control-label text-left">
Redirect URL
<portainer-tooltip
position="bottom"
message="URL used by the OAuth provider to redirect the user after successful authentication. Should be set to your Portainer instance URL"
></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
class="form-control"
id="oauth_redirect_uri"
ng-model="$ctrl.settings.RedirectURI"
placeholder="http://yourportainer.com/"
ng-class="['form-control', { 'limited-be': $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' }]"
ng-disabled="$ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom'"
/>
</div>
</div>
<div class="form-group">
<label for="oauth_logout_url" class="col-sm-3 col-lg-2 control-label text-left">
Logout URL
<portainer-tooltip
position="bottom"
message="URL used by Portainer to redirect the user to the OAuth provider in order to log the user out of the identity provider session."
></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
class="form-control"
id="oauth_logout_url"
ng-model="$ctrl.settings.LogoutURI"
ng-class="['form-control', { 'limited-be': $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' }]"
ng-disabled="$ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom'"
/>
</div>
</div>
<div class="form-group">
<label for="oauth_user_identifier" class="col-sm-3 col-lg-2 control-label text-left">
User identifier
<portainer-tooltip
position="bottom"
message="Identifier that will be used by Portainer to create an account for the authenticated user. Retrieved from the resource server specified via the Resource URL field"
></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
class="form-control"
id="oauth_user_identifier"
ng-model="$ctrl.settings.UserIdentifier"
placeholder="id"
ng-class="['form-control', { 'limited-be': $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' }]"
ng-disabled="$ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom'"
/>
</div>
</div>
<div class="form-group">
<label for="oauth_scopes" class="col-sm-3 col-lg-2 control-label text-left">
Scopes
<portainer-tooltip
position="bottom"
message="Scopes required by the OAuth provider to retrieve information about the authenticated user. Refer to your OAuth provider documentation for more information about this"
></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
class="form-control"
id="oauth_scopes"
ng-model="$ctrl.settings.Scopes"
placeholder="id,email,name"
ng-class="['form-control', { 'limited-be': $ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom' }]"
ng-disabled="$ctrl.isLimitedToBE && $ctrl.state.provider !== 'custom'"
/>
</div>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group" ng-if="$ctrl.state.provider != 'custom'">
<label for="oauth_access_token_uri" class="col-sm-3 col-lg-2 control-label text-left"> <div class="col-sm-12">
Access token URL <a class="small interactive" ng-if="!$ctrl.state.overrideConfiguration" ng-click="$ctrl.state.overrideConfiguration = true;">
<portainer-tooltip position="bottom" message="URL used by Portainer to exchange a valid OAuth authentication code for an access token"></portainer-tooltip> <i class="fa fa-wrench space-right" aria-hidden="true"></i> Override default configuration
</label> </a>
<div class="col-sm-9 col-lg-10"> <a class="small interactive" ng-if="$ctrl.state.overrideConfiguration" ng-click="$ctrl.useDefaultProviderConfiguration($ctrl.state.provider)">
<input type="text" class="form-control" id="oauth_access_token_uri" ng-model="$ctrl.settings.AccessTokenURI" placeholder="https://example.com/oauth/token" /> <i class="fa fa-cogs space-right" aria-hidden="true"></i> Use default configuration
</a>
</div> </div>
</div> </div>
</ng-form>
<div class="form-group">
<label for="oauth_resource_uri" class="col-sm-3 col-lg-2 control-label text-left">
Resource URL
<portainer-tooltip position="bottom" message="URL used by Portainer to retrieve information about the authenticated user"></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="oauth_resource_uri" ng-model="$ctrl.settings.ResourceURI" placeholder="https://example.com/user" />
</div>
</div>
<div class="form-group">
<label for="oauth_redirect_uri" class="col-sm-3 col-lg-2 control-label text-left">
Redirect URL
<portainer-tooltip
position="bottom"
message="URL used by the OAuth provider to redirect the user after successful authentication. Should be set to your Portainer instance URL"
></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="oauth_redirect_uri" ng-model="$ctrl.settings.RedirectURI" placeholder="http://yourportainer.com/" />
</div>
</div>
<div class="form-group">
<label for="oauth_logout_url" class="col-sm-3 col-lg-2 control-label text-left">
Logout URL
<portainer-tooltip
position="bottom"
message="URL used by Portainer to redirect the user to the OAuth provider in order to log the user out of the identity provider session."
></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="oauth_logout_url" ng-model="$ctrl.settings.LogoutURI" />
</div>
</div>
<div class="form-group">
<label for="oauth_user_identifier" class="col-sm-3 col-lg-2 control-label text-left">
User identifier
<portainer-tooltip
position="bottom"
message="Identifier that will be used by Portainer to create an account for the authenticated user. Retrieved from the resource server specified via the Resource URL field"
></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="oauth_user_identifier" ng-model="$ctrl.settings.UserIdentifier" placeholder="id" />
</div>
</div>
<div class="form-group">
<label for="oauth_scopes" class="col-sm-3 col-lg-2 control-label text-left">
Scopes
<portainer-tooltip
position="bottom"
message="Scopes required by the OAuth provider to retrieve information about the authenticated user. Refer to your OAuth provider documentation for more information about this"
></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="oauth_scopes" ng-model="$ctrl.settings.Scopes" placeholder="id,email,name" />
</div>
</div>
</div>

View File

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

View File

@ -0,0 +1,73 @@
<div class="datatable">
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" ng-change="$ctrl.onTextFilterChange()" placeholder="Search..." ng-model-options="{ debounce: 300 }" />
</div>
<div class="table-responsive">
<table class="table table-hover nowrap-cells">
<thead>
<tr>
<th>
<a ng-click="$ctrl.changeOrderBy('EndpointName')">
Environment
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'EndpointName' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'EndpointName' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('RoleName')">
Role
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'RoleName' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'RoleName' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>Access origin</th>
</tr>
</thead>
<tbody>
<tr
dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit)) track by $index"
>
<td>{{ item.EndpointName }}</td>
<td>{{ item.RoleName }}</td>
<td
>{{ item.TeamName ? 'Team' : 'User' }} <code ng-if="item.TeamName">{{ item.TeamName }}</code> access defined on {{ item.AccessLocation }}
<code ng-if="item.GroupName">{{ item.GroupName }}</code>
<a ng-if="item.AccessLocation === 'endpoint'" ui-sref="portainer.endpoints.endpoint.access({id: item.EndpointId})"
><i style="margin-left: 5px;" class="fa fa-users" aria-hidden="true"></i> Manage access
</a>
<a ng-if="item.AccessLocation === 'endpoint group'" ui-sref="portainer.groups.group.access({id: item.GroupId})"
><i style="margin-left: 5px;" class="fa fa-users" aria-hidden="true"></i> Manage access
</a>
</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="3" class="text-center text-muted">Select a user to show associated access and role</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="3" class="text-center text-muted">The selected user does not have access to any environment(s)</td>
</tr>
</tbody>
</table>
</div>
<div class="footer" ng-if="$ctrl.dataset">
<div class="infoBar" ng-if="$ctrl.state.selectedItemCount !== 0"> {{ $ctrl.state.selectedItemCount }} item(s) selected </div>
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;">
Items per page
</span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5"></dir-pagination-controls>
</form>
</div>
</div>
</div>

View File

@ -1,5 +1,5 @@
angular.module('portainer.app').component('accessViewerDatatable', { export const accessViewerDatatable = {
templateUrl: './accessViewerDatatable.html', templateUrl: './access-viewer-datatable.html',
controller: 'GenericDatatableController', controller: 'GenericDatatableController',
bindings: { bindings: {
titleText: '@', titleText: '@',
@ -8,4 +8,4 @@ angular.module('portainer.app').component('accessViewerDatatable', {
orderBy: '@', orderBy: '@',
dataset: '<', dataset: '<',
}, },
}); };

View File

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

View File

@ -0,0 +1,43 @@
<div class="col-sm-12" style="margin-bottom: 0px;">
<rd-widget>
<rd-widget-header icon="fa-user-lock">
<header-title>
Effective access viewer
<be-feature-indicator feature="$ctrl.limitedFeature" class="space-left"></be-feature-indicator>
</header-title>
</rd-widget-header>
<rd-widget-body>
<form class="form-horizontal">
<div class="col-sm-12 form-section-title">
User
</div>
<div class="form-group">
<div class="col-sm-12">
<span class="small text-muted" ng-if="$ctrl.users.length === 0">
No user available
</span>
<ui-select ng-if="$ctrl.users.length > 0" ng-model="$ctrl.selectedUser" ng-change="$ctrl.onUserSelect()">
<ui-select-match placeholder="Select a user">
<span>{{ $select.selected.Username }}</span>
</ui-select-match>
<ui-select-choices repeat="item in ($ctrl.users | filter: $select.search)">
<span>{{ item.Username }}</span>
</ui-select-choices>
</ui-select>
</div>
</div>
<div class="col-sm-12 form-section-title">
Access
</div>
<div>
<div class="small text-muted" style="margin-bottom: 15px;">
<i class="fa fa-info-circle blue-icon space-right" aria-hidden="true"></i>
Effective role for each environment will be displayed for the selected user
</div>
</div>
<access-viewer-datatable table-key="access_viewer" dataset="$ctrl.userRoles" order-by="EndpointName"> </access-viewer-datatable>
</form>
</rd-widget-body>
</rd-widget>
</div>

View File

@ -0,0 +1,6 @@
import controller from './access-viewer.controller';
export const accessViewer = {
templateUrl: './access-viewer.html',
controller,
};

View File

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

View File

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

View File

@ -0,0 +1,7 @@
th.be-visual-indicator-col {
width: 300px;
}
td.be-visual-indicator-col {
text-align: center;
}

View File

@ -0,0 +1,85 @@
<div class="datatable">
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }} </div>
</div>
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input
type="text"
class="searchInput"
ng-model="$ctrl.state.textFilter"
ng-change="$ctrl.onTextFilterChange()"
placeholder="Search..."
auto-focus
ng-model-options="{ debounce: 300 }"
/>
</div>
<div class="table-responsive">
<table class="table table-hover nowrap-cells">
<thead>
<tr>
<th>
<a ng-click="$ctrl.changeOrderBy('Name')">
Name
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('Description')">
Description
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Description' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Description' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th class="be-visual-indicator-col"></th>
</tr>
</thead>
<tbody>
<tr
dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))"
ng-class="{ active: item.Checked }"
>
<td>{{ item.Name }}</td>
<td>{{ item.Description }}</td>
<td class="be-visual-indicator-col" ng-switch on="$ctrl.isDefaultItem(item)">
<span ng-switch-when="false">
<be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator>
</span>
<b ng-switch-when="true">Default</b>
</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="3" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="3" class="text-center text-muted">No role available.</td>
</tr>
</tbody>
</table>
</div>
<div class="footer" ng-if="$ctrl.dataset">
<div class="infoBar" ng-if="$ctrl.state.selectedItemCount !== 0"> {{ $ctrl.state.selectedItemCount }} item(s) selected </div>
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;">
Items per page
</span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5"></dir-pagination-controls>
</form>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
import controller from './roles.controller';
export const rolesView = {
templateUrl: './roles.html',
controller,
};

View File

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

View File

@ -0,0 +1,18 @@
<rd-header>
<rd-header-title title-text="Roles">
<a data-toggle="tooltip" title="Refresh" ui-sref="portainer.roles" ui-sref-opts="{reload: true}">
<i class="fa fa-sync" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>Role management</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<roles-datatable title-text="Roles" title-icon="fa-file-code" dataset="$ctrl.roles" table-key="roles"></roles-datatable>
</div>
</div>
<div class="row">
<access-viewer> </access-viewer>
</div>

View File

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

View File

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

View File

@ -0,0 +1,14 @@
<div class="col-sm-12 form-section-title">
Automatic user provisioning
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small" ng-transclude="description"> </span>
</div>
<div class="form-group">
<div class="col-sm-12">
<label for="tls" class="control-label text-left">
Automatic user provisioning
</label>
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="$ctrl.ngModel" /><i></i> </label>
</div>
</div>

View File

@ -0,0 +1,9 @@
export const autoUserProvisionToggle = {
templateUrl: './auto-user-provision-toggle.html',
transclude: {
description: 'fieldDescription',
},
bindings: {
ngModel: '=',
},
};

View File

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

View File

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

View File

@ -0,0 +1,157 @@
<ng-form class="ad-settings" limited-feature-dir="{{::$ctrl.limitedFeatureId}}" limited-feature-class="limited-be">
<be-feature-indicator feature="$ctrl.limitedFeatureId" class="my-8 block"></be-feature-indicator>
<auto-user-provision-toggle ng-model="$ctrl.settings.AutoCreateUsers">
<field-description>
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.
</field-description>
</auto-user-provision-toggle>
<div>
<div class="col-sm-12 form-section-title">
Information
</div>
<div class="form-group col-sm-12 text-muted small">
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.
</div>
</div>
<div class="col-sm-12 form-section-title">
AD configuration
</div>
<div class="form-group">
<div class="col-sm-12 small text-muted">
<p>
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
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).
</p>
</div>
</div>
<div class="form-group">
<label for="ldap_url" class="col-sm-3 col-lg-2 control-label text-left" style="display: flex; flex-wrap: wrap;">
AD Controller
<button
type="button"
class="label label-default interactive"
style="border: 0;"
ng-click="$ctrl.addLDAPUrl()"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
>
<i class="fa fa-plus-circle" aria-hidden="true"></i> Add additional server
</button>
</label>
<div class="col-sm-9 col-lg-10">
<div ng-repeat="url in $ctrl.settings.URLs track by $index" style="display: flex; margin-bottom: 10px;">
<input
type="text"
class="form-control"
id="ldap_url"
ng-model="$ctrl.settings.URLs[$index]"
placeholder="e.g. 10.0.0.10:389 or myldap.domain.tld:389"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
/>
<button
ng-if="$index > 0"
class="btn btn-sm btn-danger"
type="button"
ng-click="$ctrl.removeLDAPUrl($index)"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
>
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
<div class="form-group">
<label for="ldap_username" class="col-sm-3 control-label text-left">
Service Account
<portainer-tooltip position="bottom" message="Account that will be used to search for users."></portainer-tooltip>
</label>
<div class="col-sm-9">
<input
type="text"
class="form-control"
id="ldap_username"
ng-model="$ctrl.settings.ReaderDN"
placeholder="reader@domain.tld"
ng-change="$ctrl.onAccountChange($ctrl.settings.ReaderDN)"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
/>
</div>
</div>
<div class="form-group">
<label for="ldap_password" class="col-sm-3 control-label text-left">
Service Account Password
<portainer-tooltip position="bottom" message="If you do not enter a password, Portainer will leave the current password unchanged."></portainer-tooltip>
</label>
<div class="col-sm-9">
<input
type="password"
class="form-control"
id="ldap_password"
ng-model="$ctrl.settings.Password"
placeholder="password"
autocomplete="new-password"
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
limited-feature-tabindex="-1"
/>
</div>
</div>
<ldap-connectivity-check
ng-if="!$ctrl.settings.TLSConfig.TLS && !$ctrl.settings.StartTLS"
settings="$ctrl.settings"
state="$ctrl.state"
connectivity-check="$ctrl.connectivityCheck"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-connectivity-check>
<ldap-settings-security
title="AD Connectivity Security"
settings="$ctrl.settings"
tlsca-cert="$ctrl.tlscaCert"
upload-in-progress="$ctrl.state.uploadInProgress"
on-tlsca-cert-change="($ctrl.onTlscaCertChange)"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-settings-security>
<ldap-connectivity-check
ng-if="$ctrl.settings.TLSConfig.TLS || $ctrl.settings.StartTLS"
settings="$ctrl.settings"
state="$ctrl.state"
connectivity-check="$ctrl.connectivityCheck"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-connectivity-check>
<ldap-user-search
style="margin-top: 5px;"
show-username-format="true"
settings="$ctrl.settings.SearchSettings"
domain-suffix="{{ $ctrl.domainSuffix }}"
base-filter="(objectClass=user)"
on-search-click="($ctrl.searchUsers)"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-user-search>
<ldap-group-search
style="margin-top: 5px;"
settings="$ctrl.settings.GroupSearchSettings"
domain-suffix="{{ $ctrl.domainSuffix }}"
base-filter="(objectClass=group)"
on-search-click="($ctrl.searchGroups)"
limited-feature-id="$ctrl.limitedFeatureId"
></ldap-group-search>
<ldap-settings-test-login settings="$ctrl.settings" limited-feature-id="$ctrl.limitedFeatureId"></ldap-settings-test-login>
</ng-form>

View File

@ -0,0 +1,12 @@
import controller from './ad-settings.controller';
export const adSettings = {
templateUrl: './ad-settings.html',
controller,
bindings: {
settings: '=',
tlscaCert: '=',
state: '=',
connectivityCheck: '<',
},
};

View File

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

View File

@ -0,0 +1,9 @@
export const ldapConnectivityCheck = {
templateUrl: './ldap-connectivity-check.html',
bindings: {
settings: '<',
state: '<',
connectivityCheck: '<',
limitedFeatureId: '<',
},
};

Some files were not shown because too many files have changed in this diff Show More