* 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 <art27@cantab.net>tags/v1.10.5
@@ -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() | |||
} |
@@ -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))) | |||
} |
@@ -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 |
@@ -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()) | |||
} |
@@ -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) | |||
}) |
@@ -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") | |||
} | |||
@@ -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 | |||
} |
@@ -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" .}} |
@@ -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> |
@@ -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> |
@@ -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> |
@@ -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 = ['${', '}']; | |||