summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--models/user_mail.go281
-rw-r--r--models/user_mail_test.go66
-rw-r--r--options/locale/locale_en-US.ini19
-rw-r--r--routers/admin/emails.go157
-rw-r--r--routers/routes/routes.go5
-rw-r--r--routers/user/auth.go10
-rw-r--r--routers/user/setting/account.go62
-rw-r--r--templates/admin/emails/list.tmpl101
-rw-r--r--templates/admin/nav.tmpl1
-rw-r--r--templates/admin/navbar.tmpl3
-rw-r--r--templates/user/settings/account.tmpl29
-rw-r--r--web_src/js/index.js12
12 files changed, 724 insertions, 22 deletions
diff --git a/models/user_mail.go b/models/user_mail.go
index 41f08c9db2..af9602e714 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)
- if err != nil {
+ sess := x.NewSession()
+ defer sess.Close()
+ if err := sess.Begin(); err != nil {
return err
}
- if user.Rands, err = GetUserSalt(); err != nil {
+ if err := email.updateActivation(sess, true); err != nil {
return err
}
+ return sess.Commit()
+}
- sess := x.NewSession()
- defer sess.Close()
- if err = sess.Begin(); err != nil {
+func (email *EmailAddress) updateActivation(e Engine, activate bool) error {
+ user, err := getUserByID(e, email.UID)
+ if err != nil {
return err
}
-
- email.IsActivated = true
- if _, err := sess.
- ID(email.ID).
- Cols("is_activated").
- Update(email); err != nil {
+ if user.Rands, err = GetUserSalt(); err != nil {
return err
- } else if err = updateUserCols(sess, user, "rands"); err != nil {
+ }
+ email.IsActivated = activate
+ if _, err := e.ID(email.ID).Cols("is_activated").Update(email); 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 3352194e1f..8237ce6642 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 4a38dc62c1..be478ad44b 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 0000000000..f0b14ce5e5
--- /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 9859ebc539..a8a08c9eca 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 3a3e3a1a54..6d762a058c 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 a9064b0e15..3c0c64ca27 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 0000000000..e9bef3c3b8
--- /dev/null
+++ b/templates/admin/emails/list.tmpl
@@ -0,0 +1,101 @@
+{{template "base/head" .}}
+<div class="admin user">
+ {{template "admin/navbar" .}}
+ <div class="ui container">
+ {{template "base/alert" .}}
+ <h4 class="ui top attached header">
+ {{.i18n.Tr "admin.emails.email_manage_panel"}} ({{.i18n.Tr "admin.total" .Total}})
+ </h4>
+ <div class="ui attached segment">
+ <div class="ui right floated secondary filter menu">
+ <!-- Sort -->
+ <div class="ui dropdown type jump item">
+ <span class="text">
+ {{.i18n.Tr "repo.issues.filter_sort"}}
+ <i class="dropdown icon"></i>
+ </span>
+ <div class="menu">
+ <a class="{{if or (eq .SortType "email") (not .SortType)}}active{{end}} item" href="{{$.Link}}?sort=email&q={{$.Keyword}}">{{.i18n.Tr "admin.emails.filter_sort.email"}}</a>
+ <a class="{{if eq .SortType "reverseemail"}}active{{end}} item" href="{{$.Link}}?sort=reverseemail&q={{$.Keyword}}">{{.i18n.Tr "admin.emails.filter_sort.email_reverse"}}</a>
+ <a class="{{if eq .SortType "username"}}active{{end}} item" href="{{$.Link}}?sort=username&q={{$.Keyword}}">{{.i18n.Tr "admin.emails.filter_sort.name"}}</a>
+ <a class="{{if eq .SortType "reverseusername"}}active{{end}} item" href="{{$.Link}}?sort=reverseusername&q={{$.Keyword}}">{{.i18n.Tr "admin.emails.filter_sort.name_reverse"}}</a>
+ </div>
+ </div>
+ </div>
+ <form class="ui form ignore-dirty" style="max-width: 90%">
+ <div class="ui fluid action input">
+ <input name="q" value="{{.Keyword}}" placeholder="{{.i18n.Tr "explore.search"}}..." autofocus>
+ <button class="ui blue button">{{.i18n.Tr "explore.search"}}</button>
+ </div>
+ </form>
+ </div>
+ <div class="ui attached table segment">
+ <table class="ui very basic striped table">
+ <thead>
+ <tr>
+ <th>{{.i18n.Tr "admin.users.name"}}</th>
+ <th>{{.i18n.Tr "admin.users.full_name"}}</th>
+ <th>{{.i18n.Tr "email"}}</th>
+ <th>{{.i18n.Tr "admin.emails.primary"}}</th>
+ <th>{{.i18n.Tr "admin.emails.activated"}}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {{range .Emails}}
+ <tr>
+ <td><a href="{{AppSubUrl}}/{{.Name}}">{{.Name}}</a></td>
+ <td><span class="text truncate">{{.FullName}}</span></td>
+ <td><span class="text email">{{.Email}}</span></td>
+ <td><i class="fa fa{{if .IsPrimary}}-check{{end}}-square-o"></i></td>
+ <td>
+ {{if .CanChange}}
+ <a class="link-email-action" href data-uid="{{.UID}}"
+ data-email="{{.Email}}"
+ data-primary="{{if .IsPrimary}}1{{else}}0{{end}}"
+ data-activate="{{if .IsActivated}}0{{else}}1{{end}}">
+ <i class="fa fa{{if .IsActivated}}-check{{end}}-square-o"></i>
+ </a>
+ {{else}}
+ <i class="fa fa{{if .IsActivated}}-check{{end}}-square-o"></i>
+ {{end}}
+ </td>
+ </tr>
+ {{end}}
+ </tbody>
+ </table>
+ </div>
+
+ {{template "base/paginate" .}}
+
+ <div class="ui basic modal" id="change-email-modal">
+ <div class="ui icon header">
+ {{.i18n.Tr "admin.emails.change_email_header"}}
+ </div>
+ <div class="content center">
+ <p>{{.i18n.Tr "admin.emails.change_email_text"}}</p>
+
+ <form class="ui form" id="email-action-form" action="{{AppSubUrl}}/admin/emails/activate" method="post">
+ {{$.CsrfTokenHtml}}
+
+ <input type="hidden" id="query-sort" name="sort" value="{{.SortType}}">
+ <input type="hidden" id="query-keyword" name="q" value="{{.Keyword}}">
+ <input type="hidden" id="query-primary" name="is_primary" value="{{.IsPrimary}}" required>
+ <input type="hidden" id="query-activated" name="is_activated" value="{{.IsActivated}}" required>
+
+ <input type="hidden" id="form-uid" name="uid" value="" required>
+ <input type="hidden" id="form-email" name="email" value="" required>
+ <input type="hidden" id="form-primary" name="primary" value="" required>
+ <input type="hidden" id="form-activate" name="activate" value="" required>
+
+ <div class="center actions">
+ <div class="ui basic cancel inverted button">{{$.i18n.Tr "settings.cancel"}}</div>
+ <button class="ui basic inverted yellow button">{{$.i18n.Tr "modal.yes"}}</button>
+ </div>
+
+ </form>
+ </div>
+ </div>
+
+ </div>
+</div>
+{{template "base/footer" .}}
diff --git a/templates/admin/nav.tmpl b/templates/admin/nav.tmpl
index d95a0d7ecf..0917d1ebe3 100644
--- a/templates/admin/nav.tmpl
+++ b/templates/admin/nav.tmpl
@@ -8,6 +8,7 @@
<li {{if .PageIsAdminRepositories}}class="current"{{end}}><a href="{{AppSubUrl}}/admin/repos">{{.i18n.Tr "admin.repositories"}}</a></li>
<li {{if .PageIsAdminHooks}}class="current"{{end}}><a href="{{AppSubUrl}}/admin/hooks">{{.i18n.Tr "admin.hooks"}}</a></li>
<li {{if .PageIsAdminAuthentications}}class="current"{{end}}><a href="{{AppSubUrl}}/admin/auths">{{.i18n.Tr "admin.authentication"}}</a></li>
+ <li {{if .PageIsAdminEmails}}class="current"{{end}}><a href="{{AppSubUrl}}/admin/emails">{{.i18n.Tr "admin.emails"}}</a></li>
<li {{if .PageIsAdminConfig}}class="current"{{end}}><a href="{{AppSubUrl}}/admin/config">{{.i18n.Tr "admin.config"}}</a></li>
<li {{if .PageIsAdminNotices}}class="current"{{end}}><a href="{{AppSubUrl}}/admin/notices">{{.i18n.Tr "admin.notices"}}</a></li>
<li {{if .PageIsAdminMonitor}}class="current"{{end}}><a href="{{AppSubUrl}}/admin/monitor">{{.i18n.Tr "admin.monitor"}}</a></li>
diff --git a/templates/admin/navbar.tmpl b/templates/admin/navbar.tmpl
index caa8c1f323..546df22e12 100644
--- a/templates/admin/navbar.tmpl
+++ b/templates/admin/navbar.tmpl
@@ -17,6 +17,9 @@
<a class="{{if .PageIsAdminAuthentications}}active{{end}} item" href="{{AppSubUrl}}/admin/auths">
{{.i18n.Tr "admin.authentication"}}
</a>
+ <a class="{{if .PageIsAdminEmails}}active{{end}} item" href="{{AppSubUrl}}/admin/emails">
+ {{.i18n.Tr "admin.emails"}}
+ </a>
<a class="{{if .PageIsAdminConfig}}active{{end}} item" href="{{AppSubUrl}}/admin/config">
{{.i18n.Tr "admin.config"}}
</a>
diff --git a/templates/user/settings/account.tmpl b/templates/user/settings/account.tmpl
index 59608a8f16..67036f66f5 100644
--- a/templates/user/settings/account.tmpl
+++ b/templates/user/settings/account.tmpl
@@ -76,7 +76,7 @@
{{$.i18n.Tr "settings.delete_email"}}
</button>
</div>
- {{if .IsActivated}}
+ {{if .CanBePrimary}}
<div class="right floated content">
<form action="{{AppSubUrl}}/user/settings/account/email" method="post">
{{$.CsrfTokenHtml}}
@@ -87,9 +87,30 @@
</div>
{{end}}
{{end}}
+ {{if not .IsActivated}}
+ <div class="right floated content">
+ <form action="{{AppSubUrl}}/user/settings/account/email" method="post">
+ {{$.CsrfTokenHtml}}
+ <input name="_method" type="hidden" value="SENDACTIVATION">
+ <input name="id" type="hidden" value="{{if .IsPrimary}}PRIMARY{{else}}}.ID{{end}}">
+ {{if $.ActivationsPending}}
+ <button disabled class="ui blue tiny button">{{$.i18n.Tr "settings.activations_pending"}}</button>
+ {{else}}
+ <button class="ui blue tiny button">{{$.i18n.Tr "settings.activate_email"}}</button>
+ {{end}}
+ </form>
+ </div>
+ {{end}}
<div class="content">
<strong>{{.Email}}</strong>
- {{if .IsPrimary}}<span class="text red">{{$.i18n.Tr "settings.primary"}}</span>{{end}}
+ {{if .IsPrimary}}
+ <div class="ui blue label">{{$.i18n.Tr "settings.primary"}}</div>
+ {{end}}
+ {{if .IsActivated}}
+ <div class="ui green label">{{$.i18n.Tr "settings.activated"}}</div>
+ {{else}}
+ <div class="ui label">{{$.i18n.Tr "settings.requires_activation"}}</div>
+ {{end}}
</div>
</div>
{{end}}
@@ -100,9 +121,9 @@
{{.CsrfTokenHtml}}
<div class="required field {{if .Err_Email}}error{{end}}">
<label for="email">{{.i18n.Tr "settings.add_new_email"}}</label>
- <input id="email" name="email" type="email" required>
+ <input id="email" name="email" type="email" required {{if not .CanAddEmails}}disabled{{end}}>
</div>
- <button class="ui green button">
+ <button class="ui green button" {{if not .CanAddEmails}}disabled{{end}}>
{{.i18n.Tr "settings.add_email"}}
</button>
</form>
diff --git a/web_src/js/index.js b/web_src/js/index.js
index ec39a1c40c..fcf9701c3c 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 = ['${', '}'];