* 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
// Copyright 2016 The Gogs Authors. All rights reserved. | // 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 | // Use of this source code is governed by a MIT-style | ||||
// license that can be found in the LICENSE file. | // license that can be found in the LICENSE file. | ||||
"errors" | "errors" | ||||
"fmt" | "fmt" | ||||
"strings" | "strings" | ||||
"code.gitea.io/gitea/modules/log" | |||||
"code.gitea.io/gitea/modules/setting" | |||||
"code.gitea.io/gitea/modules/util" | |||||
"xorm.io/builder" | |||||
) | ) | ||||
var ( | var ( | ||||
if !isPrimaryFound { | if !isPrimaryFound { | ||||
emails = append(emails, &EmailAddress{ | emails = append(emails, &EmailAddress{ | ||||
Email: u.Email, | Email: u.Email, | ||||
IsActivated: true, | |||||
IsActivated: u.IsActive, | |||||
IsPrimary: true, | IsPrimary: true, | ||||
}) | }) | ||||
} | } | ||||
return emails, nil | 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) { | func isEmailUsed(e Engine, email string) (bool, error) { | ||||
if len(email) == 0 { | if len(email) == 0 { | ||||
return true, nil | return true, nil | ||||
// Activate activates the email address to given user. | // Activate activates the email address to given user. | ||||
func (email *EmailAddress) Activate() error { | 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 | return err | ||||
} | } | ||||
if user.Rands, err = GetUserSalt(); err != nil { | |||||
if err := email.updateActivation(sess, true); err != nil { | |||||
return err | 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 | 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 | 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 err | ||||
} | } | ||||
return sess.Commit() | |||||
return updateUserCols(e, user, "rands") | |||||
} | } | ||||
// DeleteEmailAddress deletes an email address of given user. | // DeleteEmailAddress deletes an email address of given user. | ||||
return sess.Commit() | 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() | |||||
} |
import ( | import ( | ||||
"testing" | "testing" | ||||
"code.gitea.io/gitea/modules/util" | |||||
"github.com/stretchr/testify/assert" | "github.com/stretchr/testify/assert" | ||||
) | ) | ||||
assert.True(t, emails[2].IsActivated) | assert.True(t, emails[2].IsActivated) | ||||
assert.True(t, emails[2].IsPrimary) | 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))) | |||||
} |
email_desc = Your primary email address will be used for notifications and other operations. | 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. | theme_desc = This will be your default theme across the site. | ||||
primary = Primary | primary = Primary | ||||
activated = Activated | |||||
requires_activation = Requires activation | |||||
primary_email = Make Primary | primary_email = Make Primary | ||||
activate_email = Send Activation | |||||
activations_pending = Activations Pending | |||||
delete_email = Remove | delete_email = Remove | ||||
email_deletion = Remove Email Address | 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? | 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? | ||||
repositories = Repositories | repositories = Repositories | ||||
hooks = Default Webhooks | hooks = Default Webhooks | ||||
authentication = Authentication Sources | authentication = Authentication Sources | ||||
emails = User Emails | |||||
config = Configuration | config = Configuration | ||||
notices = System Notices | notices = System Notices | ||||
monitor = Monitoring | monitor = Monitoring | ||||
users.user_manage_panel = User Account Management | users.user_manage_panel = User Account Management | ||||
users.new_account = Create User Account | users.new_account = Create User Account | ||||
users.name = Username | users.name = Username | ||||
users.full_name = Full Name | |||||
users.activated = Activated | users.activated = Activated | ||||
users.admin = Admin | users.admin = Admin | ||||
users.restricted = Restricted | users.restricted = Restricted | ||||
users.still_has_org = This user is a member of an organization. Remove the user from any organizations first. | 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. | 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.org_manage_panel = Organization Management | ||||
orgs.name = Name | orgs.name = Name | ||||
orgs.teams = Teams | orgs.teams = Teams |
// 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()) | |||||
} |
m.Post("/:userid/delete", admin.DeleteUser) | m.Post("/:userid/delete", admin.DeleteUser) | ||||
}) | }) | ||||
m.Group("/emails", func() { | |||||
m.Get("", admin.Emails) | |||||
m.Post("/activate", admin.ActivateEmail) | |||||
}) | |||||
m.Group("/orgs", func() { | m.Group("/orgs", func() { | ||||
m.Get("", admin.Organizations) | m.Get("", admin.Organizations) | ||||
}) | }) |
log.Trace("Email activated: %s", email.Email) | log.Trace("Email activated: %s", email.Email) | ||||
ctx.Flash.Success(ctx.Tr("settings.add_email_success")) | 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") | ctx.Redirect(setting.AppSubURL + "/user/settings/account") | ||||
} | } | ||||
ctx.Redirect(setting.AppSubURL + "/user/settings/account") | ctx.Redirect(setting.AppSubURL + "/user/settings/account") | ||||
return | 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 | // Set Email Notification Preference | ||||
if ctx.Query("_method") == "NOTIFICATION" { | if ctx.Query("_method") == "NOTIFICATION" { | ||||
preference := ctx.Query("preference") | preference := ctx.Query("preference") | ||||
// Send confirmation email | // Send confirmation email | ||||
if setting.Service.RegisterEmailConfirm { | if setting.Service.RegisterEmailConfirm { | ||||
mailer.SendActivateEmailMail(ctx.Locale, ctx.User, email) | mailer.SendActivateEmailMail(ctx.Locale, ctx.User, email) | ||||
if err := ctx.Cache.Put("MailResendLimit_"+ctx.User.LowerName, ctx.User.LowerName, 180); err != nil { | if err := ctx.Cache.Put("MailResendLimit_"+ctx.User.LowerName, ctx.User.LowerName, 180); err != nil { | ||||
log.Error("Set cache(MailResendLimit) fail: %v", err) | log.Error("Set cache(MailResendLimit) fail: %v", err) | ||||
} | } | ||||
} | } | ||||
func loadAccountData(ctx *context.Context) { | func loadAccountData(ctx *context.Context) { | ||||
emails, err := models.GetEmailAddresses(ctx.User.ID) | |||||
emlist, err := models.GetEmailAddresses(ctx.User.ID) | |||||
if err != nil { | if err != nil { | ||||
ctx.ServerError("GetEmailAddresses", err) | ctx.ServerError("GetEmailAddresses", err) | ||||
return | 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["Emails"] = emails | ||||
ctx.Data["EmailNotificationsPreference"] = ctx.User.EmailNotifications() | ctx.Data["EmailNotificationsPreference"] = ctx.User.EmailNotifications() | ||||
ctx.Data["ActivationsPending"] = pendingActivation | |||||
ctx.Data["CanAddEmails"] = !pendingActivation || !setting.Service.RegisterEmailConfirm | |||||
} | } |
{{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" .}} |
<li {{if .PageIsAdminRepositories}}class="current"{{end}}><a href="{{AppSubUrl}}/admin/repos">{{.i18n.Tr "admin.repositories"}}</a></li> | <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 .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 .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 .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 .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> | <li {{if .PageIsAdminMonitor}}class="current"{{end}}><a href="{{AppSubUrl}}/admin/monitor">{{.i18n.Tr "admin.monitor"}}</a></li> |
<a class="{{if .PageIsAdminAuthentications}}active{{end}} item" href="{{AppSubUrl}}/admin/auths"> | <a class="{{if .PageIsAdminAuthentications}}active{{end}} item" href="{{AppSubUrl}}/admin/auths"> | ||||
{{.i18n.Tr "admin.authentication"}} | {{.i18n.Tr "admin.authentication"}} | ||||
</a> | </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"> | <a class="{{if .PageIsAdminConfig}}active{{end}} item" href="{{AppSubUrl}}/admin/config"> | ||||
{{.i18n.Tr "admin.config"}} | {{.i18n.Tr "admin.config"}} | ||||
</a> | </a> |
{{$.i18n.Tr "settings.delete_email"}} | {{$.i18n.Tr "settings.delete_email"}} | ||||
</button> | </button> | ||||
</div> | </div> | ||||
{{if .IsActivated}} | |||||
{{if .CanBePrimary}} | |||||
<div class="right floated content"> | <div class="right floated content"> | ||||
<form action="{{AppSubUrl}}/user/settings/account/email" method="post"> | <form action="{{AppSubUrl}}/user/settings/account/email" method="post"> | ||||
{{$.CsrfTokenHtml}} | {{$.CsrfTokenHtml}} | ||||
</div> | </div> | ||||
{{end}} | {{end}} | ||||
{{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"> | <div class="content"> | ||||
<strong>{{.Email}}</strong> | <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> | ||||
</div> | </div> | ||||
{{end}} | {{end}} | ||||
{{.CsrfTokenHtml}} | {{.CsrfTokenHtml}} | ||||
<div class="required field {{if .Err_Email}}error{{end}}"> | <div class="required field {{if .Err_Email}}error{{end}}"> | ||||
<label for="email">{{.i18n.Tr "settings.add_new_email"}}</label> | <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> | </div> | ||||
<button class="ui green button"> | |||||
<button class="ui green button" {{if not .CanAddEmails}}disabled{{end}}> | |||||
{{.i18n.Tr "settings.add_email"}} | {{.i18n.Tr "settings.add_email"}} | ||||
</button> | </button> | ||||
</form> | </form> |
$('.delete-button').click(showDeletePopup); | $('.delete-button').click(showDeletePopup); | ||||
$('.add-all-button').click(showAddAllPopup); | $('.add-all-button').click(showAddAllPopup); | ||||
$('.link-action').click(linkAction); | $('.link-action').click(linkAction); | ||||
$('.link-email-action').click(linkEmailAction); | |||||
$('.delete-branch-button').click(showDeletePopup); | $('.delete-branch-button').click(showDeletePopup); | ||||
}); | }); | ||||
} | } | ||||
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() { | function initVueComponents() { | ||||
const vueDelimeters = ['${', '}']; | const vueDelimeters = ['${', '}']; | ||||