mirror of
				https://github.com/go-gitea/gitea
				synced 2025-10-31 03:18:24 +00:00 
			
		
		
		
	Admin page for managing user e-mail activation (#10557)
* Implement mail activation admin panel * Add export comments * Fix another export comment * again... * And again! * Apply suggestions by @lunny * Add UI for user activated emails * Make new activation UI work * Fix lint * Prevent admin from self-deactivate; add modal Co-authored-by: zeripath <art27@cantab.net>
This commit is contained in:
		| @@ -1,4 +1,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. | ||||||
|  |  | ||||||
| @@ -8,6 +9,12 @@ import ( | |||||||
| 	"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 ( | ||||||
| @@ -54,13 +61,66 @@ func GetEmailAddresses(uid int64) ([]*EmailAddress, error) { | |||||||
| 	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 | ||||||
| @@ -118,31 +178,30 @@ func AddEmailAddresses(emails []*EmailAddress) error { | |||||||
|  |  | ||||||
| // 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) | 	sess := x.NewSession() | ||||||
|  | 	defer sess.Close() | ||||||
|  | 	if err := sess.Begin(); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	if err := email.updateActivation(sess, true); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	return sess.Commit() | ||||||
|  | } | ||||||
|  |  | ||||||
|  | func (email *EmailAddress) updateActivation(e Engine, activate bool) error { | ||||||
|  | 	user, err := getUserByID(e, email.UID) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
| 	if user.Rands, err = GetUserSalt(); err != nil { | 	if user.Rands, err = GetUserSalt(); err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
|  | 	email.IsActivated = activate | ||||||
| 	sess := x.NewSession() | 	if _, err := e.ID(email.ID).Cols("is_activated").Update(email); err != nil { | ||||||
| 	defer sess.Close() |  | ||||||
| 	if err = sess.Begin(); err != nil { |  | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
|  | 	return updateUserCols(e, user, "rands") | ||||||
| 	email.IsActivated = true |  | ||||||
| 	if _, err := sess. |  | ||||||
| 		ID(email.ID). |  | ||||||
| 		Cols("is_activated"). |  | ||||||
| 		Update(email); err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} else if err = updateUserCols(sess, user, "rands"); err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	return sess.Commit() |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // DeleteEmailAddress deletes an email address of given user. | // DeleteEmailAddress deletes an email address of given user. | ||||||
| @@ -228,3 +287,193 @@ func MakeEmailPrimary(email *EmailAddress) error { | |||||||
|  |  | ||||||
| 	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() | ||||||
|  | } | ||||||
|   | |||||||
| @@ -7,6 +7,8 @@ package models | |||||||
| import ( | import ( | ||||||
| 	"testing" | 	"testing" | ||||||
|  |  | ||||||
|  | 	"code.gitea.io/gitea/modules/util" | ||||||
|  |  | ||||||
| 	"github.com/stretchr/testify/assert" | 	"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].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))) | ||||||
|  | } | ||||||
|   | |||||||
| @@ -440,7 +440,11 @@ manage_openid = Manage OpenID Addresses | |||||||
| 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? | ||||||
| @@ -1724,6 +1728,7 @@ organizations = Organizations | |||||||
| 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 | ||||||
| @@ -1793,6 +1798,7 @@ dashboard.gc_times = GC Times | |||||||
| 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 | ||||||
| @@ -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.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 | ||||||
|   | |||||||
							
								
								
									
										157
									
								
								routers/admin/emails.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										157
									
								
								routers/admin/emails.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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.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) | ||||||
| 		}) | 		}) | ||||||
|   | |||||||
| @@ -1217,8 +1217,18 @@ func ActivateEmail(ctx *context.Context) { | |||||||
|  |  | ||||||
| 		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") | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -88,6 +88,51 @@ func EmailPost(ctx *context.Context, form auth.AddEmailForm) { | |||||||
| 		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") | ||||||
| @@ -134,7 +179,6 @@ func EmailPost(ctx *context.Context, form auth.AddEmailForm) { | |||||||
| 	// 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) | ||||||
| 		} | 		} | ||||||
| @@ -223,11 +267,25 @@ func UpdateUIThemePost(ctx *context.Context, form auth.UpdateThemeForm) { | |||||||
| } | } | ||||||
|  |  | ||||||
| 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 | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										101
									
								
								templates/admin/emails/list.tmpl
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								templates/admin/emails/list.tmpl
									
									
									
									
									
										Normal file
									
								
							| @@ -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 .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> | ||||||
|   | |||||||
| @@ -17,6 +17,9 @@ | |||||||
| 	<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> | ||||||
|   | |||||||
| @@ -76,7 +76,7 @@ | |||||||
| 									{{$.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}} | ||||||
| @@ -87,9 +87,30 @@ | |||||||
| 								</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}} | ||||||
| @@ -100,9 +121,9 @@ | |||||||
| 				{{.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> | ||||||
|   | |||||||
| @@ -2470,6 +2470,7 @@ $(document).ready(async () => { | |||||||
|   $('.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); | ||||||
|  |  | ||||||
| @@ -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() { | function initVueComponents() { | ||||||
|   const vueDelimeters = ['${', '}']; |   const vueDelimeters = ['${', '}']; | ||||||
|  |  | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user