Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90a160e83f | |||
| f58aa8cd5b | |||
| b9ff7b6f32 | |||
| 5761342069 | |||
| d8480a0db6 | |||
| 03a4f1227e | |||
| ee6c3f958f | |||
| e78519f492 | |||
| f80501b505 | |||
| 0f89ade048 | |||
| 6d0aefd7bb | |||
| 6aa0a1ffa9 | |||
| ce4b6dc586 | |||
| 4410394ede | |||
| e5eb354d7b | |||
| b660feafbf | |||
| b75f0e561b | |||
| 83cd5d9b2f | |||
| b051629f13 | |||
| 32da62cdc8 | |||
| 93124f75cf | |||
| 0fce4c98a0 | |||
| 5dad419f60 | |||
| cd9ad97235 | |||
| 67308838fd | |||
| 3360576e07 | |||
| c5a51a9fb7 | |||
| 280a2fe093 | |||
| ddd30dd17a | |||
| 15df3277ca | |||
| 47845523a5 | |||
| 2af2827cba | |||
| 8f4f5fddcc | |||
| 8b7436e4d0 | |||
| 5b8a0471e9 | |||
| 0b9e5c564f | |||
| 1ed2c8b346 | |||
| c43f771a88 | |||
| 8755a22fee | |||
| 8e3c47719e | |||
| 157393c965 | |||
| 6163aaa577 | |||
| d9a3b98275 | |||
| c0c689c2af | |||
| 4efe66d33f | |||
| 80415ab68f | |||
| fa087f0bb9 | |||
| 3994d74c71 | |||
| 537585e78c | |||
| 78202cfb25 | |||
| b60f32a25b |
@@ -41,6 +41,6 @@ jobs:
|
||||
- name: GolangCI-Lint
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
with:
|
||||
version: v1.52.2
|
||||
version: v1.54.1
|
||||
working-directory: api
|
||||
args: --timeout=10m -c .golangci.yaml
|
||||
|
||||
+11
-11
@@ -10,17 +10,17 @@ linters:
|
||||
- exportloopref
|
||||
linters-settings:
|
||||
depguard:
|
||||
list-type: denylist
|
||||
include-go-root: true
|
||||
packages:
|
||||
- github.com/sirupsen/logrus
|
||||
- golang.org/x/exp
|
||||
packages-with-error-message:
|
||||
- github.com/sirupsen/logrus: 'logging is allowed only by github.com/rs/zerolog'
|
||||
ignore-file-rules:
|
||||
- '**/*_test.go'
|
||||
- '**/base.go'
|
||||
- '**/base_tx.go'
|
||||
rules:
|
||||
main:
|
||||
deny:
|
||||
- pkg: 'github.com/sirupsen/logrus'
|
||||
desc: 'logging is allowed only by github.com/rs/zerolog'
|
||||
- pkg: 'golang.org/x/exp'
|
||||
desc: 'exp is not allowed'
|
||||
files:
|
||||
- '!**/*_test.go'
|
||||
- '!**/base.go'
|
||||
- '!**/base_tx.go'
|
||||
|
||||
# errorlint is causing a typecheck error for some reason. The go compiler will report these
|
||||
# anyway, so ignore them from the linter
|
||||
|
||||
@@ -75,10 +75,11 @@ func (service *Service) KeepTunnelAlive(endpointID portainer.EndpointID, ctx con
|
||||
log.Debug().
|
||||
Int("endpoint_id", int(endpointID)).
|
||||
Float64("max_alive_minutes", maxAlive.Minutes()).
|
||||
Msg("start")
|
||||
Msg("KeepTunnelAlive: start")
|
||||
|
||||
maxAliveTicker := time.NewTicker(maxAlive)
|
||||
defer maxAliveTicker.Stop()
|
||||
|
||||
pingTicker := time.NewTicker(tunnelCleanupInterval)
|
||||
defer pingTicker.Stop()
|
||||
|
||||
@@ -91,13 +92,13 @@ func (service *Service) KeepTunnelAlive(endpointID portainer.EndpointID, ctx con
|
||||
log.Debug().
|
||||
Int("endpoint_id", int(endpointID)).
|
||||
Err(err).
|
||||
Msg("ping agent")
|
||||
Msg("KeepTunnelAlive: ping agent")
|
||||
}
|
||||
case <-maxAliveTicker.C:
|
||||
log.Debug().
|
||||
Int("endpoint_id", int(endpointID)).
|
||||
Float64("timeout_minutes", maxAlive.Minutes()).
|
||||
Msg("tunnel keep alive timeout")
|
||||
Msg("KeepTunnelAlive: tunnel keep alive timeout")
|
||||
|
||||
return
|
||||
case <-ctx.Done():
|
||||
@@ -105,7 +106,7 @@ func (service *Service) KeepTunnelAlive(endpointID portainer.EndpointID, ctx con
|
||||
log.Debug().
|
||||
Int("endpoint_id", int(endpointID)).
|
||||
Err(err).
|
||||
Msg("tunnel stop")
|
||||
Msg("KeepTunnelAlive: tunnel stop")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
)
|
||||
|
||||
// BucketName represents the name of the bucket where this service stores data.
|
||||
@@ -144,6 +145,23 @@ func (service *Service) Create(endpoint *portainer.Endpoint) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (service *Service) EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error) {
|
||||
var endpoints = make([]portainer.Endpoint, 0)
|
||||
|
||||
return endpoints, service.connection.GetAll(
|
||||
BucketName,
|
||||
&portainer.Endpoint{},
|
||||
dataservices.FilterFn(&endpoints, func(e portainer.Endpoint) bool {
|
||||
for t := range e.TeamAccessPolicies {
|
||||
if t == teamID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// GetNextIdentifier returns the next identifier for an environment(endpoint).
|
||||
func (service *Service) GetNextIdentifier() int {
|
||||
var identifier int
|
||||
|
||||
@@ -122,6 +122,23 @@ func (service ServiceTx) Create(endpoint *portainer.Endpoint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service ServiceTx) EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error) {
|
||||
var endpoints = make([]portainer.Endpoint, 0)
|
||||
|
||||
return endpoints, service.tx.GetAll(
|
||||
BucketName,
|
||||
&portainer.Endpoint{},
|
||||
dataservices.FilterFn(&endpoints, func(e portainer.Endpoint) bool {
|
||||
for t := range e.TeamAccessPolicies {
|
||||
if t == teamID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// GetNextIdentifier returns the next identifier for an environment(endpoint).
|
||||
func (service ServiceTx) GetNextIdentifier() int {
|
||||
return service.tx.GetNextIdentifier(BucketName)
|
||||
|
||||
@@ -89,6 +89,7 @@ type (
|
||||
EndpointService interface {
|
||||
Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error)
|
||||
EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool)
|
||||
EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error)
|
||||
Heartbeat(endpointID portainer.EndpointID) (int64, bool)
|
||||
UpdateHeartbeat(endpointID portainer.EndpointID)
|
||||
Endpoints() ([]portainer.Endpoint, error)
|
||||
|
||||
@@ -944,6 +944,6 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.19.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.19.4\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
}
|
||||
}
|
||||
@@ -302,6 +302,38 @@ func (service *Service) UpdateStoreStackFileFromBytes(stackIdentifier, fileName
|
||||
return service.wrapFileStore(stackStorePath), nil
|
||||
}
|
||||
|
||||
// UpdateStoreStackFileFromBytesByVersion makes stack file backup and updates a new file from bytes.
|
||||
// It returns the path to the folder where the file is stored.
|
||||
func (service *Service) UpdateStoreStackFileFromBytesByVersion(stackIdentifier, fileName string, version int, commitHash string, data []byte) (string, error) {
|
||||
stackStorePath := JoinPaths(ComposeStorePath, stackIdentifier)
|
||||
|
||||
versionStr := ""
|
||||
if version != 0 {
|
||||
versionStr = fmt.Sprintf("v%d", version)
|
||||
}
|
||||
if commitHash != "" {
|
||||
versionStr = commitHash
|
||||
}
|
||||
|
||||
if versionStr != "" {
|
||||
stackStorePath = JoinPaths(stackStorePath, versionStr)
|
||||
}
|
||||
|
||||
composeFilePath := JoinPaths(stackStorePath, fileName)
|
||||
err := service.createBackupFileInStore(composeFilePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
r := bytes.NewReader(data)
|
||||
err = service.createFileInStore(composeFilePath, r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return service.wrapFileStore(stackStorePath), nil
|
||||
}
|
||||
|
||||
// RemoveStackFileBackup removes the stack file backup in the ComposeStorePath.
|
||||
func (service *Service) RemoveStackFileBackup(stackIdentifier, fileName string) error {
|
||||
stackStorePath := JoinPaths(ComposeStorePath, stackIdentifier)
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ require (
|
||||
github.com/portainer/libcrypto v0.0.0-20220506221303-1f4fb3b30f9a
|
||||
github.com/portainer/libhttp v0.0.0-20230615144939-a999f666d9a9
|
||||
github.com/portainer/portainer/pkg/featureflags v0.0.0-20230711022654-64b227b2e146
|
||||
github.com/portainer/portainer/pkg/libhelm v0.0.0-20230711022654-64b227b2e146
|
||||
github.com/portainer/portainer/pkg/libhelm v0.0.0-20230928223730-157393c965ce
|
||||
github.com/portainer/portainer/pkg/libstack v0.0.0-20230711022654-64b227b2e146
|
||||
github.com/portainer/portainer/third_party/digest v0.0.0-20221201002639-8fd0efa34f73
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
|
||||
@@ -320,6 +320,10 @@ github.com/portainer/portainer/pkg/featureflags v0.0.0-20230711022654-64b227b2e1
|
||||
github.com/portainer/portainer/pkg/featureflags v0.0.0-20230711022654-64b227b2e146/go.mod h1:x4Lpq/BjFhZmuNB8e8FO0ObRPQ/Z/V9rTe54bMedf1A=
|
||||
github.com/portainer/portainer/pkg/libhelm v0.0.0-20230711022654-64b227b2e146 h1:1qW7quKyFG4tOnMcnnqyYsDVfL09etO1h/Cu/3ak7KU=
|
||||
github.com/portainer/portainer/pkg/libhelm v0.0.0-20230711022654-64b227b2e146/go.mod h1:cFRD6PvOwpd2pf/O1r/IMKl+ZB12pWfo/Evleh3aCfM=
|
||||
github.com/portainer/portainer/pkg/libhelm v0.0.0-20230919060741-8f42ba025479 h1:DbmhSQZpDo5f0cr+CKLJqoqhQiuxp8QFXdZsjPS1lI4=
|
||||
github.com/portainer/portainer/pkg/libhelm v0.0.0-20230919060741-8f42ba025479/go.mod h1:cFRD6PvOwpd2pf/O1r/IMKl+ZB12pWfo/Evleh3aCfM=
|
||||
github.com/portainer/portainer/pkg/libhelm v0.0.0-20230928223730-157393c965ce h1:DQTMXYH1zn2DzuAe+4rT40JqdHLhpHHJ2pzRFhvZ/+c=
|
||||
github.com/portainer/portainer/pkg/libhelm v0.0.0-20230928223730-157393c965ce/go.mod h1:cFRD6PvOwpd2pf/O1r/IMKl+ZB12pWfo/Evleh3aCfM=
|
||||
github.com/portainer/portainer/pkg/libstack v0.0.0-20230711022654-64b227b2e146 h1:ZGj+j5HoajaO+mXgCm6NzOU+zUdIlJK2amagB+QIDvc=
|
||||
github.com/portainer/portainer/pkg/libstack v0.0.0-20230711022654-64b227b2e146/go.mod h1:+zCK2UbsH6A3yEGi0yZ45ec5VFRP7svob5Q2lW6LFgk=
|
||||
github.com/portainer/portainer/third_party/digest v0.0.0-20221201002639-8fd0efa34f73 h1:7bPOnwucE0nor0so1BQJxQKCL5t+vCWO4nAz/S0lci0=
|
||||
|
||||
@@ -24,6 +24,7 @@ type Handler struct {
|
||||
ProxyManager *proxy.Manager
|
||||
KubernetesTokenCacheManager *kubernetes.TokenCacheManager
|
||||
passwordStrengthChecker security.PasswordStrengthChecker
|
||||
bouncer security.BouncerService
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage authentication operations.
|
||||
@@ -31,6 +32,7 @@ func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimit
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
passwordStrengthChecker: passwordStrengthChecker,
|
||||
bouncer: bouncer,
|
||||
}
|
||||
|
||||
h.Handle("/auth/oauth/validate",
|
||||
@@ -38,7 +40,6 @@ func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimit
|
||||
h.Handle("/auth",
|
||||
rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.authenticate)))).Methods(http.MethodPost)
|
||||
h.Handle("/auth/logout",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.logout))).Methods(http.MethodPost)
|
||||
|
||||
bouncer.PublicAccess(httperror.LoggerHandler(h.logout))).Methods(http.MethodPost)
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@ import (
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/response"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/logoutcontext"
|
||||
)
|
||||
|
||||
// @id Logout
|
||||
// @summary Logout
|
||||
// @description **Access policy**: authenticated
|
||||
// @description **Access policy**: public
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @tags auth
|
||||
@@ -18,12 +18,12 @@ import (
|
||||
// @failure 500 "Server error"
|
||||
// @router /auth/logout [post]
|
||||
func (handler *Handler) logout(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve user details from authentication token", err)
|
||||
}
|
||||
tokenData := handler.bouncer.JWTAuthLookup(r)
|
||||
|
||||
handler.KubernetesTokenCacheManager.RemoveUserFromCache(tokenData.ID)
|
||||
if tokenData != nil {
|
||||
handler.KubernetesTokenCacheManager.RemoveUserFromCache(tokenData.ID)
|
||||
logoutcontext.Cancel(tokenData.Token)
|
||||
}
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.19.1
|
||||
// @version 2.19.4
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -4,8 +4,12 @@ import (
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
@@ -13,7 +17,8 @@ import (
|
||||
// Handler is the HTTP handler used to handle team membership operations.
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
DataStore dataservices.DataStore
|
||||
DataStore dataservices.DataStore
|
||||
K8sClientFactory *cli.ClientFactory
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage team membership operations.
|
||||
@@ -31,3 +36,27 @@ func NewHandler(bouncer security.BouncerService) *Handler {
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
func (handler *Handler) updateUserServiceAccounts(membership *portainer.TeamMembership) {
|
||||
endpoints, err := handler.DataStore.Endpoint().EndpointsByTeamID(membership.TeamID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("failed fetching environments for team %d", membership.TeamID)
|
||||
return
|
||||
}
|
||||
for _, endpoint := range endpoints {
|
||||
restrictDefaultNamespace := endpoint.Kubernetes.Configuration.RestrictDefaultNamespace
|
||||
// update kubernenets service accounts if the team is associated with a kubernetes environment
|
||||
if endpointutils.IsKubernetesEndpoint(&endpoint) {
|
||||
kubecli, err := handler.K8sClientFactory.GetKubeClient(&endpoint)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("failed getting kube client for environment %d", endpoint.ID)
|
||||
continue
|
||||
}
|
||||
teamIDs := []int{int(membership.TeamID)}
|
||||
err = kubecli.SetupUserServiceAccount(int(membership.UserID), teamIDs, restrictDefaultNamespace)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("failed setting-up service account for user %d", membership.UserID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,5 +91,7 @@ func (handler *Handler) teamMembershipCreate(w http.ResponseWriter, r *http.Requ
|
||||
return httperror.InternalServerError("Unable to persist team memberships inside the database", err)
|
||||
}
|
||||
|
||||
defer handler.updateUserServiceAccounts(membership)
|
||||
|
||||
return response.JSON(w, membership)
|
||||
}
|
||||
|
||||
@@ -52,5 +52,7 @@ func (handler *Handler) teamMembershipDelete(w http.ResponseWriter, r *http.Requ
|
||||
return httperror.InternalServerError("Unable to remove the team membership from the database", err)
|
||||
}
|
||||
|
||||
defer handler.updateUserServiceAccounts(membership)
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
@@ -90,5 +90,7 @@ func (handler *Handler) teamMembershipUpdate(w http.ResponseWriter, r *http.Requ
|
||||
return httperror.InternalServerError("Unable to persist membership changes inside the database", err)
|
||||
}
|
||||
|
||||
defer handler.updateUserServiceAccounts(membership)
|
||||
|
||||
return response.JSON(w, membership)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,13 @@ import (
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID portainer.UserID `json:"Id" example:"1"`
|
||||
Username string `json:"Username" example:"bob"`
|
||||
// User role (1 for administrator account and 2 for regular account)
|
||||
Role portainer.UserRole `json:"Role" example:"1"`
|
||||
}
|
||||
|
||||
// @id UserList
|
||||
// @summary List users
|
||||
// @description List Portainer users.
|
||||
@@ -26,24 +33,25 @@ import (
|
||||
// @failure 500 "Server error"
|
||||
// @router /users [get]
|
||||
func (handler *Handler) userList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
users, err := handler.DataStore.User().ReadAll()
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve users from the database", err)
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve info from request context", err)
|
||||
}
|
||||
|
||||
availableUsers := security.FilterUsers(users, securityContext)
|
||||
for i := range availableUsers {
|
||||
hideFields(&availableUsers[i])
|
||||
if !securityContext.IsAdmin && !securityContext.IsTeamLeader {
|
||||
return httperror.Forbidden("Permission denied to access users list", err)
|
||||
}
|
||||
|
||||
users, err := handler.DataStore.User().ReadAll()
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve users from the database", err)
|
||||
}
|
||||
|
||||
availableUsers := security.FilterUsers(users, securityContext)
|
||||
|
||||
endpointID, _ := request.RetrieveNumericQueryParameter(r, "environmentId", true)
|
||||
if endpointID == 0 {
|
||||
return response.JSON(w, availableUsers)
|
||||
return response.JSON(w, sanitizeUsers(availableUsers))
|
||||
}
|
||||
|
||||
// filter out users who do not have access to the specific endpoint
|
||||
@@ -57,11 +65,11 @@ func (handler *Handler) userList(w http.ResponseWriter, r *http.Request) *httper
|
||||
return httperror.InternalServerError("Unable to retrieve environment groups from the database", err)
|
||||
}
|
||||
|
||||
canAccessEndpoint := make([]portainer.User, 0)
|
||||
canAccessEndpoint := make([]User, 0)
|
||||
for _, user := range availableUsers {
|
||||
// the users who have the endpoint authorization
|
||||
if _, ok := user.EndpointAuthorizations[endpoint.ID]; ok {
|
||||
canAccessEndpoint = append(canAccessEndpoint, user)
|
||||
canAccessEndpoint = append(canAccessEndpoint, sanitizeUser(user))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -72,9 +80,25 @@ func (handler *Handler) userList(w http.ResponseWriter, r *http.Request) *httper
|
||||
}
|
||||
|
||||
if security.AuthorizedEndpointAccess(endpoint, endpointGroup, user.ID, teamMemberships) {
|
||||
canAccessEndpoint = append(canAccessEndpoint, user)
|
||||
canAccessEndpoint = append(canAccessEndpoint, sanitizeUser(user))
|
||||
}
|
||||
}
|
||||
|
||||
return response.JSON(w, canAccessEndpoint)
|
||||
}
|
||||
|
||||
func sanitizeUser(user portainer.User) User {
|
||||
return User{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeUsers(users []portainer.User) []User {
|
||||
u := make([]User, len(users))
|
||||
for i := range users {
|
||||
u[i] = sanitizeUser(users[i])
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
@@ -111,28 +111,14 @@ func Test_userList(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("standard user cannot list amdin users", func(t *testing.T) {
|
||||
t.Run("standard user cannot list users", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/users", nil)
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt))
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
|
||||
is.Equal(http.StatusOK, rr.Code)
|
||||
|
||||
body, err := io.ReadAll(rr.Body)
|
||||
is.NoError(err, "ReadAll should not return error")
|
||||
|
||||
var resp []portainer.User
|
||||
err = json.Unmarshal(body, &resp)
|
||||
is.NoError(err, "response should be list json")
|
||||
|
||||
is.Len(resp, 2)
|
||||
if len(resp) > 0 {
|
||||
for _, user := range resp {
|
||||
is.NotEqual(portainer.AdministratorRole, user.Role)
|
||||
}
|
||||
}
|
||||
is.Equal(http.StatusForbidden, rr.Code)
|
||||
})
|
||||
|
||||
// Case 2: the user is under an environment group and the environment group has endpoint access.
|
||||
|
||||
@@ -9,9 +9,11 @@ import (
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// @summary Attach a websocket
|
||||
@@ -74,6 +76,13 @@ func (handler *Handler) websocketAttach(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
func (handler *Handler) handleAttachRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error {
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
log.Warn().
|
||||
Err(err).
|
||||
Msg("unable to retrieve user details from authentication token")
|
||||
return err
|
||||
}
|
||||
|
||||
r.Header.Del("Origin")
|
||||
|
||||
@@ -89,10 +98,15 @@ func (handler *Handler) handleAttachRequest(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
defer websocketConn.Close()
|
||||
|
||||
return hijackAttachStartOperation(websocketConn, params.endpoint, params.ID)
|
||||
return hijackAttachStartOperation(websocketConn, params.endpoint, params.ID, tokenData.Token)
|
||||
}
|
||||
|
||||
func hijackAttachStartOperation(websocketConn *websocket.Conn, endpoint *portainer.Endpoint, attachID string) error {
|
||||
func hijackAttachStartOperation(
|
||||
websocketConn *websocket.Conn,
|
||||
endpoint *portainer.Endpoint,
|
||||
attachID string,
|
||||
token string,
|
||||
) error {
|
||||
dial, err := initDial(endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -116,7 +130,7 @@ func hijackAttachStartOperation(websocketConn *websocket.Conn, endpoint *portain
|
||||
return err
|
||||
}
|
||||
|
||||
return hijackRequest(websocketConn, httpConn, attachStartRequest)
|
||||
return hijackRequest(websocketConn, httpConn, attachStartRequest, token)
|
||||
}
|
||||
|
||||
func createAttachStartRequest(attachID string) (*http.Request, error) {
|
||||
|
||||
@@ -11,9 +11,11 @@ import (
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type execStartOperationPayload struct {
|
||||
@@ -80,6 +82,14 @@ func (handler *Handler) websocketExec(w http.ResponseWriter, r *http.Request) *h
|
||||
}
|
||||
|
||||
func (handler *Handler) handleExecRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error {
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
log.Warn().
|
||||
Err(err).
|
||||
Msg("unable to retrieve user details from authentication token")
|
||||
return err
|
||||
}
|
||||
|
||||
r.Header.Del("Origin")
|
||||
|
||||
if params.endpoint.Type == portainer.AgentOnDockerEnvironment {
|
||||
@@ -94,10 +104,15 @@ func (handler *Handler) handleExecRequest(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
defer websocketConn.Close()
|
||||
|
||||
return hijackExecStartOperation(websocketConn, params.endpoint, params.ID)
|
||||
return hijackExecStartOperation(websocketConn, params.endpoint, params.ID, tokenData.Token)
|
||||
}
|
||||
|
||||
func hijackExecStartOperation(websocketConn *websocket.Conn, endpoint *portainer.Endpoint, execID string) error {
|
||||
func hijackExecStartOperation(
|
||||
websocketConn *websocket.Conn,
|
||||
endpoint *portainer.Endpoint,
|
||||
execID string,
|
||||
token string,
|
||||
) error {
|
||||
dial, err := initDial(endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -121,7 +136,7 @@ func hijackExecStartOperation(websocketConn *websocket.Conn, endpoint *portainer
|
||||
return err
|
||||
}
|
||||
|
||||
return hijackRequest(websocketConn, httpConn, execStartRequest)
|
||||
return hijackRequest(websocketConn, httpConn, execStartRequest, token)
|
||||
}
|
||||
|
||||
func createExecStartRequest(execID string) (*http.Request, error) {
|
||||
|
||||
@@ -7,9 +7,15 @@ import (
|
||||
"net/http/httputil"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/portainer/portainer/api/internal/logoutcontext"
|
||||
)
|
||||
|
||||
func hijackRequest(websocketConn *websocket.Conn, httpConn *httputil.ClientConn, request *http.Request) error {
|
||||
func hijackRequest(
|
||||
websocketConn *websocket.Conn,
|
||||
httpConn *httputil.ClientConn,
|
||||
request *http.Request,
|
||||
token string,
|
||||
) error {
|
||||
// Server hijacks the connection, error 'connection closed' expected
|
||||
resp, err := httpConn.Do(request)
|
||||
if !errors.Is(err, httputil.ErrPersistEOF) {
|
||||
@@ -29,9 +35,15 @@ func hijackRequest(websocketConn *websocket.Conn, httpConn *httputil.ClientConn,
|
||||
go streamFromReaderToWebsocket(websocketConn, brw, errorChan)
|
||||
go streamFromWebsocketToWriter(websocketConn, tcpConn, errorChan)
|
||||
|
||||
err = <-errorChan
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) {
|
||||
return err
|
||||
logoutCtx := logoutcontext.GetContext(token)
|
||||
|
||||
select {
|
||||
case <-logoutCtx.Done():
|
||||
return fmt.Errorf("Your session has been logged out.")
|
||||
case err = <-errorChan:
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/logoutcontext"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/koding/websocketproxy"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func (handler *Handler) proxyEdgeAgentWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error {
|
||||
@@ -18,33 +23,12 @@ func (handler *Handler) proxyEdgeAgentWebsocketRequest(w http.ResponseWriter, r
|
||||
return err
|
||||
}
|
||||
|
||||
endpointURL, err := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port))
|
||||
agentURL, err := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
endpointURL.Scheme = "ws"
|
||||
proxy := websocketproxy.NewProxy(endpointURL)
|
||||
|
||||
signature, err := handler.SignatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
proxy.Director = func(incoming *http.Request, out http.Header) {
|
||||
out.Set(portainer.PortainerAgentPublicKeyHeader, handler.SignatureService.EncodedPublicKey())
|
||||
out.Set(portainer.PortainerAgentSignatureHeader, signature)
|
||||
out.Set(portainer.PortainerAgentTargetHeader, params.nodeName)
|
||||
out.Set(portainer.PortainerAgentKubernetesSATokenHeader, params.token)
|
||||
}
|
||||
|
||||
handler.ReverseTunnelService.SetTunnelStatusToActive(params.endpoint.ID)
|
||||
|
||||
handler.ReverseTunnelService.KeepTunnelAlive(params.endpoint.ID, r.Context(), portainer.WebSocketKeepAlive)
|
||||
|
||||
proxy.ServeHTTP(w, r)
|
||||
|
||||
return nil
|
||||
return handler.doProxyWebsocketRequest(w, r, params, agentURL, true)
|
||||
}
|
||||
|
||||
func (handler *Handler) proxyAgentWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error {
|
||||
@@ -59,17 +43,41 @@ func (handler *Handler) proxyAgentWebsocketRequest(w http.ResponseWriter, r *htt
|
||||
}
|
||||
|
||||
agentURL.Scheme = "ws"
|
||||
proxy := websocketproxy.NewProxy(agentURL)
|
||||
return handler.doProxyWebsocketRequest(w, r, params, agentURL, false)
|
||||
}
|
||||
|
||||
if params.endpoint.TLSConfig.TLS || params.endpoint.TLSConfig.TLSSkipVerify {
|
||||
func (handler *Handler) doProxyWebsocketRequest(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
params *webSocketRequestParams,
|
||||
agentURL *url.URL,
|
||||
isEdge bool,
|
||||
) error {
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
log.
|
||||
Warn().
|
||||
Err(err).
|
||||
Msg("unable to retrieve user details from authentication token")
|
||||
return err
|
||||
}
|
||||
|
||||
enableTLS := !isEdge && (params.endpoint.TLSConfig.TLS || params.endpoint.TLSConfig.TLSSkipVerify)
|
||||
|
||||
agentURL.Scheme = "ws"
|
||||
if enableTLS {
|
||||
agentURL.Scheme = "wss"
|
||||
}
|
||||
|
||||
proxy := websocketproxy.NewProxy(agentURL)
|
||||
proxyDialer := *websocket.DefaultDialer
|
||||
proxy.Dialer = &proxyDialer
|
||||
|
||||
if enableTLS {
|
||||
tlsConfig := crypto.CreateTLSConfiguration()
|
||||
tlsConfig.InsecureSkipVerify = params.endpoint.TLSConfig.TLSSkipVerify
|
||||
|
||||
proxy.Dialer = &websocket.Dialer{
|
||||
TLSClientConfig: tlsConfig,
|
||||
}
|
||||
proxyDialer.TLSClientConfig = tlsConfig
|
||||
}
|
||||
|
||||
signature, err := handler.SignatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
|
||||
@@ -84,7 +92,46 @@ func (handler *Handler) proxyAgentWebsocketRequest(w http.ResponseWriter, r *htt
|
||||
out.Set(portainer.PortainerAgentKubernetesSATokenHeader, params.token)
|
||||
}
|
||||
|
||||
if isEdge {
|
||||
handler.ReverseTunnelService.SetTunnelStatusToActive(params.endpoint.ID)
|
||||
handler.ReverseTunnelService.KeepTunnelAlive(params.endpoint.ID, r.Context(), portainer.WebSocketKeepAlive)
|
||||
}
|
||||
|
||||
abortProxyOnLogout(r.Context(), proxy, tokenData.Token)
|
||||
|
||||
proxy.ServeHTTP(w, r)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func abortProxyOnLogout(ctx context.Context, proxy *websocketproxy.WebsocketProxy, token string) {
|
||||
var wsConn net.Conn
|
||||
|
||||
proxy.Dialer.NetDial = func(network, addr string) (net.Conn, error) {
|
||||
netDialer := &net.Dialer{}
|
||||
|
||||
conn, err := netDialer.DialContext(context.Background(), network, addr)
|
||||
wsConn = conn
|
||||
|
||||
return conn, err
|
||||
}
|
||||
|
||||
logoutCtx := logoutcontext.GetContext(token)
|
||||
|
||||
go func() {
|
||||
log.Debug().
|
||||
Msg("logout watcher for websocket proxy started")
|
||||
|
||||
select {
|
||||
case <-logoutCtx.Done():
|
||||
log.Debug().
|
||||
Msg("logout watcher for websocket proxy stopped as user logged out")
|
||||
if wsConn != nil {
|
||||
wsConn.Close()
|
||||
}
|
||||
case <-ctx.Done():
|
||||
log.Debug().
|
||||
Msg("logout watcher for websocket proxy stopped as the ws connection closed")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const defaultServiceAccountTokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token"
|
||||
@@ -43,28 +45,62 @@ func (manager *tokenManager) GetAdminServiceAccountToken() string {
|
||||
return manager.adminToken
|
||||
}
|
||||
|
||||
func (manager *tokenManager) setupUserServiceAccounts(userID portainer.UserID, endpoint *portainer.Endpoint) error {
|
||||
memberships, err := manager.dataStore.TeamMembership().TeamMembershipsByUserID(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
teamIds := make([]int, 0, len(memberships))
|
||||
for _, membership := range memberships {
|
||||
teamIds = append(teamIds, int(membership.TeamID))
|
||||
}
|
||||
|
||||
restrictDefaultNamespace := endpoint.Kubernetes.Configuration.RestrictDefaultNamespace
|
||||
err = manager.kubecli.SetupUserServiceAccount(int(userID), teamIds, restrictDefaultNamespace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *tokenManager) UpdateUserServiceAccountsForEndpoint(endpointID portainer.EndpointID) {
|
||||
endpoint, err := manager.dataStore.Endpoint().Endpoint(endpointID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("failed fetching environments %d", endpointID)
|
||||
return
|
||||
}
|
||||
|
||||
userIDs := make([]portainer.UserID, 0)
|
||||
for u := range endpoint.UserAccessPolicies {
|
||||
userIDs = append(userIDs, u)
|
||||
}
|
||||
for t := range endpoint.TeamAccessPolicies {
|
||||
memberships, _ := manager.dataStore.TeamMembership().TeamMembershipsByTeamID(portainer.TeamID(t))
|
||||
for _, membership := range memberships {
|
||||
userIDs = append(userIDs, membership.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
for _, userID := range userIDs {
|
||||
if err := manager.setupUserServiceAccounts(userID, endpoint); err != nil {
|
||||
log.Error().Err(err).Msgf("failed setting-up service account for user %d", userID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetUserServiceAccountToken setup a user's service account if it does not exist, then retrieve its token
|
||||
func (manager *tokenManager) GetUserServiceAccountToken(userID int, endpointID portainer.EndpointID) (string, error) {
|
||||
tokenFunc := func() (string, error) {
|
||||
memberships, err := manager.dataStore.TeamMembership().TeamMembershipsByUserID(portainer.UserID(userID))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
teamIds := make([]int, 0, len(memberships))
|
||||
for _, membership := range memberships {
|
||||
teamIds = append(teamIds, int(membership.TeamID))
|
||||
}
|
||||
|
||||
endpoint, err := manager.dataStore.Endpoint().Endpoint(endpointID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("failed fetching environment %d", endpointID)
|
||||
return "", err
|
||||
}
|
||||
|
||||
restrictDefaultNamespace := endpoint.Kubernetes.Configuration.RestrictDefaultNamespace
|
||||
err = manager.kubecli.SetupUserServiceAccount(userID, teamIds, restrictDefaultNamespace)
|
||||
if err != nil {
|
||||
return "", err
|
||||
if err := manager.setupUserServiceAccounts(portainer.UserID(userID), endpoint); err != nil {
|
||||
return "", fmt.Errorf("failed setting-up service account for user %d: %w", userID, err)
|
||||
}
|
||||
|
||||
return manager.kubecli.GetServiceAccountBearerToken(userID)
|
||||
|
||||
@@ -49,7 +49,17 @@ func (transport *baseTransport) proxyKubernetesRequest(request *http.Request) (*
|
||||
apiVersionRe := regexp.MustCompile(`^(/kubernetes)?/(api|apis/apps)/v[0-9](\.[0-9])?`)
|
||||
requestPath := apiVersionRe.ReplaceAllString(request.URL.Path, "")
|
||||
|
||||
endpointRe := regexp.MustCompile(`([0-9]+)`)
|
||||
endpointIDMatch := endpointRe.FindAllString(request.RequestURI, 1)
|
||||
endpointID := 0
|
||||
if len(endpointIDMatch) > 0 {
|
||||
endpointID, _ = strconv.Atoi(endpointIDMatch[0])
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.EqualFold(requestPath, "/namespaces/portainer/configmaps/portainer-config") && (request.Method == "PUT" || request.Method == "POST"):
|
||||
defer transport.tokenManager.UpdateUserServiceAccountsForEndpoint(portainer.EndpointID(endpointID))
|
||||
return transport.executeKubernetesRequest(request)
|
||||
case strings.EqualFold(requestPath, "/namespaces"):
|
||||
return transport.executeKubernetesRequest(request)
|
||||
case strings.HasPrefix(requestPath, "/namespaces"):
|
||||
|
||||
@@ -60,15 +60,15 @@ func NewRequestBouncer(dataStore dataservices.DataStore, jwtService dataservices
|
||||
}
|
||||
}
|
||||
|
||||
// PublicAccess defines a security check for public API environments(endpoints).
|
||||
// No authentication is required to access these environments(endpoints).
|
||||
// PublicAccess defines a security check for public API endpoints.
|
||||
// No authentication is required to access these endpoints.
|
||||
func (bouncer *RequestBouncer) PublicAccess(h http.Handler) http.Handler {
|
||||
return mwSecureHeaders(h)
|
||||
}
|
||||
|
||||
// AdminAccess defines a security check for API environments(endpoints) that require an authorization check.
|
||||
// Authentication is required to access these environments(endpoints).
|
||||
// The administrator role is required to use these environments(endpoints).
|
||||
// AdminAccess defines a security check for API endpoints that require an authorization check.
|
||||
// Authentication is required to access these endpoints.
|
||||
// The administrator role is required to use these endpoints.
|
||||
// The request context will be enhanced with a RestrictedRequestContext object
|
||||
// that might be used later to inside the API operation for extra authorization validation
|
||||
// and resource filtering.
|
||||
@@ -79,8 +79,8 @@ func (bouncer *RequestBouncer) AdminAccess(h http.Handler) http.Handler {
|
||||
return h
|
||||
}
|
||||
|
||||
// RestrictedAccess defines a security check for restricted API environments(endpoints).
|
||||
// Authentication is required to access these environments(endpoints).
|
||||
// RestrictedAccess defines a security check for restricted API endpoints.
|
||||
// Authentication is required to access these endpoints.
|
||||
// The request context will be enhanced with a RestrictedRequestContext object
|
||||
// that might be used later to inside the API operation for extra authorization validation
|
||||
// and resource filtering.
|
||||
@@ -104,8 +104,8 @@ func (bouncer *RequestBouncer) TeamLeaderAccess(h http.Handler) http.Handler {
|
||||
return h
|
||||
}
|
||||
|
||||
// AuthenticatedAccess defines a security check for restricted API environments(endpoints).
|
||||
// Authentication is required to access these environments(endpoints).
|
||||
// AuthenticatedAccess defines a security check for restricted API endpoints.
|
||||
// Authentication is required to access these endpoints.
|
||||
// The request context will be enhanced with a RestrictedRequestContext object
|
||||
// that might be used later to inside the API operation for extra authorization validation
|
||||
// and resource filtering.
|
||||
|
||||
@@ -259,6 +259,7 @@ func (server *Server) Start() error {
|
||||
|
||||
var teamMembershipHandler = teammemberships.NewHandler(requestBouncer)
|
||||
teamMembershipHandler.DataStore = server.DataStore
|
||||
teamMembershipHandler.K8sClientFactory = server.KubernetesClientFactory
|
||||
|
||||
var systemHandler = system.NewHandler(requestBouncer,
|
||||
server.Status,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package logoutcontext
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const LogoutPrefix = "logout-"
|
||||
|
||||
func GetContext(token string) context.Context {
|
||||
return GetService(logoutToken(token)).GetLogoutCtx()
|
||||
}
|
||||
|
||||
func Cancel(token string) {
|
||||
GetService(logoutToken(token)).Cancel()
|
||||
RemoveService(logoutToken(token))
|
||||
}
|
||||
|
||||
func logoutToken(token string) string {
|
||||
return LogoutPrefix + token
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package logoutcontext
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type (
|
||||
Service struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
)
|
||||
|
||||
func NewService() *Service {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &Service{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Cancel() {
|
||||
s.cancel()
|
||||
}
|
||||
|
||||
func (s *Service) GetLogoutCtx() context.Context {
|
||||
return s.ctx
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package logoutcontext
|
||||
|
||||
import "sync"
|
||||
|
||||
type (
|
||||
ServiceFactory struct {
|
||||
mu sync.Mutex
|
||||
services map[string]*Service
|
||||
}
|
||||
)
|
||||
|
||||
var serviceFactory = ServiceFactory{
|
||||
services: make(map[string]*Service),
|
||||
}
|
||||
|
||||
func GetService(token string) *Service {
|
||||
serviceFactory.mu.Lock()
|
||||
defer serviceFactory.mu.Unlock()
|
||||
|
||||
service, ok := serviceFactory.services[token]
|
||||
if !ok {
|
||||
service = NewService()
|
||||
serviceFactory.services[token] = service
|
||||
}
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
func RemoveService(token string) {
|
||||
serviceFactory.mu.Lock()
|
||||
defer serviceFactory.mu.Unlock()
|
||||
|
||||
delete(serviceFactory.services, token)
|
||||
}
|
||||
@@ -301,6 +301,19 @@ func (s *stubEndpointService) GetNextIdentifier() int {
|
||||
return len(s.endpoints)
|
||||
}
|
||||
|
||||
func (s *stubEndpointService) EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error) {
|
||||
var endpoints = make([]portainer.Endpoint, 0)
|
||||
|
||||
for _, e := range s.endpoints {
|
||||
for t := range e.TeamAccessPolicies {
|
||||
if t == teamID {
|
||||
endpoints = append(endpoints, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
return endpoints, nil
|
||||
}
|
||||
|
||||
// WithEndpoints option will instruct testDatastore to return provided environments(endpoints)
|
||||
func WithEndpoints(endpoints []portainer.Endpoint) datastoreOption {
|
||||
return func(d *testDatastore) {
|
||||
|
||||
@@ -137,6 +137,7 @@ func (service *Service) ParseAndVerifyToken(token string) (*portainer.TokenData,
|
||||
ID: portainer.UserID(cl.UserID),
|
||||
Username: cl.Username,
|
||||
Role: portainer.UserRole(cl.Role),
|
||||
Token: token,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
+9
-1
@@ -1269,6 +1269,7 @@ type (
|
||||
Username string
|
||||
Role UserRole
|
||||
ForceChangePassword bool
|
||||
Token string
|
||||
}
|
||||
|
||||
// TunnelDetails represents information associated to a tunnel
|
||||
@@ -1403,6 +1404,7 @@ type (
|
||||
StoreStackFileFromBytes(stackIdentifier, fileName string, data []byte) (string, error)
|
||||
StoreStackFileFromBytesByVersion(stackIdentifier, fileName string, version int, data []byte) (string, error)
|
||||
UpdateStoreStackFileFromBytes(stackIdentifier, fileName string, data []byte) (string, error)
|
||||
UpdateStoreStackFileFromBytesByVersion(stackIdentifier, fileName string, version int, commitHash string, data []byte) (string, error)
|
||||
RemoveStackFileBackup(stackIdentifier, fileName string) error
|
||||
RemoveStackFileBackupByVersion(stackIdentifier string, version int, fileName string) error
|
||||
RollbackStackFile(stackIdentifier, fileName string) error
|
||||
@@ -1559,7 +1561,7 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.19.1"
|
||||
APIVersion = "2.19.4"
|
||||
// Edition is what this edition of Portainer is called
|
||||
Edition = PortainerCE
|
||||
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
|
||||
@@ -1680,6 +1682,12 @@ const (
|
||||
EdgeStackStatusDeploying
|
||||
// EdgeStackStatusRemoving represents an Edge stack which is being removed
|
||||
EdgeStackStatusRemoving
|
||||
// EdgeStackStatusPausedDeploying represents a paused Edge stack
|
||||
EdgeStackStatusPausedDeploying
|
||||
// EdgeStackStatusRollingBack represents an Edge stack which is being rolled back
|
||||
EdgeStackStatusRollingBack
|
||||
// EdgeStackStatusRolledBack represents an Edge stack which has rolled back
|
||||
EdgeStackStatusRolledBack
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package deployments
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/agent"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/git/update"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
@@ -70,6 +73,10 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
|
||||
return &StackAuthorMissingErr{int(stack.ID), author}
|
||||
}
|
||||
|
||||
if !isEnvironmentOnline(endpoint) {
|
||||
return nil
|
||||
}
|
||||
|
||||
var gitCommitChangedOrForceUpdate bool
|
||||
if !stack.FromAppTemplate {
|
||||
updated, newHash, err := update.UpdateGitObject(gitService, fmt.Sprintf("stack:%d", stackID), stack.GitConfig, false, false, stack.ProjectPath)
|
||||
@@ -129,6 +136,8 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
|
||||
return errors.Errorf("cannot update stack, type %v is unsupported", stack.Type)
|
||||
}
|
||||
|
||||
stack.Status = portainer.StackStatusActive
|
||||
|
||||
if err := datastore.Stack().Update(stack.ID, stack); err != nil {
|
||||
return errors.WithMessagef(err, "failed to update the stack %v", stack.ID)
|
||||
}
|
||||
@@ -160,3 +169,22 @@ func getUserRegistries(datastore dataservices.DataStore, user *portainer.User, e
|
||||
|
||||
return filteredRegistries, nil
|
||||
}
|
||||
|
||||
func isEnvironmentOnline(endpoint *portainer.Endpoint) bool {
|
||||
if endpoint.Type != portainer.AgentOnDockerEnvironment &&
|
||||
endpoint.Type != portainer.AgentOnKubernetesEnvironment {
|
||||
return true
|
||||
}
|
||||
|
||||
var err error
|
||||
var tlsConfig *tls.Config
|
||||
if endpoint.TLSConfig.TLS {
|
||||
tlsConfig, err = crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig.TLSCACertPath, endpoint.TLSConfig.TLSCertPath, endpoint.TLSConfig.TLSKeyPath, endpoint.TLSConfig.TLSSkipVerify)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
_, _, err = agent.GetAgentVersionAndPlatform(endpoint.URL, tlsConfig)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
@@ -1,18 +1,78 @@
|
||||
package deployments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const localhostCert = `-----BEGIN CERTIFICATE-----
|
||||
MIIEOjCCAiKgAwIBAgIRALg8rJET2/9LjKSxHj0dQhYwDQYJKoZIhvcNAQELBQAw
|
||||
FzEVMBMGA1UEAxMMUG9ydGFpbmVyIENBMB4XDTIzMTAxMTE5NDcxMVoXDTI1MDQx
|
||||
MTE5NTM0MVowFDESMBAGA1UEAxMJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
|
||||
AAOCAQ8AMIIBCgKCAQEAx4nNGiwcCqUCxZyVLIHqvjTy20ZtZDVCedssTv1W5tmz
|
||||
YqOIYGaW3CqzlRn6vBHu9bMHXef4+XfS0igKBn76MAKn5IcTccIWIal+5jq48pI3
|
||||
c2FzQ3qNujX2zqZPjAjhJnVeVCP3kJu4wUtuubswLPBVLdktGa6EkL+8nu6o0Phw
|
||||
6scV6s3gUmQk5/lpH4FIff8M7NAdTOxiFImQ1M0vplKtaEeiCnskpgyI8CbZl7X0
|
||||
38Pu178W3+LqB7N4iMy2gKnBwjsXzw/+1dfUGkKjYdDBD+kNEKrQ4dwkjkrkQVdt
|
||||
Z+GN26NvXHoeeyX/MLnVgdLbiIjvsf0DDIhabKqTcwIDAQABo4GDMIGAMA4GA1Ud
|
||||
DwEB/wQEAwIDuDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwHQYDVR0O
|
||||
BBYEFPCefmK5Szzlfs8FRCa5+kRCIEWuMB8GA1UdIwQYMBaAFKZZ074SR/ajD3zE
|
||||
gxpLGRvFT3XAMA8GA1UdEQQIMAaHBH8AAAEwDQYJKoZIhvcNAQELBQADggIBABcQ
|
||||
/WPSUpuQvrcVBsmIlOMz74cDZYuIDls/mAcB/yP3mm+oWlO0qvH/F/BMs1P/bkgj
|
||||
fByQZq8Zmi6/TEZNlGvW7KGx077VxDKi8jd1jL3gLDPmkFjYuGeIWQusgxBu1y3m
|
||||
0WoTTqnkoism1mzV/dgNwrm3YQIV4H/fi9EEdQSm0UFRTKSAGBkwS7N2pmNb5yQO
|
||||
U8glFpyznCv4evDJbs/JUUXKYExgFFhWUd25P7iBRLXg/BFfqdSTiUGUj/Msz0pO
|
||||
Evqmq78eIiXjyyKSxzve6/mEIeq6AE3AC9zH+fwTd6Mhp+T2P/S/iO4EU19IMR4m
|
||||
sbNBd6h/3GvRekO1KbqQ42awuMnxvWT0NVclSxiU1lMpAmRmk/w9z7wB3r4n7oh4
|
||||
iiOTl5VSw1UBkcLDOJw+HB/FU2PdVFfIJKRfjLCZOGrcJX9vEcz7dYGpB5HrdqOc
|
||||
/8q5j1g6f/pGE+20HITrtz6ChguETzqw5dLNeKeolC6bVH8yEtmpnP2n8VPnT9Di
|
||||
V+hnONcJ+wd/dkBqabGr7LPG24Kj1F2Zp3CDDvJA94FaEsgaLfSg3JD+43uRCOWM
|
||||
RuqU8bGuhQRqilR2dSIOrFaW2+MeUHsb24cUn/pkHqKpSg+RBEnf6QfGDlIgqYEl
|
||||
19f/HFVBc/a8lM/D81lMyDbjQ9zH4LDYj4ipBbkL
|
||||
-----END CERTIFICATE-----`
|
||||
|
||||
const localhostKey = `-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEpAIBAAKCAQEAx4nNGiwcCqUCxZyVLIHqvjTy20ZtZDVCedssTv1W5tmzYqOI
|
||||
YGaW3CqzlRn6vBHu9bMHXef4+XfS0igKBn76MAKn5IcTccIWIal+5jq48pI3c2Fz
|
||||
Q3qNujX2zqZPjAjhJnVeVCP3kJu4wUtuubswLPBVLdktGa6EkL+8nu6o0Phw6scV
|
||||
6s3gUmQk5/lpH4FIff8M7NAdTOxiFImQ1M0vplKtaEeiCnskpgyI8CbZl7X038Pu
|
||||
178W3+LqB7N4iMy2gKnBwjsXzw/+1dfUGkKjYdDBD+kNEKrQ4dwkjkrkQVdtZ+GN
|
||||
26NvXHoeeyX/MLnVgdLbiIjvsf0DDIhabKqTcwIDAQABAoIBAQCqSP6BPG195A52
|
||||
iEeCISksw9ERsou+fflKNvIcQvV7swP0xOyooERUhhiVwQMKpx9QDUXXLRV8CHch
|
||||
JExR+OEYQdv4GhJM/b6XYafLYQfe80thKyQLzTXQWSdUeffe4OEMShODKOKoRUyp
|
||||
oO9Qj9/wKfX3V6S2iwnU4dxdofztv+YP9rYQyjnhKbv/9OfeCp2Pb9eFKKRsA+QQ
|
||||
xneDz1+wr8ToTuiTn8HBPNSeSAKvhzXuzyluI7VAetRloNgCtumrA9kpVbW2cDgE
|
||||
Gk0q3RY125ejFELQO/cOJFuBsqoJlvPxzg8/vHyfyF9hFMqbqvcUw2e1eqHpnJd5
|
||||
dP4+ZGYZAoGBAOOFuPXMLBts0rN9mfNbVfx36H+aOCL77SafZvWm0D+rH69QN3/q
|
||||
/ZSWQEjwH5Tzn1e+NVcl/Um2vL/dIyEGBklXQ7yAyJo25gpEOD/rt1U94HKzMOwy
|
||||
yKtsKghRAOx0piie7ORS6MGbEOQxU3/1Eg1uvd0qoSnALqJ/le75QpFXAoGBAOCD
|
||||
aZQTszzDddr1cFPzLyqjIGJWfPcDYSONXVcCeQmhvC7mkfw9SWdIfku7JbdNgFYq
|
||||
ZAAU0klsLX0lEe8f4A12FnHNylKoxmTWdE3wWPptejdA1KUgzt/2kNljgOMFuY0Q
|
||||
rlCEW/Jabrg5aFMwVVG8bHLZR0xalfniDvXLvnFFAoGACdztJLKiIto31BIYz2Th
|
||||
OF2WVZnA3ztej3MPioydsHThnb7zePcd4QgWZ1MJe3KIMMyNEWcTMNPcINEcSb0y
|
||||
HpHK3OwURiMlG8LTUWoNe4OALFi6QTL+YfgBZnTkflucLFyfVlKFxobLV6kPvpdI
|
||||
Hg7z6heD/wRWwTKYtFBX42cCgYBIeoQJ9rYlRqB0eEm0AEzYweLBfFRJVgD0/j0E
|
||||
ytqSPnFG3s6AFLTur9t9zUPmwhFNP9Aaqp4cb9zbiq0YejzVe6rRQHMxbiTmBslz
|
||||
I8VFyzPqRHahfE7sxGeMlm/UWlPFc34ipigcvA8EUBwaxv60LVUBWp2Gy7OhANZ9
|
||||
iTHI1QKBgQCdHFj9dnbpaEHA426CoaPsyj5cv2nBLRf8p1cs71sq+qQOGlGJfajm
|
||||
L9x22ol5c5rToZa1qKSnSdSDCud298MyRujMUy2UcUKHeNs3MK9AT41sDv266I7b
|
||||
vJUUCFYm8+9p6gTVOcoMit+eGSwa81PCPEs1TnU1PV/PaDFeUhn/mg==
|
||||
-----END RSA PRIVATE KEY-----`
|
||||
|
||||
type noopDeployer struct{}
|
||||
|
||||
// without unpacker
|
||||
@@ -54,6 +114,42 @@ func (s *noopDeployer) StopRemoteSwarmStack(stack *portainer.Stack, endpoint *po
|
||||
return nil
|
||||
}
|
||||
|
||||
func agentServer(t *testing.T) string {
|
||||
h := http.NewServeMux()
|
||||
|
||||
h.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set(portainer.PortainerAgentHeader, "v2.19.0")
|
||||
w.Header().Set(portainer.HTTPResponseAgentPlatform, strconv.Itoa(int(portainer.AgentPlatformDocker)))
|
||||
|
||||
response.Empty(w)
|
||||
})
|
||||
|
||||
cert, err := tls.X509KeyPair([]byte(localhostCert), []byte(localhostKey))
|
||||
require.NoError(t, err)
|
||||
|
||||
tlsConfig := &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
}
|
||||
|
||||
l, err := tls.Listen("tcp", "127.0.0.1:0", tlsConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
s := &http.Server{
|
||||
Handler: h,
|
||||
}
|
||||
|
||||
go func() {
|
||||
err := s.Serve(l)
|
||||
require.ErrorIs(t, err, http.ErrServerClosed)
|
||||
}()
|
||||
|
||||
t.Cleanup(func() {
|
||||
s.Shutdown(context.Background())
|
||||
})
|
||||
|
||||
return "http://" + l.Addr().String()
|
||||
}
|
||||
|
||||
func Test_redeployWhenChanged_FailsWhenCannotFindStack(t *testing.T) {
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
|
||||
@@ -114,7 +210,12 @@ func Test_redeployWhenChanged_FailsWhenCannotClone(t *testing.T) {
|
||||
assert.NoError(t, err, "error creating an admin")
|
||||
|
||||
err = store.Endpoint().Create(&portainer.Endpoint{
|
||||
ID: 0,
|
||||
ID: 0,
|
||||
URL: agentServer(t),
|
||||
TLSConfig: portainer.TLSConfiguration{
|
||||
TLS: true,
|
||||
TLSSkipVerify: true,
|
||||
},
|
||||
})
|
||||
assert.NoError(t, err, "error creating environment")
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
|
||||
--orange-1: #e86925;
|
||||
|
||||
--BE-only: var(--ui-warning-7);
|
||||
--BE-only: var(--ui-gray-6);
|
||||
|
||||
--text-log-viewer-color-json-grey: var(--text-log-viewer-color);
|
||||
--text-log-viewer-color-json-magenta: var(--text-log-viewer-color);
|
||||
@@ -259,8 +259,7 @@
|
||||
|
||||
/* Dark Theme */
|
||||
[theme='dark'] {
|
||||
--BE-only: var(--ui-blue-8);
|
||||
--bg-BE-only: rgba(225, 223, 223, 0.08);
|
||||
--BE-only: var(--ui-gray-6);
|
||||
|
||||
--text-log-viewer-color-json-grey: var(--text-log-viewer-color);
|
||||
--text-log-viewer-color-json-magenta: var(--text-log-viewer-color);
|
||||
@@ -434,6 +433,7 @@
|
||||
|
||||
/* High Contrast Theme */
|
||||
[theme='highcontrast'] {
|
||||
--BE-only: var(--ui-gray-6);
|
||||
--text-log-viewer-color-json-grey: var(--text-log-viewer-color);
|
||||
--text-log-viewer-color-json-magenta: var(--text-log-viewer-color);
|
||||
--text-log-viewer-color-json-yellow: var(--text-log-viewer-color);
|
||||
|
||||
@@ -7,10 +7,8 @@ angular.module('portainer.docker').factory('ConfigHelper', [
|
||||
return {
|
||||
Id: config.ConfigID,
|
||||
Name: config.ConfigName,
|
||||
FileName: config.File.Name,
|
||||
Uid: config.File.UID,
|
||||
Gid: config.File.GID,
|
||||
Mode: config.File.Mode,
|
||||
...(config.File ? { FileName: config.File.Name, Uid: config.File.UID, Gid: config.File.GID, Mode: config.File.Mode } : {}),
|
||||
credSpec: !!config.Runtime,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
@@ -20,12 +18,15 @@ angular.module('portainer.docker').factory('ConfigHelper', [
|
||||
return {
|
||||
ConfigID: config.Id,
|
||||
ConfigName: config.Name,
|
||||
File: {
|
||||
Name: config.FileName || config.Name,
|
||||
UID: config.Uid || '0',
|
||||
GID: config.Gid || '0',
|
||||
Mode: config.Mode || 292,
|
||||
},
|
||||
File: config.credSpec
|
||||
? null
|
||||
: {
|
||||
Name: config.FileName || config.Name,
|
||||
UID: config.Uid || '0',
|
||||
GID: config.Gid || '0',
|
||||
Mode: config.Mode || 292,
|
||||
},
|
||||
Runtime: config.credSpec ? {} : null,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
|
||||
@@ -66,7 +66,6 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
|
||||
}
|
||||
|
||||
const params = {
|
||||
token: LocalStorage.getJWT(),
|
||||
endpointId: $state.params.endpointId,
|
||||
id: attachId,
|
||||
};
|
||||
@@ -107,7 +106,6 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
|
||||
ContainerService.createExec(execConfig)
|
||||
.then(function success(data) {
|
||||
const params = {
|
||||
token: LocalStorage.getJWT(),
|
||||
endpointId: $state.params.endpointId,
|
||||
id: data.Id,
|
||||
};
|
||||
@@ -166,6 +164,9 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
|
||||
if ($transition$.params().nodeName) {
|
||||
url += '&nodeName=' + $transition$.params().nodeName;
|
||||
}
|
||||
|
||||
url += '&token=' + LocalStorage.getJWT();
|
||||
|
||||
if (url.indexOf('https') > -1) {
|
||||
url = url.replace('https://', 'wss://');
|
||||
} else {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<rd-widget-body classes="no-padding">
|
||||
<div class="form-inline" style="padding: 10px" authorization="DockerServiceUpdate">
|
||||
Add a config:
|
||||
<select class="form-control !h-[30px] !text-[13px]" ng-options="config.Name for config in configs | orderBy: 'Name'" ng-model="newConfig">
|
||||
<select class="form-control !h-[30px] !text-[13px]" ng-options="config.Name for config in filterConfigs(configs) | orderBy: 'Name'" ng-model="newConfig">
|
||||
<option selected disabled hidden value="">Select a config</option>
|
||||
</select>
|
||||
<a class="btn btn-default btn-sm" ng-click="addConfig(service, newConfig)"> <pr-icon icon="'plus'"></pr-icon> add config </a>
|
||||
@@ -22,10 +22,10 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr ng-repeat="config in service.ServiceConfigs">
|
||||
<td
|
||||
><a ui-sref="docker.configs.config({id: config.Id})">{{ config.Name }}</a></td
|
||||
>
|
||||
<td>
|
||||
<a ui-sref="docker.configs.config({id: config.Id})">{{ config.Name }}</a>
|
||||
</td>
|
||||
<td ng-if="!config.credSpec">
|
||||
<input
|
||||
class="form-control"
|
||||
ng-model="config.FileName"
|
||||
@@ -33,11 +33,13 @@
|
||||
placeholder="e.g. /path/in/container"
|
||||
required
|
||||
disable-authorization="DockerServiceUpdate"
|
||||
ng-disabled="config.credSpec"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ config.Uid }}</td>
|
||||
<td>{{ config.Gid }}</td>
|
||||
<td>{{ config.Mode }}</td>
|
||||
<td ng-if="!config.credSpec">{{ config.Uid }}</td>
|
||||
<td ng-if="!config.credSpec">{{ config.Gid }}</td>
|
||||
<td ng-if="!config.credSpec">{{ config.Mode }}</td>
|
||||
<td ng-if="config.credSpec" colspan="4">Credential Spec</td>
|
||||
<td authorization="DockerServiceUpdate">
|
||||
<button class="btn btn-dangerlight pull-right" type="button" ng-click="removeConfig(service, $index)" ng-disabled="isUpdating">
|
||||
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
|
||||
|
||||
@@ -91,6 +91,7 @@ angular.module('portainer.docker').controller('ServiceController', [
|
||||
endpoint
|
||||
) {
|
||||
$scope.resourceType = ResourceControlType.Service;
|
||||
$scope.WebhookExists = false;
|
||||
|
||||
$scope.onUpdateResourceControlSuccess = function () {
|
||||
$state.reload();
|
||||
@@ -462,6 +463,27 @@ angular.module('portainer.docker').controller('ServiceController', [
|
||||
|
||||
config.TaskTemplate.ContainerSpec.Secrets = service.ServiceSecrets ? service.ServiceSecrets.map(SecretHelper.secretConfig) : [];
|
||||
config.TaskTemplate.ContainerSpec.Configs = service.ServiceConfigs ? service.ServiceConfigs.map(ConfigHelper.configConfig) : [];
|
||||
|
||||
// support removal and (future) editing of credential specs
|
||||
const credSpec = service.ServiceConfigs.find((config) => config.credSpec);
|
||||
const credSpecId = credSpec ? credSpec.Id : '';
|
||||
const oldCredSpecId =
|
||||
(config.TaskTemplate.ContainerSpec.Privileges &&
|
||||
config.TaskTemplate.ContainerSpec.Privileges.CredentialSpec &&
|
||||
config.TaskTemplate.ContainerSpec.Privileges.CredentialSpec.Config) ||
|
||||
'';
|
||||
if (oldCredSpecId && !credSpecId) {
|
||||
delete config.TaskTemplate.ContainerSpec.Privileges.CredentialSpec;
|
||||
} else if (credSpec && oldCredSpecId !== credSpec) {
|
||||
config.TaskTemplate.ContainerSpec.Privileges = {
|
||||
...(config.TaskTemplate.ContainerSpec.Privileges || {}),
|
||||
CredentialSpec: {
|
||||
...((config.TaskTemplate.ContainerSpec.Privileges && config.TaskTemplate.ContainerSpec.Privileges.CredentialSpec) || {}),
|
||||
Config: credSpec,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
config.TaskTemplate.ContainerSpec.Hosts = service.Hosts ? ServiceHelper.translateHostnameIPToHostsEntries(service.Hosts) : [];
|
||||
|
||||
if (service.Mode === 'replicated') {
|
||||
@@ -582,8 +604,7 @@ angular.module('portainer.docker').controller('ServiceController', [
|
||||
}
|
||||
|
||||
$scope.updateService = function updateService(service) {
|
||||
let config = {};
|
||||
service, (config = buildChanges(service));
|
||||
const config = buildChanges(service);
|
||||
ServiceService.update(service, config).then(
|
||||
function (data) {
|
||||
if (data.message && data.message.match(/^rpc error:/)) {
|
||||
@@ -735,7 +756,6 @@ angular.module('portainer.docker').controller('ServiceController', [
|
||||
$scope.isAdmin = Authentication.isAdmin();
|
||||
$scope.availableNetworks = data.availableNetworks;
|
||||
$scope.swarmNetworks = _.filter($scope.availableNetworks, (network) => network.Scope === 'swarm');
|
||||
$scope.WebhookExists = false;
|
||||
|
||||
const serviceNetworks = _.uniqBy(_.concat($scope.service.Model.Spec.Networks || [], $scope.service.Model.Spec.TaskTemplate.Networks || []), 'Target');
|
||||
const networks = _.filter(
|
||||
@@ -832,6 +852,15 @@ angular.module('portainer.docker').controller('ServiceController', [
|
||||
return networks.filter((network) => !network.Ingress && (network.Id === current.Id || $scope.service.Networks.every((serviceNetwork) => network.Id !== serviceNetwork.Id)));
|
||||
}
|
||||
|
||||
$scope.filterConfigs = filterConfigs;
|
||||
function filterConfigs(configs) {
|
||||
if (!configs) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return configs.filter((config) => $scope.service.ServiceConfigs.every((serviceConfig) => config.Id !== serviceConfig.Id));
|
||||
}
|
||||
|
||||
function updateServiceArray(service, name) {
|
||||
previousServiceValues.push(name);
|
||||
service.hasChanges = true;
|
||||
|
||||
@@ -1302,16 +1302,18 @@
|
||||
</div>
|
||||
|
||||
<!-- kubernetes services options -->
|
||||
<kube-services-form
|
||||
on-change="(ctrl.onServicesChange)"
|
||||
values="ctrl.formValues.Services"
|
||||
load-balancer-enabled="ctrl.publishViaLoadBalancerEnabled()"
|
||||
app-name="ctrl.formValues.Name"
|
||||
selector="ctrl.formValues.Selector"
|
||||
validation-data="{nodePortServices: ctrl.state.nodePortServices, formServices: ctrl.formValues.Services, ingressPaths: ctrl.ingressPaths, originalIngressPaths: ctrl.originalIngressPaths}"
|
||||
is-edit-mode="ctrl.state.isEdit"
|
||||
namespace="ctrl.formValues.ResourcePool.Namespace.Name"
|
||||
></kube-services-form>
|
||||
<div ng-if="ctrl.formValues.ResourcePool">
|
||||
<kube-services-form
|
||||
on-change="(ctrl.onServicesChange)"
|
||||
values="ctrl.formValues.Services"
|
||||
load-balancer-enabled="ctrl.publishViaLoadBalancerEnabled()"
|
||||
app-name="ctrl.formValues.Name"
|
||||
selector="ctrl.formValues.Selector"
|
||||
validation-data="{nodePortServices: ctrl.state.nodePortServices, formServices: ctrl.formValues.Services, ingressPaths: ctrl.ingressPaths, originalIngressPaths: ctrl.originalIngressPaths}"
|
||||
is-edit-mode="ctrl.state.isEdit"
|
||||
namespace="ctrl.formValues.ResourcePool.Namespace.Name"
|
||||
></kube-services-form>
|
||||
</div>
|
||||
<!-- kubernetes services options -->
|
||||
|
||||
<!-- summary -->
|
||||
@@ -1353,15 +1355,17 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- kubernetes services options -->
|
||||
<kube-services-form
|
||||
on-change="(ctrl.onServicesChange)"
|
||||
values="ctrl.formValues.Services"
|
||||
app-name="ctrl.formValues.Name"
|
||||
selector="ctrl.formValues.Selector"
|
||||
validation-data="{nodePortServices: ctrl.state.nodePortServices, formServices: ctrl.formValues.Services, ingressPaths: ctrl.ingressPaths, originalIngressPaths: ctrl.originalIngressPaths}"
|
||||
is-edit-mode="ctrl.state.isEdit"
|
||||
namespace="ctrl.formValues.ResourcePool.Namespace.Name"
|
||||
></kube-services-form>
|
||||
<div ng-if="ctrl.formValues.ResourcePool">
|
||||
<kube-services-form
|
||||
on-change="(ctrl.onServicesChange)"
|
||||
values="ctrl.formValues.Services"
|
||||
app-name="ctrl.formValues.Name"
|
||||
selector="ctrl.formValues.Selector"
|
||||
validation-data="{nodePortServices: ctrl.state.nodePortServices, formServices: ctrl.formValues.Services, ingressPaths: ctrl.ingressPaths, originalIngressPaths: ctrl.originalIngressPaths}"
|
||||
is-edit-mode="ctrl.state.isEdit"
|
||||
namespace="ctrl.formValues.ResourcePool.Namespace.Name"
|
||||
></kube-services-form>
|
||||
</div>
|
||||
<!-- kubernetes services options -->
|
||||
</div>
|
||||
|
||||
@@ -1376,7 +1380,7 @@
|
||||
ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.APPLICATION_FORM"
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm !ml-0"
|
||||
ng-disabled="!kubernetesApplicationCreationForm.$valid || ctrl.isDeployUpdateButtonDisabled() || !ctrl.imageValidityIsValid() || ctrl.hasPortErrors()"
|
||||
ng-disabled="!kubernetesApplicationCreationForm.$valid || ctrl.isDeployUpdateButtonDisabled() || !ctrl.imageValidityIsValid() || ctrl.hasPortErrors() || !ctrl.formValues.ResourcePool"
|
||||
ng-click="ctrl.deployApplication()"
|
||||
button-spinner="ctrl.state.actionInProgress"
|
||||
data-cy="k8sAppCreate-deployButton"
|
||||
|
||||
@@ -12,18 +12,33 @@ import { reactModule } from './react';
|
||||
import { sidebarModule } from './react/views/sidebar';
|
||||
import environmentsModule from './environments';
|
||||
import { helpersModule } from './helpers';
|
||||
import { AXIOS_UNAUTHENTICATED } from './services/axios';
|
||||
|
||||
async function initAuthentication(authManager, Authentication, $rootScope, $state) {
|
||||
authManager.checkAuthOnRefresh();
|
||||
|
||||
function handleUnauthenticated(data, performReload) {
|
||||
if (!_.includes(data.config.url, '/v2/') && !_.includes(data.config.url, '/api/v4/') && isTransitionRequiresAuthentication($state.transition)) {
|
||||
$state.go('portainer.logout', { error: 'Your session has expired' });
|
||||
if (performReload) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The unauthenticated event is broadcasted by the jwtInterceptor when
|
||||
// hitting a 401. We're using this instead of the usual combination of
|
||||
// authManager.redirectWhenUnauthenticated() + unauthenticatedRedirector
|
||||
// to have more controls on which URL should trigger the unauthenticated state.
|
||||
$rootScope.$on('unauthenticated', function (event, data) {
|
||||
if (!_.includes(data.config.url, '/v2/') && !_.includes(data.config.url, '/api/v4/') && isTransitionRequiresAuthentication($state.transition)) {
|
||||
$state.go('portainer.logout', { error: 'Your session has expired' });
|
||||
window.location.reload();
|
||||
}
|
||||
handleUnauthenticated(data, true);
|
||||
});
|
||||
|
||||
// the AXIOS_UNAUTHENTICATED event is emitted by axios when a request returns with a 401 code
|
||||
// the event contains the entire AxiosError in detail.err
|
||||
window.addEventListener(AXIOS_UNAUTHENTICATED, (event) => {
|
||||
const data = event.detail.err;
|
||||
handleUnauthenticated(data);
|
||||
});
|
||||
|
||||
return await Authentication.init();
|
||||
@@ -154,7 +169,6 @@ angular
|
||||
url: '/logout',
|
||||
params: {
|
||||
error: '',
|
||||
performApiLogout: false,
|
||||
},
|
||||
views: {
|
||||
'content@': {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<a class="vertical-center be-indicator ml-5" href="{{ $ctrl.url }}" target="_blank" rel="noopener" ng-if="$ctrl.limitedToBE">
|
||||
<ng-transclude></ng-transclude>
|
||||
<pr-icon icon="'briefcase'" class-name="'space-right be-indicator-icon'"></pr-icon>
|
||||
<span class="be-indicator-label">Business Edition Feature</span>
|
||||
<span class="be-indicator-label">Business Feature</span>
|
||||
</a>
|
||||
|
||||
@@ -76,7 +76,7 @@ class PorAccessManagementController {
|
||||
}
|
||||
|
||||
if (this.isRoleLimitedToBE(role)) {
|
||||
return `${role.Name} (Business Edition Feature)`;
|
||||
return `${role.Name} (Business Feature)`;
|
||||
}
|
||||
|
||||
return `${role.Name} (Default)`;
|
||||
|
||||
@@ -169,68 +169,46 @@
|
||||
|
||||
<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 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">
|
||||
{{ $ctrl.state.provider == 'microsoft' ? 'Application ID' : 'Client ID' }}
|
||||
<portainer-tooltip message="'Public identifier of the OAuth application'"></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<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">
|
||||
{{ $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"
|
||||
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 ng-if="$ctrl.state.provider == 'custom' || $ctrl.state.overrideConfiguration">
|
||||
<div class="col-sm-12 form-section-title">OAuth Configuration</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="oauth_client_id" class="col-sm-3 col-lg-2 control-label text-left">
|
||||
{{ $ctrl.state.provider == 'microsoft' ? 'Application ID' : 'Client ID' }}
|
||||
<portainer-tooltip message="'Public identifier of the OAuth application'"></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<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">
|
||||
{{ $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"
|
||||
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
|
||||
@@ -363,26 +341,100 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<save-auth-settings-button
|
||||
on-save-settings="($ctrl.onSaveSettings)"
|
||||
save-button-state="($ctrl.saveButtonState)"
|
||||
save-button-disabled="!$ctrl.isOAuthTeamMembershipFormValid() || oauthSettingsForm.$invalid"
|
||||
limited-feature-id="$ctrl.limitedFeature"
|
||||
limited-feature-class="$ctrl.limitedFeatureClass"
|
||||
class-name="'oauth-save-settings-button'"
|
||||
></save-auth-settings-button>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-if="$ctrl.state.provider != 'custom'">
|
||||
<div class="col-sm-12">
|
||||
<a class="small interactive vertical-center" ng-if="!$ctrl.state.overrideConfiguration" ng-click="$ctrl.state.overrideConfiguration = true;">
|
||||
<pr-icon icon="'wrench'"></pr-icon>
|
||||
Override default configuration
|
||||
</a>
|
||||
<a class="small interactive vertical-center" ng-if="$ctrl.state.overrideConfiguration" ng-click="$ctrl.useDefaultProviderConfiguration($ctrl.state.provider)">
|
||||
<pr-icon icon="'settings'"></pr-icon>
|
||||
Use default configuration
|
||||
</a>
|
||||
<div ng-if="$ctrl.state.provider != 'custom'" class="limited-be be-indicator-container">
|
||||
<div class="limited-be-link vertical-center"><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator></div>
|
||||
<div class="limited-be-content">
|
||||
<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 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">
|
||||
{{ $ctrl.state.provider == 'microsoft' ? 'Application ID' : 'Client ID' }}
|
||||
<portainer-tooltip message="'Public identifier of the OAuth application'"></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<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">
|
||||
{{ $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"
|
||||
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">
|
||||
<div class="col-sm-12">
|
||||
<a class="small interactive vertical-center" ng-if="!$ctrl.state.overrideConfiguration" ng-click="$ctrl.state.overrideConfiguration = true;">
|
||||
<pr-icon icon="'wrench'"></pr-icon>
|
||||
Override default configuration
|
||||
</a>
|
||||
<a class="small interactive vertical-center" ng-if="$ctrl.state.overrideConfiguration" ng-click="$ctrl.useDefaultProviderConfiguration($ctrl.state.provider)">
|
||||
<pr-icon icon="'settings'"></pr-icon>
|
||||
Use default configuration
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<save-auth-settings-button
|
||||
on-save-settings="($ctrl.onSaveSettings)"
|
||||
save-button-state="($ctrl.saveButtonState)"
|
||||
save-button-disabled="!$ctrl.isOAuthTeamMembershipFormValid() || oauthSettingsForm.$invalid"
|
||||
limited-feature-id="$ctrl.limitedFeature"
|
||||
limited-feature-class="$ctrl.limitedFeatureClass"
|
||||
class-name="'oauth-save-settings-button'"
|
||||
></save-auth-settings-button>
|
||||
</div>
|
||||
</div>
|
||||
<save-auth-settings-button
|
||||
on-save-settings="($ctrl.onSaveSettings)"
|
||||
save-button-state="($ctrl.saveButtonState)"
|
||||
save-button-disabled="!$ctrl.isOAuthTeamMembershipFormValid() || oauthSettingsForm.$invalid"
|
||||
limited-feature-id="$ctrl.limitedFeature"
|
||||
limited-feature-class="$ctrl.limitedFeatureClass"
|
||||
class-name="'oauth-save-settings-button'"
|
||||
></save-auth-settings-button>
|
||||
</ng-form>
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
<div class="col-sm-12" style="margin-bottom: 0px">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="user-x">
|
||||
<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>
|
||||
<div class="be-indicator-container limited-be">
|
||||
<div class="limited-be-link vertical-center"><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator></div>
|
||||
<div class="limited-be-content">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="user-x">
|
||||
<header-title> Effective access viewer </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>
|
||||
|
||||
<por-select ng-if="$ctrl.users.length > 0" value="$ctrl.selectedUserId" options="$ctrl.users" on-change="($ctrl.onUserSelect)" placeholder="'Select a user'">
|
||||
</por-select>
|
||||
</div>
|
||||
</div>
|
||||
<access-viewer-datatable table-key="access_viewer" dataset="$ctrl.userRoles" order-by="EndpointName" is-admin="$ctrl.isAdmin"> </access-viewer-datatable>
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
<por-select ng-if="$ctrl.users.length > 0" value="$ctrl.selectedUserId" options="$ctrl.users" on-change="($ctrl.onUserSelect)" placeholder="'Select a user'">
|
||||
</por-select>
|
||||
</div>
|
||||
</div>
|
||||
<access-viewer-datatable table-key="access_viewer" dataset="$ctrl.userRoles" order-by="EndpointName" is-admin="$ctrl.isAdmin"> </access-viewer-datatable>
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,7 +71,6 @@ export const ngModule = angular
|
||||
'message',
|
||||
'buttonText',
|
||||
'className',
|
||||
'icon',
|
||||
'buttonClassName',
|
||||
])
|
||||
)
|
||||
@@ -82,7 +81,7 @@ export const ngModule = angular
|
||||
|
||||
.component(
|
||||
'portainerTooltip',
|
||||
r2a(Tooltip, ['message', 'position', 'className', 'setHtmlMessage'])
|
||||
r2a(Tooltip, ['message', 'position', 'className', 'setHtmlMessage', 'size'])
|
||||
)
|
||||
.component('badge', r2a(Badge, ['type', 'className']))
|
||||
.component('fileUploadField', fileUploadField)
|
||||
|
||||
@@ -40,8 +40,8 @@ angular.module('portainer.app').factory('Authentication', [
|
||||
}
|
||||
}
|
||||
|
||||
async function logoutAsync(performApiLogout) {
|
||||
if (performApiLogout) {
|
||||
async function logoutAsync() {
|
||||
if (isAuthenticated()) {
|
||||
await Auth.logout().$promise;
|
||||
}
|
||||
|
||||
@@ -53,8 +53,8 @@ angular.module('portainer.app').factory('Authentication', [
|
||||
tryAutoLoginExtension();
|
||||
}
|
||||
|
||||
function logout(performApiLogout) {
|
||||
return $async(logoutAsync, performApiLogout);
|
||||
function logout() {
|
||||
return $async(logoutAsync);
|
||||
}
|
||||
|
||||
function init() {
|
||||
|
||||
@@ -49,6 +49,8 @@ export function agentInterceptor(config: AxiosRequestConfig) {
|
||||
|
||||
axios.interceptors.request.use(agentInterceptor);
|
||||
|
||||
export const AXIOS_UNAUTHENTICATED = '__axios__unauthenticated__';
|
||||
|
||||
/**
|
||||
* Parses an Axios error and returns a PortainerError.
|
||||
* @param err The original error.
|
||||
@@ -72,6 +74,16 @@ export function parseAxiosError(
|
||||
} else {
|
||||
resultMsg = msg || details;
|
||||
}
|
||||
// dispatch an event for unauthorized errors that AngularJS can catch
|
||||
if (err.response?.status === 401) {
|
||||
dispatchEvent(
|
||||
new CustomEvent(AXIOS_UNAUTHENTICATED, {
|
||||
detail: {
|
||||
err,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return new PortainerError(resultMsg, resultErr);
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
<ng-form class="ad-settings" limited-feature-dir="{{::$ctrl.limitedFeatureId}}" limited-feature-class="limited-be">
|
||||
<div class="overlay">
|
||||
<div class="limited-be-link vertical-center"
|
||||
><be-feature-indicator feature="$ctrl.limitedFeatureId"></be-feature-indicator
|
||||
><portainer-tooltip message="'This feature is currently limited to Business Edition users only. '"></portainer-tooltip
|
||||
></div>
|
||||
<div class="be-indicator-container">
|
||||
<div class="limited-be-link vertical-center"><be-feature-indicator feature="$ctrl.limitedFeatureId"></be-feature-indicator></div>
|
||||
<div class="limited-be-content">
|
||||
<auto-user-provision-toggle ng-model="$ctrl.settings.AutoCreateUsers">
|
||||
<field-description>
|
||||
|
||||
+114
-118
@@ -1,131 +1,127 @@
|
||||
<div class="col-sm-12 form-section-title ldap-custom-admin-group-title" ng-style="$ctrl.isLimitedFeatureSelfContained && { 'padding-bottom': '15px' }">
|
||||
<div class="col-sm-12 form-section-title flex items-center" style="float: initial">
|
||||
Auto-populate team admins <be-feature-indicator feature="$ctrl.limitedFeatureId" class="space-left" ng-if="$ctrl.isLimitedFeatureSelfContained"></be-feature-indicator>
|
||||
</div>
|
||||
|
||||
<rd-widget ng-repeat="config in $ctrl.settings.AdminGroupSearchSettings | limitTo: (1 - $ctrl.settings.AdminGroupSearchSettings)">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<div class="form-group mb-3" ng-if="$index > 0">
|
||||
<span class="col-sm-12 text-muted small"> Extra search configuration </span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ldap_admin_group_basedn_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
|
||||
Group Base DN
|
||||
<portainer-tooltip message="'The distinguished name of the element from which the LDAP server will search for groups.'"></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-8 col-md-4">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="ldap_admin_group_basedn_{{ $index }}"
|
||||
ng-model="config.GroupBaseDN"
|
||||
placeholder="dc=ldap,dc=domain,dc=tld"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
|
||||
ng-disabled="{{ $ctrl.isLimitedFeatureSelfContained }}"
|
||||
limited-feature-tabindex="-1"
|
||||
/>
|
||||
<div class="w-full pt-3" ng-repeat="config in $ctrl.settings.AdminGroupSearchSettings | limitTo: (1 - $ctrl.settings.AdminGroupSearchSettings)">
|
||||
<div class="form-group mb-3" ng-if="$index > 0">
|
||||
<span class="col-sm-12 text-muted small"> Extra search configuration </span>
|
||||
</div>
|
||||
|
||||
<label for="ldap_admin_group_att_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
|
||||
Group Membership Attribute
|
||||
<portainer-tooltip message="'LDAP attribute which denotes the group membership.'"></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-8 col-md-4">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="ldap_admin_group_att_{{ $index }}"
|
||||
ng-model="config.GroupAttribute"
|
||||
placeholder="member"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
|
||||
ng-disabled="{{ $ctrl.isLimitedFeatureSelfContained }}"
|
||||
limited-feature-tabindex="-1"
|
||||
/>
|
||||
<div class="form-group">
|
||||
<label for="ldap_admin_group_basedn_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
|
||||
Group Base DN
|
||||
<portainer-tooltip message="'The distinguished name of the element from which the LDAP server will search for groups.'"></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-8 col-md-4">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="ldap_admin_group_basedn_{{ $index }}"
|
||||
ng-model="config.GroupBaseDN"
|
||||
placeholder="dc=ldap,dc=domain,dc=tld"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
|
||||
ng-disabled="{{ $ctrl.isLimitedFeatureSelfContained }}"
|
||||
limited-feature-tabindex="-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label for="ldap_admin_group_att_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
|
||||
Group Membership Attribute
|
||||
<portainer-tooltip message="'LDAP attribute which denotes the group membership.'"></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-8 col-md-4">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="ldap_admin_group_att_{{ $index }}"
|
||||
ng-model="config.GroupAttribute"
|
||||
placeholder="member"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
|
||||
ng-disabled="{{ $ctrl.isLimitedFeatureSelfContained }}"
|
||||
limited-feature-tabindex="-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ldap_admin_group_filter_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
|
||||
Group Filter
|
||||
<portainer-tooltip message="'The LDAP search filter used to select group elements, optional.'"></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-8 col-md-10 vertical-center">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="ldap_admin_group_filter_{{ $index }}"
|
||||
ng-model="config.GroupFilter"
|
||||
placeholder="(objectClass=groupOfNames)"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
|
||||
ng-disabled="{{ $ctrl.isLimitedFeatureSelfContained }}"
|
||||
limited-feature-tabindex="-1"
|
||||
/>
|
||||
<button class="btn btn-md btn-danger" type="button" ng-click="$ctrl.onRemoveClick($index)" ng-if="$index > 0">
|
||||
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ldap_admin_group_filter_{{ $index }}" class="col-sm-4 col-md-2 control-label text-left">
|
||||
Group Filter
|
||||
<portainer-tooltip message="'The LDAP search filter used to select group elements, optional.'"></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-8 col-md-10 vertical-center">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="ldap_admin_group_filter_{{ $index }}"
|
||||
ng-model="config.GroupFilter"
|
||||
placeholder="(objectClass=groupOfNames)"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
|
||||
ng-disabled="{{ $ctrl.isLimitedFeatureSelfContained }}"
|
||||
limited-feature-tabindex="-1"
|
||||
/>
|
||||
<button class="btn btn-md btn-danger" type="button" ng-click="$ctrl.onRemoveClick($index)" ng-if="$index > 0">
|
||||
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
|
||||
|
||||
<div class="form-group mt-3">
|
||||
<div class="col-sm-12">
|
||||
<button disabled class="btn btn-sm btn-light vertical-center !ml-0" ng-click="$ctrl.onAddClick()">
|
||||
<pr-icon icon="'plus'"></pr-icon>
|
||||
Add group search configuration
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-sm-12 vertical-center mt-2">
|
||||
<button
|
||||
class="btn btm-sm btn-primary !ml-0"
|
||||
type="button"
|
||||
ng-click="$ctrl.search()"
|
||||
limited-feature-dir="{{ $ctrl.limitedFeatureId }}"
|
||||
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
|
||||
ng-disabled="{{ $ctrl.isLimitedFeatureSelfContained }}"
|
||||
limited-feature-tabindex="-1"
|
||||
>
|
||||
Fetch Admin Group(s)
|
||||
</button>
|
||||
<span ng-if="$ctrl.groups && $ctrl.groups.length === 0" class="vertical-center ml-6">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
||||
No groups found
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 vertical-center">
|
||||
<label for="admin-auto-populate" class="control-label text-muted !pt-0 text-left" ng-class="{ 'text-muted': !$ctrl.enableAssignAdminGroup }">
|
||||
Assign admin rights to group(s)
|
||||
</label>
|
||||
<label class="switch my-0 ml-7" ng-class="{ 'business limited': $ctrl.isLimitedFeatureSelfContained }">
|
||||
<input id="admin-auto-populate" ng-disabled="!$ctrl.enableAssignAdminGroup" name="admin-auto-populate" type="checkbox" ng-model="$ctrl.settings.AdminAutoPopulate" />
|
||||
<span class="slider round before:content-['']"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-if="$ctrl.settings.AdminAutoPopulate && $ctrl.groups">
|
||||
<label for="group-access" class="control-label col-sm-2 text-left"> Select Group(s) </label>
|
||||
<div class="col-sm-8">
|
||||
<por-select
|
||||
data-cy="'group-access-selector'"
|
||||
input-id="'group-access'"
|
||||
value="$ctrl.selectedAdminGroups"
|
||||
on-change="($ctrl.onAdminGroupChange)"
|
||||
options="$ctrl.groups"
|
||||
placeholder="'Select one or more groups'"
|
||||
is-multi="true"
|
||||
>
|
||||
</por-select>
|
||||
</div>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
|
||||
<div class="form-group mt-3">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
class="label label-default interactive no-border vertical-center"
|
||||
ng-click="$ctrl.onAddClick()"
|
||||
limited-feature-dir="{{ $ctrl.limitedFeatureId }}"
|
||||
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
|
||||
limited-feature-disabled
|
||||
>
|
||||
<pr-icon icon="'plus-circle'"></pr-icon>
|
||||
add group search configuration
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-sm-12 vertical-center mt-2">
|
||||
<button
|
||||
class="btn btm-sm btn-primary"
|
||||
type="button"
|
||||
ng-click="$ctrl.search()"
|
||||
limited-feature-dir="{{ $ctrl.limitedFeatureId }}"
|
||||
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
|
||||
ng-disabled="{{ $ctrl.isLimitedFeatureSelfContained }}"
|
||||
limited-feature-tabindex="-1"
|
||||
>
|
||||
Fetch Admin Group(s)
|
||||
</button>
|
||||
<span ng-if="$ctrl.groups && $ctrl.groups.length === 0" class="vertical-center ml-6">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
||||
No groups found
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 vertical-center">
|
||||
<label for="admin-auto-populate" class="control-label text-muted !pt-0 text-left" ng-class="{ 'text-muted': !$ctrl.enableAssignAdminGroup }">
|
||||
Assign admin rights to group(s)
|
||||
</label>
|
||||
<label class="switch my-0 ml-7" ng-class="{ 'business limited': $ctrl.isLimitedFeatureSelfContained }">
|
||||
<input id="admin-auto-populate" ng-disabled="!$ctrl.enableAssignAdminGroup" name="admin-auto-populate" type="checkbox" ng-model="$ctrl.settings.AdminAutoPopulate" />
|
||||
<span class="slider round before:content-['']"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-if="$ctrl.settings.AdminAutoPopulate && $ctrl.groups">
|
||||
<label for="group-access" class="control-label col-sm-2 text-left"> Select Group(s) </label>
|
||||
<div class="col-sm-8">
|
||||
<por-select
|
||||
data-cy="'group-access-selector'"
|
||||
input-id="'group-access'"
|
||||
value="$ctrl.selectedAdminGroups"
|
||||
on-change="($ctrl.onAdminGroupChange)"
|
||||
options="$ctrl.groups"
|
||||
placeholder="'Select one or more groups'"
|
||||
is-multi="true"
|
||||
>
|
||||
</por-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+10
-15
@@ -51,24 +51,19 @@
|
||||
|
||||
<div class="form-group" style="margin-top: 10px">
|
||||
<div class="col-sm-12">
|
||||
<button class="label label-default interactive vertical-center" style="border: 0" ng-click="$ctrl.onAddClick()">
|
||||
<pr-icon icon="'plus-circle'"></pr-icon>
|
||||
add group search configuration
|
||||
<button class="btn btn-sm btn-light vertical-center !ml-0" ng-click="$ctrl.onAddClick()">
|
||||
<pr-icon icon="'plus'"></pr-icon>
|
||||
Add group search configuration
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-sm-12" style="margin-top: 10px">
|
||||
<button
|
||||
class="btn btm-sm btn-primary"
|
||||
type="button"
|
||||
ng-click="$ctrl.search()"
|
||||
limited-feature-dir="{{ $ctrl.limitedFeatureId }}"
|
||||
limited-feature-tabindex="-1"
|
||||
limited-feature-disabled
|
||||
limited-feature-class="limited-be"
|
||||
>
|
||||
Display User/Group matching
|
||||
</button>
|
||||
<be-feature-indicator feature="$ctrl.limitedFeatureId" class="space-left"></be-feature-indicator>
|
||||
<be-teaser-button
|
||||
feature-id="$ctrl.limitedFeatureId"
|
||||
heading="'Display User/Group matching'"
|
||||
message="'Show the list of users and groups that match the Portainer search configurations.'"
|
||||
button-text="'Display User/Group matching'"
|
||||
button-class-name="'!ml-0'"
|
||||
></be-teaser-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+10
-15
@@ -40,24 +40,19 @@
|
||||
|
||||
<div class="form-group" style="margin-top: 10px">
|
||||
<div class="col-sm-12">
|
||||
<button class="label label-default interactive vertical-center" style="border: 0" ng-click="$ctrl.onAddClick()">
|
||||
<pr-icon icon="'plus-circle'"></pr-icon>
|
||||
add user search configuration
|
||||
<button class="btn btn-sm btn-light vertical-center !ml-0" ng-click="$ctrl.onAddClick()">
|
||||
<pr-icon icon="'plus'"></pr-icon>
|
||||
Add user search configuration
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-sm-12" style="margin-top: 10px">
|
||||
<button
|
||||
class="btn btm-sm btn-primary"
|
||||
type="button"
|
||||
ng-click="$ctrl.search()"
|
||||
limited-feature-dir="{{ $ctrl.limitedFeatureId }}"
|
||||
limited-feature-disabled
|
||||
limited-feature-class="limited-be"
|
||||
limited-feature-tabindex="-1"
|
||||
>
|
||||
Display Users
|
||||
</button>
|
||||
<be-feature-indicator feature="$ctrl.limitedFeatureId" class="space-left"></be-feature-indicator>
|
||||
<be-teaser-button
|
||||
feature-id="$ctrl.limitedFeatureId"
|
||||
heading="'Display Users'"
|
||||
message="'Allows you to display users from your LDAP server.'"
|
||||
button-text="'Display Users'"
|
||||
button-class-name="'!ml-0'"
|
||||
></be-teaser-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+72
-82
@@ -1,88 +1,78 @@
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<div ng-if="$ctrl.index > 0" style="margin-bottom: 10px">
|
||||
<span class="text-muted small"> Extra search configuration </span>
|
||||
<button
|
||||
class="btn btn-sm btn-danger"
|
||||
type="button"
|
||||
ng-click="$ctrl.onRemoveClick($ctrl.index)"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-tabindex="-1"
|
||||
>
|
||||
<pr-icon icon="'trash-2'"></pr-icon>
|
||||
</button>
|
||||
<div class="w-full px-5 pt-3">
|
||||
<div ng-if="$ctrl.index > 0" style="margin-bottom: 10px">
|
||||
<span class="text-muted small"> Extra search configuration </span>
|
||||
<button
|
||||
class="btn btn-sm btn-danger"
|
||||
type="button"
|
||||
ng-click="$ctrl.onRemoveClick($ctrl.index)"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-tabindex="-1"
|
||||
>
|
||||
<pr-icon icon="'trash-2'"></pr-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ldap-settings-dn-builder
|
||||
label="Group Search Path (optional)"
|
||||
suffix="{{ $ctrl.domainSuffix }}"
|
||||
ng-model="$ctrl.config.GroupBaseDN"
|
||||
on-change="($ctrl.onChangeBaseDN)"
|
||||
limited-feature-id="$ctrl.limitedFeatureId"
|
||||
></ldap-settings-dn-builder>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="col-sm-4 col-md-2 control-label text-left"> Group Base DN </label>
|
||||
<div class="col-sm-8 col-md-10">
|
||||
{{ $ctrl.config.GroupBaseDN }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ldap-settings-dn-builder
|
||||
label="Group Search Path (optional)"
|
||||
suffix="{{ $ctrl.domainSuffix }}"
|
||||
ng-model="$ctrl.config.GroupBaseDN"
|
||||
on-change="($ctrl.onChangeBaseDN)"
|
||||
limited-feature-id="$ctrl.limitedFeatureId"
|
||||
></ldap-settings-dn-builder>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="col-sm-4 col-md-2 control-label text-left"> Group Base DN </label>
|
||||
<div class="col-sm-8 col-md-10">
|
||||
{{ $ctrl.config.GroupBaseDN }}
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 vertical-center" style="margin-bottom: 5px">
|
||||
<label class="control-label !pt-0 text-left">Groups</label>
|
||||
<span class="label label-default interactive vertical-center" style="margin-left: 10px" ng-click="$ctrl.addGroup()">
|
||||
<pr-icon icon="'plus-circle'"></pr-icon>
|
||||
add another group
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-sm-12" ng-if="$ctrl.groups.length">
|
||||
<div class="w-full px-5 pt-3">
|
||||
<div class="form-group no-margin-last-child" ng-repeat="entry in $ctrl.groups">
|
||||
<div class="col-sm-4">
|
||||
<select class="form-control" ng-model="entry.type" ng-change="$ctrl.onGroupsChange()" limited-feature-dir="{{::$ctrl.limitedFeatureId}}" limited-feature-tabindex="-1">
|
||||
<option value="ou">OU Name</option>
|
||||
<option value="cn">Folder Name</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-5">
|
||||
<input
|
||||
class="form-control"
|
||||
ng-model="entry.value"
|
||||
ng-change="$ctrl.onGroupsChange()"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-tabindex="-1"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-sm-3 text-right">
|
||||
<button
|
||||
class="btn btn-md btn-danger"
|
||||
type="button"
|
||||
ng-click="$ctrl.removeGroup($index)"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-tabindex="-1"
|
||||
>
|
||||
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 vertical-center" style="margin-bottom: 5px">
|
||||
<label class="control-label !pt-0 text-left">Groups</label>
|
||||
<span class="label label-default interactive vertical-center" style="margin-left: 10px" ng-click="$ctrl.addGroup()">
|
||||
<pr-icon icon="'plus-circle'"></pr-icon>
|
||||
add another group
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-sm-12" ng-if="$ctrl.groups.length">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<div class="form-group no-margin-last-child" ng-repeat="entry in $ctrl.groups">
|
||||
<div class="col-sm-4">
|
||||
<select
|
||||
class="form-control"
|
||||
ng-model="entry.type"
|
||||
ng-change="$ctrl.onGroupsChange()"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-tabindex="-1"
|
||||
>
|
||||
<option value="ou">OU Name</option>
|
||||
<option value="cn">Folder Name</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-5">
|
||||
<input
|
||||
class="form-control"
|
||||
ng-model="entry.value"
|
||||
ng-change="$ctrl.onGroupsChange()"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-tabindex="-1"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-sm-3 text-right">
|
||||
<button
|
||||
class="btn btn-md btn-danger"
|
||||
type="button"
|
||||
ng-click="$ctrl.removeGroup($index)"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-tabindex="-1"
|
||||
>
|
||||
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
<div class="form-group no-margin-last-child">
|
||||
<label class="col-sm-4 col-md-2 control-label text-left"> Group Filter </label>
|
||||
<div class="col-sm-8 col-md-10">
|
||||
{{ $ctrl.config.GroupFilter }}
|
||||
</div>
|
||||
|
||||
<div class="form-group no-margin-last-child">
|
||||
<label class="col-sm-4 col-md-2 control-label text-left"> Group Filter </label>
|
||||
<div class="col-sm-8 col-md-10">
|
||||
{{ $ctrl.config.GroupFilter }}
|
||||
</div>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,15 +13,9 @@
|
||||
|
||||
<div class="form-group" style="margin-top: 10px">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
class="label label-default interactive vertical-center"
|
||||
style="border: 0"
|
||||
ng-click="$ctrl.onAddClick()"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-tabindex="-1"
|
||||
>
|
||||
<pr-icon icon="'plus-circle'"></pr-icon>
|
||||
add group search configuration
|
||||
<button class="btn btn-sm btn-light vertical-center !ml-0" ng-click="$ctrl.onAddClick()" disabled>
|
||||
<pr-icon icon="'plus'"></pr-icon>
|
||||
Add group search configuration
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-sm-12" style="margin-top: 10px">
|
||||
|
||||
+10
-14
@@ -18,20 +18,7 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ldap_url" class="col-sm-3 col-lg-2 control-label flex flex-wrap text-left">
|
||||
LDAP Server
|
||||
<button
|
||||
type="button"
|
||||
class="label label-default interactive no-border vertical-center"
|
||||
ng-click="$ctrl.addLDAPUrl()"
|
||||
limited-feature-dir="{{ $ctrl.limitedFeatureId }}"
|
||||
limited-feature-disabled
|
||||
limited-feature-class="limited-be"
|
||||
>
|
||||
<pr-icon icon="'plus-circle'"></pr-icon>
|
||||
Add additional server
|
||||
</button>
|
||||
</label>
|
||||
<label for="ldap_url" class="col-sm-3 col-lg-2 control-label flex flex-wrap text-left"> LDAP Server </label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<div class="mb-3 flex" ng-repeat="url in $ctrl.settings.URLs track by $index">
|
||||
<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" required />
|
||||
@@ -40,6 +27,15 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-12">
|
||||
<be-teaser-button
|
||||
feature-id="$ctrl.limitedFeatureId"
|
||||
heading="'Add additional server'"
|
||||
message="'Allows you to add an additional LDAP server.'"
|
||||
button-text="'Add additional server'"
|
||||
button-class-name="'!ml-0'"
|
||||
></be-teaser-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
||||
+2
-5
@@ -1,9 +1,6 @@
|
||||
<ng-form limited-feature-dir="{{::$ctrl.limitedFeatureId}}" limited-feature-class="limited-be" class="ldap-settings-openldap">
|
||||
<div class="overlay">
|
||||
<div class="limited-be-link vertical-center"
|
||||
><be-feature-indicator feature="$ctrl.limitedFeatureId"></be-feature-indicator
|
||||
><portainer-tooltip message="'This feature is currently limited to Business Edition users only. '"></portainer-tooltip
|
||||
></div>
|
||||
<div class="be-indicator-container">
|
||||
<div class="limited-be-link vertical-center"><be-feature-indicator feature="$ctrl.limitedFeatureId"></be-feature-indicator></div>
|
||||
<div class="limited-be-content">
|
||||
<div>
|
||||
<div class="col-sm-12 form-section-title"> Information </div>
|
||||
|
||||
+52
-47
@@ -1,4 +1,4 @@
|
||||
<div class="col-sm-12 form-section-title" ng-style="$ctrl.isLimitedFeatureSelfContained && { 'padding-bottom': '15px' }">
|
||||
<div class="col-sm-12 form-section-title flex items-center" style="float: initial">
|
||||
Test login
|
||||
<be-feature-indicator
|
||||
ng-if="$ctrl.showBeIndicatorIfNeeded"
|
||||
@@ -7,52 +7,57 @@
|
||||
ng-if="$ctrl.isLimitedFeatureSelfContained"
|
||||
></be-feature-indicator>
|
||||
</div>
|
||||
<div class="form-inline">
|
||||
<div class="form-group" style="margin: 0">
|
||||
<label for="ldap_test_username" style="font-size: 0.9em; margin-right: 5px"> Username </label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="ldap_test_username"
|
||||
ng-model="$ctrl.username"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
|
||||
ng-disabled="{{ $ctrl.isLimitedFeatureSelfContained }}"
|
||||
limited-feature-tabindex="-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group no-margin">
|
||||
<label for="ldap_test_password"> Password </label>
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
id="ldap_test_password"
|
||||
ng-model="$ctrl.password"
|
||||
autocomplete="new-password"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
|
||||
ng-disabled="{{ $ctrl.isLimitedFeatureSelfContained }}"
|
||||
limited-feature-tabindex="-1"
|
||||
/>
|
||||
</div>
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<div class="form-inline">
|
||||
<div class="form-group" style="margin: 0">
|
||||
<label for="ldap_test_username" style="font-size: 0.9em; margin-right: 5px"> Username </label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="ldap_test_username"
|
||||
ng-model="$ctrl.username"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
|
||||
ng-disabled="{{ $ctrl.isLimitedFeatureSelfContained }}"
|
||||
limited-feature-tabindex="-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group !ml-0">
|
||||
<div class="vertical-center">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
ng-disabled="$ctrl.state.testStatus === $ctrl.TEST_STATUS.LOADING || !$ctrl.username || !$ctrl.password"
|
||||
ng-click="$ctrl.testLogin($ctrl.username, $ctrl.password)"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
|
||||
limited-feature-tabindex="-1"
|
||||
>
|
||||
<span ng-if="$ctrl.state.testStatus !== $ctrl.TEST_STATUS.LOADING">Test</span>
|
||||
<span ng-if="$ctrl.state.testStatus === $ctrl.TEST_STATUS.LOADING">Testing...</span>
|
||||
</button>
|
||||
<pr-icon icon="'check'" class="icon-success" ng-if="$ctrl.state.testStatus === $ctrl.TEST_STATUS.SUCCESS"></pr-icon>
|
||||
<pr-icon icon="'x'" class="icon-danger" ng-if="$ctrl.state.testStatus === $ctrl.TEST_STATUS.FAILURE"></pr-icon>
|
||||
<div class="form-group no-margin">
|
||||
<label for="ldap_test_password"> Password </label>
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
id="ldap_test_password"
|
||||
ng-model="$ctrl.password"
|
||||
autocomplete="new-password"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
|
||||
ng-disabled="{{ $ctrl.isLimitedFeatureSelfContained }}"
|
||||
limited-feature-tabindex="-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group !ml-0">
|
||||
<div class="vertical-center">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
ng-disabled="$ctrl.state.testStatus === $ctrl.TEST_STATUS.LOADING || !$ctrl.username || !$ctrl.password"
|
||||
ng-click="$ctrl.testLogin($ctrl.username, $ctrl.password)"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-class=" {{ $ctrl.isLimitedFeatureSelfContained && 'limited-be' }}"
|
||||
limited-feature-tabindex="-1"
|
||||
>
|
||||
<span ng-if="$ctrl.state.testStatus !== $ctrl.TEST_STATUS.LOADING">Test</span>
|
||||
<span ng-if="$ctrl.state.testStatus === $ctrl.TEST_STATUS.LOADING">Testing...</span>
|
||||
</button>
|
||||
<pr-icon icon="'check'" class="icon-success" ng-if="$ctrl.state.testStatus === $ctrl.TEST_STATUS.SUCCESS"></pr-icon>
|
||||
<pr-icon icon="'x'" class="icon-danger" ng-if="$ctrl.state.testStatus === $ctrl.TEST_STATUS.FAILURE"></pr-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
|
||||
+88
-90
@@ -1,101 +1,99 @@
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<div ng-if="$ctrl.index > 0" style="margin-bottom: 10px">
|
||||
<span class="text-muted small"> Extra search configuration </span>
|
||||
<div class="w-full px-5 pt-3">
|
||||
<div ng-if="$ctrl.index > 0" style="margin-bottom: 10px">
|
||||
<span class="text-muted small"> Extra search configuration </span>
|
||||
<button
|
||||
ng-if="$ctrl.index > 0"
|
||||
class="btn btn-sm btn-danger"
|
||||
type="button"
|
||||
ng-click="$ctrl.onRemoveClick($ctrl.index)"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-tabindex="-1"
|
||||
>
|
||||
<pr-icon icon="'trash-2'"></pr-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-if="$ctrl.showUsernameFormat">
|
||||
<div class="col-sm-4" style="margin-bottom: 5px">
|
||||
<label class="control-label text-left">Username Format</label>
|
||||
</div>
|
||||
<div class="col-sm-8">
|
||||
<div class="input-group">
|
||||
<div class="input-group-btn">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
ng-model="$ctrl.config.UserNameAttribute"
|
||||
uib-btn-radio="'sAMAccountName'"
|
||||
style="margin-left: 0px"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-tabindex="-1"
|
||||
>username</button
|
||||
>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
ng-model="$ctrl.config.UserNameAttribute"
|
||||
uib-btn-radio="'userPrincipalName'"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-tabindex="-1"
|
||||
>user@domainname</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="col-sm-4 control-label text-left"> Root Domain </label>
|
||||
<div class="col-sm-8">
|
||||
{{ $ctrl.config.BaseDN }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ldap-settings-dn-builder
|
||||
ng-model="$ctrl.config.BaseDN"
|
||||
label="User Search Path (optional)"
|
||||
suffix="{{ $ctrl.domainSuffix }}"
|
||||
on-change="($ctrl.onBaseDNChange)"
|
||||
limited-feature-id="$ctrl.limitedFeatureId"
|
||||
></ldap-settings-dn-builder>
|
||||
|
||||
<div class="form-group no-margin-last-child">
|
||||
<div class="col-sm-12" style="margin-bottom: 5px">
|
||||
<label class="control-label text-left">Allowed Groups (optional)</label>
|
||||
<button
|
||||
ng-if="$ctrl.index > 0"
|
||||
class="btn btn-sm btn-danger"
|
||||
type="button"
|
||||
ng-click="$ctrl.onRemoveClick($ctrl.index)"
|
||||
class="label label-default interactive vertical-center"
|
||||
style="margin-left: 10px; border: 0"
|
||||
ng-click="$ctrl.addGroup()"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-tabindex="-1"
|
||||
>
|
||||
<pr-icon icon="'trash-2'"></pr-icon>
|
||||
<pr-icon icon="'plus-circle'"></pr-icon>
|
||||
add another group
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-if="$ctrl.showUsernameFormat">
|
||||
<div class="col-sm-4" style="margin-bottom: 5px">
|
||||
<label class="control-label text-left">Username Format</label>
|
||||
</div>
|
||||
<div class="col-sm-8">
|
||||
<div class="input-group">
|
||||
<div class="input-group-btn">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
ng-model="$ctrl.config.UserNameAttribute"
|
||||
uib-btn-radio="'sAMAccountName'"
|
||||
style="margin-left: 0px"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-tabindex="-1"
|
||||
>username</button
|
||||
>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
ng-model="$ctrl.config.UserNameAttribute"
|
||||
uib-btn-radio="'userPrincipalName'"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-tabindex="-1"
|
||||
>user@domainname</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-12">
|
||||
<div ng-repeat="group in $ctrl.groups track by $index" style="margin-bottom: 10px">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<ldap-settings-group-dn-builder
|
||||
ng-model="group"
|
||||
index="$index"
|
||||
suffix="{{ $ctrl.domainSuffix }}"
|
||||
on-change="($ctrl.onGroupChange)"
|
||||
on-remove-click="($ctrl.removeGroup)"
|
||||
limited-feature-id="$ctrl.limitedFeatureId"
|
||||
></ldap-settings-group-dn-builder>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="col-sm-4 control-label text-left"> Root Domain </label>
|
||||
<div class="col-sm-8">
|
||||
{{ $ctrl.config.BaseDN }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-sm-4 control-label text-left"> User Filter </label>
|
||||
<div class="col-sm-8">
|
||||
{{ $ctrl.config.Filter }}
|
||||
</div>
|
||||
|
||||
<ldap-settings-dn-builder
|
||||
ng-model="$ctrl.config.BaseDN"
|
||||
label="User Search Path (optional)"
|
||||
suffix="{{ $ctrl.domainSuffix }}"
|
||||
on-change="($ctrl.onBaseDNChange)"
|
||||
limited-feature-id="$ctrl.limitedFeatureId"
|
||||
></ldap-settings-dn-builder>
|
||||
|
||||
<div class="form-group no-margin-last-child">
|
||||
<div class="col-sm-12" style="margin-bottom: 5px">
|
||||
<label class="control-label text-left">Allowed Groups (optional)</label>
|
||||
<button
|
||||
type="button"
|
||||
class="label label-default interactive vertical-center"
|
||||
style="margin-left: 10px; border: 0"
|
||||
ng-click="$ctrl.addGroup()"
|
||||
limited-feature-dir="{{::$ctrl.limitedFeatureId}}"
|
||||
limited-feature-tabindex="-1"
|
||||
>
|
||||
<pr-icon icon="'plus-circle'"></pr-icon>
|
||||
add another group
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-sm-12">
|
||||
<div ng-repeat="group in $ctrl.groups track by $index" style="margin-bottom: 10px">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<ldap-settings-group-dn-builder
|
||||
ng-model="group"
|
||||
index="$index"
|
||||
suffix="{{ $ctrl.domainSuffix }}"
|
||||
on-change="($ctrl.onGroupChange)"
|
||||
on-remove-click="($ctrl.removeGroup)"
|
||||
limited-feature-id="$ctrl.limitedFeatureId"
|
||||
></ldap-settings-group-dn-builder>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="col-sm-4 control-label text-left"> User Filter </label>
|
||||
<div class="col-sm-8">
|
||||
{{ $ctrl.config.Filter }}
|
||||
</div>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
limited-feature-tabindex="-1"
|
||||
>
|
||||
<pr-icon icon="'plus-circle'"></pr-icon>
|
||||
add user search configuration
|
||||
Add user search configuration
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-sm-12" style="margin-top: 10px">
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
type="button"
|
||||
ng-class="[$ctrl.className, 'btn btn-primary btn-sm']"
|
||||
ng-class="[$ctrl.className, 'btn btn-primary btn-sm !ml-0']"
|
||||
ng-click="$ctrl.onSaveSettings()"
|
||||
ng-disabled="$ctrl.saveButtonDisabled || $ctrl.saveButtonState"
|
||||
button-spinner="$ctrl.saveButtonState"
|
||||
|
||||
-2
@@ -6,9 +6,7 @@
|
||||
<div class="widget-icon space-right">
|
||||
<pr-icon icon="'history'"></pr-icon>
|
||||
</div>
|
||||
|
||||
Activity Logs
|
||||
<be-feature-indicator feature="$ctrl.feature"></be-feature-indicator>
|
||||
</div>
|
||||
<div class="vertical-center">
|
||||
<datatable-searchbar on-change="($ctrl.onChangeKeyword)" value="$ctrl.keyword"></datatable-searchbar>
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
<page-header title="'User Activity'" breadcrumbs="['Activity Logs']" reload="true"> </page-header>
|
||||
|
||||
<div class="be-indicator-container limited-be mx-4">
|
||||
<div class="overlay">
|
||||
<div class="limited-be-link vertical-center"
|
||||
><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator
|
||||
><portainer-tooltip message="'This feature is currently limited to Business Edition users only. '"></portainer-tooltip
|
||||
></div>
|
||||
<div>
|
||||
<div class="limited-be-link vertical-center"><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator></div>
|
||||
<div class="limited-be-content">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
@@ -29,18 +26,7 @@
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="be-indicator-container limited-be mx-4">
|
||||
<div class="overlay">
|
||||
<div class="limited-be-link vertical-center"
|
||||
><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator
|
||||
><portainer-tooltip message="'This feature is currently limited to Business Edition users only. '"></portainer-tooltip
|
||||
></div>
|
||||
<div class="limited-be-content">
|
||||
<div class="row">
|
||||
<div class="row mt-5">
|
||||
<activity-logs-datatable
|
||||
logs="$ctrl.state.logs"
|
||||
keyword="$ctrl.state.keyword"
|
||||
|
||||
-2
@@ -6,9 +6,7 @@
|
||||
<div class="widget-icon space-right">
|
||||
<pr-icon icon="'history'"></pr-icon>
|
||||
</div>
|
||||
|
||||
Authentication Events
|
||||
<be-feature-indicator feature="$ctrl.feature"></be-feature-indicator>
|
||||
</div>
|
||||
<div class="vertical-center">
|
||||
<datatable-searchbar on-change="($ctrl.onChangeKeyword)"></datatable-searchbar>
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
<page-header title="'User Activity'" breadcrumbs="['User authentication activity']" reload="true"> </page-header>
|
||||
|
||||
<div class="be-indicator-container limited-be mx-4">
|
||||
<div class="overlay">
|
||||
<div class="limited-be-link vertical-center"
|
||||
><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator
|
||||
><portainer-tooltip message="'This feature is currently limited to Business Edition users only. '"></portainer-tooltip
|
||||
></div>
|
||||
<div>
|
||||
<div class="limited-be-link vertical-center"><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator></div>
|
||||
<div class="limited-be-content">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
@@ -28,18 +25,7 @@
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="be-indicator-container limited-be mx-4">
|
||||
<div class="overlay">
|
||||
<div class="limited-be-link vertical-center"
|
||||
><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator
|
||||
><portainer-tooltip message="'This feature is currently limited to Business Edition users only. '"></portainer-tooltip
|
||||
></div>
|
||||
<div class="limited-be-content">
|
||||
<div class="row">
|
||||
<div class="row mt-5">
|
||||
<auth-logs-datatable
|
||||
logs="$ctrl.state.logs"
|
||||
keyword="$ctrl.state.keyword"
|
||||
|
||||
@@ -25,11 +25,10 @@ class LogoutController {
|
||||
*/
|
||||
async logoutAsync() {
|
||||
const error = this.$transition$.params().error;
|
||||
const performApiLogout = this.$transition$.params().performApiLogout;
|
||||
const settings = await this.SettingsService.publicSettings();
|
||||
|
||||
try {
|
||||
await this.Authentication.logout(performApiLogout);
|
||||
await this.Authentication.logout();
|
||||
} finally {
|
||||
this.LocalStorage.storeLogoutReason(error);
|
||||
if (settings.OAuthLogoutURI && this.Authentication.getUserDetails().ID !== 1) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
.be-indicator {
|
||||
border: solid 1px var(--BE-only);
|
||||
@apply border border-solid border-gray-6;
|
||||
@apply text-xs text-gray-6;
|
||||
border-radius: 15px;
|
||||
padding: 5px 10px;
|
||||
font-weight: 400;
|
||||
@@ -9,11 +10,12 @@
|
||||
}
|
||||
|
||||
.be-indicator .be-indicator-icon {
|
||||
@apply text-black th-highcontrast:text-white th-dark:text-blue-8;
|
||||
@apply text-inherit;
|
||||
}
|
||||
|
||||
.be-indicator:hover {
|
||||
@apply no-underline;
|
||||
@apply underline;
|
||||
@apply border-blue-9 text-blue-9;
|
||||
}
|
||||
|
||||
.be-indicator:hover .be-indicator-label {
|
||||
@@ -21,5 +23,5 @@
|
||||
}
|
||||
|
||||
.be-indicator-container {
|
||||
border: solid 1px var(--BE-only);
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
@@ -29,16 +29,14 @@ export function BEFeatureIndicator({
|
||||
}
|
||||
return (
|
||||
<a
|
||||
className={clsx('be-indicator vertical-center', className)}
|
||||
className={clsx('be-indicator vertical-center text-xs', className)}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
{showIcon && <Icon icon={Briefcase} className="be-indicator-icon mr-1" />}
|
||||
<span className="be-indicator-label break-words">
|
||||
Business Edition Feature
|
||||
</span>
|
||||
<span className="be-indicator-label break-words">Business Feature</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
import { Tooltip } from '@@/Tip/Tooltip';
|
||||
|
||||
import { BEFeatureIndicator } from '.';
|
||||
import { BEFeatureIndicator } from './BEFeatureIndicator';
|
||||
|
||||
export function BEOverlay({
|
||||
featureId,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
featureId: FeatureId;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const isLimited = isLimitedToBE(featureId);
|
||||
if (!isLimited) {
|
||||
@@ -19,13 +21,10 @@ export function BEOverlay({
|
||||
|
||||
return (
|
||||
<div className="be-indicator-container limited-be">
|
||||
<div className="overlay">
|
||||
<div className="limited-be-link vertical-center">
|
||||
<BEFeatureIndicator featureId={FeatureId.CA_FILE} />
|
||||
<Tooltip message="This feature is currently limited to Business Edition users only. " />
|
||||
</div>
|
||||
<div className="limited-be-content">{children}</div>
|
||||
<div className="limited-be-link vertical-center">
|
||||
<BEFeatureIndicator featureId={featureId} />
|
||||
</div>
|
||||
<div className={clsx('limited-be-content', className)}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Briefcase } from 'lucide-react';
|
||||
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
@@ -11,7 +11,6 @@ interface Props {
|
||||
message: string;
|
||||
buttonText: string;
|
||||
className?: string;
|
||||
icon?: ReactNode;
|
||||
buttonClassName?: string;
|
||||
}
|
||||
|
||||
@@ -21,7 +20,6 @@ export function BETeaserButton({
|
||||
message,
|
||||
buttonText,
|
||||
className,
|
||||
icon,
|
||||
buttonClassName,
|
||||
}: Props) {
|
||||
return (
|
||||
@@ -34,9 +32,9 @@ export function BETeaserButton({
|
||||
<span>
|
||||
<Button
|
||||
className={buttonClassName}
|
||||
icon={icon}
|
||||
icon={Briefcase}
|
||||
type="button"
|
||||
color="warninglight"
|
||||
color="default"
|
||||
size="small"
|
||||
onClick={() => {}}
|
||||
disabled
|
||||
|
||||
@@ -38,12 +38,12 @@
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
/* used for BE teaser. Dark theme specs defined by EE-5621 */
|
||||
/* used for BE teaser */
|
||||
.box-selector-item.limited.business label,
|
||||
.box-selector-item.limited.business input:checked + label {
|
||||
@apply border-warning-7 bg-warning-1 text-black;
|
||||
@apply th-dark:border-blue-8 th-dark:bg-[color:var(--bg-BE-only)] th-dark:text-white;
|
||||
@apply th-highcontrast:bg-warning-8 th-highcontrast:bg-opacity-10 th-highcontrast:text-white;
|
||||
@apply border-gray-6 bg-gray-6 bg-opacity-10;
|
||||
@apply th-dark:border-gray-6 th-dark:bg-gray-6 th-dark:bg-opacity-10;
|
||||
@apply th-highcontrast:border-gray-6 th-highcontrast:bg-gray-6 th-highcontrast:bg-opacity-10;
|
||||
|
||||
filter: none;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { getFeatureDetails } from '@@/BEFeatureIndicator/utils';
|
||||
|
||||
import styles from './BoxSelectorItem.module.css';
|
||||
import { BoxSelectorOption, Value } from './types';
|
||||
import { LimitedToBeIndicator } from './LimitedToBeIndicator';
|
||||
import { LimitedToBeBoxSelectorIndicator } from './LimitedToBeBoxSelectorIndicator';
|
||||
import { BoxOption } from './BoxOption';
|
||||
import { LogoIcon } from './LogoIcon';
|
||||
|
||||
@@ -40,8 +40,6 @@ export function BoxSelectorItem<T extends Value>({
|
||||
option.feature
|
||||
);
|
||||
|
||||
const beIndicatorTooltipId = `box-selector-item-${radioName}-${option.id}-limited`;
|
||||
|
||||
const ContentBox = slim ? 'div' : Fragment;
|
||||
|
||||
return (
|
||||
@@ -60,13 +58,14 @@ export function BoxSelectorItem<T extends Value>({
|
||||
checkIcon={checkIcon}
|
||||
>
|
||||
{limitedToBE && (
|
||||
<LimitedToBeIndicator
|
||||
tooltipId={beIndicatorTooltipId}
|
||||
<LimitedToBeBoxSelectorIndicator
|
||||
url={featureUrl}
|
||||
// show tooltip only for radio type options because be-only checkbox options can't be selected
|
||||
showTooltip={type === 'radio'}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={clsx('flex gap-2', {
|
||||
className={clsx('flex min-w-[140px] gap-2', {
|
||||
'opacity-30': limitedToBE,
|
||||
'h-full flex-col justify-start': !slim,
|
||||
'slim items-center': slim,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Briefcase } from 'lucide-react';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
import { Tooltip } from '@@/Tip/Tooltip';
|
||||
|
||||
interface Props {
|
||||
url?: string;
|
||||
showTooltip?: boolean;
|
||||
}
|
||||
|
||||
export function LimitedToBeBoxSelectorIndicator({
|
||||
url,
|
||||
showTooltip = true,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="absolute left-0 top-0 w-full">
|
||||
<div className="mx-auto flex max-w-fit items-center rounded-b-lg border border-t-0 border-solid border-gray-6 bg-transparent px-3 py-1 text-gray-6">
|
||||
<a
|
||||
className="inline-flex items-center text-xs text-gray-6"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Icon icon={Briefcase} className="!mr-1" />
|
||||
<span>Business Feature</span>
|
||||
</a>
|
||||
{showTooltip && (
|
||||
<Tooltip
|
||||
size="sm"
|
||||
message="Select this option to preview this business feature."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { HelpCircle } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
|
||||
|
||||
interface Props {
|
||||
tooltipId: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export function LimitedToBeIndicator({ tooltipId, url }: Props) {
|
||||
return (
|
||||
<div className="absolute left-0 top-0 w-full">
|
||||
<div className="mx-auto flex max-w-fit items-center gap-1 rounded-b-lg bg-warning-4 py-1 px-3 text-sm th-dark:bg-[color:var(--bg-BE-only)]">
|
||||
<a
|
||||
className="text-warning-9 th-dark:text-blue-8"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
BE Feature
|
||||
</a>
|
||||
<TooltipWithChildren
|
||||
position="bottom"
|
||||
className={clsx(tooltipId, 'portainer-tooltip')}
|
||||
heading="Business Edition feature."
|
||||
message="This feature is currently limited to Business Edition users only."
|
||||
>
|
||||
<HelpCircle
|
||||
className="ml-1 !text-warning-7 th-dark:!text-blue-8"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</TooltipWithChildren>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -56,7 +56,6 @@ export function UserMenu() {
|
||||
to="portainer.logout"
|
||||
label="Log out"
|
||||
data-cy="userMenu-logOut"
|
||||
params={{ performApiLogout: true }}
|
||||
/>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
import { HelpCircle } from 'lucide-react';
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
import sanitize from 'sanitize-html';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { TooltipWithChildren, Position } from '../TooltipWithChildren';
|
||||
|
||||
type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
|
||||
const sizeClasses: Record<Size, string> = {
|
||||
xs: 'text-xs',
|
||||
sm: 'text-sm',
|
||||
md: 'text-base',
|
||||
lg: 'text-lg',
|
||||
xl: 'text-xl',
|
||||
};
|
||||
|
||||
export interface Props {
|
||||
position?: Position;
|
||||
message: ReactNode;
|
||||
className?: string;
|
||||
setHtmlMessage?: boolean;
|
||||
size?: Size;
|
||||
}
|
||||
|
||||
export function Tooltip({
|
||||
@@ -16,6 +28,7 @@ export function Tooltip({
|
||||
position = 'bottom',
|
||||
className,
|
||||
setHtmlMessage,
|
||||
size = 'md',
|
||||
}: Props) {
|
||||
// allow angular views to set html messages for the tooltip
|
||||
const htmlMessage = useMemo(() => {
|
||||
@@ -27,14 +40,14 @@ export function Tooltip({
|
||||
}, [setHtmlMessage, message]);
|
||||
|
||||
return (
|
||||
<TooltipWithChildren
|
||||
message={htmlMessage || message}
|
||||
position={position}
|
||||
className={className}
|
||||
>
|
||||
<span className="inline-flex text-base">
|
||||
<HelpCircle className="lucide ml-1" aria-hidden="true" />
|
||||
</span>
|
||||
</TooltipWithChildren>
|
||||
<span className={clsx('ml-1 inline-flex items-center', sizeClasses[size])}>
|
||||
<TooltipWithChildren
|
||||
message={htmlMessage || message}
|
||||
position={position}
|
||||
className={className}
|
||||
>
|
||||
<HelpCircle className="lucide" aria-hidden="true" />
|
||||
</TooltipWithChildren>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,9 +34,5 @@
|
||||
}
|
||||
|
||||
.tooltip-beteaser {
|
||||
@apply text-warning-5 th-dark:text-blue-8;
|
||||
}
|
||||
|
||||
.tooltip-beteaser:hover {
|
||||
@apply text-warning-5 th-dark:text-blue-8;
|
||||
@apply text-blue-8 hover:text-blue-9;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export function TooltipWithChildren({
|
||||
rel="noreferrer"
|
||||
className={styles.tooltipBeteaser}
|
||||
>
|
||||
Business Edition Only
|
||||
Business Feature
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -276,7 +276,9 @@ function globalFilterFn<D>(
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((item) => item.toLowerCase().includes(filterValueLower));
|
||||
return value.some((item) =>
|
||||
item.toString().toLowerCase().includes(filterValueLower)
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
+17
-7
@@ -6,6 +6,7 @@ import { EdgeStackStatus, StatusType } from '@/react/edge/edge-stacks/types';
|
||||
import { useEnvironmentList } from '@/react/portainer/environments/queries';
|
||||
import { useParamState } from '@/react/hooks/useParamState';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
import { Datatable } from '@@/datatables';
|
||||
import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
|
||||
@@ -78,6 +79,21 @@ export function EnvironmentsDatatable() {
|
||||
]
|
||||
);
|
||||
|
||||
const envStatusSelectOptions = [
|
||||
{ value: StatusType.Pending, label: 'Pending' },
|
||||
{ value: StatusType.Acknowledged, label: 'Acknowledged' },
|
||||
{ value: StatusType.ImagesPulled, label: 'Images pre-pulled' },
|
||||
{ value: StatusType.Running, label: 'Deployed' },
|
||||
{ value: StatusType.Error, label: 'Failed' },
|
||||
];
|
||||
if (isBE) {
|
||||
envStatusSelectOptions.concat([
|
||||
{ value: StatusType.PausedDeploying, label: 'Paused' },
|
||||
{ value: StatusType.RollingBack, label: 'Rolling back' },
|
||||
{ value: StatusType.RolledBack, label: 'Rolled back' },
|
||||
]);
|
||||
}
|
||||
|
||||
return (
|
||||
<Datatable
|
||||
columns={columns}
|
||||
@@ -99,13 +115,7 @@ export function EnvironmentsDatatable() {
|
||||
bindToBody
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e ?? undefined)}
|
||||
options={[
|
||||
{ value: StatusType.Pending, label: 'Pending' },
|
||||
{ value: StatusType.Acknowledged, label: 'Acknowledged' },
|
||||
{ value: StatusType.ImagesPulled, label: 'Images pre-pulled' },
|
||||
{ value: StatusType.Running, label: 'Deployed' },
|
||||
{ value: StatusType.Error, label: 'Failed' },
|
||||
]}
|
||||
options={envStatusSelectOptions}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -155,6 +155,15 @@ function endpointStatusLabel(statusArray: Array<DeploymentStatus>) {
|
||||
if (status.Type === StatusType.Error) {
|
||||
labels.push('Failed');
|
||||
}
|
||||
if (status.Type === StatusType.PausedDeploying) {
|
||||
labels.push('Paused');
|
||||
}
|
||||
if (status.Type === StatusType.RollingBack) {
|
||||
labels.push('Rolling Back');
|
||||
}
|
||||
if (status.Type === StatusType.RolledBack) {
|
||||
labels.push('Rolled Back');
|
||||
}
|
||||
});
|
||||
|
||||
if (!labels.length) {
|
||||
@@ -283,6 +292,9 @@ function getStateColor(type: StatusType): 'orange' | 'green' | 'red' {
|
||||
case StatusType.Pending:
|
||||
case StatusType.Deploying:
|
||||
case StatusType.Removing:
|
||||
case StatusType.PausedDeploying:
|
||||
case StatusType.RollingBack:
|
||||
case StatusType.RolledBack:
|
||||
default:
|
||||
return 'orange';
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@ export enum StatusType {
|
||||
Deploying,
|
||||
/** Removing represents an Edge stack which is being removed */
|
||||
Removing,
|
||||
/** PausedDeploying represents an Edge stack which is paused for deployment */
|
||||
PausedDeploying,
|
||||
/** PausedRemoving represents an Edge stack which is being rolled back */
|
||||
RollingBack,
|
||||
/** PausedRemoving represents an Edge stack which has been rolled back */
|
||||
RolledBack,
|
||||
}
|
||||
|
||||
export interface DeploymentStatus {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
import { BETeaserButton } from '@@/BETeaserButton';
|
||||
@@ -42,7 +40,6 @@ export function AnnotationsBeTeaser() {
|
||||
message="Allows specifying of annotations on this resource."
|
||||
featureId={FeatureId.K8S_ANNOTATIONS}
|
||||
buttonClassName="!ml-0"
|
||||
icon={Plus}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
import { Terminal as TerminalIcon } from 'lucide-react';
|
||||
import { Terminal } from 'xterm';
|
||||
|
||||
import { useLocalStorage } from '@/react/hooks/useLocalStorage';
|
||||
import { get } from '@/react/hooks/useLocalStorage';
|
||||
import { baseHref } from '@/portainer/helpers/pathHelper';
|
||||
import { notifyError } from '@/portainer/services/notifications';
|
||||
|
||||
@@ -27,7 +27,6 @@ export function ConsoleView() {
|
||||
},
|
||||
} = useCurrentStateAndParams();
|
||||
|
||||
const [jwtToken] = useLocalStorage('JWT', '');
|
||||
const [command, setCommand] = useState('/bin/sh');
|
||||
const [connectionStatus, setConnectionStatus] = useState('closed');
|
||||
const [terminal, setTerminal] = useState(null as Terminal | null);
|
||||
@@ -132,6 +131,10 @@ export function ConsoleView() {
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
id="consoleCommand"
|
||||
// disable eslint because we want to autofocus
|
||||
// this is ok because we only have one input on the page
|
||||
// https://portainer.atlassian.net/browse/EE-5752
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
@@ -166,6 +169,8 @@ export function ConsoleView() {
|
||||
);
|
||||
|
||||
function connectConsole() {
|
||||
const jwtToken = get('JWT', '');
|
||||
|
||||
const params: StringDictionary = {
|
||||
token: jwtToken,
|
||||
endpointId: environmentId,
|
||||
|
||||
-3
@@ -1,5 +1,3 @@
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
import { BETeaserButton } from '@@/BETeaserButton';
|
||||
@@ -10,7 +8,6 @@ export function RestartApplicationButton() {
|
||||
buttonClassName="!ml-0"
|
||||
data-cy="k8sAppDetail-restartButton"
|
||||
heading="Rolling restart"
|
||||
icon={RefreshCw}
|
||||
featureId={FeatureId.K8S_ROLLING_RESTART}
|
||||
message="A rolling restart of the application is performed."
|
||||
buttonText="Rolling restart"
|
||||
|
||||
+31
-19
@@ -11,6 +11,7 @@ import { Icon } from '@@/Icon';
|
||||
import { confirm } from '@@/modals/confirm';
|
||||
import { ModalType } from '@@/modals';
|
||||
import { buildConfirmButton } from '@@/modals/utils';
|
||||
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
|
||||
|
||||
import {
|
||||
useApplicationRevisionList,
|
||||
@@ -60,27 +61,38 @@ export function RollbackApplicationButton({
|
||||
appName
|
||||
);
|
||||
|
||||
const isRollbackNotAvailable =
|
||||
!app ||
|
||||
!appRevisions ||
|
||||
appRevisions?.length < 2 ||
|
||||
appDeployMethod !== 'application form' ||
|
||||
patchAppMutation.isLoading;
|
||||
|
||||
const rollbackButton = (
|
||||
<Button
|
||||
ng-if="!ctrl.isExternalApplication()"
|
||||
type="button"
|
||||
color="light"
|
||||
size="small"
|
||||
className="!ml-0"
|
||||
disabled={isRollbackNotAvailable}
|
||||
onClick={() => rollbackApplication()}
|
||||
data-cy="k8sAppDetail-rollbackButton"
|
||||
>
|
||||
<Icon icon={RotateCcw} className="mr-1" />
|
||||
Rollback to previous configuration
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Authorized authorizations="K8sApplicationDetailsW">
|
||||
<Button
|
||||
ng-if="!ctrl.isExternalApplication()"
|
||||
type="button"
|
||||
color="light"
|
||||
size="small"
|
||||
className="!ml-0"
|
||||
disabled={
|
||||
!app ||
|
||||
!appRevisions ||
|
||||
appRevisions?.length < 2 ||
|
||||
appDeployMethod !== 'application form' ||
|
||||
patchAppMutation.isLoading
|
||||
}
|
||||
onClick={() => rollbackApplication()}
|
||||
data-cy="k8sAppDetail-rollbackButton"
|
||||
>
|
||||
<Icon icon={RotateCcw} className="mr-1" />
|
||||
Rollback to previous configuration
|
||||
</Button>
|
||||
{isRollbackNotAvailable ? (
|
||||
<TooltipWithChildren message="Cannot roll back to previous configuration as none currently exists">
|
||||
<span>{rollbackButton}</span>
|
||||
</TooltipWithChildren>
|
||||
) : (
|
||||
rollbackButton
|
||||
)}
|
||||
</Authorized>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,10 +1,52 @@
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import { isAgentEnvironment } from '@/react/portainer/environments/utils';
|
||||
import {
|
||||
isAgentEnvironment,
|
||||
isEdgeEnvironment,
|
||||
} from '@/react/portainer/environments/utils';
|
||||
import { isVersionSmaller } from '@/react/common/semver-utils';
|
||||
import { useSystemStatus } from '@/react/portainer/system/useSystemStatus';
|
||||
|
||||
import { Tooltip } from '@@/Tip/Tooltip';
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
export function AgentDetails({ environment }: { environment: Environment }) {
|
||||
if (!isAgentEnvironment(environment.Type)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isEdgeEnvironment(environment.Type)) {
|
||||
return <EdgeAgentDetails environment={environment} />;
|
||||
}
|
||||
|
||||
return <span>{environment.Agent.Version}</span>;
|
||||
}
|
||||
|
||||
function EdgeAgentDetails({ environment }: { environment: Environment }) {
|
||||
const { data: systemStatus } = useSystemStatus();
|
||||
const associated = !!environment.EdgeID;
|
||||
|
||||
if (!systemStatus || !associated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const agentVersion = environment.Agent.Version;
|
||||
|
||||
const { Version } = systemStatus;
|
||||
const isSmaller =
|
||||
!agentVersion || // agents before 2.15 don't send the version so it will be empty
|
||||
isVersionSmaller(agentVersion, Version);
|
||||
|
||||
if (!isSmaller) {
|
||||
return <span>{agentVersion}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon icon={AlertTriangle} className="icon-warning" />
|
||||
<span className="icon-warning">{agentVersion || '< 2.15'}</span>
|
||||
<Tooltip message="Features and bug fixes in your current Portainer Server release may not be available to this Edge Agent until it is upgraded." />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
+6
-6
@@ -4,6 +4,7 @@ import { createMockTeams, createMockUsers } from '@/react-tools/test-mocks';
|
||||
import { renderWithQueryClient } from '@/react-tools/test-utils';
|
||||
import { rest, server } from '@/setup-tests/server';
|
||||
import { Role } from '@/portainer/users/types';
|
||||
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||
|
||||
import {
|
||||
ResourceControlOwnership,
|
||||
@@ -82,7 +83,7 @@ for (let i = 0; i < inheritanceTests.length; i += 1) {
|
||||
});
|
||||
}
|
||||
|
||||
test('when resource is limited to specific users, show comma separated list of their names', async () => {
|
||||
test('when resource is limited to specific users, show number of users', async () => {
|
||||
const users = createMockUsers(10, Role.Standard);
|
||||
|
||||
server.use(rest.get('/api/users', (req, res, ctx) => res(ctx.json(users))));
|
||||
@@ -106,7 +107,7 @@ test('when resource is limited to specific users, show comma separated list of t
|
||||
expect(queryByText(/Authorized users/)).toBeVisible();
|
||||
|
||||
await expect(findByLabelText('authorized-users')).resolves.toHaveTextContent(
|
||||
restrictedToUsers.map((user) => user.Username).join(', ')
|
||||
`${restrictedToUsers.length} users`
|
||||
);
|
||||
});
|
||||
|
||||
@@ -143,11 +144,10 @@ async function renderComponent(
|
||||
resourceType: ResourceControlType = ResourceControlType.Container,
|
||||
resourceControl?: ResourceControlViewModel
|
||||
) {
|
||||
const WithUser = withUserProvider(AccessControlPanelDetails);
|
||||
|
||||
const queries = renderWithQueryClient(
|
||||
<AccessControlPanelDetails
|
||||
resourceControl={resourceControl}
|
||||
resourceType={resourceType}
|
||||
/>
|
||||
<WithUser resourceControl={resourceControl} resourceType={resourceType} />
|
||||
);
|
||||
await expect(queries.findByText('Ownership')).resolves.toBeVisible();
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Edit, Eye } from 'lucide-react';
|
||||
import { useUser } from '@/react/hooks/useUser';
|
||||
import { Icon } from '@/react/components/Icon';
|
||||
import { TeamMembership, TeamRole } from '@/react/portainer/users/teams/types';
|
||||
import { useUserMembership } from '@/portainer/users/queries';
|
||||
import { useIsTeamLeader, useUserMembership } from '@/portainer/users/queries';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { TableContainer, TableTitle } from '@@/datatables';
|
||||
@@ -34,7 +34,7 @@ export function AccessControlPanel({
|
||||
onUpdateSuccess,
|
||||
}: Props) {
|
||||
const [isEditMode, toggleEditMode] = useReducer((state) => !state, false);
|
||||
const { isAdmin } = useUser();
|
||||
const { user, isAdmin } = useUser();
|
||||
|
||||
const isInherited = checkIfInherited();
|
||||
|
||||
@@ -46,12 +46,15 @@ export function AccessControlPanel({
|
||||
isInherited ||
|
||||
(!isAdmin && !isPartOfRestrictedUsers && !isLeaderOfAnyRestrictedTeams);
|
||||
|
||||
const isTeamLeader = useIsTeamLeader(user) as boolean;
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<TableTitle label="Access control" icon={Eye} />
|
||||
<AccessControlPanelDetails
|
||||
resourceType={resourceType}
|
||||
resourceControl={resourceControl}
|
||||
isAuthorisedToFetchUsers={isAdmin || isTeamLeader}
|
||||
/>
|
||||
|
||||
{!isEditDisabled && !isEditMode && (
|
||||
|
||||
+28
-7
@@ -8,6 +8,7 @@ import { UserId } from '@/portainer/users/types';
|
||||
import { TeamId } from '@/react/portainer/users/teams/types';
|
||||
import { useTeams } from '@/react/portainer/users/teams/queries';
|
||||
import { useUsers } from '@/portainer/users/queries';
|
||||
import { pluralize } from '@/portainer/helpers/strings';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
import { Tooltip } from '@@/Tip/Tooltip';
|
||||
@@ -23,11 +24,13 @@ import { ResourceControlViewModel } from '../models/ResourceControlViewModel';
|
||||
interface Props {
|
||||
resourceControl?: ResourceControlViewModel;
|
||||
resourceType: ResourceControlType;
|
||||
isAuthorisedToFetchUsers?: boolean;
|
||||
}
|
||||
|
||||
export function AccessControlPanelDetails({
|
||||
resourceControl,
|
||||
resourceType,
|
||||
isAuthorisedToFetchUsers = false,
|
||||
}: Props) {
|
||||
const inheritanceMessage = getInheritanceMessage(
|
||||
resourceType,
|
||||
@@ -40,9 +43,31 @@ export function AccessControlPanelDetails({
|
||||
TeamAccesses: restrictedToTeams = [],
|
||||
} = resourceControl || {};
|
||||
|
||||
const users = useAuthorizedUsers(restrictedToUsers.map((ra) => ra.UserId));
|
||||
const users = useAuthorizedUsers(
|
||||
restrictedToUsers.map((ra) => ra.UserId),
|
||||
isAuthorisedToFetchUsers
|
||||
);
|
||||
const teams = useAuthorizedTeams(restrictedToTeams.map((ra) => ra.TeamId));
|
||||
|
||||
const teamsLength = teams.data ? teams.data.length : 0;
|
||||
const unauthoisedTeams = restrictedToTeams.length - teamsLength;
|
||||
|
||||
let teamsMessage = teams.data && teams.data.join(', ');
|
||||
if (unauthoisedTeams > 0 && teams.isFetched) {
|
||||
teamsMessage += teamsLength > 0 ? ' and' : '';
|
||||
teamsMessage += ` ${unauthoisedTeams} ${pluralize(
|
||||
unauthoisedTeams,
|
||||
'team'
|
||||
)} you are not part of`;
|
||||
}
|
||||
|
||||
const userMessage = users.data
|
||||
? users.data.join(', ')
|
||||
: `${restrictedToUsers.length} ${pluralize(
|
||||
restrictedToUsers.length,
|
||||
'user'
|
||||
)}`;
|
||||
|
||||
return (
|
||||
<table className="table">
|
||||
<tbody>
|
||||
@@ -62,17 +87,13 @@ export function AccessControlPanelDetails({
|
||||
{restrictedToUsers.length > 0 && (
|
||||
<tr data-cy="access-authorisedUsers">
|
||||
<td>Authorized users</td>
|
||||
<td aria-label="authorized-users">
|
||||
{users.data && users.data.join(', ')}
|
||||
</td>
|
||||
<td aria-label="authorized-users">{userMessage}</td>
|
||||
</tr>
|
||||
)}
|
||||
{restrictedToTeams.length > 0 && (
|
||||
<tr data-cy="access-authorisedTeams">
|
||||
<td>Authorized teams</td>
|
||||
<td aria-label="authorized-teams">
|
||||
{teams.data && teams.data.join(', ')}
|
||||
</td>
|
||||
<td aria-label="authorized-teams">{teamsMessage}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
|
||||
@@ -32,7 +32,7 @@ export function EditDetails({
|
||||
}: Props) {
|
||||
const { user, isAdmin } = useUser();
|
||||
|
||||
const { users, teams, isLoading } = useLoadState(environmentId);
|
||||
const { users, teams, isLoading } = useLoadState(environmentId, isAdmin);
|
||||
const handleChange = useCallback(
|
||||
(partialValues: Partial<typeof values>) => {
|
||||
onChange({ ...values, ...partialValues });
|
||||
@@ -41,7 +41,7 @@ export function EditDetails({
|
||||
[values, onChange]
|
||||
);
|
||||
|
||||
if (isLoading || !teams || !users) {
|
||||
if (isLoading || !teams || (isAdmin && !users) || !values.authorizedUsers) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export function EditDetails({
|
||||
{isAdmin && (
|
||||
<UsersField
|
||||
name={withNamespace('authorizedUsers')}
|
||||
users={users}
|
||||
users={users || []}
|
||||
onChange={(authorizedUsers) => handleChange({ authorizedUsers })}
|
||||
value={values.authorizedUsers}
|
||||
errors={errors?.authorizedUsers}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useTeams } from '@/react/portainer/users/teams/queries';
|
||||
import { useUsers } from '@/portainer/users/queries';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
export function useLoadState(environmentId?: EnvironmentId) {
|
||||
export function useLoadState(environmentId?: EnvironmentId, enabled = true) {
|
||||
const teams = useTeams(false, environmentId);
|
||||
const users = useUsers(false, environmentId);
|
||||
const users = useUsers(false, environmentId, enabled);
|
||||
|
||||
return {
|
||||
teams: teams.data,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Clock, Trash2 } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { withLimitToBE } from '@/react/hooks/useLimitToBE';
|
||||
import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
|
||||
|
||||
import { confirmDelete } from '@@/modals/confirm';
|
||||
import { Datatable } from '@@/datatables';
|
||||
@@ -17,6 +20,7 @@ import { BetaAlert } from '../common/BetaAlert';
|
||||
|
||||
import { columns } from './columns';
|
||||
import { createStore } from './datatable-store';
|
||||
import { DecoratedItem } from './types';
|
||||
|
||||
const storageKey = 'update-schedules-list';
|
||||
const settingsStore = createStore(storageKey);
|
||||
@@ -27,8 +31,24 @@ export function ListView() {
|
||||
const tableState = useTableState(settingsStore, storageKey);
|
||||
|
||||
const listQuery = useList(true);
|
||||
const groupsQuery = useEdgeGroups({
|
||||
select: (groups) => Object.fromEntries(groups.map((g) => [g.Id, g.Name])),
|
||||
});
|
||||
|
||||
if (!listQuery.data) {
|
||||
const items: Array<DecoratedItem> = useMemo(() => {
|
||||
if (!listQuery.data || !groupsQuery.data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return listQuery.data.map((item) => ({
|
||||
...item,
|
||||
edgeGroupNames: _.compact(
|
||||
item.edgeGroupIds.map((id) => groupsQuery.data[id])
|
||||
),
|
||||
}));
|
||||
}, [listQuery.data, groupsQuery.data]);
|
||||
|
||||
if (!listQuery.data || !groupsQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -46,7 +66,7 @@ export function ListView() {
|
||||
/>
|
||||
|
||||
<Datatable
|
||||
dataset={listQuery.data}
|
||||
dataset={items}
|
||||
columns={columns}
|
||||
settingsManager={tableState}
|
||||
title="Update & rollback"
|
||||
|
||||
@@ -1,27 +1,18 @@
|
||||
import _ from 'lodash';
|
||||
import { CellContext } from '@tanstack/react-table';
|
||||
|
||||
import { EdgeGroup } from '@/react/edge/edge-groups/types';
|
||||
import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
|
||||
|
||||
import { EdgeUpdateListItemResponse } from '../../queries/list';
|
||||
import { DecoratedItem } from '../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const groups = columnHelper.accessor('edgeGroupIds', {
|
||||
header: 'Groups',
|
||||
export const groups = columnHelper.accessor('edgeGroupNames', {
|
||||
header: 'Edge Groups',
|
||||
cell: GroupsCell,
|
||||
});
|
||||
|
||||
export function GroupsCell({
|
||||
getValue,
|
||||
}: CellContext<EdgeUpdateListItemResponse, Array<EdgeGroup['Id']>>) {
|
||||
const groupsIds = getValue();
|
||||
const groupsQuery = useEdgeGroups();
|
||||
}: CellContext<DecoratedItem, Array<string>>) {
|
||||
const groups = getValue();
|
||||
|
||||
const groups = _.compact(
|
||||
groupsIds.map((id) => groupsQuery.data?.find((g) => g.Id === id))
|
||||
);
|
||||
|
||||
return groups.map((g) => g.Name).join(', ');
|
||||
return groups.join(', ');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
import { EdgeUpdateListItemResponse } from '../../queries/list';
|
||||
import { DecoratedItem } from '../types';
|
||||
|
||||
export const columnHelper = createColumnHelper<EdgeUpdateListItemResponse>();
|
||||
export const columnHelper = createColumnHelper<DecoratedItem>();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { buildNameColumn } from '@@/datatables/NameCell';
|
||||
|
||||
import { EdgeUpdateListItemResponse } from '../../queries/list';
|
||||
import { DecoratedItem } from '../types';
|
||||
|
||||
import { created } from './created';
|
||||
import { groups } from './groups';
|
||||
@@ -9,7 +9,7 @@ import { scheduledTime } from './scheduled-time';
|
||||
import { scheduleType } from './type';
|
||||
|
||||
export const columns = [
|
||||
buildNameColumn<EdgeUpdateListItemResponse>('name', 'id', '.item'),
|
||||
buildNameColumn<DecoratedItem>('name', 'id', '.item'),
|
||||
scheduledTime,
|
||||
groups,
|
||||
scheduleType,
|
||||
|
||||
+2
-5
@@ -1,7 +1,7 @@
|
||||
import { CellContext } from '@tanstack/react-table';
|
||||
|
||||
import { EdgeUpdateListItemResponse } from '../../queries/list';
|
||||
import { StatusType } from '../../types';
|
||||
import { DecoratedItem } from '../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
@@ -15,10 +15,7 @@ function StatusCell({
|
||||
row: {
|
||||
original: { statusMessage },
|
||||
},
|
||||
}: CellContext<
|
||||
EdgeUpdateListItemResponse,
|
||||
EdgeUpdateListItemResponse['status']
|
||||
>) {
|
||||
}: CellContext<DecoratedItem, DecoratedItem['status']>) {
|
||||
const status = getValue();
|
||||
|
||||
switch (status) {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { EdgeUpdateListItemResponse } from '../queries/list';
|
||||
|
||||
export type DecoratedItem = EdgeUpdateListItemResponse & {
|
||||
edgeGroupNames: string[];
|
||||
};
|
||||
+4
-6
@@ -21,9 +21,8 @@ export function KubeConfigTeaserForm() {
|
||||
return (
|
||||
<Formik initialValues={initialValues} onSubmit={() => {}} validateOnMount>
|
||||
{() => (
|
||||
<Form className="mt-5">
|
||||
<Form>
|
||||
<FormSectionTitle>Environment details</FormSectionTitle>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<TextTip color="blue">
|
||||
@@ -32,10 +31,9 @@ export function KubeConfigTeaserForm() {
|
||||
href="https://docs.portainer.io/admin/environments/add/kubernetes/import"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mx-1"
|
||||
>
|
||||
Import the kubeconfig file
|
||||
</a>
|
||||
</a>{' '}
|
||||
of an existing Kubernetes cluster located on-premise or on a
|
||||
cloud platform. This will create a corresponding environment
|
||||
in Portainer and install the agent on the cluster. Please
|
||||
@@ -43,7 +41,7 @@ export function KubeConfigTeaserForm() {
|
||||
</span>
|
||||
</TextTip>
|
||||
</div>
|
||||
<div className="col-sm-12 text-muted small">
|
||||
<div className="col-sm-12 text-muted text-xs">
|
||||
<ul className="p-2 pl-4">
|
||||
<li>You have a load balancer enabled in your cluster</li>
|
||||
<li>You specify current-context in your kubeconfig</li>
|
||||
@@ -81,7 +79,7 @@ export function KubeConfigTeaserForm() {
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
className="wizard-connect-button"
|
||||
className="wizard-connect-button !ml-0"
|
||||
loadingText="Connecting environment..."
|
||||
isLoading={false}
|
||||
disabled
|
||||
|
||||
+5
-6
@@ -11,7 +11,7 @@ import EdgeAgentAsyncIcon from '@/react/edge/components/edge-agent-async.svg?c';
|
||||
|
||||
import { BoxSelectorOption } from '@@/BoxSelector/types';
|
||||
import { BoxSelector } from '@@/BoxSelector';
|
||||
import { BEFeatureIndicator } from '@@/BEFeatureIndicator';
|
||||
import { BEOverlay } from '@@/BEFeatureIndicator/BEOverlay';
|
||||
|
||||
import { AnalyticsStateKey } from '../types';
|
||||
import { EdgeAgentTab } from '../shared/EdgeAgentTab';
|
||||
@@ -112,11 +112,10 @@ export function WizardKubernetes({ onCreate }: Props) {
|
||||
);
|
||||
case 'kubeconfig':
|
||||
return (
|
||||
<div className="border border-solid border-orange-1 px-1 py-5">
|
||||
<BEFeatureIndicator
|
||||
featureId={options.find((o) => o.value === type)?.feature}
|
||||
/>
|
||||
<KubeConfigTeaserForm />
|
||||
<div className="mb-3">
|
||||
<BEOverlay featureId={FeatureId.K8S_CREATE_FROM_KUBECONFIG}>
|
||||
<KubeConfigTeaserForm />
|
||||
</BEOverlay>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user