diff --git a/controllers/ng/base.go b/controllers/ng/base.go index d703d5921..c0eb02c54 100644 --- a/controllers/ng/base.go +++ b/controllers/ng/base.go @@ -1,20 +1,111 @@ package ng import ( + "net/http" + "os" "path/filepath" + "strings" "github.com/astaxie/beego" + "github.com/beego/i18n" + "github.com/vmware/harbor/dao" + "github.com/vmware/harbor/utils/log" ) type BaseController struct { beego.Controller + i18n.Locale + SelfRegistration bool + IsAdmin bool + AuthMode string +} + +type langType struct { + Lang string + Name string } const ( - viewPath = "sections" - prefixNg = "ng" + viewPath = "sections" + prefixNg = "ng" + defaultLang = "en-US" ) +var supportLanguages map[string]langType + +// Prepare extracts the language information from request and populate data for rendering templates. +func (b *BaseController) Prepare() { + + var lang string + al := b.Ctx.Request.Header.Get("Accept-Language") + + if len(al) > 4 { + al = al[:5] // Only compare first 5 letters. + if i18n.IsExist(al) { + lang = al + } + } + + if _, exist := supportLanguages[lang]; exist == false { //Check if support the request language. + lang = defaultLang //Set default language if not supported. + } + + sessionLang := b.GetSession("lang") + if sessionLang != nil { + b.SetSession("Lang", lang) + lang = sessionLang.(string) + } + + curLang := langType{ + Lang: lang, + } + + restLangs := make([]*langType, 0, len(langTypes)-1) + for _, v := range langTypes { + if lang != v.Lang { + restLangs = append(restLangs, v) + } else { + curLang.Name = v.Name + } + } + + // Set language properties. + b.Lang = lang + b.Data["Lang"] = curLang.Lang + b.Data["CurLang"] = curLang.Name + b.Data["RestLangs"] = restLangs + + authMode := strings.ToLower(os.Getenv("AUTH_MODE")) + if authMode == "" { + authMode = "db_auth" + } + b.AuthMode = authMode + b.Data["AuthMode"] = b.AuthMode + + selfRegistration := strings.ToLower(os.Getenv("SELF_REGISTRATION")) + + if selfRegistration == "on" { + b.SelfRegistration = true + } + + sessionUserID := b.GetSession("userId") + if sessionUserID != nil { + b.Data["Username"] = b.GetSession("username") + b.Data["UserId"] = sessionUserID.(int) + + var err error + b.IsAdmin, err = dao.IsAdminRole(sessionUserID.(int)) + if err != nil { + log.Errorf("Error occurred in IsAdminRole:%v", err) + b.CustomAbort(http.StatusInternalServerError, "Internal error.") + } + } + + b.Data["IsAdmin"] = b.IsAdmin + b.Data["SelfRegistration"] = b.SelfRegistration + +} + func (bc *BaseController) Forward(title, templateName string) { bc.Layout = filepath.Join(prefixNg, "layout.htm") bc.TplName = filepath.Join(prefixNg, templateName) @@ -26,3 +117,41 @@ func (bc *BaseController) Forward(title, templateName string) { bc.LayoutSections["FooterContent"] = filepath.Join(prefixNg, viewPath, "footer-content.htm") } + +var langTypes []*langType + +func init() { + + //conf/app.conf -> os.Getenv("config_path") + configPath := os.Getenv("CONFIG_PATH") + if len(configPath) != 0 { + log.Infof("Config path: %s", configPath) + beego.AppConfigPath = configPath + if err := beego.ParseConfig(); err != nil { + log.Warningf("Failed to parse config file: %s, error: %v", configPath, err) + } + } + + beego.AddFuncMap("i18n", i18n.Tr) + + langs := strings.Split(beego.AppConfig.String("lang::types"), "|") + names := strings.Split(beego.AppConfig.String("lang::names"), "|") + + supportLanguages = make(map[string]langType) + + langTypes = make([]*langType, 0, len(langs)) + for i, v := range langs { + t := langType{ + Lang: v, + Name: names[i], + } + langTypes = append(langTypes, &t) + supportLanguages[v] = t + } + + for _, lang := range langs { + if err := i18n.SetMessage(lang, "static/i18n/"+"locale_"+lang+".ini"); err != nil { + log.Errorf("Fail to set message file: %s", err.Error()) + } + } +} diff --git a/controllers/ng/password.go b/controllers/ng/password.go new file mode 100644 index 000000000..a360254bd --- /dev/null +++ b/controllers/ng/password.go @@ -0,0 +1,175 @@ +package ng + +import ( + "bytes" + "net/http" + "os" + "regexp" + "text/template" + + "github.com/astaxie/beego" + "github.com/vmware/harbor/dao" + "github.com/vmware/harbor/models" + "github.com/vmware/harbor/utils" + "github.com/vmware/harbor/utils/log" +) + +type CommonController struct { + BaseController +} + +func (cc *CommonController) Render() error { + return nil +} + +type messageDetail struct { + Hint string + URL string + UUID string +} + +// SendEmail verifies the Email address and contact SMTP server to send reset password Email. +func (cc *CommonController) SendEmail() { + + email := cc.GetString("email") + + pass, _ := regexp.MatchString(`^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$`, email) + + if !pass { + cc.CustomAbort(http.StatusBadRequest, "email_content_illegal") + } else { + + queryUser := models.User{Email: email} + exist, err := dao.UserExists(queryUser, "email") + if err != nil { + log.Errorf("Error occurred in UserExists: %v", err) + cc.CustomAbort(http.StatusInternalServerError, "Internal error.") + } + if !exist { + cc.CustomAbort(http.StatusNotFound, "email_does_not_exist") + } + + messageTemplate, err := template.ParseFiles("views/ng/reset-password-mail.tpl") + if err != nil { + log.Errorf("Parse email template file failed: %v", err) + cc.CustomAbort(http.StatusInternalServerError, err.Error()) + } + + message := new(bytes.Buffer) + + harborURL := os.Getenv("HARBOR_URL") + if harborURL == "" { + harborURL = "localhost" + } + uuid, err := dao.GenerateRandomString() + if err != nil { + log.Errorf("Error occurred in GenerateRandomString: %v", err) + cc.CustomAbort(http.StatusInternalServerError, "Internal error.") + } + err = messageTemplate.Execute(message, messageDetail{ + Hint: cc.Tr("reset_email_hint"), + URL: harborURL, + UUID: uuid, + }) + + if err != nil { + log.Errorf("Message template error: %v", err) + cc.CustomAbort(http.StatusInternalServerError, "internal_error") + } + + config, err := beego.AppConfig.GetSection("mail") + if err != nil { + log.Errorf("Can not load app.conf: %v", err) + cc.CustomAbort(http.StatusInternalServerError, "internal_error") + } + + mail := utils.Mail{ + From: config["from"], + To: []string{email}, + Subject: cc.Tr("reset_email_subject"), + Message: message.String()} + + err = mail.SendMail() + + if err != nil { + log.Errorf("Send email failed: %v", err) + cc.CustomAbort(http.StatusInternalServerError, "send_email_failed") + } + + user := models.User{ResetUUID: uuid, Email: email} + dao.UpdateUserResetUUID(user) + + } + +} + +type ForgotPasswordController struct { + BaseController +} + +func (fpc *ForgotPasswordController) Get() { + fpc.Forward("Forgot Password", "forgot-password.htm") +} + +// ResetPasswordController handles request to /resetPassword +type ResetPasswordController struct { + BaseController +} + +// Get checks if reset_uuid in the reset link is valid and render the result page for user to reset password. +func (rpc *ResetPasswordController) Get() { + + resetUUID := rpc.GetString("reset_uuid") + if resetUUID == "" { + log.Error("Reset uuid is blank.") + rpc.Redirect("/", http.StatusFound) + return + } + + queryUser := models.User{ResetUUID: resetUUID} + user, err := dao.GetUser(queryUser) + if err != nil { + log.Errorf("Error occurred in GetUser: %v", err) + rpc.CustomAbort(http.StatusInternalServerError, "Internal error.") + } + + if user != nil { + rpc.Data["ResetUuid"] = user.ResetUUID + rpc.Forward("Reset Password", "reset-password.htm") + } else { + rpc.Redirect("/", http.StatusFound) + } +} + +// ResetPassword handles request from the reset page and reset password +func (cc *CommonController) ResetPassword() { + + resetUUID := cc.GetString("reset_uuid") + if resetUUID == "" { + cc.CustomAbort(http.StatusBadRequest, "Reset uuid is blank.") + } + + queryUser := models.User{ResetUUID: resetUUID} + user, err := dao.GetUser(queryUser) + if err != nil { + log.Errorf("Error occurred in GetUser: %v", err) + cc.CustomAbort(http.StatusInternalServerError, "Internal error.") + } + if user == nil { + log.Error("User does not exist") + cc.CustomAbort(http.StatusBadRequest, "User does not exist") + } + + password := cc.GetString("password") + + if password != "" { + user.Password = password + err = dao.ResetUserPassword(*user) + if err != nil { + log.Errorf("Error occurred in ResetUserPassword: %v", err) + cc.CustomAbort(http.StatusInternalServerError, "Internal error.") + } + } else { + cc.CustomAbort(http.StatusBadRequest, "password_is_required") + } +} diff --git a/ngrouter.go b/ngrouter.go index 1af19231e..45ac58f69 100644 --- a/ngrouter.go +++ b/ngrouter.go @@ -14,4 +14,6 @@ func initNgRouters() { beego.Router("/ng/repository", &ng.RepositoryController{}) beego.Router("/ng/sign_up", &ng.SignUpController{}) beego.Router("/ng/account_setting", &ng.AccountSettingController{}) + beego.Router("/ng/forgot_password", &ng.ForgotPasswordController{}) + beego.Router("/ng/reset_password", &ng.ResetPasswordController{}) } diff --git a/static/ng/resources/css/header.css b/static/ng/resources/css/header.css index 0a1bfe866..7d2fabcdf 100644 --- a/static/ng/resources/css/header.css +++ b/static/ng/resources/css/header.css @@ -55,4 +55,68 @@ nav .container-custom { .nav-custom .active { border-bottom: 3px solid #EFEFEF; +} + +.dropdown { + float: left; +} + +.dropdown .btn-link:hover, +.dropdown .btn-link:visited, +.dropdown .btn-link:link { + display:inline-block; + text-decoration: none; + color: #FFFFFF; +} + +.dropdown-menu { + left: 9%; +} + +.dropdown-submenu { + position: relative; +} + +.dropdown-submenu>.dropdown-menu { + top: 0; + left: 100%; + margin-top: -6px; + margin-left: -1px; + -webkit-border-radius: 0 6px 6px 6px; + -moz-border-radius: 0 6px 6px; + border-radius: 0 6px 6px 6px; +} + +.dropdown-submenu:hover>.dropdown-menu { + display: block; +} + +.dropdown-submenu>a:after { + display: block; + content: " "; + float: right; + width: 0; + height: 0; + border-color: transparent; + border-style: solid; + border-width: 5px 0 5px 5px; + border-left-color: #ccc; + margin-top: 5px; + margin-right: -10px; +} + +.dropdown-submenu:hover>a:after { + border-left-color: #fff; +} + +.dropdown-submenu.pull-left { + float: none; +} + +.dropdown-submenu.pull-left>.dropdown-menu { + left: -100%; + margin-left: 10px; + -webkit-border-radius: 6px 0 6px 6px; + -moz-border-radius: 6px 0 6px 6px; + border-radius: 6px 0 6px 6px; } \ No newline at end of file diff --git a/static/ng/resources/css/index.css b/static/ng/resources/css/index.css index dfcc78269..f191674ce 100644 --- a/static/ng/resources/css/index.css +++ b/static/ng/resources/css/index.css @@ -27,6 +27,7 @@ body { .thumbnail { display: inline-block; border: none; + padding: 2px; box-shadow: none; } diff --git a/static/ng/resources/js/components/log/list-log.directive.js b/static/ng/resources/js/components/log/list-log.directive.js index f1229669b..c92ddc11c 100644 --- a/static/ng/resources/js/components/log/list-log.directive.js +++ b/static/ng/resources/js/components/log/list-log.directive.js @@ -92,6 +92,7 @@ restrict: 'E', templateUrl: '/static/ng/resources/js/components/log/list-log.directive.html', replace: true, + scope: true, controller: ListLogController, controllerAs: 'vm', bindToController: true diff --git a/static/ng/resources/js/components/optional-menu/optional-menu.directive.html b/static/ng/resources/js/components/optional-menu/optional-menu.directive.html index 87343e82f..ef4da29d6 100644 --- a/static/ng/resources/js/components/optional-menu/optional-menu.directive.html +++ b/static/ng/resources/js/components/optional-menu/optional-menu.directive.html @@ -1,22 +1,30 @@ -