From 4e77c72fa276f46a67b7b5de2ecba570695ae3ff Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Thu, 15 Dec 2016 16:33:47 +1300 Subject: [PATCH] feat(global): add authentication support with single admin account --- api/api.go | 41 ++ api/auth.go | 88 ++++ api/datastore.go | 98 ++++ api/handler.go | 23 +- api/jwt.go | 29 ++ api/main.go | 9 +- api/middleware.go | 65 +++ api/users.go | 219 +++++++++ app/app.js | 425 +++++++++++++++--- app/components/auth/auth.html | 101 +++++ app/components/auth/authController.js | 68 +++ app/components/containers/containers.html | 8 +- .../containers/containersController.js | 14 +- .../createContainerController.js | 7 +- .../createContainer/createcontainer.html | 6 +- app/components/dashboard/dashboard.html | 6 +- .../dashboard/dashboardController.js | 7 +- .../master-ctrl.js => main/mainController.js} | 26 +- app/components/networks/networks.html | 4 +- app/components/settings/settings.html | 67 +++ app/components/settings/settingsController.js | 30 ++ app/components/sidebar/sidebar.html | 55 +++ app/components/sidebar/sidebarController.js | 10 + app/components/swarm/swarm.html | 28 +- app/components/swarm/swarmController.js | 4 +- app/components/templates/templates.html | 8 +- .../templates/templatesController.js | 5 - app/directives/header-content.js | 2 +- app/directives/header-title.js | 9 +- app/shared/services.js | 91 +++- assets/css/app.css | 67 ++- assets/images/logo_alt.png | Bin 0 -> 23441 bytes bower.json | 6 +- gruntFile.js | 10 +- index.html | 59 +-- 35 files changed, 1475 insertions(+), 220 deletions(-) create mode 100644 api/auth.go create mode 100644 api/datastore.go create mode 100644 api/jwt.go create mode 100644 api/middleware.go create mode 100644 api/users.go create mode 100644 app/components/auth/auth.html create mode 100644 app/components/auth/authController.js rename app/components/{dashboard/master-ctrl.js => main/mainController.js} (51%) create mode 100644 app/components/settings/settings.html create mode 100644 app/components/settings/settingsController.js create mode 100644 app/components/sidebar/sidebar.html create mode 100644 app/components/sidebar/sidebarController.js create mode 100644 assets/images/logo_alt.png diff --git a/api/api.go b/api/api.go index af3eb23ed..5b71f7fdf 100644 --- a/api/api.go +++ b/api/api.go @@ -2,6 +2,8 @@ package main import ( "crypto/tls" + "errors" + "github.com/gorilla/securecookie" "log" "net/http" "net/url" @@ -15,6 +17,8 @@ type ( dataPath string tlsConfig *tls.Config templatesURL string + dataStore *dataStore + secret []byte } apiConfig struct { @@ -31,7 +35,21 @@ type ( } ) +const ( + datastoreFileName = "portainer.db" +) + +var ( + errSecretKeyGeneration = errors.New("Unable to generate secret key to sign JWT") +) + func (a *api) run(settings *Settings) { + err := a.initDatabase() + if err != nil { + log.Fatal(err) + } + defer a.cleanUp() + handler := a.newHandler(settings) log.Printf("Starting portainer on %s", a.bindAddress) if err := http.ListenAndServe(a.bindAddress, handler); err != nil { @@ -39,12 +57,34 @@ func (a *api) run(settings *Settings) { } } +func (a *api) cleanUp() { + a.dataStore.cleanUp() +} + +func (a *api) initDatabase() error { + dataStore, err := newDataStore(a.dataPath + "/" + datastoreFileName) + if err != nil { + return err + } + err = dataStore.initDataStore() + if err != nil { + return err + } + a.dataStore = dataStore + return nil +} + func newAPI(apiConfig apiConfig) *api { endpointURL, err := url.Parse(apiConfig.Endpoint) if err != nil { log.Fatal(err) } + secret := securecookie.GenerateRandomKey(32) + if secret == nil { + log.Fatal(errSecretKeyGeneration) + } + var tlsConfig *tls.Config if apiConfig.TLSEnabled { tlsConfig = newTLSConfig(apiConfig.TLSCACertPath, apiConfig.TLSCertPath, apiConfig.TLSKeyPath) @@ -57,5 +97,6 @@ func newAPI(apiConfig apiConfig) *api { dataPath: apiConfig.DataPath, tlsConfig: tlsConfig, templatesURL: apiConfig.TemplatesURL, + secret: secret, } } diff --git a/api/auth.go b/api/auth.go new file mode 100644 index 000000000..74355c00b --- /dev/null +++ b/api/auth.go @@ -0,0 +1,88 @@ +package main + +import ( + "encoding/json" + "github.com/asaskevich/govalidator" + "golang.org/x/crypto/bcrypt" + "io/ioutil" + "log" + "net/http" +) + +type ( + credentials struct { + Username string `valid:"alphanum,required"` + Password string `valid:"length(8)"` + } + authResponse struct { + JWT string `json:"jwt"` + } +) + +func hashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", nil + } + return string(hash), nil +} + +func checkPasswordValidity(password string, hash string) error { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) +} + +// authHandler defines a handler function used to authenticate users +func (api *api) authHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + w.Header().Set("Allow", "POST") + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := ioutil.ReadAll(r.Body) + if err != nil { + http.Error(w, "Unable to parse request body", http.StatusBadRequest) + return + } + + var credentials credentials + err = json.Unmarshal(body, &credentials) + if err != nil { + http.Error(w, "Unable to parse credentials", http.StatusBadRequest) + return + } + + _, err = govalidator.ValidateStruct(credentials) + if err != nil { + http.Error(w, "Invalid credentials format", http.StatusBadRequest) + return + } + + var username = credentials.Username + var password = credentials.Password + u, err := api.dataStore.getUserByUsername(username) + if err != nil { + log.Printf("User not found: %s", username) + http.Error(w, "User not found", http.StatusNotFound) + return + } + + err = checkPasswordValidity(password, u.Password) + if err != nil { + log.Printf("Invalid credentials for user: %s", username) + http.Error(w, "Invalid credentials", http.StatusUnprocessableEntity) + return + } + + token, err := api.generateJWTToken(username) + if err != nil { + log.Printf("Unable to generate JWT token: %s", err.Error()) + http.Error(w, "Unable to generate JWT token", http.StatusInternalServerError) + return + } + + response := authResponse{ + JWT: token, + } + json.NewEncoder(w).Encode(response) +} diff --git a/api/datastore.go b/api/datastore.go new file mode 100644 index 000000000..58efd7f5e --- /dev/null +++ b/api/datastore.go @@ -0,0 +1,98 @@ +package main + +import ( + "encoding/json" + "errors" + "github.com/boltdb/bolt" +) + +const ( + userBucketName = "users" +) + +type ( + dataStore struct { + db *bolt.DB + } + + userItem struct { + Username string `json:"username"` + Password string `json:"password,omitempty"` + } +) + +var ( + errUserNotFound = errors.New("User not found") +) + +func (dataStore *dataStore) initDataStore() error { + return dataStore.db.Update(func(tx *bolt.Tx) error { + _, err := tx.CreateBucketIfNotExists([]byte(userBucketName)) + if err != nil { + return err + } + return nil + }) +} + +func (dataStore *dataStore) cleanUp() { + dataStore.db.Close() +} + +func newDataStore(databasePath string) (*dataStore, error) { + db, err := bolt.Open(databasePath, 0600, nil) + if err != nil { + return nil, err + } + + return &dataStore{ + db: db, + }, nil +} + +func (dataStore *dataStore) getUserByUsername(username string) (*userItem, error) { + var data []byte + + err := dataStore.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(userBucketName)) + value := bucket.Get([]byte(username)) + if value == nil { + return errUserNotFound + } + + data = make([]byte, len(value)) + copy(data, value) + return nil + }) + if err != nil { + return nil, err + } + + var user userItem + err = json.Unmarshal(data, &user) + if err != nil { + return nil, err + } + return &user, nil +} + +func (dataStore *dataStore) updateUser(user userItem) error { + buffer, err := json.Marshal(user) + if err != nil { + return err + } + + err = dataStore.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(userBucketName)) + err = bucket.Put([]byte(user.Username), buffer) + if err != nil { + return err + } + return nil + }) + + if err != nil { + return err + } + return nil +} diff --git a/api/handler.go b/api/handler.go index ef60a8583..f0d902659 100644 --- a/api/handler.go +++ b/api/handler.go @@ -1,6 +1,7 @@ package main import ( + "github.com/gorilla/mux" "golang.org/x/net/websocket" "log" "net/http" @@ -12,21 +13,35 @@ import ( // newHandler creates a new http.Handler with CSRF protection func (a *api) newHandler(settings *Settings) http.Handler { var ( - mux = http.NewServeMux() + mux = mux.NewRouter() fileHandler = http.FileServer(http.Dir(a.assetPath)) ) - handler := a.newAPIHandler() - mux.Handle("/", fileHandler) - mux.Handle("/dockerapi/", http.StripPrefix("/dockerapi", handler)) mux.Handle("/ws/exec", websocket.Handler(a.execContainer)) + mux.HandleFunc("/auth", a.authHandler) + mux.Handle("/users", addMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + a.usersHandler(w, r) + }), a.authenticate, secureHeaders)) + mux.Handle("/users/{username}", addMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + a.userHandler(w, r) + }), a.authenticate, secureHeaders)) + mux.Handle("/users/{username}/passwd", addMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + a.userPasswordHandler(w, r) + }), a.authenticate, secureHeaders)) + mux.HandleFunc("/users/admin/check", a.checkAdminHandler) + mux.HandleFunc("/users/admin/init", a.initAdminHandler) mux.HandleFunc("/settings", func(w http.ResponseWriter, r *http.Request) { settingsHandler(w, r, settings) }) mux.HandleFunc("/templates", func(w http.ResponseWriter, r *http.Request) { templatesHandler(w, r, a.templatesURL) }) + // mux.PathPrefix("/dockerapi/").Handler(http.StripPrefix("/dockerapi", handler)) + mux.PathPrefix("/dockerapi/").Handler(http.StripPrefix("/dockerapi", addMiddleware(handler, a.authenticate, secureHeaders))) + + mux.PathPrefix("/").Handler(http.StripPrefix("/", fileHandler)) + // CSRF protection is disabled for the moment // CSRFHandler := newCSRFHandler(a.dataPath) // return CSRFHandler(newCSRFWrapper(mux)) diff --git a/api/jwt.go b/api/jwt.go new file mode 100644 index 000000000..880a23a70 --- /dev/null +++ b/api/jwt.go @@ -0,0 +1,29 @@ +package main + +import ( + "github.com/dgrijalva/jwt-go" + "time" +) + +type claims struct { + Username string `json:"username"` + jwt.StandardClaims +} + +func (api *api) generateJWTToken(username string) (string, error) { + expireToken := time.Now().Add(time.Hour * 8).Unix() + claims := claims{ + username, + jwt.StandardClaims{ + ExpiresAt: expireToken, + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + signedToken, err := token.SignedString(api.secret) + if err != nil { + return "", err + } + + return signedToken, nil +} diff --git a/api/main.go b/api/main.go index 82fbd5651..a63d5a534 100644 --- a/api/main.go +++ b/api/main.go @@ -4,14 +4,19 @@ import ( "gopkg.in/alecthomas/kingpin.v2" ) +const ( + // Version number of portainer API + Version = "1.10.2" +) + // main is the entry point of the program func main() { - kingpin.Version("1.10.2") + kingpin.Version(Version) var ( endpoint = kingpin.Flag("host", "Dockerd endpoint").Default("unix:///var/run/docker.sock").Short('H').String() addr = kingpin.Flag("bind", "Address and port to serve Portainer").Default(":9000").Short('p').String() assets = kingpin.Flag("assets", "Path to the assets").Default(".").Short('a').String() - data = kingpin.Flag("data", "Path to the data").Default(".").Short('d').String() + data = kingpin.Flag("data", "Path to the folder where the data is stored").Default("/data").Short('d').String() tlsverify = kingpin.Flag("tlsverify", "TLS support").Default("false").Bool() tlscacert = kingpin.Flag("tlscacert", "Path to the CA").Default("/certs/ca.pem").String() tlscert = kingpin.Flag("tlscert", "Path to the TLS certificate file").Default("/certs/cert.pem").String() diff --git a/api/middleware.go b/api/middleware.go new file mode 100644 index 000000000..da12ae730 --- /dev/null +++ b/api/middleware.go @@ -0,0 +1,65 @@ +package main + +import ( + "fmt" + "github.com/dgrijalva/jwt-go" + "net/http" + "strings" +) + +func addMiddleware(h http.Handler, middleware ...func(http.Handler) http.Handler) http.Handler { + for _, mw := range middleware { + h = mw(h) + } + return h +} + +// authenticate provides Authentication middleware for handlers +func (api *api) authenticate(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var token string + + // Get token from the Authorization header + // format: Authorization: Bearer + tokens, ok := r.Header["Authorization"] + if ok && len(tokens) >= 1 { + token = tokens[0] + token = strings.TrimPrefix(token, "Bearer ") + } + + if token == "" { + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + + parsedToken, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + msg := fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) + return nil, msg + } + return api.secret, nil + }) + if err != nil { + http.Error(w, "Invalid JWT token", http.StatusUnauthorized) + return + } + + if parsedToken == nil || !parsedToken.Valid { + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + + // context.Set(r, "user", parsedToken) + next.ServeHTTP(w, r) + return + }) +} + +// SecureHeaders adds secure headers to the API +func secureHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("X-Content-Type-Options", "nosniff") + w.Header().Add("X-Frame-Options", "DENY") + next.ServeHTTP(w, r) + }) +} diff --git a/api/users.go b/api/users.go new file mode 100644 index 000000000..d0a48aceb --- /dev/null +++ b/api/users.go @@ -0,0 +1,219 @@ +package main + +import ( + "encoding/json" + "github.com/gorilla/mux" + "io/ioutil" + "log" + "net/http" +) + +type ( + passwordCheckRequest struct { + Password string `json:"password"` + } + passwordCheckResponse struct { + Valid bool `json:"valid"` + } + initAdminRequest struct { + Password string `json:"password"` + } +) + +// handle /users +// Allowed methods: POST +func (api *api) usersHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + w.Header().Set("Allow", "POST") + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := ioutil.ReadAll(r.Body) + if err != nil { + http.Error(w, "Unable to parse request body", http.StatusBadRequest) + return + } + + var user userItem + err = json.Unmarshal(body, &user) + if err != nil { + http.Error(w, "Unable to parse user data", http.StatusBadRequest) + return + } + + user.Password, err = hashPassword(user.Password) + if err != nil { + http.Error(w, "Unable to hash user password", http.StatusInternalServerError) + return + } + + err = api.dataStore.updateUser(user) + if err != nil { + log.Printf("Unable to persist user: %s", err.Error()) + http.Error(w, "Unable to persist user", http.StatusInternalServerError) + return + } +} + +// handle /users/admin/check +// Allowed methods: POST +func (api *api) checkAdminHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + w.Header().Set("Allow", "GET") + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + user, err := api.dataStore.getUserByUsername("admin") + if err == errUserNotFound { + log.Printf("User not found: %s", "admin") + http.Error(w, "User not found", http.StatusNotFound) + return + } + if err != nil { + log.Printf("Unable to retrieve user: %s", err.Error()) + http.Error(w, "Unable to retrieve user", http.StatusInternalServerError) + return + } + + user.Password = "" + json.NewEncoder(w).Encode(user) +} + +// handle /users/admin/init +// Allowed methods: POST +func (api *api) initAdminHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + w.Header().Set("Allow", "POST") + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := ioutil.ReadAll(r.Body) + if err != nil { + http.Error(w, "Unable to parse request body", http.StatusBadRequest) + return + } + + var requestData initAdminRequest + err = json.Unmarshal(body, &requestData) + if err != nil { + http.Error(w, "Unable to parse user data", http.StatusBadRequest) + return + } + + user := userItem{ + Username: "admin", + } + user.Password, err = hashPassword(requestData.Password) + if err != nil { + http.Error(w, "Unable to hash user password", http.StatusInternalServerError) + return + } + + err = api.dataStore.updateUser(user) + if err != nil { + log.Printf("Unable to persist user: %s", err.Error()) + http.Error(w, "Unable to persist user", http.StatusInternalServerError) + return + } +} + +// handle /users/{username} +// Allowed methods: PUT, GET +func (api *api) userHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "PUT" { + body, err := ioutil.ReadAll(r.Body) + if err != nil { + http.Error(w, "Unable to parse request body", http.StatusBadRequest) + return + } + + var user userItem + err = json.Unmarshal(body, &user) + if err != nil { + http.Error(w, "Unable to parse user data", http.StatusBadRequest) + return + } + + user.Password, err = hashPassword(user.Password) + if err != nil { + http.Error(w, "Unable to hash user password", http.StatusInternalServerError) + return + } + + err = api.dataStore.updateUser(user) + if err != nil { + log.Printf("Unable to persist user: %s", err.Error()) + http.Error(w, "Unable to persist user", http.StatusInternalServerError) + return + } + } else if r.Method == "GET" { + vars := mux.Vars(r) + username := vars["username"] + + user, err := api.dataStore.getUserByUsername(username) + if err == errUserNotFound { + log.Printf("User not found: %s", username) + http.Error(w, "User not found", http.StatusNotFound) + return + } + if err != nil { + log.Printf("Unable to retrieve user: %s", err.Error()) + http.Error(w, "Unable to retrieve user", http.StatusInternalServerError) + return + } + + user.Password = "" + json.NewEncoder(w).Encode(user) + } else { + w.Header().Set("Allow", "PUT, GET") + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } +} + +// handle /users/{username}/passwd +// Allowed methods: POST +func (api *api) userPasswordHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + w.Header().Set("Allow", "POST") + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + vars := mux.Vars(r) + username := vars["username"] + + body, err := ioutil.ReadAll(r.Body) + if err != nil { + http.Error(w, "Unable to parse request body", http.StatusBadRequest) + return + } + + var data passwordCheckRequest + err = json.Unmarshal(body, &data) + if err != nil { + http.Error(w, "Unable to parse user data", http.StatusBadRequest) + return + } + + user, err := api.dataStore.getUserByUsername(username) + if err != nil { + log.Printf("Unable to retrieve user: %s", err.Error()) + http.Error(w, "Unable to retrieve user", http.StatusInternalServerError) + return + } + + valid := true + err = checkPasswordValidity(data.Password, user.Password) + if err != nil { + valid = false + } + + response := passwordCheckResponse{ + Valid: valid, + } + json.NewEncoder(w).Encode(response) +} diff --git a/app/app.js b/app/app.js index f06c050fc..144f407d8 100644 --- a/app/app.js +++ b/app/app.js @@ -6,9 +6,12 @@ angular.module('portainer', [ 'ngCookies', 'ngSanitize', 'angularUtils.directives.dirPagination', + 'LocalStorageModule', + 'angular-jwt', 'portainer.services', 'portainer.helpers', 'portainer.filters', + 'auth', 'dashboard', 'container', 'containerConsole', @@ -19,8 +22,11 @@ angular.module('portainer', [ 'events', 'images', 'image', + 'main', 'service', 'services', + 'settings', + 'sidebar', 'createService', 'stats', 'swarm', @@ -31,131 +37,430 @@ angular.module('portainer', [ 'templates', 'volumes', 'createVolume']) - .config(['$stateProvider', '$urlRouterProvider', '$httpProvider', function ($stateProvider, $urlRouterProvider, $httpProvider) { + .config(['$stateProvider', '$urlRouterProvider', '$httpProvider', 'localStorageServiceProvider', 'jwtOptionsProvider', function ($stateProvider, $urlRouterProvider, $httpProvider, localStorageServiceProvider, jwtOptionsProvider) { 'use strict'; - $urlRouterProvider.otherwise('/'); + localStorageServiceProvider + .setStorageType('sessionStorage') + .setPrefix('portainer'); + + jwtOptionsProvider.config({ + tokenGetter: ['localStorageService', function(localStorageService) { + return localStorageService.get('JWT'); + }], + unauthenticatedRedirector: ['$state', function($state) { + $state.go('auth', {error: 'Your session has expired'}); + }] + }); + $httpProvider.interceptors.push('jwtInterceptor'); + + $urlRouterProvider.otherwise('/auth'); $stateProvider - .state('index', { - url: '/', - templateUrl: 'app/components/dashboard/dashboard.html', - controller: 'DashboardController' + .state('auth', { + url: '/auth', + params: { + logout: false, + error: '' + }, + views: { + "content": { + templateUrl: 'app/components/auth/auth.html', + controller: 'AuthenticationController' + } + } }) .state('containers', { url: '/containers/', - templateUrl: 'app/components/containers/containers.html', - controller: 'ContainersController' + views: { + "content": { + templateUrl: 'app/components/containers/containers.html', + controller: 'ContainersController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('container', { url: "^/containers/:id", - templateUrl: 'app/components/container/container.html', - controller: 'ContainerController' + views: { + "content": { + templateUrl: 'app/components/container/container.html', + controller: 'ContainerController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('stats', { url: "^/containers/:id/stats", - templateUrl: 'app/components/stats/stats.html', - controller: 'StatsController' + views: { + "content": { + templateUrl: 'app/components/stats/stats.html', + controller: 'StatsController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('logs', { url: "^/containers/:id/logs", - templateUrl: 'app/components/containerLogs/containerlogs.html', - controller: 'ContainerLogsController' + views: { + "content": { + templateUrl: 'app/components/containerLogs/containerlogs.html', + controller: 'ContainerLogsController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('console', { url: "^/containers/:id/console", - templateUrl: 'app/components/containerConsole/containerConsole.html', - controller: 'ContainerConsoleController' + views: { + "content": { + templateUrl: 'app/components/containerConsole/containerConsole.html', + controller: 'ContainerConsoleController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } + }) + .state('dashboard', { + url: '/dashboard', + views: { + "content": { + templateUrl: 'app/components/dashboard/dashboard.html', + controller: 'DashboardController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('actions', { abstract: true, url: "/actions", - template: '' + views: { + "content": { + template: '
' + }, + "sidebar": { + template: '
' + } + } }) .state('actions.create', { abstract: true, url: "/create", - template: '' + views: { + "content": { + template: '
' + }, + "sidebar": { + template: '
' + } + } }) .state('actions.create.container', { url: "/container", - templateUrl: 'app/components/createContainer/createcontainer.html', - controller: 'CreateContainerController' + views: { + "content": { + templateUrl: 'app/components/createContainer/createcontainer.html', + controller: 'CreateContainerController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('actions.create.network', { url: "/network", - templateUrl: 'app/components/createNetwork/createnetwork.html', - controller: 'CreateNetworkController' + views: { + "content": { + templateUrl: 'app/components/createNetwork/createnetwork.html', + controller: 'CreateNetworkController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('actions.create.service', { url: "/service", - templateUrl: 'app/components/createService/createservice.html', - controller: 'CreateServiceController' + views: { + "content": { + templateUrl: 'app/components/createService/createservice.html', + controller: 'CreateServiceController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('actions.create.volume', { url: "/volume", - templateUrl: 'app/components/createVolume/createvolume.html', - controller: 'CreateVolumeController' + views: { + "content": { + templateUrl: 'app/components/createVolume/createvolume.html', + controller: 'CreateVolumeController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('docker', { url: '/docker/', - templateUrl: 'app/components/docker/docker.html', - controller: 'DockerController' + views: { + "content": { + templateUrl: 'app/components/docker/docker.html', + controller: 'DockerController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('events', { url: '/events/', - templateUrl: 'app/components/events/events.html', - controller: 'EventsController' + views: { + "content": { + templateUrl: 'app/components/events/events.html', + controller: 'EventsController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('images', { url: '/images/', - templateUrl: 'app/components/images/images.html', - controller: 'ImagesController' + views: { + "content": { + templateUrl: 'app/components/images/images.html', + controller: 'ImagesController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('image', { url: '^/images/:id/', - templateUrl: 'app/components/image/image.html', - controller: 'ImageController' + views: { + "content": { + templateUrl: 'app/components/image/image.html', + controller: 'ImageController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('networks', { url: '/networks/', - templateUrl: 'app/components/networks/networks.html', - controller: 'NetworksController' + views: { + "content": { + templateUrl: 'app/components/networks/networks.html', + controller: 'NetworksController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('network', { url: '^/networks/:id/', - templateUrl: 'app/components/network/network.html', - controller: 'NetworkController' + views: { + "content": { + templateUrl: 'app/components/network/network.html', + controller: 'NetworkController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('services', { url: '/services/', - templateUrl: 'app/components/services/services.html', - controller: 'ServicesController' + views: { + "content": { + templateUrl: 'app/components/services/services.html', + controller: 'ServicesController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('service', { url: '^/service/:id/', - templateUrl: 'app/components/service/service.html', - controller: 'ServiceController' + views: { + "content": { + templateUrl: 'app/components/service/service.html', + controller: 'ServiceController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } + }) + .state('settings', { + url: '/settings/', + views: { + "content": { + templateUrl: 'app/components/settings/settings.html', + controller: 'SettingsController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('task', { url: '^/task/:id', - templateUrl: 'app/components/task/task.html', - controller: 'TaskController' + views: { + "content": { + templateUrl: 'app/components/task/task.html', + controller: 'TaskController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('templates', { url: '/templates/', - templateUrl: 'app/components/templates/templates.html', - controller: 'TemplatesController' + views: { + "content": { + templateUrl: 'app/components/templates/templates.html', + controller: 'TemplatesController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('volumes', { url: '/volumes/', - templateUrl: 'app/components/volumes/volumes.html', - controller: 'VolumesController' + views: { + "content": { + templateUrl: 'app/components/volumes/volumes.html', + controller: 'VolumesController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }) .state('swarm', { url: '/swarm/', - templateUrl: 'app/components/swarm/swarm.html', - controller: 'SwarmController' + views: { + "content": { + templateUrl: 'app/components/swarm/swarm.html', + controller: 'SwarmController' + }, + "sidebar": { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + }, + data: { + requiresLogin: true + } }); // The Docker API likes to return plaintext errors, this catches them and disp @@ -165,7 +470,7 @@ angular.module('portainer', [ return { 'response': function(response) { if (typeof(response.data) === 'string' && - (response.data.startsWith('Conflict.') || response.data.startsWith('conflict:'))) { + (response.data.startsWith('Conflict.') || response.data.startsWith('conflict:'))) { $.gritter.add({ title: 'Error', text: $('
').text(response.data).html(), @@ -182,12 +487,28 @@ angular.module('portainer', [ }; }); }]) + .run(['$rootScope', '$state', 'Authentication', 'authManager', 'EndpointMode', function ($rootScope, $state, Authentication, authManager, EndpointMode) { + authManager.checkAuthOnRefresh(); + authManager.redirectWhenUnauthenticated(); + Authentication.init(); + $rootScope.$state = $state; + $rootScope.$on('tokenHasExpired', function($state) { + $state.go('auth', {error: 'Your session has expired'}); + }); + + $rootScope.$on("$stateChangeStart", function(event, toState, toParams, fromState, fromParams) { + if ((fromState.name === 'auth' || fromState.name === '') && Authentication.isAuthenticated()) { + EndpointMode.determineEndpointMode(); + } + }); + }]) // This is your docker url that the api will use to make requests // You need to set this to the api endpoint without the port i.e. http://192.168.1.9 .constant('DOCKER_ENDPOINT', 'dockerapi') - .constant('DOCKER_PORT', '') // Docker port, leave as an empty string if no port is requred. If you have a port, prefix it with a ':' i.e. :4243 + .constant('DOCKER_PORT', '') // Docker port, leave as an empty string if no port is required. If you have a port, prefix it with a ':' i.e. :4243 .constant('CONFIG_ENDPOINT', 'settings') + .constant('AUTH_ENDPOINT', 'auth') .constant('TEMPLATES_ENDPOINT', 'templates') .constant('PAGINATION_MAX_ITEMS', 10) .constant('UI_VERSION', 'v1.10.2'); diff --git a/app/components/auth/auth.html b/app/components/auth/auth.html new file mode 100644 index 000000000..f335c52ef --- /dev/null +++ b/app/components/auth/auth.html @@ -0,0 +1,101 @@ + diff --git a/app/components/auth/authController.js b/app/components/auth/authController.js new file mode 100644 index 000000000..6fef01af2 --- /dev/null +++ b/app/components/auth/authController.js @@ -0,0 +1,68 @@ +angular.module('auth', []) +.controller('AuthenticationController', ['$scope', '$state', '$stateParams', '$window', '$timeout', '$sanitize', 'Config', 'Authentication', 'Users', 'Messages', +function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Config, Authentication, Users, Messages) { + + $scope.authData = { + username: 'admin', + password: '', + error: '' + }; + $scope.initPasswordData = { + password: '', + password_confirmation: '', + error: false + }; + + if ($stateParams.logout) { + Authentication.logout(); + } + + if ($stateParams.error) { + $scope.authData.error = $stateParams.error; + Authentication.logout(); + } + + if (Authentication.isAuthenticated()) { + $state.go('dashboard'); + } + + Config.$promise.then(function (c) { + $scope.logo = c.logo; + }); + + Users.checkAdminUser({}, function (d) {}, + function (e) { + if (e.status === 404) { + $scope.initPassword = true; + } else { + Messages.error("Failure", e, 'Unable to verify administrator account existence'); + } + }); + + $scope.createAdminUser = function() { + var password = $sanitize($scope.initPasswordData.password); + Users.initAdminUser({password: password}, function (d) { + $scope.initPassword = false; + $timeout(function() { + var element = $window.document.getElementById('password'); + if(element) { + element.focus(); + } + }); + }, function (e) { + $scope.initPassword.error = true; + }); + }; + + $scope.authenticateUser = function() { + $scope.authenticationError = false; + var username = $sanitize($scope.authData.username); + var password = $sanitize($scope.authData.password); + Authentication.login(username, password) + .then(function() { + $state.go('dashboard'); + }, function() { + $scope.authData.error = 'Invalid credentials'; + }); + }; +}]); diff --git a/app/components/containers/containers.html b/app/components/containers/containers.html index 97a9f677a..c7d86a426 100644 --- a/app/components/containers/containers.html +++ b/app/components/containers/containers.html @@ -66,7 +66,7 @@ - + Host IP @@ -86,11 +86,11 @@ {{ container.Status|containerstatus }} - {{ container|swarmcontainername}} - {{ container|containername}} + {{ container|swarmcontainername}} + {{ container|containername}} {{ container.Image }} {{ container.IP ? container.IP : '-' }} - {{ container.hostIP }} + {{ container.hostIP }} {{p.public}}:{{ p.private }} diff --git a/app/components/containers/containersController.js b/app/components/containers/containersController.js index 63db2d0b9..acaa912ed 100644 --- a/app/components/containers/containersController.js +++ b/app/components/containers/containersController.js @@ -7,9 +7,7 @@ function ($scope, Container, ContainerHelper, Info, Settings, Messages, Config) $scope.sortType = 'State'; $scope.sortReverse = false; $scope.state.selectedItemCount = 0; - $scope.swarm_mode = false; $scope.pagination_count = Settings.pagination_count; - $scope.order = function (sortType) { $scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false; $scope.sortType = sortType; @@ -28,7 +26,7 @@ function ($scope, Container, ContainerHelper, Info, Settings, Messages, Config) if (model.IP) { $scope.state.displayIP = true; } - if ($scope.swarm && !$scope.swarm_mode) { + if ($scope.endpointMode.provider === 'DOCKER_SWARM') { model.hostIP = $scope.swarm_hosts[_.split(container.Names[0], '/')[1]]; } return model; @@ -150,17 +148,11 @@ function ($scope, Container, ContainerHelper, Info, Settings, Messages, Config) return swarm_hosts; } - $scope.swarm = false; Config.$promise.then(function (c) { $scope.containersToHideLabels = c.hiddenLabels; - $scope.swarm = c.swarm; - if (c.swarm) { + if (c.swarm && $scope.endpointMode.provider === 'DOCKER_SWARM') { Info.get({}, function (d) { - if (!_.startsWith(d.ServerVersion, 'swarm')) { - $scope.swarm_mode = true; - } else { - $scope.swarm_hosts = retrieveSwarmHostsInfo(d); - } + $scope.swarm_hosts = retrieveSwarmHostsInfo(d); update({all: Settings.displayAll ? 1 : 0}); }); } else { diff --git a/app/components/createContainer/createContainerController.js b/app/components/createContainer/createContainerController.js index fa5f785d1..617f3d49b 100644 --- a/app/components/createContainer/createContainerController.js +++ b/app/components/createContainer/createContainerController.js @@ -53,11 +53,6 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai Config.$promise.then(function (c) { $scope.swarm = c.swarm; - Info.get({}, function(info) { - if ($scope.swarm && !_.startsWith(info.ServerVersion, 'swarm')) { - $scope.swarm_mode = true; - } - }); var containersToHideLabels = c.hiddenLabels; Volume.query({}, function (d) { @@ -216,7 +211,7 @@ function ($scope, $state, $stateParams, $filter, Config, Info, Container, Contai var containerName = container; if (container && typeof container === 'object') { containerName = $filter('trimcontainername')(container.Names[0]); - if ($scope.swarm && !$scope.swarm_mode) { + if ($scope.swarm && $scope.endpointMode.provider === 'DOCKER_SWARM') { containerName = $filter('swarmcontainername')(container); } } diff --git a/app/components/createContainer/createcontainer.html b/app/components/createContainer/createcontainer.html index 795ecfefa..b6aa18322 100644 --- a/app/components/createContainer/createcontainer.html +++ b/app/components/createContainer/createcontainer.html @@ -258,7 +258,7 @@
-
+
@@ -278,10 +278,10 @@
- -
diff --git a/app/components/dashboard/dashboard.html b/app/components/dashboard/dashboard.html index 9e4366633..f9beebe91 100644 --- a/app/components/dashboard/dashboard.html +++ b/app/components/dashboard/dashboard.html @@ -6,7 +6,7 @@
-
+
@@ -33,7 +33,7 @@
-
+
@@ -60,7 +60,7 @@
-
+
diff --git a/app/components/dashboard/dashboardController.js b/app/components/dashboard/dashboardController.js index 884633943..c48b76b1b 100644 --- a/app/components/dashboard/dashboardController.js +++ b/app/components/dashboard/dashboardController.js @@ -14,7 +14,6 @@ function ($scope, $q, Config, Container, ContainerHelper, Image, Network, Volume $scope.volumeData = { total: 0 }; - $scope.swarm_mode = false; function prepareContainerData(d, containersToHideLabels) { var running = 0; @@ -64,9 +63,6 @@ function ($scope, $q, Config, Container, ContainerHelper, Image, Network, Volume function prepareInfoData(d) { var info = d; $scope.infoData = info; - if ($scope.swarm && !_.startsWith(info.ServerVersion, 'swarm')) { - $scope.swarm_mode = true; - } } function fetchDashboardData(containersToHideLabels) { @@ -84,6 +80,9 @@ function ($scope, $q, Config, Container, ContainerHelper, Image, Network, Volume prepareNetworkData(d[3]); prepareInfoData(d[4]); $('#loadingViewSpinner').hide(); + }, function(e) { + $('#loadingViewSpinner').hide(); + Messages.error("Failure", e, "Unable to load dashboard data"); }); } diff --git a/app/components/dashboard/master-ctrl.js b/app/components/main/mainController.js similarity index 51% rename from app/components/dashboard/master-ctrl.js rename to app/components/main/mainController.js index 356ce7de4..3a48c9bc4 100644 --- a/app/components/dashboard/master-ctrl.js +++ b/app/components/main/mainController.js @@ -1,31 +1,15 @@ -angular.module('dashboard') -.controller('MasterCtrl', ['$scope', '$cookieStore', 'Settings', 'Config', 'Info', -function ($scope, $cookieStore, Settings, Config, Info) { +angular.module('main', []) +.controller('MainController', ['$scope', '$cookieStore', +function ($scope, $cookieStore) { + /** * Sidebar Toggle & Cookie Control */ var mobileView = 992; - $scope.getWidth = function() { return window.innerWidth; }; - $scope.swarm_mode = false; - - Config.$promise.then(function (c) { - $scope.logo = c.logo; - $scope.swarm = c.swarm; - Info.get({}, function(d) { - if ($scope.swarm && !_.startsWith(d.ServerVersion, 'swarm')) { - $scope.swarm_mode = true; - $scope.swarm_manager = false; - if (d.Swarm.ControlAvailable) { - $scope.swarm_manager = true; - } - } - }); - }); - $scope.$watch($scope.getWidth, function(newValue, oldValue) { if (newValue >= mobileView) { if (angular.isDefined($cookieStore.get('toggle'))) { @@ -47,6 +31,4 @@ function ($scope, $cookieStore, Settings, Config, Info) { window.onresize = function() { $scope.$apply(); }; - - $scope.uiVersion = Settings.uiVersion; }]); diff --git a/app/components/networks/networks.html b/app/components/networks/networks.html index b8ef43cd4..806640a95 100644 --- a/app/components/networks/networks.html +++ b/app/components/networks/networks.html @@ -23,12 +23,12 @@
-
+
Note: The network will be created using the overlay driver and will allow containers to communicate across the hosts of your cluster.
-
+
Note: The network will be created using the bridge driver.
diff --git a/app/components/settings/settings.html b/app/components/settings/settings.html new file mode 100644 index 000000000..bc63db071 --- /dev/null +++ b/app/components/settings/settings.html @@ -0,0 +1,67 @@ + + + + Settings + + +
+
+ + + + + +
+ +
+
+ + +
+
+
+ +
+

+ + Your new password must be at least 8 characters long +

+
+ +
+ +
+
+ + +
+
+
+ + +
+ +
+
+ + + +
+
+
+ +
+
+ +
+
+

+ Current password is not valid +

+
+
+ +
+
+
+
diff --git a/app/components/settings/settingsController.js b/app/components/settings/settingsController.js new file mode 100644 index 000000000..5e4fd565a --- /dev/null +++ b/app/components/settings/settingsController.js @@ -0,0 +1,30 @@ +angular.module('settings', []) +.controller('SettingsController', ['$scope', '$state', '$sanitize', 'Users', 'Messages', +function ($scope, $state, $sanitize, Users, Messages) { + $scope.formValues = { + currentPassword: '', + newPassword: '', + confirmPassword: '' + }; + + $scope.updatePassword = function() { + $scope.invalidPassword = false; + $scope.error = false; + var currentPassword = $sanitize($scope.formValues.currentPassword); + Users.checkPassword({ username: $scope.username, password: currentPassword }, function (d) { + if (d.valid) { + var newPassword = $sanitize($scope.formValues.newPassword); + Users.update({ username: $scope.username, password: newPassword }, function (d) { + Messages.send("Success", "Password successfully updated"); + $state.go('settings', {}, {reload: true}); + }, function (e) { + Messages.error("Failure", e, "Unable to update password"); + }); + } else { + $scope.invalidPassword = true; + } + }, function (e) { + Messages.error("Failure", e, "Unable to check password validity"); + }); + }; +}]); diff --git a/app/components/sidebar/sidebar.html b/app/components/sidebar/sidebar.html new file mode 100644 index 000000000..f3b133a76 --- /dev/null +++ b/app/components/sidebar/sidebar.html @@ -0,0 +1,55 @@ + + + diff --git a/app/components/sidebar/sidebarController.js b/app/components/sidebar/sidebarController.js new file mode 100644 index 000000000..090e23cef --- /dev/null +++ b/app/components/sidebar/sidebarController.js @@ -0,0 +1,10 @@ +angular.module('sidebar', []) +.controller('SidebarController', ['$scope', 'Settings', 'Config', 'Info', +function ($scope, Settings, Config, Info) { + + Config.$promise.then(function (c) { + $scope.logo = c.logo; + }); + + $scope.uiVersion = Settings.uiVersion; +}]); diff --git a/app/components/swarm/swarm.html b/app/components/swarm/swarm.html index 5754c5973..71ed078ca 100644 --- a/app/components/swarm/swarm.html +++ b/app/components/swarm/swarm.html @@ -16,14 +16,14 @@ Nodes - {{ swarm.Nodes }} - {{ info.Swarm.Nodes }} + {{ swarm.Nodes }} + {{ info.Swarm.Nodes }} - + Images {{ info.Images }} - + Swarm version {{ docker.Version|swarmversion }} @@ -31,29 +31,29 @@ Docker API version {{ docker.ApiVersion }} - + Strategy {{ swarm.Strategy }} Total CPU - {{ info.NCPU }} - {{ totalCPU }} + {{ info.NCPU }} + {{ totalCPU }} Total memory - {{ info.MemTotal|humansize: 2 }} - {{ totalMemory|humansize: 2 }} + {{ info.MemTotal|humansize: 2 }} + {{ totalMemory|humansize: 2 }} - + Operating system {{ info.OperatingSystem }} - + Kernel version {{ info.KernelVersion }} - + Go version {{ docker.GoVersion }} @@ -65,7 +65,7 @@
-
+
@@ -133,7 +133,7 @@
-
+
diff --git a/app/components/swarm/swarmController.js b/app/components/swarm/swarmController.js index a04886564..2f7659407 100644 --- a/app/components/swarm/swarmController.js +++ b/app/components/swarm/swarmController.js @@ -7,7 +7,6 @@ function ($scope, Info, Version, Node, Settings) { $scope.info = {}; $scope.docker = {}; $scope.swarm = {}; - $scope.swarm_mode = false; $scope.totalCPU = 0; $scope.totalMemory = 0; $scope.pagination_count = Settings.pagination_count; @@ -23,8 +22,7 @@ function ($scope, Info, Version, Node, Settings) { Info.get({}, function (d) { $scope.info = d; - if (!_.startsWith(d.ServerVersion, 'swarm')) { - $scope.swarm_mode = true; + if ($scope.endpointMode.provider === 'DOCKER_SWARM_MODE') { Node.query({}, function(d) { $scope.nodes = d; var CPU = 0, memory = 0; diff --git a/app/components/templates/templates.html b/app/components/templates/templates.html index 66ed097a6..c4f0bc48a 100644 --- a/app/components/templates/templates.html +++ b/app/components/templates/templates.html @@ -13,12 +13,12 @@
-
+
When using Swarm, we recommend deploying containers in a shared network. Looks like you don't have any shared network, head over the networks view to create one.
-
+
App templates cannot be used with swarm-mode at the moment. You can still use them to quickly deploy containers to the Docker host. @@ -41,10 +41,10 @@
- - diff --git a/app/components/templates/templatesController.js b/app/components/templates/templatesController.js index 5c6860eff..261ad96ad 100644 --- a/app/components/templates/templatesController.js +++ b/app/components/templates/templatesController.js @@ -204,11 +204,6 @@ function ($scope, $q, $state, $filter, $anchorScroll, Config, Info, Container, C Config.$promise.then(function (c) { $scope.swarm = c.swarm; - Info.get({}, function(info) { - if ($scope.swarm && !_.startsWith(info.ServerVersion, 'swarm')) { - $scope.swarm_mode = true; - } - }); var containersToHideLabels = c.hiddenLabels; Network.query({}, function (d) { var networks = d; diff --git a/app/directives/header-content.js b/app/directives/header-content.js index 40df9a066..862356650 100644 --- a/app/directives/header-content.js +++ b/app/directives/header-content.js @@ -4,7 +4,7 @@ angular var directive = { requires: '^rdHeader', transclude: true, - template: '', + template: '', restrict: 'E' }; return directive; diff --git a/app/directives/header-title.js b/app/directives/header-title.js index b0816529d..352aa0643 100644 --- a/app/directives/header-title.js +++ b/app/directives/header-title.js @@ -1,14 +1,17 @@ angular .module('portainer') -.directive('rdHeaderTitle', function rdHeaderTitle() { +.directive('rdHeaderTitle', ['$rootScope', function rdHeaderTitle($rootScope) { var directive = { requires: '^rdHeader', scope: { title: '@' }, + link: function (scope, iElement, iAttrs) { + scope.username = $rootScope.username; + }, transclude: true, - template: '
{{title}}
', + template: '
{{title}} {{username}}
', restrict: 'E' }; return directive; -}); +}]); diff --git a/app/shared/services.js b/app/shared/services.js index 0a7bf27b3..513ceaa9d 100644 --- a/app/shared/services.js +++ b/app/shared/services.js @@ -166,14 +166,6 @@ angular.module('portainer.services', ['ngResource', 'ngSanitize']) get: {method: 'GET'} }); }]) - .factory('Auth', ['$resource', 'Settings', function AuthFactory($resource, Settings) { - 'use strict'; - // http://docs.docker.com/reference/api/docker_remote_api_<%= remoteApiVersion %>/#check-auth-configuration - return $resource(Settings.url + '/auth', {}, { - get: {method: 'GET'}, - update: {method: 'POST'} - }); - }]) .factory('Info', ['$resource', 'Settings', function InfoFactory($resource, Settings) { 'use strict'; // http://docs.docker.com/reference/api/docker_remote_api_<%= remoteApiVersion %>/#display-system-wide-information @@ -229,6 +221,89 @@ angular.module('portainer.services', ['ngResource', 'ngSanitize']) pagination_count: PAGINATION_MAX_ITEMS }; }]) + .factory('Auth', ['$resource', 'AUTH_ENDPOINT', function AuthFactory($resource, AUTH_ENDPOINT) { + 'use strict'; + return $resource(AUTH_ENDPOINT, {}, { + login: { + method: 'POST' + } + }); + }]) + .factory('Users', ['$resource', function UsersFactory($resource) { + 'use strict'; + return $resource('/users/:username/:action', {}, { + create: { method: 'POST' }, + get: {method: 'GET', params: { username: '@username' } }, + update: { method: 'PUT', params: { username: '@username' } }, + checkPassword: { method: 'POST', params: { username: '@username', action: 'passwd' } }, + checkAdminUser: {method: 'GET', params: { username: 'admin', action: 'check' }}, + initAdminUser: {method: 'POST', params: { username: 'admin', action: 'init' }} + }); + }]) + .factory('EndpointMode', ['$rootScope', 'Info', function EndpointMode($rootScope, Info) { + 'use strict'; + return { + determineEndpointMode: function() { + Info.get({}, function(d) { + var mode = { + provider: '', + role: '' + }; + if (_.startsWith(d.ServerVersion, 'swarm')) { + mode.provider = "DOCKER_SWARM"; + if (d.SystemStatus[0][1] === 'primary') { + mode.role = "PRIMARY"; + } else { + mode.role = "REPLICA"; + } + } else { + if (!d.Swarm || _.isEmpty(d.Swarm.NodeID)) { + mode.provider = "DOCKER_STANDALONE"; + } else { + mode.provider = "DOCKER_SWARM_MODE"; + if (d.Swarm.ControlAvailable) { + mode.role = "MANAGER"; + } else { + mode.role = "WORKER"; + } + } + } + $rootScope.endpointMode = mode; + }); + } + }; + }]) + .factory('Authentication', ['$q', '$rootScope', 'Auth', 'jwtHelper', 'localStorageService', function AuthenticationFactory($q, $rootScope, Auth, jwtHelper, localStorageService) { + 'use strict'; + return { + init: function() { + var jwt = localStorageService.get('JWT'); + if (jwt) { + var tokenPayload = jwtHelper.decodeToken(jwt); + $rootScope.username = tokenPayload.username; + } + }, + login: function(username, password) { + return $q(function (resolve, reject) { + Auth.login({username: username, password: password}).$promise + .then(function(data) { + localStorageService.set('JWT', data.jwt); + $rootScope.username = username; + resolve(); + }, function() { + reject(); + }); + }); + }, + logout: function() { + localStorageService.remove('JWT'); + }, + isAuthenticated: function() { + var jwt = localStorageService.get('JWT'); + return jwt && !jwtHelper.isTokenExpired(jwt); + } + }; + }]) .factory('Messages', ['$rootScope', '$sanitize', function MessagesFactory($rootScope, $sanitize) { 'use strict'; return { diff --git a/assets/css/app.css b/assets/css/app.css index 31b712997..fd7f667b0 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -1,18 +1,27 @@ +html, body, #content-wrapper, .page-content, #view { + height: 100%; + width: 100%; +} + +.white-space-normal { + white-space: normal !important; +} + .btn-group button { - margin: 3px; + margin: 3px; } .messages { - max-height: 50px; - overflow-x: hidden; - overflow-y: scroll; + max-height: 50px; + overflow-x: hidden; + overflow-y: scroll; } .legend .title { - padding: 0 0.3em; - margin: 0.5em; - border-style: solid; - border-width: 0 0 0 1em; + padding: 0 0.3em; + margin: 0.5em; + border-style: solid; + border-width: 0 0 0 1em; } .logo { @@ -203,6 +212,48 @@ input[type="radio"] { margin-bottom: 5px; } +.login-wrapper { + margin-top: 25px; + height: 100%; + width: 100%; + display: flex; + align-items: center; +} + +.login-box { + margin-bottom: 80px; +} + +.login-box > div:first-child { + padding-bottom: 10px; +} + +.login-logo { + display: block; + margin: auto; + position: relative; + width: 240px; + margin-bottom: 10px; +} + +.login-form > div { + margin-bottom: 25px; +} + +.login-form > div:last-child { + margin-top: 10px; + margin-bottom: 10px; +} + +.panel-body { + padding-top: 30px; + background-color: #ffffff; +} + .pagination-controls { margin-left: 10px; } + +.user-box { + margin-right: 25px; +} diff --git a/assets/images/logo_alt.png b/assets/images/logo_alt.png new file mode 100644 index 0000000000000000000000000000000000000000..63318532ca832c9c21f8a43dda48758c895de9bc GIT binary patch literal 23441 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 portainer-checksum.txt', 'mkdir -p dist', 'mv api/portainer dist/' @@ -303,7 +305,7 @@ module.exports = function (grunt) { }, buildUnixArmBinary: { command: [ - 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="linux" -e BUILD_GOARCH="arm" centurylink/golang-builder-cross', + 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="linux" -e BUILD_GOARCH="arm" portainer/golang-builder:cross-platform', 'shasum api/portainer-linux-arm > portainer-checksum.txt', 'mkdir -p dist', 'mv api/portainer-linux-arm dist/portainer' @@ -311,7 +313,7 @@ module.exports = function (grunt) { }, buildDarwinBinary: { command: [ - 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="darwin" -e BUILD_GOARCH="amd64" centurylink/golang-builder-cross', + 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="darwin" -e BUILD_GOARCH="amd64" portainer/golang-builder:cross-platform', 'shasum api/portainer-darwin-amd64 > portainer-checksum.txt', 'mkdir -p dist', 'mv api/portainer-darwin-amd64 dist/portainer' @@ -319,7 +321,7 @@ module.exports = function (grunt) { }, buildWindowsBinary: { command: [ - 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="windows" -e BUILD_GOARCH="amd64" centurylink/golang-builder-cross', + 'docker run --rm -v $(pwd)/api:/src -e BUILD_GOOS="windows" -e BUILD_GOARCH="amd64" portainer/golang-builder:cross-platform', 'shasum api/portainer-windows-amd64 > portainer-checksum.txt', 'mkdir -p dist', 'mv api/portainer-windows-amd64 dist/portainer.exe' diff --git a/index.html b/index.html index 726d1243d..09c2ae1c9 100644 --- a/index.html +++ b/index.html @@ -24,67 +24,16 @@ - -

+ +
- - - +
-
+