parent
8096c5e8bc
commit
b7841e7fc3
|
@ -18,6 +18,7 @@ import (
|
|||
"github.com/portainer/portainer/api/http/handler/file"
|
||||
"github.com/portainer/portainer/api/http/handler/helm"
|
||||
"github.com/portainer/portainer/api/http/handler/kubernetes"
|
||||
"github.com/portainer/portainer/api/http/handler/ldap"
|
||||
"github.com/portainer/portainer/api/http/handler/motd"
|
||||
"github.com/portainer/portainer/api/http/handler/registries"
|
||||
"github.com/portainer/portainer/api/http/handler/resourcecontrols"
|
||||
|
@ -53,6 +54,7 @@ type Handler struct {
|
|||
HelmTemplatesHandler *helm.Handler
|
||||
KubernetesHandler *kubernetes.Handler
|
||||
FileHandler *file.Handler
|
||||
LDAPHandler *ldap.Handler
|
||||
MOTDHandler *motd.Handler
|
||||
RegistryHandler *registries.Handler
|
||||
ResourceControlHandler *resourcecontrols.Handler
|
||||
|
@ -189,6 +191,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
default:
|
||||
http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r)
|
||||
}
|
||||
case strings.HasPrefix(r.URL.Path, "/api/ldap"):
|
||||
http.StripPrefix("/api", h.LDAPHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/motd"):
|
||||
http.StripPrefix("/api", h.MOTDHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/registries"):
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package settings
|
||||
package ldap
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
@ -7,42 +7,43 @@ import (
|
|||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
)
|
||||
|
||||
type settingsLDAPCheckPayload struct {
|
||||
type checkPayload struct {
|
||||
LDAPSettings portainer.LDAPSettings
|
||||
}
|
||||
|
||||
func (payload *settingsLDAPCheckPayload) Validate(r *http.Request) error {
|
||||
func (payload *checkPayload) Validate(r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// @id SettingsLDAPCheck
|
||||
// @id LDAPCheck
|
||||
// @summary Test LDAP connectivity
|
||||
// @description Test LDAP connectivity using LDAP details
|
||||
// @description **Access policy**: administrator
|
||||
// @tags settings
|
||||
// @tags ldap
|
||||
// @security jwt
|
||||
// @accept json
|
||||
// @param body body settingsLDAPCheckPayload true "details"
|
||||
// @param body body checkPayload true "details"
|
||||
// @success 204 "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 500 "Server error"
|
||||
// @router /settings/ldap/check [put]
|
||||
func (handler *Handler) settingsLDAPCheck(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
var payload settingsLDAPCheckPayload
|
||||
// @router /ldap/check [post]
|
||||
func (handler *Handler) ldapCheck(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
var payload checkPayload
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||
}
|
||||
|
||||
if (payload.LDAPSettings.TLSConfig.TLS || payload.LDAPSettings.StartTLS) && !payload.LDAPSettings.TLSConfig.TLSSkipVerify {
|
||||
caCertPath, _ := handler.FileService.GetPathForTLSFile(filesystem.LDAPStorePath, portainer.TLSFileCA)
|
||||
payload.LDAPSettings.TLSConfig.TLSCACertPath = caCertPath
|
||||
settings := &payload.LDAPSettings
|
||||
|
||||
err = handler.prefillSettings(settings)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to fetch default settings", err}
|
||||
}
|
||||
|
||||
err = handler.LDAPService.TestConnectivity(&payload.LDAPSettings)
|
||||
err = handler.LDAPService.TestConnectivity(settings)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to connect to LDAP server", err}
|
||||
}
|
|
@ -5,7 +5,7 @@ import (
|
|||
|
||||
"github.com/gorilla/mux"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/portainer/api"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
|
||||
|
@ -35,8 +35,6 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
|||
bouncer.AdminAccess(httperror.LoggerHandler(h.settingsUpdate))).Methods(http.MethodPut)
|
||||
h.Handle("/settings/public",
|
||||
bouncer.PublicAccess(httperror.LoggerHandler(h.settingsPublic))).Methods(http.MethodGet)
|
||||
h.Handle("/settings/authentication/checkLDAP",
|
||||
bouncer.AdminAccess(httperror.LoggerHandler(h.settingsLDAPCheck))).Methods(http.MethodPut)
|
||||
|
||||
return h
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@ import (
|
|||
"github.com/portainer/portainer/api/http/handler/file"
|
||||
"github.com/portainer/portainer/api/http/handler/helm"
|
||||
kubehandler "github.com/portainer/portainer/api/http/handler/kubernetes"
|
||||
"github.com/portainer/portainer/api/http/handler/ldap"
|
||||
"github.com/portainer/portainer/api/http/handler/motd"
|
||||
"github.com/portainer/portainer/api/http/handler/registries"
|
||||
"github.com/portainer/portainer/api/http/handler/resourcecontrols"
|
||||
|
@ -175,6 +176,11 @@ func (server *Server) Start() error {
|
|||
|
||||
var helmTemplatesHandler = helm.NewTemplateHandler(requestBouncer, server.HelmPackageManager)
|
||||
|
||||
var ldapHandler = ldap.NewHandler(requestBouncer)
|
||||
ldapHandler.DataStore = server.DataStore
|
||||
ldapHandler.FileService = server.FileService
|
||||
ldapHandler.LDAPService = server.LDAPService
|
||||
|
||||
var motdHandler = motd.NewHandler(requestBouncer)
|
||||
|
||||
var registryHandler = registries.NewHandler(requestBouncer)
|
||||
|
@ -255,6 +261,7 @@ func (server *Server) Start() error {
|
|||
EndpointEdgeHandler: endpointEdgeHandler,
|
||||
EndpointProxyHandler: endpointProxyHandler,
|
||||
FileHandler: fileHandler,
|
||||
LDAPHandler: ldapHandler,
|
||||
HelmTemplatesHandler: helmTemplatesHandler,
|
||||
KubernetesHandler: kubernetesHandler,
|
||||
MOTDHandler: motdHandler,
|
||||
|
|
212
api/ldap/ldap.go
212
api/ldap/ldap.go
|
@ -1,11 +1,11 @@
|
|||
package ldap
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
ldap "github.com/go-ldap/ldap/v3"
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
|
@ -20,55 +20,28 @@ var (
|
|||
// Service represents a service used to authenticate users against a LDAP/AD.
|
||||
type Service struct{}
|
||||
|
||||
func searchUser(username string, conn *ldap.Conn, settings []portainer.LDAPSearchSettings) (string, error) {
|
||||
var userDN string
|
||||
found := false
|
||||
usernameEscaped := ldap.EscapeFilter(username)
|
||||
|
||||
for _, searchSettings := range settings {
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
searchSettings.BaseDN,
|
||||
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
|
||||
fmt.Sprintf("(&%s(%s=%s))", searchSettings.Filter, searchSettings.UserNameAttribute, usernameEscaped),
|
||||
[]string{"dn"},
|
||||
nil,
|
||||
)
|
||||
|
||||
// Deliberately skip errors on the search request so that we can jump to other search settings
|
||||
// if any issue arise with the current one.
|
||||
sr, err := conn.Search(searchRequest)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(sr.Entries) == 1 {
|
||||
found = true
|
||||
userDN = sr.Entries[0].DN
|
||||
break
|
||||
}
|
||||
func createConnection(settings *portainer.LDAPSettings) (*ldap.Conn, error) {
|
||||
conn, err := createConnectionForURL(settings.URL, settings)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed creating LDAP connection")
|
||||
}
|
||||
|
||||
if !found {
|
||||
return "", errUserNotFound
|
||||
}
|
||||
|
||||
return userDN, nil
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func createConnection(settings *portainer.LDAPSettings) (*ldap.Conn, error) {
|
||||
|
||||
func createConnectionForURL(url string, settings *portainer.LDAPSettings) (*ldap.Conn, error) {
|
||||
if settings.TLSConfig.TLS || settings.StartTLS {
|
||||
config, err := crypto.CreateTLSConfigurationFromDisk(settings.TLSConfig.TLSCACertPath, settings.TLSConfig.TLSCertPath, settings.TLSConfig.TLSKeyPath, settings.TLSConfig.TLSSkipVerify)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config.ServerName = strings.Split(settings.URL, ":")[0]
|
||||
config.ServerName = strings.Split(url, ":")[0]
|
||||
|
||||
if settings.TLSConfig.TLS {
|
||||
return ldap.DialTLS("tcp", settings.URL, config)
|
||||
return ldap.DialTLS("tcp", url, config)
|
||||
}
|
||||
|
||||
conn, err := ldap.Dial("tcp", settings.URL)
|
||||
conn, err := ldap.Dial("tcp", url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -81,7 +54,7 @@ func createConnection(settings *portainer.LDAPSettings) (*ldap.Conn, error) {
|
|||
return conn, nil
|
||||
}
|
||||
|
||||
return ldap.Dial("tcp", settings.URL)
|
||||
return ldap.Dial("tcp", url)
|
||||
}
|
||||
|
||||
// AuthenticateUser is used to authenticate a user against a LDAP/AD.
|
||||
|
@ -133,13 +106,157 @@ func (*Service) GetUserGroups(username string, settings *portainer.LDAPSettings)
|
|||
return nil, err
|
||||
}
|
||||
|
||||
userGroups := getGroups(userDN, connection, settings.GroupSearchSettings)
|
||||
userGroups := getGroupsByUser(userDN, connection, settings.GroupSearchSettings)
|
||||
|
||||
return userGroups, nil
|
||||
}
|
||||
|
||||
// SearchUsers searches for users with the specified settings
|
||||
func (*Service) SearchUsers(settings *portainer.LDAPSettings) ([]string, error) {
|
||||
connection, err := createConnection(settings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer connection.Close()
|
||||
|
||||
if !settings.AnonymousMode {
|
||||
err = connection.Bind(settings.ReaderDN, settings.Password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
users := map[string]bool{}
|
||||
|
||||
for _, searchSettings := range settings.SearchSettings {
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
searchSettings.BaseDN,
|
||||
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
|
||||
searchSettings.Filter,
|
||||
[]string{"dn", searchSettings.UserNameAttribute},
|
||||
nil,
|
||||
)
|
||||
|
||||
sr, err := connection.Search(searchRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, user := range sr.Entries {
|
||||
username := user.GetAttributeValue(searchSettings.UserNameAttribute)
|
||||
if username != "" {
|
||||
users[username] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
usersList := []string{}
|
||||
for user := range users {
|
||||
usersList = append(usersList, user)
|
||||
}
|
||||
|
||||
return usersList, nil
|
||||
}
|
||||
|
||||
// SearchGroups searches for groups with the specified settings
|
||||
func (*Service) SearchGroups(settings *portainer.LDAPSettings) ([]portainer.LDAPUser, error) {
|
||||
type groupSet map[string]bool
|
||||
|
||||
connection, err := createConnection(settings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer connection.Close()
|
||||
|
||||
if !settings.AnonymousMode {
|
||||
err = connection.Bind(settings.ReaderDN, settings.Password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
userGroups := map[string]groupSet{}
|
||||
|
||||
for _, searchSettings := range settings.GroupSearchSettings {
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
searchSettings.GroupBaseDN,
|
||||
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
|
||||
searchSettings.GroupFilter,
|
||||
[]string{"cn", searchSettings.GroupAttribute},
|
||||
nil,
|
||||
)
|
||||
|
||||
sr, err := connection.Search(searchRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, entry := range sr.Entries {
|
||||
members := entry.GetAttributeValues(searchSettings.GroupAttribute)
|
||||
for _, username := range members {
|
||||
_, ok := userGroups[username]
|
||||
if !ok {
|
||||
userGroups[username] = groupSet{}
|
||||
}
|
||||
userGroups[username][entry.GetAttributeValue("cn")] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
users := []portainer.LDAPUser{}
|
||||
|
||||
for username, groups := range userGroups {
|
||||
groupList := []string{}
|
||||
for group := range groups {
|
||||
groupList = append(groupList, group)
|
||||
}
|
||||
user := portainer.LDAPUser{
|
||||
Name: username,
|
||||
Groups: groupList,
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func searchUser(username string, conn *ldap.Conn, settings []portainer.LDAPSearchSettings) (string, error) {
|
||||
var userDN string
|
||||
found := false
|
||||
usernameEscaped := ldap.EscapeFilter(username)
|
||||
|
||||
for _, searchSettings := range settings {
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
searchSettings.BaseDN,
|
||||
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
|
||||
fmt.Sprintf("(&%s(%s=%s))", searchSettings.Filter, searchSettings.UserNameAttribute, usernameEscaped),
|
||||
[]string{"dn"},
|
||||
nil,
|
||||
)
|
||||
|
||||
// Deliberately skip errors on the search request so that we can jump to other search settings
|
||||
// if any issue arise with the current one.
|
||||
sr, err := conn.Search(searchRequest)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(sr.Entries) == 1 {
|
||||
found = true
|
||||
userDN = sr.Entries[0].DN
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return "", errUserNotFound
|
||||
}
|
||||
|
||||
return userDN, nil
|
||||
}
|
||||
|
||||
// Get a list of group names for specified user from LDAP/AD
|
||||
func getGroups(userDN string, conn *ldap.Conn, settings []portainer.LDAPGroupSearchSettings) []string {
|
||||
func getGroupsByUser(userDN string, conn *ldap.Conn, settings []portainer.LDAPGroupSearchSettings) []string {
|
||||
groups := make([]string, 0)
|
||||
userDNEscaped := ldap.EscapeFilter(userDN)
|
||||
|
||||
|
@ -179,9 +296,18 @@ func (*Service) TestConnectivity(settings *portainer.LDAPSettings) error {
|
|||
}
|
||||
defer connection.Close()
|
||||
|
||||
err = connection.Bind(settings.ReaderDN, settings.Password)
|
||||
if err != nil {
|
||||
return err
|
||||
if !settings.AnonymousMode {
|
||||
err = connection.Bind(settings.ReaderDN, settings.Password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
} else {
|
||||
err = connection.UnauthenticatedBind("")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -513,6 +513,12 @@ type (
|
|||
AutoCreateUsers bool `json:"AutoCreateUsers" example:"true"`
|
||||
}
|
||||
|
||||
// LDAPUser represents a LDAP user
|
||||
LDAPUser struct {
|
||||
Name string
|
||||
Groups []string
|
||||
}
|
||||
|
||||
// LicenseInformation represents information about an extension license
|
||||
LicenseInformation struct {
|
||||
LicenseKey string `json:"LicenseKey,omitempty"`
|
||||
|
@ -1295,6 +1301,8 @@ type (
|
|||
AuthenticateUser(username, password string, settings *LDAPSettings) error
|
||||
TestConnectivity(settings *LDAPSettings) error
|
||||
GetUserGroups(username string, settings *LDAPSettings) ([]string, error)
|
||||
SearchGroups(settings *LDAPSettings) ([]LDAPUser, error)
|
||||
SearchUsers(settings *LDAPSettings) ([]string, error)
|
||||
}
|
||||
|
||||
// OAuthService represents a service used to authenticate users using OAuth
|
||||
|
|
|
@ -816,10 +816,6 @@ json-tree .branch-preview {
|
|||
}
|
||||
/* !spinkit override */
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* uib-typeahead override */
|
||||
#scrollable-dropdown-menu .dropdown-menu {
|
||||
max-height: 300px;
|
||||
|
@ -827,17 +823,33 @@ json-tree .branch-preview {
|
|||
}
|
||||
/* !uib-typeahead override */
|
||||
|
||||
.my-8 {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.kubectl-shell {
|
||||
display: block;
|
||||
text-align: center;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.my-8 {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.text-wrap {
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
|
|
|
@ -89,8 +89,13 @@ html {
|
|||
--green-1: #164;
|
||||
--green-2: #1ec863;
|
||||
--green-3: #23ae89;
|
||||
|
||||
--orange-1: #e86925;
|
||||
|
||||
--BE-only: var(--orange-1);
|
||||
}
|
||||
|
||||
/* Default Theme */
|
||||
:root {
|
||||
--bg-card-color: var(--grey-10);
|
||||
--bg-main-color: var(--white-color);
|
||||
|
|
|
@ -401,3 +401,14 @@ input:-webkit-autofill {
|
|||
color: var(--white-color);
|
||||
}
|
||||
/* Overide Vendor CSS */
|
||||
|
||||
.btn.disabled,
|
||||
.btn[disabled],
|
||||
fieldset[disabled] .btn {
|
||||
pointer-events: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.multiSelect.inlineBlock button {
|
||||
margin: 0;
|
||||
}
|
||||
|
|
|
@ -178,14 +178,14 @@
|
|||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left">
|
||||
Allow resource over-commit
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" checked disabled /><i data-cy="kubeSetup-resourceOverCommitToggle"></i> </label>
|
||||
<span class="text-muted small" style="margin-left: 15px;">
|
||||
<i class="fa fa-user" aria-hidden="true"></i>
|
||||
This feature is available in <a href="https://www.portainer.io/business-upsell?from=k8s-setup-overcommit" target="_blank"> Portainer Business Edition</a>.
|
||||
</span>
|
||||
<por-switch-field
|
||||
label="Allow resource over-commit"
|
||||
name="resource-over-commit-switch"
|
||||
feature="ctrl.limitedFeature"
|
||||
ng-model="ctrl.formValues.EnableResourceOverCommit"
|
||||
ng-change="ctrl.onChangeEnableResourceOverCommit()"
|
||||
ng-data-cy="kubeSetup-resourceOverCommitToggle"
|
||||
></por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@ import { KubernetesIngressClass } from 'Kubernetes/ingress/models';
|
|||
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
|
||||
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
|
||||
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
|
||||
|
||||
import { K8S_SETUP_DEFAULT } from '@/portainer/feature-flags/feature-ids';
|
||||
class KubernetesConfigureController {
|
||||
/* #region CONSTRUCTOR */
|
||||
|
||||
|
@ -38,6 +38,7 @@ class KubernetesConfigureController {
|
|||
|
||||
this.onInit = this.onInit.bind(this);
|
||||
this.configureAsync = this.configureAsync.bind(this);
|
||||
this.limitedFeature = K8S_SETUP_DEFAULT;
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
|
|
|
@ -162,14 +162,13 @@
|
|||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left">
|
||||
Load Balancer quota
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;" data-cy="k8sNamespaceCreate-loadBalancerQuotaToggle"> <input type="checkbox" disabled /><i></i> </label>
|
||||
<span class="text-muted small" style="margin-left: 15px;">
|
||||
<i class="fa fa-user" aria-hidden="true"></i>
|
||||
This feature is available in <a href="https://www.portainer.io/business-upsell?from=k8s-resourcepool-lbquota" target="_blank"> Portainer Business Edition</a>.
|
||||
</span>
|
||||
<por-switch-field
|
||||
ng-data-cy="k8sNamespaceCreate-loadBalancerQuotaToggle"
|
||||
label="Load Balancer quota"
|
||||
name="k8s-resourcepool-Ibquota"
|
||||
feature="$ctrl.LBQuotaFeatureId"
|
||||
ng-model="lbquota"
|
||||
></por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
<!-- #endregion -->
|
||||
|
@ -192,15 +191,13 @@
|
|||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left">
|
||||
Enable quota
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;" data-cy="k8sNamespaceCreate-enableQuotaToggle"> <input type="checkbox" disabled /><i></i> </label>
|
||||
<span class="text-muted small" style="margin-left: 15px;">
|
||||
<i class="fa fa-user" aria-hidden="true"></i>
|
||||
This feature is available in
|
||||
<a href="https://www.portainer.io/business-upsell?from=k8s-resourcepool-storagequota" target="_blank"> Portainer Business Edition</a>.
|
||||
</span>
|
||||
<por-switch-field
|
||||
ng-data-cy="k8sNamespaceCreate-enableQuotaToggle"
|
||||
label="Enable quota"
|
||||
name="k8s-resourcepool-storagequota"
|
||||
feature="$ctrl.StorageQuotaFeatureId"
|
||||
ng-model="storagequota"
|
||||
></por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
<!-- #endregion -->
|
||||
|
|
|
@ -12,6 +12,8 @@ import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHel
|
|||
import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
|
||||
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
|
||||
|
||||
import { K8S_RESOURCE_POOL_LB_QUOTA, K8S_RESOURCE_POOL_STORAGE_QUOTA } from '@/portainer/feature-flags/feature-ids';
|
||||
|
||||
class KubernetesCreateResourcePoolController {
|
||||
/* #region CONSTRUCTOR */
|
||||
/* @ngInject */
|
||||
|
@ -28,6 +30,8 @@ class KubernetesCreateResourcePoolController {
|
|||
});
|
||||
|
||||
this.IngressClassTypes = KubernetesIngressClassTypes;
|
||||
this.LBQuotaFeatureId = K8S_RESOURCE_POOL_LB_QUOTA;
|
||||
this.StorageQuotaFeatureId = K8S_RESOURCE_POOL_STORAGE_QUOTA;
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
|
|
|
@ -146,14 +146,13 @@
|
|||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left">
|
||||
Load Balancer quota
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" disabled /><i></i> </label>
|
||||
<span class="text-muted small" style="margin-left: 15px;">
|
||||
<i class="fa fa-user" aria-hidden="true"></i>
|
||||
This feature is available in <a href="https://www.portainer.io/business-upsell?from=k8s-resourcepool-lbquota" target="_blank"> Portainer Business Edition</a>.
|
||||
</span>
|
||||
<por-switch-field
|
||||
ng-data-cy="k8sNamespaceCreate-loadBalancerQuotaToggle"
|
||||
label="Load Balancer quota"
|
||||
name="k8s-resourcepool-Lbquota"
|
||||
feature="ctrl.LBQuotaFeatureId"
|
||||
ng-model="lbquota"
|
||||
></por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
<!-- #endregion -->
|
||||
|
@ -389,15 +388,13 @@
|
|||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left">
|
||||
Enable quota
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" disabled /><i></i> </label>
|
||||
<span class="text-muted small" style="margin-left: 15px;">
|
||||
<i class="fa fa-user" aria-hidden="true"></i>
|
||||
This feature is available in
|
||||
<a href="https://www.portainer.io/business-upsell?from=k8s-resourcepool-storagequota" target="_blank"> Portainer Business Edition</a>.
|
||||
</span>
|
||||
<por-switch-field
|
||||
ng-data-cy="k8sNamespaceCreate-enableQuotaToggle"
|
||||
label="Enable quota"
|
||||
name="k8s-resourcepool-storagequota"
|
||||
feature="ctrl.StorageQuotaFeatureId"
|
||||
ng-model="storagequota"
|
||||
></por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
<!-- #endregion -->
|
||||
|
|
|
@ -16,6 +16,7 @@ import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHel
|
|||
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
|
||||
import KubernetesResourceQuotaConverter from 'Kubernetes/converters/resourceQuota';
|
||||
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
|
||||
import { K8S_RESOURCE_POOL_LB_QUOTA, K8S_RESOURCE_POOL_STORAGE_QUOTA } from '@/portainer/feature-flags/feature-ids';
|
||||
|
||||
class KubernetesResourcePoolController {
|
||||
/* #region CONSTRUCTOR */
|
||||
|
@ -60,6 +61,9 @@ class KubernetesResourcePoolController {
|
|||
this.IngressClassTypes = KubernetesIngressClassTypes;
|
||||
this.ResourceQuotaDefaults = KubernetesResourceQuotaDefaults;
|
||||
|
||||
this.LBQuotaFeatureId = K8S_RESOURCE_POOL_LB_QUOTA;
|
||||
this.StorageQuotaFeatureId = K8S_RESOURCE_POOL_STORAGE_QUOTA;
|
||||
|
||||
this.updateResourcePoolAsync = this.updateResourcePoolAsync.bind(this);
|
||||
this.getEvents = this.getEvents.bind(this);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
import _ from 'lodash-es';
|
||||
|
||||
import './rbac';
|
||||
import componentsModule from './components';
|
||||
import settingsModule from './settings';
|
||||
import featureFlagModule from './feature-flags';
|
||||
import userActivityModule from './user-activity';
|
||||
|
||||
async function initAuthentication(authManager, Authentication, $rootScope, $state) {
|
||||
authManager.checkAuthOnRefresh();
|
||||
|
@ -18,7 +21,7 @@ async function initAuthentication(authManager, Authentication, $rootScope, $stat
|
|||
return await Authentication.init();
|
||||
}
|
||||
|
||||
angular.module('portainer.app', ['portainer.oauth', componentsModule, settingsModule]).config([
|
||||
angular.module('portainer.app', ['portainer.oauth', 'portainer.rbac', componentsModule, settingsModule, featureFlagModule, userActivityModule, 'portainer.shared.datatable']).config([
|
||||
'$stateRegistryProvider',
|
||||
function ($stateRegistryProvider) {
|
||||
'use strict';
|
||||
|
@ -51,6 +54,18 @@ angular.module('portainer.app', ['portainer.oauth', componentsModule, settingsMo
|
|||
controller: 'SidebarController',
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
featuresServiceInitialized: /* @ngInject */ function featuresServiceInitialized($async, featureService, Notifications) {
|
||||
return $async(async () => {
|
||||
try {
|
||||
await featureService.init();
|
||||
} catch (e) {
|
||||
Notifications.error('Failed initializing features service', e);
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var endpointRoot = {
|
||||
|
@ -403,16 +418,6 @@ angular.module('portainer.app', ['portainer.oauth', componentsModule, settingsMo
|
|||
},
|
||||
};
|
||||
|
||||
var roles = {
|
||||
name: 'portainer.roles',
|
||||
url: '/roles',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: './views/roles/roles.html',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
$stateRegistryProvider.register(root);
|
||||
$stateRegistryProvider.register(endpointRoot);
|
||||
$stateRegistryProvider.register(portainer);
|
||||
|
@ -444,7 +449,6 @@ angular.module('portainer.app', ['portainer.oauth', componentsModule, settingsMo
|
|||
$stateRegistryProvider.register(user);
|
||||
$stateRegistryProvider.register(teams);
|
||||
$stateRegistryProvider.register(team);
|
||||
$stateRegistryProvider.register(roles);
|
||||
},
|
||||
]);
|
||||
|
||||
|
|
|
@ -11,8 +11,8 @@
|
|||
ng-if="$ctrl.options.length > 0"
|
||||
input-model="$ctrl.options"
|
||||
output-model="$ctrl.value"
|
||||
button-label="icon '-' Name"
|
||||
item-label="icon '-' Name"
|
||||
button-label="icon Name"
|
||||
item-label="icon Name"
|
||||
tick-property="ticked"
|
||||
helper-elements="filter"
|
||||
search-property="Name"
|
||||
|
|
|
@ -9,5 +9,6 @@ export const porAccessManagement = {
|
|||
updateAccess: '<',
|
||||
actionInProgress: '<',
|
||||
filterUsers: '<',
|
||||
limitedFeature: '<',
|
||||
},
|
||||
};
|
||||
|
|
|
@ -4,17 +4,31 @@
|
|||
<rd-widget-header icon="fa-user-lock" title-text="Create access"></rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<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>
|
||||
|
||||
<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">
|
||||
Role
|
||||
</label>
|
||||
<div class="col-sm-9 col-lg-4">
|
||||
<span class="text-muted small">
|
||||
<i class="fa fa-user" aria-hidden="true"></i>
|
||||
This feature is available in <a href="https://www.portainer.io/business-upsell?from=k8s-rbac-access" target="_blank"> Portainer Business Edition</a>.
|
||||
</span>
|
||||
<div class="col-sm-9 col-lg-6">
|
||||
<div class="flex items-center">
|
||||
<select
|
||||
class="form-control"
|
||||
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>
|
||||
|
||||
|
@ -48,6 +62,10 @@
|
|||
title-icon="fa-user-lock"
|
||||
table-key="{{ 'access_' + ctrl.entityType }}"
|
||||
order-by="Name"
|
||||
show-warning="ctrl.entityType !== 'registry'"
|
||||
is-update-enabled="ctrl.entityType !== 'registry'"
|
||||
show-roles="ctrl.entityType !== 'registry'"
|
||||
roles="ctrl.roles"
|
||||
inherit-from="ctrl.inheritFrom"
|
||||
dataset="ctrl.authorizedUsersAndTeams"
|
||||
update-action="ctrl.updateAction"
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
import _ from 'lodash-es';
|
||||
|
||||
import angular from 'angular';
|
||||
|
||||
import { RoleTypes } from '@/portainer/rbac/models/role';
|
||||
|
||||
class PorAccessManagementController {
|
||||
/* @ngInject */
|
||||
constructor(Notifications, AccessService) {
|
||||
this.Notifications = Notifications;
|
||||
this.AccessService = AccessService;
|
||||
constructor(Notifications, AccessService, RoleService, featureService) {
|
||||
Object.assign(this, { Notifications, AccessService, RoleService, featureService });
|
||||
|
||||
this.limitedToBE = false;
|
||||
|
||||
this.unauthorizeAccess = this.unauthorizeAccess.bind(this);
|
||||
this.updateAction = this.updateAction.bind(this);
|
||||
|
@ -29,10 +31,11 @@ class PorAccessManagementController {
|
|||
const entity = this.accessControlledEntity;
|
||||
const oldUserAccessPolicies = entity.UserAccessPolicies;
|
||||
const oldTeamAccessPolicies = entity.TeamAccessPolicies;
|
||||
const selectedRoleId = this.formValues.selectedRole.Id;
|
||||
const selectedUserAccesses = _.filter(this.formValues.multiselectOutput, (access) => access.Type === 'user');
|
||||
const selectedTeamAccesses = _.filter(this.formValues.multiselectOutput, (access) => access.Type === 'team');
|
||||
|
||||
const accessPolicies = this.AccessService.generateAccessPolicies(oldUserAccessPolicies, oldTeamAccessPolicies, selectedUserAccesses, selectedTeamAccesses, 0);
|
||||
const accessPolicies = this.AccessService.generateAccessPolicies(oldUserAccessPolicies, oldTeamAccessPolicies, selectedUserAccesses, selectedTeamAccesses, selectedRoleId);
|
||||
this.accessControlledEntity.UserAccessPolicies = accessPolicies.userAccessPolicies;
|
||||
this.accessControlledEntity.TeamAccessPolicies = accessPolicies.teamAccessPolicies;
|
||||
this.updateAccess();
|
||||
|
@ -50,11 +53,41 @@ class PorAccessManagementController {
|
|||
this.updateAccess();
|
||||
}
|
||||
|
||||
isRoleLimitedToBE(role) {
|
||||
if (!this.limitedToBE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return role.ID !== RoleTypes.STANDARD;
|
||||
}
|
||||
|
||||
roleLabel(role) {
|
||||
if (!this.limitedToBE) {
|
||||
return role.Name;
|
||||
}
|
||||
|
||||
if (this.isRoleLimitedToBE(role)) {
|
||||
return `${role.Name} (Business Edition Feature)`;
|
||||
}
|
||||
|
||||
return `${role.Name} (Default)`;
|
||||
}
|
||||
|
||||
async $onInit() {
|
||||
try {
|
||||
if (this.limitedFeature) {
|
||||
this.limitedToBE = this.featureService.isLimitedToBE(this.limitedFeature);
|
||||
}
|
||||
|
||||
const entity = this.accessControlledEntity;
|
||||
const parent = this.inheritFrom;
|
||||
|
||||
const roles = await this.RoleService.roles();
|
||||
this.roles = _.orderBy(roles, 'Priority', 'asc');
|
||||
this.formValues = {
|
||||
selectedRole: this.roles.find((role) => !this.isRoleLimitedToBE(role)),
|
||||
};
|
||||
|
||||
const data = await this.AccessService.accesses(entity, parent, this.roles);
|
||||
|
||||
if (this.filterUsers) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
|
@ -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);
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
<div
|
||||
class="box-selector-item"
|
||||
ng-class="{ business: $ctrl.limitedToBE, limited: $ctrl.limitedToBE }"
|
||||
tooltip-append-to-body="true"
|
||||
tooltip-placement="bottom"
|
||||
tooltip-class="portainer-tooltip"
|
||||
|
@ -14,11 +15,13 @@
|
|||
ng-value="$ctrl.option.value"
|
||||
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">
|
||||
<i ng-class="$ctrl.option.icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
{{ $ctrl.option.label }}
|
||||
</div>
|
||||
<p ng-if="$ctrl.option.description">{{ $ctrl.option.description }}</p>
|
||||
<p class="box-selector-item-description">{{ $ctrl.option.description }}</p>
|
||||
</label>
|
||||
</div>
|
||||
|
|
|
@ -1,7 +1,15 @@
|
|||
import angular from 'angular';
|
||||
|
||||
import './box-selector-item.css';
|
||||
|
||||
import controller from './box-selector-item.controller';
|
||||
|
||||
angular.module('portainer.app').component('boxSelectorItem', {
|
||||
templateUrl: './box-selector-item.html',
|
||||
controller,
|
||||
require: {
|
||||
formCtrl: '^^form',
|
||||
},
|
||||
bindings: {
|
||||
radioName: '@',
|
||||
isChecked: '<',
|
||||
|
|
|
@ -4,10 +4,10 @@ export default class BoxSelectorController {
|
|||
this.change = this.change.bind(this);
|
||||
}
|
||||
|
||||
change(value) {
|
||||
change(value, limited) {
|
||||
this.ngModel = value;
|
||||
if (this.onChange) {
|
||||
this.onChange(value);
|
||||
this.onChange(value, limited);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -3,89 +3,3 @@
|
|||
flex-flow: row wrap;
|
||||
margin: 0.5rem;
|
||||
}
|
||||
|
||||
.boxselector_wrapper > div,
|
||||
.boxselector_wrapper box-selector-item {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.boxselector_wrapper .boxselector_header {
|
||||
font-size: 14px;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.boxselector_header .fa,
|
||||
.fab {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.boxselector_wrapper input[type='radio'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.boxselector_wrapper input[type='radio']:not(:disabled) ~ label {
|
||||
cursor: pointer;
|
||||
background-color: var(--bg-boxselector-wrapper-disabled-color);
|
||||
}
|
||||
|
||||
.boxselector_wrapper input[type='radio']:not(:disabled):hover ~ label:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.boxselector_wrapper label {
|
||||
font-weight: normal;
|
||||
font-size: 12px;
|
||||
display: block;
|
||||
background: var(--bg-boxselector-color);
|
||||
border: 1px solid var(--border-boxselector-color);
|
||||
border-radius: 2px;
|
||||
padding: 10px 10px 0 10px;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-boxselector-color);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.box-selector-item input:disabled + label,
|
||||
.boxselector_wrapper label.boxselector_disabled {
|
||||
background: var(--bg-boxselector-disabled-color) !important;
|
||||
border-color: #787878;
|
||||
color: #787878;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.boxselector_wrapper input[type='radio']:checked + label {
|
||||
background: #337ab7;
|
||||
color: white;
|
||||
padding-top: 2rem;
|
||||
border-color: #337ab7;
|
||||
}
|
||||
|
||||
.boxselector_wrapper input[type='radio']:checked + label::after {
|
||||
color: #337ab7;
|
||||
font-family: 'Font Awesome 5 Free';
|
||||
border: 2px solid #337ab7;
|
||||
content: '\f00c';
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
position: absolute;
|
||||
top: -15px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
line-height: 26px;
|
||||
text-align: center;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
box-shadow: 0 2px 5px -2px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 700px) {
|
||||
.boxselector_wrapper {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,6 +15,6 @@ angular.module('portainer.app').component('boxSelector', {
|
|||
},
|
||||
});
|
||||
|
||||
export function buildOption(id, icon, label, description, value) {
|
||||
return { id, icon, label, description, value };
|
||||
export function buildOption(id, icon, label, description, value, feature) {
|
||||
return { id, icon, label, description, value, feature };
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -92,6 +92,10 @@
|
|||
float: none;
|
||||
}
|
||||
|
||||
.datatable.datatable-empty .table > tbody > tr > td {
|
||||
padding: 15px 0;
|
||||
}
|
||||
|
||||
.tableMenu {
|
||||
color: #767676;
|
||||
padding: 10px;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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,
|
||||
};
|
|
@ -128,7 +128,11 @@ angular.module('portainer.app').controller('GenericDatatableController', [
|
|||
* https://github.com/portainer/portainer/pull/2877#issuecomment-503333425
|
||||
* https://github.com/portainer/portainer/pull/2877#issuecomment-503537249
|
||||
*/
|
||||
this.$onInit = function () {
|
||||
this.$onInit = function $onInit() {
|
||||
this.$onInitGeneric();
|
||||
};
|
||||
|
||||
this.$onInitGeneric = function $onInitGeneric() {
|
||||
this.setDefaults();
|
||||
this.prepareTableFromDataset();
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -0,0 +1,9 @@
|
|||
export const datatablePagination = {
|
||||
bindings: {
|
||||
onChangeLimit: '<',
|
||||
limit: '<',
|
||||
enableNoLimit: '<',
|
||||
onChangePage: '<',
|
||||
},
|
||||
templateUrl: './pagination.html',
|
||||
};
|
|
@ -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>
|
|
@ -87,15 +87,10 @@
|
|||
</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>
|
||||
<span
|
||||
ng-if="$ctrl.canBrowse(item)"
|
||||
class="text-muted space-left"
|
||||
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
|
||||
>
|
||||
<be-feature-indicator feature="$ctrl.limitedFeature" 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>
|
||||
</be-feature-indicator>
|
||||
|
||||
<span ng-if="!$ctrl.canBrowse(item) && !$ctrl.canManageAccess(item)"> - </span>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { PortainerEndpointTypes } from 'Portainer/models/endpoint/models';
|
||||
|
||||
import { REGISTRY_MANAGEMENT } from '@/portainer/feature-flags/feature-ids';
|
||||
angular.module('portainer.docker').controller('RegistriesDatatableController', RegistriesDatatableController);
|
||||
|
||||
/* @ngInject */
|
||||
|
@ -45,6 +45,7 @@ function RegistriesDatatableController($scope, $controller, $state, Authenticati
|
|||
};
|
||||
|
||||
this.$onInit = function () {
|
||||
this.limitedFeature = REGISTRY_MANAGEMENT;
|
||||
this.isAdmin = Authentication.isAdmin();
|
||||
this.setDefaults();
|
||||
this.prepareTableFromDataset();
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1,7 @@
|
|||
export const datatableSearchbar = {
|
||||
bindings: {
|
||||
onChange: '<',
|
||||
ngModel: '<',
|
||||
},
|
||||
templateUrl: './datatable-searchbar.html',
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
export default class datatableSortIconController {
|
||||
isCurrentSortOrder() {
|
||||
return this.selectedSortKey === this.key;
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -0,0 +1,11 @@
|
|||
import controller from './datatable-sort-icon.controller';
|
||||
|
||||
export const datatableSortIcon = {
|
||||
bindings: {
|
||||
key: '@',
|
||||
selectedSortKey: '@',
|
||||
reverseOrder: '<',
|
||||
},
|
||||
controller,
|
||||
templateUrl: './datatable-sort-icon.html',
|
||||
};
|
|
@ -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>
|
|
@ -0,0 +1,8 @@
|
|||
export const datatableTitlebar = {
|
||||
bindings: {
|
||||
icon: '@',
|
||||
title: '@',
|
||||
feature: '@',
|
||||
},
|
||||
templateUrl: './datatable-titlebar.html',
|
||||
};
|
|
@ -10,5 +10,7 @@
|
|||
ng-model="$ctrl.ngModel"
|
||||
disabled="$ctrl.disabled"
|
||||
on-change="($ctrl.onChange)"
|
||||
feature="$ctrl.feature"
|
||||
ng-data-cy="{{::$ctrl.ngDataCy}}"
|
||||
></por-switch>
|
||||
</label>
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import angular from 'angular';
|
||||
|
||||
import './por-switch-field.css';
|
||||
|
||||
export const porSwitchField = {
|
||||
templateUrl: './por-switch-field.html',
|
||||
bindings: {
|
||||
|
@ -10,8 +8,10 @@ export const porSwitchField = {
|
|||
label: '@',
|
||||
name: '@',
|
||||
labelClass: '@',
|
||||
ngDataCy: '@',
|
||||
disabled: '<',
|
||||
onChange: '<',
|
||||
feature: '<', // feature id
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -51,3 +51,18 @@
|
|||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.switch.limited {
|
||||
pointer-events: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.switch.limited i {
|
||||
opacity: 1;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.switch.business i {
|
||||
background-color: var(--BE-only);
|
||||
box-shadow: inset 0 0 1px rgb(0 0 0 / 50%), inset 0 0 40px var(--BE-only);
|
||||
}
|
|
@ -1,4 +1,12 @@
|
|||
<label class="switch" ng-class="$ctrl.className" 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)" />
|
||||
<i></i>
|
||||
<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 || $ctrl.limitedToBE"
|
||||
ng-change="$ctrl.onChange($ctrl.ngModel)"
|
||||
/>
|
||||
<i data-cy="{{::$ctrl.ngDataCy}}"></i>
|
||||
</label>
|
||||
<be-feature-indicator ng-if="$ctrl.limitedToBE" feature="$ctrl.feature"></be-feature-indicator>
|
||||
|
|
|
@ -1,14 +1,20 @@
|
|||
import angular from 'angular';
|
||||
import controller from './por-switch.controller';
|
||||
|
||||
import './por-switch.css';
|
||||
|
||||
const porSwitch = {
|
||||
templateUrl: './por-switch.html',
|
||||
controller,
|
||||
bindings: {
|
||||
ngModel: '=',
|
||||
id: '@',
|
||||
className: '@',
|
||||
name: '@',
|
||||
ngDataCy: '@',
|
||||
disabled: '<',
|
||||
onChange: '<',
|
||||
feature: '<', // feature id
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -6,9 +6,20 @@ angular.module('portainer.app').directive('rdWidgetHeader', function rdWidgetTit
|
|||
icon: '@',
|
||||
classes: '@?',
|
||||
},
|
||||
transclude: true,
|
||||
template:
|
||||
'<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>',
|
||||
transclude: {
|
||||
title: '?headerTitle',
|
||||
},
|
||||
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',
|
||||
};
|
||||
return directive;
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
export const EDITIONS = {
|
||||
CE: 0,
|
||||
BE: 1,
|
||||
};
|
||||
|
||||
export const STATES = {
|
||||
HIDDEN: 0,
|
||||
VISIBLE: 1,
|
||||
LIMITED_BE: 2,
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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';
|
|
@ -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;
|
|
@ -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());
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,8 +1,11 @@
|
|||
import angular from 'angular';
|
||||
import controller from './oauth-provider-selector.controller';
|
||||
|
||||
angular.module('portainer.oauth').component('oauthProvidersSelector', {
|
||||
templateUrl: './oauth-providers-selector.html',
|
||||
bindings: {
|
||||
onSelect: '<',
|
||||
provider: '=',
|
||||
onChange: '<',
|
||||
value: '<',
|
||||
},
|
||||
controller: 'OAuthProviderSelectorController',
|
||||
controller,
|
||||
});
|
|
@ -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];
|
||||
}
|
||||
});
|
|
@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
|
@ -2,18 +2,4 @@
|
|||
Provider
|
||||
</div>
|
||||
|
||||
<div class="form-group"></div>
|
||||
<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>
|
||||
<box-selector ng-model="$ctrl.value" options="$ctrl.options" on-change="($ctrl.onChange)" radio-name="oauth_provider"></box-selector>
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
import angular from 'angular';
|
||||
import controller from './oauth-settings.controller';
|
||||
|
||||
angular.module('portainer.oauth').component('oauthSettings', {
|
||||
templateUrl: './oauth-settings.html',
|
||||
bindings: {
|
||||
settings: '=',
|
||||
teams: '<',
|
||||
},
|
||||
controller: 'OAuthSettingsController',
|
||||
controller,
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
});
|
|
@ -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 = [];
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,54 +1,50 @@
|
|||
<div
|
||||
><div class="col-sm-12 form-section-title">
|
||||
<ng-form name="$ctrl.oauthSettingsForm">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Single Sign-On
|
||||
</div>
|
||||
|
||||
<!-- SSO -->
|
||||
<div class="form-group">
|
||||
<label for="use-sso" class="control-label col-sm-2 text-left" style="padding-top: 0;">
|
||||
Use SSO
|
||||
<portainer-tooltip position="bottom" message="When using SSO the OAuth provider is not forced to prompt for credentials."></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-9">
|
||||
<label class="switch"> <input id="use-sso" type="checkbox" ng-model="$ctrl.settings.SSO" /><i></i> </label>
|
||||
<div class="col-sm-12">
|
||||
<por-switch-field
|
||||
label="Use SSO"
|
||||
tooltip="When using SSO the OAuth provider is not forced to prompt for credentials."
|
||||
name="use-sso"
|
||||
ng-model="$ctrl.settings.SSO"
|
||||
on-change="$ctrl.updateSSO()"
|
||||
></por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !SSO -->
|
||||
|
||||
<!-- HideInternalAuth -->
|
||||
<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;">
|
||||
Hide internal authentication prompt
|
||||
</label>
|
||||
<div class="col-sm-9">
|
||||
<label class="switch"> <input id="hide-internal-auth" type="checkbox" disabled /><i></i> </label>
|
||||
<span class="text-muted small" style="margin-left: 15px;">
|
||||
This feature is available in <a href="https://www.portainer.io/business-upsell?from=hide-internal-auth" target="_blank"> Portainer Business Edition</a>.
|
||||
</span>
|
||||
<div class="col-sm-12">
|
||||
<por-switch-field
|
||||
label="Hide internal authentication prompt"
|
||||
name="hide-internal-auth"
|
||||
feature="$ctrl.limitedFeature"
|
||||
ng-model="$ctrl.settings.HideInternalAuth"
|
||||
></por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !HideInternalAuth -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Automatic user provisioning
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
|
||||
<auto-user-provision-toggle ng-model="$ctrl.settings.OAuthAutoCreateUsers">
|
||||
<field-description>
|
||||
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.
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
</field-description>
|
||||
</auto-user-provision-toggle>
|
||||
|
||||
<div ng-if="$ctrl.settings.OAuthAutoCreateUsers">
|
||||
<div class="form-group">
|
||||
<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
|
||||
>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
|
||||
>
|
||||
<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
|
||||
users won't be able to access any environments.
|
||||
</p>
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
|
@ -56,13 +52,33 @@
|
|||
<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>
|
||||
<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
|
||||
></button>
|
||||
<div class="col-sm-9 col-lg-9" ng-if="$ctrl.teams.length > 0">
|
||||
<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 class="col-sm-9" ng-if="$ctrl.teams.length > 0">
|
||||
<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>
|
||||
The default team option will be disabled when automatic team membership is enabled
|
||||
</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>
|
||||
|
@ -71,118 +87,296 @@
|
|||
Team membership
|
||||
</div>
|
||||
<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.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="text-muted small" style="margin-left: 15px;">
|
||||
<i class="fa fa-user" aria-hidden="true"></i>
|
||||
This feature is available in <a href="https://www.portainer.io/business-upsell?from=oauth-group-membership" target="_blank"> Portainer Business Edition</a>.
|
||||
</span>
|
||||
<div class="col-sm-12">
|
||||
<por-switch-field label="Automatic team membership" name="tls" feature="$ctrl.limitedFeature" ng-model="$ctrl.settings.OAuthAutoMapTeamMemberships"></por-switch-field>
|
||||
</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="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">
|
||||
<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>
|
||||
</label>
|
||||
<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 class="form-group">
|
||||
<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>
|
||||
<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 class="form-group">
|
||||
<label for="oauth_authorization_uri" class="col-sm-3 col-lg-2 control-label text-left">
|
||||
Authorization URL
|
||||
<portainer-tooltip
|
||||
position="bottom"
|
||||
message="URL used to authenticate against the OAuth provider. Will redirect the user to the OAuth provider login view"
|
||||
></portainer-tooltip>
|
||||
</label>
|
||||
<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" />
|
||||
<div ng-if="$ctrl.state.provider == 'custom' || $ctrl.state.overrideConfiguration">
|
||||
<div class="form-group">
|
||||
<label for="oauth_authorization_uri" class="col-sm-3 col-lg-2 control-label text-left">
|
||||
Authorization URL
|
||||
<portainer-tooltip
|
||||
position="bottom"
|
||||
message="URL used to authenticate against the OAuth provider. Will redirect the user to the OAuth provider login view"
|
||||
></portainer-tooltip>
|
||||
</label>
|
||||
<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 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" />
|
||||
<div class="form-group" ng-if="$ctrl.state.provider != 'custom'">
|
||||
<div class="col-sm-12">
|
||||
<a class="small interactive" ng-if="!$ctrl.state.overrideConfiguration" ng-click="$ctrl.state.overrideConfiguration = true;">
|
||||
<i class="fa fa-wrench space-right" aria-hidden="true"></i> Override default configuration
|
||||
</a>
|
||||
<a class="small interactive" ng-if="$ctrl.state.overrideConfiguration" ng-click="$ctrl.useDefaultProviderConfiguration($ctrl.state.provider)">
|
||||
<i class="fa fa-cogs space-right" aria-hidden="true"></i> Use default configuration
|
||||
</a>
|
||||
</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" />
|
||||
</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>
|
||||
</ng-form>
|
||||
|
|
|
@ -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';
|
||||
}
|
|
@ -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>
|
|
@ -1,5 +1,5 @@
|
|||
angular.module('portainer.app').component('accessViewerDatatable', {
|
||||
templateUrl: './accessViewerDatatable.html',
|
||||
export const accessViewerDatatable = {
|
||||
templateUrl: './access-viewer-datatable.html',
|
||||
controller: 'GenericDatatableController',
|
||||
bindings: {
|
||||
titleText: '@',
|
||||
|
@ -8,4 +8,4 @@ angular.module('portainer.app').component('accessViewerDatatable', {
|
|||
orderBy: '@',
|
||||
dataset: '<',
|
||||
},
|
||||
});
|
||||
};
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -0,0 +1,6 @@
|
|||
import controller from './access-viewer.controller';
|
||||
|
||||
export const accessViewer = {
|
||||
templateUrl: './access-viewer.html',
|
||||
controller,
|
||||
};
|
|
@ -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: '<',
|
||||
},
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
th.be-visual-indicator-col {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
td.be-visual-indicator-col {
|
||||
text-align: center;
|
||||
}
|
|
@ -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>
|
|
@ -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);
|
||||
}
|
|
@ -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';
|
||||
}
|
|
@ -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,
|
||||
});
|
|
@ -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' } },
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import controller from './roles.controller';
|
||||
|
||||
export const rolesView = {
|
||||
templateUrl: './roles.html',
|
||||
controller,
|
||||
};
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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',
|
||||
};
|
|
@ -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',
|
||||
};
|
|
@ -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>
|
|
@ -0,0 +1,9 @@
|
|||
export const autoUserProvisionToggle = {
|
||||
templateUrl: './auto-user-provision-toggle.html',
|
||||
transclude: {
|
||||
description: 'fieldDescription',
|
||||
},
|
||||
bindings: {
|
||||
ngModel: '=',
|
||||
},
|
||||
};
|
|
@ -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;
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -0,0 +1,12 @@
|
|||
import controller from './ad-settings.controller';
|
||||
|
||||
export const adSettings = {
|
||||
templateUrl: './ad-settings.html',
|
||||
controller,
|
||||
bindings: {
|
||||
settings: '=',
|
||||
tlscaCert: '=',
|
||||
state: '=',
|
||||
connectivityCheck: '<',
|
||||
},
|
||||
};
|
|
@ -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;
|
|
@ -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
Loading…
Reference in New Issue