From 5e1438ba92fe5b4398ebf468e4ede21c7ef60409 Mon Sep 17 00:00:00 2001 From: guillep2k <18600385+guillep2k@users.noreply.github.com> Date: Mon, 2 Mar 2020 15:25:36 -0300 Subject: [PATCH] Admin page for managing user e-mail activation (#10557) * Implement mail activation admin panel * Add export comments * Fix another export comment * again... * And again! * Apply suggestions by @lunny * Add UI for user activated emails * Make new activation UI work * Fix lint * Prevent admin from self-deactivate; add modal Co-authored-by: zeripath --- models/user_mail.go | 285 +++++++++++++++++++++++++-- models/user_mail_test.go | 66 +++++++ options/locale/locale_en-US.ini | 19 ++ routers/admin/emails.go | 157 +++++++++++++++ routers/routes/routes.go | 5 + routers/user/auth.go | 10 + routers/user/setting/account.go | 62 +++++- templates/admin/emails/list.tmpl | 101 ++++++++++ templates/admin/nav.tmpl | 1 + templates/admin/navbar.tmpl | 3 + templates/user/settings/account.tmpl | 29 ++- web_src/js/index.js | 12 ++ 12 files changed, 726 insertions(+), 24 deletions(-) create mode 100644 routers/admin/emails.go create mode 100644 templates/admin/emails/list.tmpl diff --git a/models/user_mail.go b/models/user_mail.go index 41f08c9db..af9602e71 100644 --- a/models/user_mail.go +++ b/models/user_mail.go @@ -1,4 +1,5 @@ // Copyright 2016 The Gogs Authors. All rights reserved. +// Copyright 2020 The Gitea Authors. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. @@ -8,6 +9,12 @@ import ( "errors" "fmt" "strings" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + + "xorm.io/builder" ) var ( @@ -54,13 +61,66 @@ func GetEmailAddresses(uid int64) ([]*EmailAddress, error) { if !isPrimaryFound { emails = append(emails, &EmailAddress{ Email: u.Email, - IsActivated: true, + IsActivated: u.IsActive, IsPrimary: true, }) } return emails, nil } +// GetEmailAddressByID gets a user's email address by ID +func GetEmailAddressByID(uid, id int64) (*EmailAddress, error) { + // User ID is required for security reasons + email := &EmailAddress{ID: id, UID: uid} + if has, err := x.Get(email); err != nil { + return nil, err + } else if !has { + return nil, nil + } + return email, nil +} + +func isEmailActive(e Engine, email string, userID, emailID int64) (bool, error) { + if len(email) == 0 { + return true, nil + } + + // Can't filter by boolean field unless it's explicit + cond := builder.NewCond() + cond = cond.And(builder.Eq{"email": email}, builder.Neq{"id": emailID}) + if setting.Service.RegisterEmailConfirm { + // Inactive (unvalidated) addresses don't count as active if email validation is required + cond = cond.And(builder.Eq{"is_activated": true}) + } + + em := EmailAddress{} + + if has, err := e.Where(cond).Get(&em); has || err != nil { + if has { + log.Info("isEmailActive('%s',%d,%d) found duplicate in email ID %d", email, userID, emailID, em.ID) + } + return has, err + } + + // Can't filter by boolean field unless it's explicit + cond = builder.NewCond() + cond = cond.And(builder.Eq{"email": email}, builder.Neq{"id": userID}) + if setting.Service.RegisterEmailConfirm { + cond = cond.And(builder.Eq{"is_active": true}) + } + + us := User{} + + if has, err := e.Where(cond).Get(&us); has || err != nil { + if has { + log.Info("isEmailActive('%s',%d,%d) found duplicate in user ID %d", email, userID, emailID, us.ID) + } + return has, err + } + + return false, nil +} + func isEmailUsed(e Engine, email string) (bool, error) { if len(email) == 0 { return true, nil @@ -118,31 +178,30 @@ func AddEmailAddresses(emails []*EmailAddress) error { // Activate activates the email address to given user. func (email *EmailAddress) Activate() error { - user, err := GetUserByID(email.UID) + sess := x.NewSession() + defer sess.Close() + if err := sess.Begin(); err != nil { + return err + } + if err := email.updateActivation(sess, true); err != nil { + return err + } + return sess.Commit() +} + +func (email *EmailAddress) updateActivation(e Engine, activate bool) error { + user, err := getUserByID(e, email.UID) if err != nil { return err } if user.Rands, err = GetUserSalt(); err != nil { return err } - - sess := x.NewSession() - defer sess.Close() - if err = sess.Begin(); err != nil { + email.IsActivated = activate + if _, err := e.ID(email.ID).Cols("is_activated").Update(email); err != nil { return err } - - email.IsActivated = true - if _, err := sess. - ID(email.ID). - Cols("is_activated"). - Update(email); err != nil { - return err - } else if err = updateUserCols(sess, user, "rands"); err != nil { - return err - } - - return sess.Commit() + return updateUserCols(e, user, "rands") } // DeleteEmailAddress deletes an email address of given user. @@ -228,3 +287,193 @@ func MakeEmailPrimary(email *EmailAddress) error { return sess.Commit() } + +// SearchEmailOrderBy is used to sort the results from SearchEmails() +type SearchEmailOrderBy string + +func (s SearchEmailOrderBy) String() string { + return string(s) +} + +// Strings for sorting result +const ( + SearchEmailOrderByEmail SearchEmailOrderBy = "emails.email ASC, is_primary DESC, sortid ASC" + SearchEmailOrderByEmailReverse SearchEmailOrderBy = "emails.email DESC, is_primary ASC, sortid DESC" + SearchEmailOrderByName SearchEmailOrderBy = "`user`.lower_name ASC, is_primary DESC, sortid ASC" + SearchEmailOrderByNameReverse SearchEmailOrderBy = "`user`.lower_name DESC, is_primary ASC, sortid DESC" +) + +// SearchEmailOptions are options to search e-mail addresses for the admin panel +type SearchEmailOptions struct { + ListOptions + Keyword string + SortType SearchEmailOrderBy + IsPrimary util.OptionalBool + IsActivated util.OptionalBool +} + +// SearchEmailResult is an e-mail address found in the user or email_address table +type SearchEmailResult struct { + UID int64 + Email string + IsActivated bool + IsPrimary bool + // From User + Name string + FullName string +} + +// SearchEmails takes options i.e. keyword and part of email name to search, +// it returns results in given range and number of total results. +func SearchEmails(opts *SearchEmailOptions) ([]*SearchEmailResult, int64, error) { + // Unfortunately, UNION support for SQLite in xorm is currently broken, so we must + // build the SQL ourselves. + where := make([]string, 0, 5) + args := make([]interface{}, 0, 5) + + emailsSQL := "(SELECT id as sortid, uid, email, is_activated, 0 as is_primary " + + "FROM email_address " + + "UNION ALL " + + "SELECT id as sortid, id AS uid, email, is_active AS is_activated, 1 as is_primary " + + "FROM `user` " + + "WHERE type = ?) AS emails" + args = append(args, UserTypeIndividual) + + if len(opts.Keyword) > 0 { + // Note: % can be injected in the Keyword parameter, but it won't do any harm. + where = append(where, "(lower(`user`.full_name) LIKE ? OR `user`.lower_name LIKE ? OR emails.email LIKE ?)") + likeStr := "%" + strings.ToLower(opts.Keyword) + "%" + args = append(args, likeStr) + args = append(args, likeStr) + args = append(args, likeStr) + } + + switch { + case opts.IsPrimary.IsTrue(): + where = append(where, "emails.is_primary = ?") + args = append(args, true) + case opts.IsPrimary.IsFalse(): + where = append(where, "emails.is_primary = ?") + args = append(args, false) + } + + switch { + case opts.IsActivated.IsTrue(): + where = append(where, "emails.is_activated = ?") + args = append(args, true) + case opts.IsActivated.IsFalse(): + where = append(where, "emails.is_activated = ?") + args = append(args, false) + } + + var whereStr string + if len(where) > 0 { + whereStr = "WHERE " + strings.Join(where, " AND ") + } + + joinSQL := "FROM " + emailsSQL + " INNER JOIN `user` ON `user`.id = emails.uid " + whereStr + + count, err := x.SQL("SELECT count(*) "+joinSQL, args...).Count() + if err != nil { + return nil, 0, fmt.Errorf("Count: %v", err) + } + + orderby := opts.SortType.String() + if orderby == "" { + orderby = SearchEmailOrderByEmail.String() + } + + querySQL := "SELECT emails.uid, emails.email, emails.is_activated, emails.is_primary, " + + "`user`.name, `user`.full_name " + joinSQL + " ORDER BY " + orderby + + opts.setDefaultValues() + + rows, err := x.SQL(querySQL, args...).Rows(new(SearchEmailResult)) + if err != nil { + return nil, 0, fmt.Errorf("Emails: %v", err) + } + + // Page manually because xorm can't handle Limit() with raw SQL + defer rows.Close() + + emails := make([]*SearchEmailResult, 0, opts.PageSize) + skip := (opts.Page - 1) * opts.PageSize + + for rows.Next() { + var email SearchEmailResult + if err := rows.Scan(&email); err != nil { + return nil, 0, err + } + if skip > 0 { + skip-- + continue + } + emails = append(emails, &email) + if len(emails) == opts.PageSize { + break + } + } + + return emails, count, err +} + +// ActivateUserEmail will change the activated state of an email address, +// either primary (in the user table) or secondary (in the email_address table) +func ActivateUserEmail(userID int64, email string, primary, activate bool) (err error) { + sess := x.NewSession() + defer sess.Close() + if err = sess.Begin(); err != nil { + return err + } + if primary { + // Activate/deactivate a user's primary email address + user := User{ID: userID, Email: email} + if has, err := sess.Get(&user); err != nil { + return err + } else if !has { + return fmt.Errorf("no such user: %d (%s)", userID, email) + } + if user.IsActive == activate { + // Already in the desired state; no action + return nil + } + if activate { + if used, err := isEmailActive(sess, email, userID, 0); err != nil { + return fmt.Errorf("isEmailActive(): %v", err) + } else if used { + return ErrEmailAlreadyUsed{Email: email} + } + } + user.IsActive = activate + if user.Rands, err = GetUserSalt(); err != nil { + return fmt.Errorf("generate salt: %v", err) + } + if err = updateUserCols(sess, &user, "is_active", "rands"); err != nil { + return fmt.Errorf("updateUserCols(): %v", err) + } + } else { + // Activate/deactivate a user's secondary email address + // First check if there's another user active with the same address + addr := EmailAddress{UID: userID, Email: email} + if has, err := sess.Get(&addr); err != nil { + return err + } else if !has { + return fmt.Errorf("no such email: %d (%s)", userID, email) + } + if addr.IsActivated == activate { + // Already in the desired state; no action + return nil + } + if activate { + if used, err := isEmailActive(sess, email, 0, addr.ID); err != nil { + return fmt.Errorf("isEmailActive(): %v", err) + } else if used { + return ErrEmailAlreadyUsed{Email: email} + } + } + if err = addr.updateActivation(sess, activate); err != nil { + return fmt.Errorf("updateActivation(): %v", err) + } + } + return sess.Commit() +} diff --git a/models/user_mail_test.go b/models/user_mail_test.go index 3352194e1..8237ce664 100644 --- a/models/user_mail_test.go +++ b/models/user_mail_test.go @@ -7,6 +7,8 @@ package models import ( "testing" + "code.gitea.io/gitea/modules/util" + "github.com/stretchr/testify/assert" ) @@ -169,3 +171,67 @@ func TestActivate(t *testing.T) { assert.True(t, emails[2].IsActivated) assert.True(t, emails[2].IsPrimary) } + +func TestListEmails(t *testing.T) { + assert.NoError(t, PrepareTestDatabase()) + + // Must find all users and their emails + opts := &SearchEmailOptions{} + emails, count, err := SearchEmails(opts) + assert.NoError(t, err) + assert.NotEqual(t, int64(0), count) + assert.True(t, count > 5) + + contains := func(match func(s *SearchEmailResult) bool) bool { + for _, v := range emails { + if match(v) { + return true + } + } + return false + } + + assert.True(t, contains(func(s *SearchEmailResult) bool { return s.UID == 18 })) + // 'user3' is an organization + assert.False(t, contains(func(s *SearchEmailResult) bool { return s.UID == 3 })) + + // Must find no records + opts = &SearchEmailOptions{Keyword: "NOTFOUND"} + emails, count, err = SearchEmails(opts) + assert.NoError(t, err) + assert.Equal(t, int64(0), count) + + // Must find users 'user2', 'user28', etc. + opts = &SearchEmailOptions{Keyword: "user2"} + emails, count, err = SearchEmails(opts) + assert.NoError(t, err) + assert.NotEqual(t, int64(0), count) + assert.True(t, contains(func(s *SearchEmailResult) bool { return s.UID == 2 })) + assert.True(t, contains(func(s *SearchEmailResult) bool { return s.UID == 27 })) + + // Must find only primary addresses (i.e. from the `user` table) + opts = &SearchEmailOptions{IsPrimary: util.OptionalBoolTrue} + emails, count, err = SearchEmails(opts) + assert.NoError(t, err) + assert.True(t, contains(func(s *SearchEmailResult) bool { return s.IsPrimary })) + assert.False(t, contains(func(s *SearchEmailResult) bool { return !s.IsPrimary })) + + // Must find only inactive addresses (i.e. not validated) + opts = &SearchEmailOptions{IsActivated: util.OptionalBoolFalse} + emails, count, err = SearchEmails(opts) + assert.NoError(t, err) + assert.True(t, contains(func(s *SearchEmailResult) bool { return !s.IsActivated })) + assert.False(t, contains(func(s *SearchEmailResult) bool { return s.IsActivated })) + + // Must find more than one page, but retrieve only one + opts = &SearchEmailOptions{ + ListOptions: ListOptions{ + PageSize: 5, + Page: 1, + }, + } + emails, count, err = SearchEmails(opts) + assert.NoError(t, err) + assert.Equal(t, 5, len(emails)) + assert.True(t, count > int64(len(emails))) +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 4a38dc62c..be478ad44 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -440,7 +440,11 @@ manage_openid = Manage OpenID Addresses email_desc = Your primary email address will be used for notifications and other operations. theme_desc = This will be your default theme across the site. primary = Primary +activated = Activated +requires_activation = Requires activation primary_email = Make Primary +activate_email = Send Activation +activations_pending = Activations Pending delete_email = Remove email_deletion = Remove Email Address email_deletion_desc = The email address and related information will be removed from your account. Git commits by this email address will remain unchanged. Continue? @@ -1724,6 +1728,7 @@ organizations = Organizations repositories = Repositories hooks = Default Webhooks authentication = Authentication Sources +emails = User Emails config = Configuration notices = System Notices monitor = Monitoring @@ -1793,6 +1798,7 @@ dashboard.gc_times = GC Times users.user_manage_panel = User Account Management users.new_account = Create User Account users.name = Username +users.full_name = Full Name users.activated = Activated users.admin = Admin users.restricted = Restricted @@ -1824,6 +1830,19 @@ users.still_own_repo = This user still owns one or more repositories. Delete or users.still_has_org = This user is a member of an organization. Remove the user from any organizations first. users.deletion_success = The user account has been deleted. +emails.email_manage_panel = User Email Management +emails.primary = Primary +emails.activated = Activated +emails.filter_sort.email = Email +emails.filter_sort.email_reverse = Email (reverse) +emails.filter_sort.name = User Name +emails.filter_sort.name_reverse = User Name (reverse) +emails.updated = Email updated +emails.not_updated = Failed to update the requested email address: %v +emails.duplicate_active = This email address is already active for a different user. +emails.change_email_header = Update Email Properties +emails.change_email_text = Are your sure you want to update this email address? + orgs.org_manage_panel = Organization Management orgs.name = Name orgs.teams = Teams diff --git a/routers/admin/emails.go b/routers/admin/emails.go new file mode 100644 index 000000000..f0b14ce5e --- /dev/null +++ b/routers/admin/emails.go @@ -0,0 +1,157 @@ +// Copyright 2020 The Gitea Authors. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package admin + +import ( + "bytes" + "net/url" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/util" + + "github.com/unknwon/com" +) + +const ( + tplEmails base.TplName = "admin/emails/list" +) + +// Emails show all emails +func Emails(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.emails") + ctx.Data["PageIsAdmin"] = true + ctx.Data["PageIsAdminEmails"] = true + + opts := &models.SearchEmailOptions{ + ListOptions: models.ListOptions{ + PageSize: setting.UI.Admin.UserPagingNum, + Page: ctx.QueryInt("page"), + }, + } + + if opts.Page <= 1 { + opts.Page = 1 + } + + type ActiveEmail struct { + models.SearchEmailResult + CanChange bool + } + + var ( + baseEmails []*models.SearchEmailResult + emails []ActiveEmail + count int64 + err error + orderBy models.SearchEmailOrderBy + ) + + ctx.Data["SortType"] = ctx.Query("sort") + switch ctx.Query("sort") { + case "email": + orderBy = models.SearchEmailOrderByEmail + case "reverseemail": + orderBy = models.SearchEmailOrderByEmailReverse + case "username": + orderBy = models.SearchEmailOrderByName + case "reverseusername": + orderBy = models.SearchEmailOrderByNameReverse + default: + ctx.Data["SortType"] = "email" + orderBy = models.SearchEmailOrderByEmail + } + + opts.Keyword = ctx.QueryTrim("q") + opts.SortType = orderBy + if len(ctx.Query("is_activated")) != 0 { + opts.IsActivated = util.OptionalBoolOf(ctx.QueryBool("activated")) + } + if len(ctx.Query("is_primary")) != 0 { + opts.IsPrimary = util.OptionalBoolOf(ctx.QueryBool("primary")) + } + + if len(opts.Keyword) == 0 || isKeywordValid(opts.Keyword) { + baseEmails, count, err = models.SearchEmails(opts) + if err != nil { + ctx.ServerError("SearchEmails", err) + return + } + emails = make([]ActiveEmail, len(baseEmails)) + for i := range baseEmails { + emails[i].SearchEmailResult = *baseEmails[i] + // Don't let the admin deactivate its own primary email address + // We already know the user is admin + emails[i].CanChange = ctx.User.ID != emails[i].UID || !emails[i].IsPrimary + } + } + ctx.Data["Keyword"] = opts.Keyword + ctx.Data["Total"] = count + ctx.Data["Emails"] = emails + + pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5) + pager.SetDefaultParams(ctx) + ctx.Data["Page"] = pager + + ctx.HTML(200, tplEmails) +} + +var ( + nullByte = []byte{0x00} +) + +func isKeywordValid(keyword string) bool { + return !bytes.Contains([]byte(keyword), nullByte) +} + +// ActivateEmail serves a POST request for activating/deactivating a user's email +func ActivateEmail(ctx *context.Context) { + + truefalse := map[string]bool{"1": true, "0": false} + + uid := com.StrTo(ctx.Query("uid")).MustInt64() + email := ctx.Query("email") + primary, okp := truefalse[ctx.Query("primary")] + activate, oka := truefalse[ctx.Query("activate")] + + if uid == 0 || len(email) == 0 || !okp || !oka { + ctx.Error(400) + return + } + + log.Info("Changing activation for User ID: %d, email: %s, primary: %v to %v", uid, email, primary, activate) + + if err := models.ActivateUserEmail(uid, email, primary, activate); err != nil { + log.Error("ActivateUserEmail(%v,%v,%v,%v): %v", uid, email, primary, activate, err) + if models.IsErrEmailAlreadyUsed(err) { + ctx.Flash.Error(ctx.Tr("admin.emails.duplicate_active")) + } else { + ctx.Flash.Error(ctx.Tr("admin.emails.not_updated", err)) + } + } else { + log.Info("Activation for User ID: %d, email: %s, primary: %v changed to %v", uid, email, primary, activate) + ctx.Flash.Info(ctx.Tr("admin.emails.updated")) + } + + redirect, _ := url.Parse(setting.AppSubURL + "/admin/emails") + q := url.Values{} + if val := ctx.QueryTrim("q"); len(val) > 0 { + q.Set("q", val) + } + if val := ctx.QueryTrim("sort"); len(val) > 0 { + q.Set("sort", val) + } + if val := ctx.QueryTrim("is_primary"); len(val) > 0 { + q.Set("is_primary", val) + } + if val := ctx.QueryTrim("is_activated"); len(val) > 0 { + q.Set("is_activated", val) + } + redirect.RawQuery = q.Encode() + ctx.Redirect(redirect.String()) +} diff --git a/routers/routes/routes.go b/routers/routes/routes.go index 9859ebc53..a8a08c9ec 100644 --- a/routers/routes/routes.go +++ b/routers/routes/routes.go @@ -444,6 +444,11 @@ func RegisterRoutes(m *macaron.Macaron) { m.Post("/:userid/delete", admin.DeleteUser) }) + m.Group("/emails", func() { + m.Get("", admin.Emails) + m.Post("/activate", admin.ActivateEmail) + }) + m.Group("/orgs", func() { m.Get("", admin.Organizations) }) diff --git a/routers/user/auth.go b/routers/user/auth.go index 3a3e3a1a5..6d762a058 100644 --- a/routers/user/auth.go +++ b/routers/user/auth.go @@ -1217,8 +1217,18 @@ func ActivateEmail(ctx *context.Context) { log.Trace("Email activated: %s", email.Email) ctx.Flash.Success(ctx.Tr("settings.add_email_success")) + + if u, err := models.GetUserByID(email.UID); err != nil { + log.Warn("GetUserByID: %d", email.UID) + } else { + // Allow user to validate more emails + _ = ctx.Cache.Delete("MailResendLimit_" + u.LowerName) + } } + // FIXME: e-mail verification does not require the user to be logged in, + // so this could be redirecting to the login page. + // Should users be logged in automatically here? (consider 2FA requirements, etc.) ctx.Redirect(setting.AppSubURL + "/user/settings/account") } diff --git a/routers/user/setting/account.go b/routers/user/setting/account.go index a9064b0e1..3c0c64ca2 100644 --- a/routers/user/setting/account.go +++ b/routers/user/setting/account.go @@ -88,6 +88,51 @@ func EmailPost(ctx *context.Context, form auth.AddEmailForm) { ctx.Redirect(setting.AppSubURL + "/user/settings/account") return } + // Send activation Email + if ctx.Query("_method") == "SENDACTIVATION" { + var address string + if ctx.Cache.IsExist("MailResendLimit_" + ctx.User.LowerName) { + log.Error("Send activation: activation still pending") + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + if ctx.Query("id") == "PRIMARY" { + if ctx.User.IsActive { + log.Error("Send activation: email not set for activation") + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + mailer.SendActivateAccountMail(ctx.Locale, ctx.User) + address = ctx.User.Email + } else { + id := ctx.QueryInt64("id") + email, err := models.GetEmailAddressByID(ctx.User.ID, id) + if err != nil { + log.Error("GetEmailAddressByID(%d,%d) error: %v", ctx.User.ID, id, err) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + if email == nil { + log.Error("Send activation: EmailAddress not found; user:%d, id: %d", ctx.User.ID, id) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + if email.IsActivated { + log.Error("Send activation: email not set for activation") + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } + mailer.SendActivateEmailMail(ctx.Locale, ctx.User, email) + address = email.Email + } + + if err := ctx.Cache.Put("MailResendLimit_"+ctx.User.LowerName, ctx.User.LowerName, 180); err != nil { + log.Error("Set cache(MailResendLimit) fail: %v", err) + } + ctx.Flash.Info(ctx.Tr("settings.add_email_confirmation_sent", address, timeutil.MinutesToFriendly(setting.Service.ActiveCodeLives, ctx.Locale.Language()))) + ctx.Redirect(setting.AppSubURL + "/user/settings/account") + return + } // Set Email Notification Preference if ctx.Query("_method") == "NOTIFICATION" { preference := ctx.Query("preference") @@ -134,7 +179,6 @@ func EmailPost(ctx *context.Context, form auth.AddEmailForm) { // Send confirmation email if setting.Service.RegisterEmailConfirm { mailer.SendActivateEmailMail(ctx.Locale, ctx.User, email) - if err := ctx.Cache.Put("MailResendLimit_"+ctx.User.LowerName, ctx.User.LowerName, 180); err != nil { log.Error("Set cache(MailResendLimit) fail: %v", err) } @@ -223,11 +267,25 @@ func UpdateUIThemePost(ctx *context.Context, form auth.UpdateThemeForm) { } func loadAccountData(ctx *context.Context) { - emails, err := models.GetEmailAddresses(ctx.User.ID) + emlist, err := models.GetEmailAddresses(ctx.User.ID) if err != nil { ctx.ServerError("GetEmailAddresses", err) return } + type UserEmail struct { + models.EmailAddress + CanBePrimary bool + } + pendingActivation := ctx.Cache.IsExist("MailResendLimit_" + ctx.User.LowerName) + emails := make([]*UserEmail, len(emlist)) + for i, em := range emlist { + var email UserEmail + email.EmailAddress = *em + email.CanBePrimary = em.IsActivated + emails[i] = &email + } ctx.Data["Emails"] = emails ctx.Data["EmailNotificationsPreference"] = ctx.User.EmailNotifications() + ctx.Data["ActivationsPending"] = pendingActivation + ctx.Data["CanAddEmails"] = !pendingActivation || !setting.Service.RegisterEmailConfirm } diff --git a/templates/admin/emails/list.tmpl b/templates/admin/emails/list.tmpl new file mode 100644 index 000000000..e9bef3c3b --- /dev/null +++ b/templates/admin/emails/list.tmpl @@ -0,0 +1,101 @@ +{{template "base/head" .}} +
+ {{template "admin/navbar" .}} +
+ {{template "base/alert" .}} +

+ {{.i18n.Tr "admin.emails.email_manage_panel"}} ({{.i18n.Tr "admin.total" .Total}}) +

+ +
+ + + + + + + + + + + + {{range .Emails}} + + + + + + + + {{end}} + +
{{.i18n.Tr "admin.users.name"}}{{.i18n.Tr "admin.users.full_name"}}{{.i18n.Tr "email"}}{{.i18n.Tr "admin.emails.primary"}}{{.i18n.Tr "admin.emails.activated"}}
{{.Name}}{{.FullName}} + {{if .CanChange}} + + + + {{else}} + + {{end}} +
+
+ + {{template "base/paginate" .}} + + + +
+
+{{template "base/footer" .}} diff --git a/templates/admin/nav.tmpl b/templates/admin/nav.tmpl index d95a0d7ec..0917d1ebe 100644 --- a/templates/admin/nav.tmpl +++ b/templates/admin/nav.tmpl @@ -8,6 +8,7 @@
  • {{.i18n.Tr "admin.repositories"}}
  • {{.i18n.Tr "admin.hooks"}}
  • {{.i18n.Tr "admin.authentication"}}
  • +
  • {{.i18n.Tr "admin.emails"}}
  • {{.i18n.Tr "admin.config"}}
  • {{.i18n.Tr "admin.notices"}}
  • {{.i18n.Tr "admin.monitor"}}
  • diff --git a/templates/admin/navbar.tmpl b/templates/admin/navbar.tmpl index caa8c1f32..546df22e1 100644 --- a/templates/admin/navbar.tmpl +++ b/templates/admin/navbar.tmpl @@ -17,6 +17,9 @@ {{.i18n.Tr "admin.authentication"}} + + {{.i18n.Tr "admin.emails"}} + {{.i18n.Tr "admin.config"}} diff --git a/templates/user/settings/account.tmpl b/templates/user/settings/account.tmpl index 59608a8f1..67036f66f 100644 --- a/templates/user/settings/account.tmpl +++ b/templates/user/settings/account.tmpl @@ -76,7 +76,7 @@ {{$.i18n.Tr "settings.delete_email"}} - {{if .IsActivated}} + {{if .CanBePrimary}}
    {{$.CsrfTokenHtml}} @@ -87,9 +87,30 @@
    {{end}} {{end}} + {{if not .IsActivated}} +
    + + {{$.CsrfTokenHtml}} + + + {{if $.ActivationsPending}} + + {{else}} + + {{end}} + +
    + {{end}}
    {{.Email}} - {{if .IsPrimary}}{{$.i18n.Tr "settings.primary"}}{{end}} + {{if .IsPrimary}} +
    {{$.i18n.Tr "settings.primary"}}
    + {{end}} + {{if .IsActivated}} +
    {{$.i18n.Tr "settings.activated"}}
    + {{else}} +
    {{$.i18n.Tr "settings.requires_activation"}}
    + {{end}}
    {{end}} @@ -100,9 +121,9 @@ {{.CsrfTokenHtml}}
    - +
    - diff --git a/web_src/js/index.js b/web_src/js/index.js index ec39a1c40..fcf9701c3 100644 --- a/web_src/js/index.js +++ b/web_src/js/index.js @@ -2470,6 +2470,7 @@ $(document).ready(async () => { $('.delete-button').click(showDeletePopup); $('.add-all-button').click(showAddAllPopup); $('.link-action').click(linkAction); + $('.link-email-action').click(linkEmailAction); $('.delete-branch-button').click(showDeletePopup); @@ -2749,6 +2750,17 @@ function linkAction() { }); } +function linkEmailAction(e) { + const $this = $(this); + $('#form-uid').val($this.data('uid')); + $('#form-email').val($this.data('email')); + $('#form-primary').val($this.data('primary')); + $('#form-activate').val($this.data('activate')); + $('#form-uid').val($this.data('uid')); + $('#change-email-modal').modal('show'); + e.preventDefault(); +} + function initVueComponents() { const vueDelimeters = ['${', '}'];