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 000000000..63318532c Binary files /dev/null and b/assets/images/logo_alt.png differ diff --git a/bower.json b/bower.json index 793042685..6ef02d692 100644 --- a/bower.json +++ b/bower.json @@ -33,15 +33,17 @@ "angular-resource": "~1.5.0", "angular-ui-select": "~0.17.1", "angular-utils-pagination": "~0.11.1", + "angular-local-storage": "~0.5.2", + "angular-jwt": "~0.1.8", "bootstrap": "~3.3.6", - "font-awesome": "~4.6.3", "filesize": "~3.3.0", "jquery": "1.11.1", "jquery.gritter": "1.7.4", "lodash": "4.12.0", "rdash-ui": "1.0.*", "moment": "~2.14.1", - "xterm.js": "~2.0.1" + "xterm.js": "~2.0.1", + "font-awesome": "~4.7.0" }, "resolutions": { "angular": "1.5.5" diff --git a/gruntFile.js b/gruntFile.js index 00d99c317..58f35279e 100644 --- a/gruntFile.js +++ b/gruntFile.js @@ -193,6 +193,8 @@ module.exports = function (grunt) { src: ['bower_components/angular/angular.min.js', 'bower_components/angular-sanitize/angular-sanitize.min.js', 'bower_components/angular-cookies/angular-cookies.min.js', + 'bower_components/angular-local-storage/dist/angular-local-storage.min.js', + 'bower_components/angular-jwt/dist/angular-jwt.min.js', 'bower_components/angular-ui-router/release/angular-ui-router.min.js', 'bower_components/angular-resource/angular-resource.min.js', 'bower_components/angular-bootstrap/ui-bootstrap-tpls.min.js', @@ -295,7 +297,7 @@ module.exports = function (grunt) { }, buildBinary: { command: [ - 'docker run --rm -v $(pwd)/api:/src centurylink/golang-builder', + 'docker run --rm -v $(pwd)/api:/src portainer/golang-builder', 'shasum api/portainer > 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 @@ - -
+ +
- - - +
-
+