mirror of
https://github.com/drakkan/sftpgo.git
synced 2024-11-24 08:30:27 +00:00
user: add additional emails
Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
parent
bdd6de10a5
commit
eba4c93efd
17 changed files with 267 additions and 28 deletions
2
go.mod
2
go.mod
|
@ -52,7 +52,7 @@ require (
|
||||||
github.com/rs/cors v1.11.1
|
github.com/rs/cors v1.11.1
|
||||||
github.com/rs/xid v1.6.0
|
github.com/rs/xid v1.6.0
|
||||||
github.com/rs/zerolog v1.33.0
|
github.com/rs/zerolog v1.33.0
|
||||||
github.com/sftpgo/sdk v0.1.9-0.20241002160417-3a2e25af00c1
|
github.com/sftpgo/sdk v0.1.9-0.20241011171103-64fc18a344f9
|
||||||
github.com/shirou/gopsutil/v3 v3.24.5
|
github.com/shirou/gopsutil/v3 v3.24.5
|
||||||
github.com/spf13/afero v1.11.0
|
github.com/spf13/afero v1.11.0
|
||||||
github.com/spf13/cobra v1.8.1
|
github.com/spf13/cobra v1.8.1
|
||||||
|
|
4
go.sum
4
go.sum
|
@ -371,8 +371,8 @@ github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJ
|
||||||
github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY=
|
github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY=
|
||||||
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||||
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
github.com/sftpgo/sdk v0.1.9-0.20241002160417-3a2e25af00c1 h1:UR1rI03lk+rLbt/FmUszQoY+hE3XxVCEGSumjbMZx/I=
|
github.com/sftpgo/sdk v0.1.9-0.20241011171103-64fc18a344f9 h1:wlXBnaNfJJJRZjHO2AerSS5gp0ckkYUgBzSXivUo0Wo=
|
||||||
github.com/sftpgo/sdk v0.1.9-0.20241002160417-3a2e25af00c1/go.mod h1:Isl0IEzS/Muvh8Fr4X+NWFsOS/fZQHRD4oPQpoY7C4g=
|
github.com/sftpgo/sdk v0.1.9-0.20241011171103-64fc18a344f9/go.mod h1:ehimvlTP+XTEiE3t1CPwWx9n7+6A6OGvMGlZ7ouvKFk=
|
||||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||||
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||||
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||||
|
|
|
@ -2459,7 +2459,7 @@ func executePwdExpirationCheckForUser(user *dataprovider.User, config dataprovid
|
||||||
}
|
}
|
||||||
subject := "SFTPGo password expiration notification"
|
subject := "SFTPGo password expiration notification"
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
if err := smtp.SendEmail([]string{user.Email}, nil, subject, body.String(), smtp.EmailContentTypeTextHTML); err != nil {
|
if err := smtp.SendEmail(user.GetEmailAddresses(), nil, subject, body.String(), smtp.EmailContentTypeTextHTML); err != nil {
|
||||||
eventManagerLog(logger.LevelError, "unable to notify password expiration for user %s: %v, elapsed: %s",
|
eventManagerLog(logger.LevelError, "unable to notify password expiration for user %s: %v, elapsed: %s",
|
||||||
user.Username, err, time.Since(startTime))
|
user.Username, err, time.Since(startTime))
|
||||||
return err
|
return err
|
||||||
|
@ -2554,6 +2554,9 @@ func preserveUserProfile(user, newUser *dataprovider.User) {
|
||||||
if user.Email != "" {
|
if user.Email != "" {
|
||||||
newUser.Email = user.Email
|
newUser.Email = user.Email
|
||||||
}
|
}
|
||||||
|
if len(user.Filters.AdditionalEmails) > 0 {
|
||||||
|
newUser.Filters.AdditionalEmails = user.Filters.AdditionalEmails
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if newUser.CanChangeAPIKeyAuth() {
|
if newUser.CanChangeAPIKeyAuth() {
|
||||||
newUser.Filters.AllowAPIKeyAuth = user.Filters.AllowAPIKeyAuth
|
newUser.Filters.AllowAPIKeyAuth = user.Filters.AllowAPIKeyAuth
|
||||||
|
|
|
@ -1418,6 +1418,7 @@ func TestIDPAccountCheckRule(t *testing.T) {
|
||||||
// Update the profile attribute and make sure they are preserved
|
// Update the profile attribute and make sure they are preserved
|
||||||
user.Password = "secret"
|
user.Password = "secret"
|
||||||
user.Email = "example@example.com"
|
user.Email = "example@example.com"
|
||||||
|
user.Filters.AdditionalEmails = []string{"alias@example.com"}
|
||||||
user.Description = "some desc"
|
user.Description = "some desc"
|
||||||
user.Filters.TLSCerts = []string{serverCert}
|
user.Filters.TLSCerts = []string{serverCert}
|
||||||
user.PublicKeys = []string{"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC03jj0D+djk7pxIf/0OhrxrchJTRZklofJ1NoIu4752Sq02mdXmarMVsqJ1cAjV5LBVy3D1F5U6XW4rppkXeVtd04Pxb09ehtH0pRRPaoHHlALiJt8CoMpbKYMA8b3KXPPriGxgGomvtU2T2RMURSwOZbMtpsugfjYSWenyYX+VORYhylWnSXL961LTyC21ehd6d6QnW9G7E5hYMITMY9TuQZz3bROYzXiTsgN0+g6Hn7exFQp50p45StUMfV/SftCMdCxlxuyGny2CrN/vfjO7xxOo2uv7q1qm10Q46KPWJQv+pgZ/OfL+EDjy07n5QVSKHlbx+2nT4Q0EgOSQaCTYwn3YjtABfIxWwgAFdyj6YlPulCL22qU4MYhDcA6PSBwDdf8hvxBfvsiHdM+JcSHvv8/VeJhk6CmnZxGY0fxBupov27z3yEO8nAg8k+6PaUiW1MSUfuGMF/ktB8LOstXsEPXSszuyXiOv4DaryOXUiSn7bmRqKcEFlJusO6aZP0= nicola@p1"}
|
user.PublicKeys = []string{"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC03jj0D+djk7pxIf/0OhrxrchJTRZklofJ1NoIu4752Sq02mdXmarMVsqJ1cAjV5LBVy3D1F5U6XW4rppkXeVtd04Pxb09ehtH0pRRPaoHHlALiJt8CoMpbKYMA8b3KXPPriGxgGomvtU2T2RMURSwOZbMtpsugfjYSWenyYX+VORYhylWnSXL961LTyC21ehd6d6QnW9G7E5hYMITMY9TuQZz3bROYzXiTsgN0+g6Hn7exFQp50p45StUMfV/SftCMdCxlxuyGny2CrN/vfjO7xxOo2uv7q1qm10Q46KPWJQv+pgZ/OfL+EDjy07n5QVSKHlbx+2nT4Q0EgOSQaCTYwn3YjtABfIxWwgAFdyj6YlPulCL22qU4MYhDcA6PSBwDdf8hvxBfvsiHdM+JcSHvv8/VeJhk6CmnZxGY0fxBupov27z3yEO8nAg8k+6PaUiW1MSUfuGMF/ktB8LOstXsEPXSszuyXiOv4DaryOXUiSn7bmRqKcEFlJusO6aZP0= nicola@p1"}
|
||||||
|
@ -1432,6 +1433,7 @@ func TestIDPAccountCheckRule(t *testing.T) {
|
||||||
assert.Len(t, user.PublicKeys, 1)
|
assert.Len(t, user.PublicKeys, 1)
|
||||||
assert.Len(t, user.Filters.TLSCerts, 1)
|
assert.Len(t, user.Filters.TLSCerts, 1)
|
||||||
assert.NotEmpty(t, user.Email)
|
assert.NotEmpty(t, user.Email)
|
||||||
|
assert.Len(t, user.Filters.AdditionalEmails, 1)
|
||||||
assert.NotEmpty(t, user.Description)
|
assert.NotEmpty(t, user.Description)
|
||||||
|
|
||||||
err = dataprovider.DeleteUser(username, "", "", "")
|
err = dataprovider.DeleteUser(username, "", "", "")
|
||||||
|
|
|
@ -7709,6 +7709,7 @@ func TestEventRulePasswordExpiration(t *testing.T) {
|
||||||
_, _, err = httpdtest.UpdateEventRule(rule1, http.StatusOK)
|
_, _, err = httpdtest.UpdateEventRule(rule1, http.StatusOK)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
user.Email = "user@example.net"
|
user.Email = "user@example.net"
|
||||||
|
user.Filters.AdditionalEmails = []string{"additional@example.net"}
|
||||||
_, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
|
_, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
conn, client, err = getSftpClient(user)
|
conn, client, err = getSftpClient(user)
|
||||||
|
@ -7724,8 +7725,9 @@ func TestEventRulePasswordExpiration(t *testing.T) {
|
||||||
return lastReceivedEmail.get().From != ""
|
return lastReceivedEmail.get().From != ""
|
||||||
}, 1500*time.Millisecond, 100*time.Millisecond)
|
}, 1500*time.Millisecond, 100*time.Millisecond)
|
||||||
email := lastReceivedEmail.get()
|
email := lastReceivedEmail.get()
|
||||||
assert.Len(t, email.To, 1)
|
assert.Len(t, email.To, 2)
|
||||||
assert.Contains(t, email.To, user.Email)
|
assert.Contains(t, email.To, user.Email)
|
||||||
|
assert.Contains(t, email.To, user.Filters.AdditionalEmails[0])
|
||||||
assert.Contains(t, email.Data, "your SFTPGo password expires in 5 days")
|
assert.Contains(t, email.Data, "your SFTPGo password expires in 5 days")
|
||||||
err = client.RemoveDirectory(dirName)
|
err = client.RemoveDirectory(dirName)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
|
@ -3230,6 +3230,24 @@ func validateCombinedUserFilters(user *User) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateEmails(user *User) error {
|
||||||
|
if user.Email != "" && !util.IsEmailValid(user.Email) {
|
||||||
|
return util.NewI18nError(
|
||||||
|
util.NewValidationError(fmt.Sprintf("email %q is not valid", user.Email)),
|
||||||
|
util.I18nErrorInvalidEmail,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for _, email := range user.Filters.AdditionalEmails {
|
||||||
|
if !util.IsEmailValid(email) {
|
||||||
|
return util.NewI18nError(
|
||||||
|
util.NewValidationError(fmt.Sprintf("email %q is not valid", email)),
|
||||||
|
util.I18nErrorInvalidEmail,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func validateBaseParams(user *User) error {
|
func validateBaseParams(user *User) error {
|
||||||
if user.Username == "" {
|
if user.Username == "" {
|
||||||
return util.NewI18nError(util.NewValidationError("username is mandatory"), util.I18nErrorUsernameRequired)
|
return util.NewI18nError(util.NewValidationError("username is mandatory"), util.I18nErrorUsernameRequired)
|
||||||
|
@ -3237,11 +3255,8 @@ func validateBaseParams(user *User) error {
|
||||||
if err := checkReservedUsernames(user.Username); err != nil {
|
if err := checkReservedUsernames(user.Username); err != nil {
|
||||||
return util.NewI18nError(err, util.I18nErrorReservedUsername)
|
return util.NewI18nError(err, util.I18nErrorReservedUsername)
|
||||||
}
|
}
|
||||||
if user.Email != "" && !util.IsEmailValid(user.Email) {
|
if err := validateEmails(user); err != nil {
|
||||||
return util.NewI18nError(
|
return err
|
||||||
util.NewValidationError(fmt.Sprintf("email %q is not valid", user.Email)),
|
|
||||||
util.I18nErrorInvalidEmail,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if config.NamingRules&1 == 0 && !usernameRegex.MatchString(user.Username) {
|
if config.NamingRules&1 == 0 && !usernameRegex.MatchString(user.Username) {
|
||||||
return util.NewI18nError(
|
return util.NewI18nError(
|
||||||
|
|
|
@ -124,6 +124,8 @@ type UserFilters struct {
|
||||||
sdk.BaseUserFilters
|
sdk.BaseUserFilters
|
||||||
// User must change password from WebClient/REST API at next login.
|
// User must change password from WebClient/REST API at next login.
|
||||||
RequirePasswordChange bool `json:"require_password_change,omitempty"`
|
RequirePasswordChange bool `json:"require_password_change,omitempty"`
|
||||||
|
// AdditionalEmails defines additional email addresses
|
||||||
|
AdditionalEmails []string `json:"additional_emails,omitempty"`
|
||||||
// Time-based one time passwords configuration
|
// Time-based one time passwords configuration
|
||||||
TOTPConfig UserTOTPConfig `json:"totp_config,omitempty"`
|
TOTPConfig UserTOTPConfig `json:"totp_config,omitempty"`
|
||||||
// Recovery codes to use if the user loses access to their second factor auth device.
|
// Recovery codes to use if the user loses access to their second factor auth device.
|
||||||
|
@ -404,6 +406,15 @@ func (u *User) CheckMaxShareExpiration(expiresAt time.Time) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetEmailAddresses returns all the email addresses.
|
||||||
|
func (u *User) GetEmailAddresses() []string {
|
||||||
|
var res []string
|
||||||
|
if u.Email != "" {
|
||||||
|
res = append(res, u.Email)
|
||||||
|
}
|
||||||
|
return slices.Concat(res, u.Filters.AdditionalEmails)
|
||||||
|
}
|
||||||
|
|
||||||
// GetSubDirPermissions returns permissions for sub directories
|
// GetSubDirPermissions returns permissions for sub directories
|
||||||
func (u *User) GetSubDirPermissions() []sdk.DirectoryPermissions {
|
func (u *User) GetSubDirPermissions() []sdk.DirectoryPermissions {
|
||||||
var result []sdk.DirectoryPermissions
|
var result []sdk.DirectoryPermissions
|
||||||
|
@ -1784,6 +1795,8 @@ func (u *User) getACopy() User {
|
||||||
filters.TOTPConfig.Secret = u.Filters.TOTPConfig.Secret.Clone()
|
filters.TOTPConfig.Secret = u.Filters.TOTPConfig.Secret.Clone()
|
||||||
filters.TOTPConfig.Protocols = make([]string, len(u.Filters.TOTPConfig.Protocols))
|
filters.TOTPConfig.Protocols = make([]string, len(u.Filters.TOTPConfig.Protocols))
|
||||||
copy(filters.TOTPConfig.Protocols, u.Filters.TOTPConfig.Protocols)
|
copy(filters.TOTPConfig.Protocols, u.Filters.TOTPConfig.Protocols)
|
||||||
|
filters.AdditionalEmails = make([]string, len(u.Filters.AdditionalEmails))
|
||||||
|
copy(filters.AdditionalEmails, u.Filters.AdditionalEmails)
|
||||||
filters.RecoveryCodes = make([]RecoveryCode, 0, len(u.Filters.RecoveryCodes))
|
filters.RecoveryCodes = make([]RecoveryCode, 0, len(u.Filters.RecoveryCodes))
|
||||||
for _, code := range u.Filters.RecoveryCodes {
|
for _, code := range u.Filters.RecoveryCodes {
|
||||||
if code.Secret == nil {
|
if code.Secret == nil {
|
||||||
|
|
|
@ -469,8 +469,9 @@ func getUserProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
Description: user.Description,
|
Description: user.Description,
|
||||||
AllowAPIKeyAuth: user.Filters.AllowAPIKeyAuth,
|
AllowAPIKeyAuth: user.Filters.AllowAPIKeyAuth,
|
||||||
},
|
},
|
||||||
PublicKeys: user.PublicKeys,
|
AdditionalEmails: user.Filters.AdditionalEmails,
|
||||||
TLSCerts: user.Filters.TLSCerts,
|
PublicKeys: user.PublicKeys,
|
||||||
|
TLSCerts: user.Filters.TLSCerts,
|
||||||
}
|
}
|
||||||
render.JSON(w, r, resp)
|
render.JSON(w, r, resp)
|
||||||
}
|
}
|
||||||
|
@ -508,6 +509,7 @@ func updateUserProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
if userMerged.CanChangeInfo() {
|
if userMerged.CanChangeInfo() {
|
||||||
user.Email = req.Email
|
user.Email = req.Email
|
||||||
|
user.Filters.AdditionalEmails = req.AdditionalEmails
|
||||||
user.Description = req.Description
|
user.Description = req.Description
|
||||||
}
|
}
|
||||||
if err := dataprovider.UpdateUser(&user, dataprovider.ActionExecutorSelf, util.GetIPFromRemoteAddress(r.RemoteAddr), user.Role); err != nil {
|
if err := dataprovider.UpdateUser(&user, dataprovider.ActionExecutorSelf, util.GetIPFromRemoteAddress(r.RemoteAddr), user.Role); err != nil {
|
||||||
|
|
|
@ -72,8 +72,9 @@ type adminProfile struct {
|
||||||
|
|
||||||
type userProfile struct {
|
type userProfile struct {
|
||||||
baseProfile
|
baseProfile
|
||||||
PublicKeys []string `json:"public_keys,omitempty"`
|
AdditionalEmails []string `json:"additional_emails,omitempty"`
|
||||||
TLSCerts []string `json:"tls_certs,omitempty"`
|
PublicKeys []string `json:"public_keys,omitempty"`
|
||||||
|
TLSCerts []string `json:"tls_certs,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendAPIResponse(w http.ResponseWriter, r *http.Request, err error, message string, code int) {
|
func sendAPIResponse(w http.ResponseWriter, r *http.Request, err error, message string, code int) {
|
||||||
|
@ -786,7 +787,8 @@ func getActiveUser(username string, r *http.Request) (dataprovider.User, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleForgotPassword(r *http.Request, username string, isAdmin bool) error {
|
func handleForgotPassword(r *http.Request, username string, isAdmin bool) error {
|
||||||
var email, subject string
|
var emails []string
|
||||||
|
var subject string
|
||||||
var err error
|
var err error
|
||||||
var admin dataprovider.Admin
|
var admin dataprovider.Admin
|
||||||
var user dataprovider.User
|
var user dataprovider.User
|
||||||
|
@ -796,11 +798,13 @@ func handleForgotPassword(r *http.Request, username string, isAdmin bool) error
|
||||||
}
|
}
|
||||||
if isAdmin {
|
if isAdmin {
|
||||||
admin, err = getActiveAdmin(username, util.GetIPFromRemoteAddress(r.RemoteAddr))
|
admin, err = getActiveAdmin(username, util.GetIPFromRemoteAddress(r.RemoteAddr))
|
||||||
email = admin.Email
|
if admin.Email != "" {
|
||||||
|
emails = []string{admin.Email}
|
||||||
|
}
|
||||||
subject = fmt.Sprintf("Email Verification Code for admin %q", username)
|
subject = fmt.Sprintf("Email Verification Code for admin %q", username)
|
||||||
} else {
|
} else {
|
||||||
user, err = getActiveUser(username, r)
|
user, err = getActiveUser(username, r)
|
||||||
email = user.Email
|
emails = user.GetEmailAddresses()
|
||||||
subject = fmt.Sprintf("Email Verification Code for user %q", username)
|
subject = fmt.Sprintf("Email Verification Code for user %q", username)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if !isUserAllowedToResetPassword(r, &user) {
|
if !isUserAllowedToResetPassword(r, &user) {
|
||||||
|
@ -821,7 +825,7 @@ func handleForgotPassword(r *http.Request, username string, isAdmin bool) error
|
||||||
}
|
}
|
||||||
return util.NewI18nError(util.NewGenericError("Error retrieving your account, please try again later"), util.I18nErrorGetUser)
|
return util.NewI18nError(util.NewGenericError("Error retrieving your account, please try again later"), util.I18nErrorGetUser)
|
||||||
}
|
}
|
||||||
if email == "" {
|
if len(emails) == 0 {
|
||||||
return util.NewI18nError(
|
return util.NewI18nError(
|
||||||
util.NewValidationError("Your account does not have an email address, it is not possible to reset your password by sending an email verification code"),
|
util.NewValidationError("Your account does not have an email address, it is not possible to reset your password by sending an email verification code"),
|
||||||
util.I18nErrorPwdResetNoEmail,
|
util.I18nErrorPwdResetNoEmail,
|
||||||
|
@ -836,7 +840,7 @@ func handleForgotPassword(r *http.Request, username string, isAdmin bool) error
|
||||||
return util.NewGenericError("Unable to render password reset template")
|
return util.NewGenericError("Unable to render password reset template")
|
||||||
}
|
}
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
if err := smtp.SendEmail([]string{email}, nil, subject, body.String(), smtp.EmailContentTypeTextHTML); err != nil {
|
if err := smtp.SendEmail(emails, nil, subject, body.String(), smtp.EmailContentTypeTextHTML); err != nil {
|
||||||
logger.Warn(logSender, middleware.GetReqID(r.Context()), "unable to send password reset code via email: %v, elapsed: %v",
|
logger.Warn(logSender, middleware.GetReqID(r.Context()), "unable to send password reset code via email: %v, elapsed: %v",
|
||||||
err, time.Since(startTime))
|
err, time.Since(startTime))
|
||||||
return util.NewI18nError(
|
return util.NewI18nError(
|
||||||
|
@ -844,8 +848,8 @@ func handleForgotPassword(r *http.Request, username string, isAdmin bool) error
|
||||||
util.I18nErrorPwdResetSendEmail,
|
util.I18nErrorPwdResetSendEmail,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
logger.Debug(logSender, middleware.GetReqID(r.Context()), "reset code sent via email to %q, email: %q, is admin? %v, elapsed: %v",
|
logger.Debug(logSender, middleware.GetReqID(r.Context()), "reset code sent via email to %q, emails: %+v, is admin? %v, elapsed: %v",
|
||||||
username, email, isAdmin, time.Since(startTime))
|
username, emails, isAdmin, time.Since(startTime))
|
||||||
return resetCodesMgr.Add(c)
|
return resetCodesMgr.Add(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -623,6 +623,7 @@ func TestInitialization(t *testing.T) {
|
||||||
func TestBasicUserHandling(t *testing.T) {
|
func TestBasicUserHandling(t *testing.T) {
|
||||||
u := getTestUser()
|
u := getTestUser()
|
||||||
u.Email = "user@user.com"
|
u.Email = "user@user.com"
|
||||||
|
u.Filters.AdditionalEmails = []string{"email1@user.com", "email2@user.com"}
|
||||||
user, resp, err := httpdtest.AddUser(u, http.StatusCreated)
|
user, resp, err := httpdtest.AddUser(u, http.StatusCreated)
|
||||||
assert.NoError(t, err, string(resp))
|
assert.NoError(t, err, string(resp))
|
||||||
_, resp, err = httpdtest.AddUser(u, http.StatusConflict)
|
_, resp, err = httpdtest.AddUser(u, http.StatusConflict)
|
||||||
|
@ -663,6 +664,12 @@ func TestBasicUserHandling(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Contains(t, string(body), "Validation error: email")
|
assert.Contains(t, string(body), "Validation error: email")
|
||||||
|
|
||||||
|
user.Email = ""
|
||||||
|
user.Filters.AdditionalEmails = []string{"invalid@email"}
|
||||||
|
_, body, err = httpdtest.UpdateUser(user, http.StatusBadRequest, "")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Contains(t, string(body), "Validation error: email")
|
||||||
|
|
||||||
_, err = httpdtest.RemoveUser(user, http.StatusOK)
|
_, err = httpdtest.RemoveUser(user, http.StatusOK)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
@ -11298,6 +11305,7 @@ func TestWebAPIChangeUserProfileMock(t *testing.T) {
|
||||||
checkResponseCode(t, http.StatusBadRequest, rr)
|
checkResponseCode(t, http.StatusBadRequest, rr)
|
||||||
|
|
||||||
email := "userapi@example.com"
|
email := "userapi@example.com"
|
||||||
|
additionalEmails := []string{"userapi1@example.com"}
|
||||||
description := "user API description"
|
description := "user API description"
|
||||||
profileReq := make(map[string]any)
|
profileReq := make(map[string]any)
|
||||||
profileReq["allow_api_key_auth"] = true
|
profileReq["allow_api_key_auth"] = true
|
||||||
|
@ -11305,6 +11313,7 @@ func TestWebAPIChangeUserProfileMock(t *testing.T) {
|
||||||
profileReq["description"] = description
|
profileReq["description"] = description
|
||||||
profileReq["public_keys"] = []string{testPubKey, testPubKey1}
|
profileReq["public_keys"] = []string{testPubKey, testPubKey1}
|
||||||
profileReq["tls_certs"] = []string{httpsCert}
|
profileReq["tls_certs"] = []string{httpsCert}
|
||||||
|
profileReq["additional_emails"] = additionalEmails
|
||||||
asJSON, err := json.Marshal(profileReq)
|
asJSON, err := json.Marshal(profileReq)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
req, err = http.NewRequest(http.MethodPut, userProfilePath, bytes.NewBuffer(asJSON))
|
req, err = http.NewRequest(http.MethodPut, userProfilePath, bytes.NewBuffer(asJSON))
|
||||||
|
@ -11322,6 +11331,7 @@ func TestWebAPIChangeUserProfileMock(t *testing.T) {
|
||||||
err = json.Unmarshal(rr.Body.Bytes(), &profileReq)
|
err = json.Unmarshal(rr.Body.Bytes(), &profileReq)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, email, profileReq["email"].(string))
|
assert.Equal(t, email, profileReq["email"].(string))
|
||||||
|
assert.Len(t, profileReq["additional_emails"].([]interface{}), 1)
|
||||||
assert.Equal(t, description, profileReq["description"].(string))
|
assert.Equal(t, description, profileReq["description"].(string))
|
||||||
assert.True(t, profileReq["allow_api_key_auth"].(bool))
|
assert.True(t, profileReq["allow_api_key_auth"].(bool))
|
||||||
val, ok := profileReq["public_keys"].([]any)
|
val, ok := profileReq["public_keys"].([]any)
|
||||||
|
@ -11343,6 +11353,17 @@ func TestWebAPIChangeUserProfileMock(t *testing.T) {
|
||||||
rr = executeRequest(req)
|
rr = executeRequest(req)
|
||||||
checkResponseCode(t, http.StatusBadRequest, rr)
|
checkResponseCode(t, http.StatusBadRequest, rr)
|
||||||
assert.Contains(t, rr.Body.String(), "Validation error: email")
|
assert.Contains(t, rr.Body.String(), "Validation error: email")
|
||||||
|
// set an invalid additional email
|
||||||
|
profileReq = make(map[string]any)
|
||||||
|
profileReq["additional_emails"] = []string{"not an email"}
|
||||||
|
asJSON, err = json.Marshal(profileReq)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
req, err = http.NewRequest(http.MethodPut, userProfilePath, bytes.NewBuffer(asJSON))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
setBearerForReq(req, token)
|
||||||
|
rr = executeRequest(req)
|
||||||
|
checkResponseCode(t, http.StatusBadRequest, rr)
|
||||||
|
assert.Contains(t, rr.Body.String(), "Validation error: email")
|
||||||
// set an invalid public key
|
// set an invalid public key
|
||||||
profileReq = make(map[string]any)
|
profileReq = make(map[string]any)
|
||||||
profileReq["public_keys"] = []string{"not a public key"}
|
profileReq["public_keys"] = []string{"not a public key"}
|
||||||
|
@ -19859,6 +19880,7 @@ func TestWebUserProfile(t *testing.T) {
|
||||||
form.Set("public_keys[0][public_key]", testPubKey)
|
form.Set("public_keys[0][public_key]", testPubKey)
|
||||||
form.Set("public_keys[1][public_key]", testPubKey1)
|
form.Set("public_keys[1][public_key]", testPubKey1)
|
||||||
form.Set("tls_certs[0][tls_cert]", httpsCert)
|
form.Set("tls_certs[0][tls_cert]", httpsCert)
|
||||||
|
form.Set("additional_emails[0][additional_email]", "email1@user.com")
|
||||||
// no csrf token
|
// no csrf token
|
||||||
req, err := http.NewRequest(http.MethodPost, webClientProfilePath, bytes.NewBuffer([]byte(form.Encode())))
|
req, err := http.NewRequest(http.MethodPost, webClientProfilePath, bytes.NewBuffer([]byte(form.Encode())))
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
@ -19885,6 +19907,9 @@ func TestWebUserProfile(t *testing.T) {
|
||||||
assert.Len(t, user.Filters.TLSCerts, 1)
|
assert.Len(t, user.Filters.TLSCerts, 1)
|
||||||
assert.Equal(t, email, user.Email)
|
assert.Equal(t, email, user.Email)
|
||||||
assert.Equal(t, description, user.Description)
|
assert.Equal(t, description, user.Description)
|
||||||
|
if assert.Len(t, user.Filters.AdditionalEmails, 1) {
|
||||||
|
assert.Equal(t, "email1@user.com", user.Filters.AdditionalEmails[0])
|
||||||
|
}
|
||||||
|
|
||||||
// set an invalid email
|
// set an invalid email
|
||||||
form.Set("email", "not an email")
|
form.Set("email", "not an email")
|
||||||
|
@ -21268,6 +21293,7 @@ func TestWebUserAddMock(t *testing.T) {
|
||||||
user.AdditionalInfo = "info"
|
user.AdditionalInfo = "info"
|
||||||
user.Description = "user dsc"
|
user.Description = "user dsc"
|
||||||
user.Email = "test@test.com"
|
user.Email = "test@test.com"
|
||||||
|
user.Filters.AdditionalEmails = []string{"example1@test.com", "example2@test.com"}
|
||||||
mappedDir := filepath.Join(os.TempDir(), "mapped")
|
mappedDir := filepath.Join(os.TempDir(), "mapped")
|
||||||
folderName := filepath.Base(mappedDir)
|
folderName := filepath.Base(mappedDir)
|
||||||
f := vfs.BaseVirtualFolder{
|
f := vfs.BaseVirtualFolder{
|
||||||
|
@ -21285,6 +21311,8 @@ func TestWebUserAddMock(t *testing.T) {
|
||||||
form.Set(csrfFormToken, csrfToken)
|
form.Set(csrfFormToken, csrfToken)
|
||||||
form.Set("username", user.Username)
|
form.Set("username", user.Username)
|
||||||
form.Set("email", user.Email)
|
form.Set("email", user.Email)
|
||||||
|
form.Set("additional_emails[0][additional_email]", user.Filters.AdditionalEmails[0])
|
||||||
|
form.Set("additional_emails[1][additional_email]", user.Filters.AdditionalEmails[1])
|
||||||
form.Set("home_dir", user.HomeDir)
|
form.Set("home_dir", user.HomeDir)
|
||||||
form.Set("osfs_read_buffer_size", "2")
|
form.Set("osfs_read_buffer_size", "2")
|
||||||
form.Set("osfs_write_buffer_size", "3")
|
form.Set("osfs_write_buffer_size", "3")
|
||||||
|
@ -21611,6 +21639,7 @@ func TestWebUserAddMock(t *testing.T) {
|
||||||
assert.True(t, newUser.Filters.DisableFsChecks)
|
assert.True(t, newUser.Filters.DisableFsChecks)
|
||||||
assert.False(t, newUser.Filters.AllowAPIKeyAuth)
|
assert.False(t, newUser.Filters.AllowAPIKeyAuth)
|
||||||
assert.Equal(t, user.Email, newUser.Email)
|
assert.Equal(t, user.Email, newUser.Email)
|
||||||
|
assert.Equal(t, len(user.Filters.AdditionalEmails), len(newUser.Filters.AdditionalEmails))
|
||||||
assert.Equal(t, "/start/dir", newUser.Filters.StartDirectory)
|
assert.Equal(t, "/start/dir", newUser.Filters.StartDirectory)
|
||||||
assert.Equal(t, 0, newUser.Filters.FTPSecurity)
|
assert.Equal(t, 0, newUser.Filters.FTPSecurity)
|
||||||
assert.Equal(t, 10, newUser.Filters.DefaultSharesExpiration)
|
assert.Equal(t, 10, newUser.Filters.DefaultSharesExpiration)
|
||||||
|
|
|
@ -1981,6 +1981,13 @@ func updateRepeaterFormFields(r *http.Request) {
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if hasPrefixAndSuffix(k, "additional_emails[", "][additional_email]") {
|
||||||
|
email := strings.TrimSpace(r.Form.Get(k))
|
||||||
|
if email != "" {
|
||||||
|
r.Form.Add("additional_emails", email)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
if hasPrefixAndSuffix(k, "virtual_folders[", "][vfolder_path]") {
|
if hasPrefixAndSuffix(k, "virtual_folders[", "][vfolder_path]") {
|
||||||
base, _ := strings.CutSuffix(k, "[vfolder_path]")
|
base, _ := strings.CutSuffix(k, "[vfolder_path]")
|
||||||
r.Form.Add("vfolder_path", strings.TrimSpace(r.Form.Get(k)))
|
r.Form.Add("vfolder_path", strings.TrimSpace(r.Form.Get(k)))
|
||||||
|
@ -2114,6 +2121,7 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
|
||||||
Filters: dataprovider.UserFilters{
|
Filters: dataprovider.UserFilters{
|
||||||
BaseUserFilters: filters,
|
BaseUserFilters: filters,
|
||||||
RequirePasswordChange: r.Form.Get("require_password_change") != "",
|
RequirePasswordChange: r.Form.Get("require_password_change") != "",
|
||||||
|
AdditionalEmails: r.Form["additional_emails"],
|
||||||
},
|
},
|
||||||
VirtualFolders: getVirtualFoldersFromPostFields(r),
|
VirtualFolders: getVirtualFoldersFromPostFields(r),
|
||||||
FsConfig: fsConfig,
|
FsConfig: fsConfig,
|
||||||
|
@ -3317,6 +3325,7 @@ func (s *httpdServer) handleWebTemplateUserGet(w http.ResponseWriter, r *http.Re
|
||||||
user.SetEmptySecrets()
|
user.SetEmptySecrets()
|
||||||
user.PublicKeys = nil
|
user.PublicKeys = nil
|
||||||
user.Email = ""
|
user.Email = ""
|
||||||
|
user.Filters.AdditionalEmails = nil
|
||||||
user.Description = ""
|
user.Description = ""
|
||||||
if user.ExpirationDate == 0 && admin.Filters.Preferences.DefaultUsersExpiration > 0 {
|
if user.ExpirationDate == 0 && admin.Filters.Preferences.DefaultUsersExpiration > 0 {
|
||||||
user.ExpirationDate = util.GetTimeAsMsSinceEpoch(time.Now().Add(24 * time.Hour * time.Duration(admin.Filters.Preferences.DefaultUsersExpiration)))
|
user.ExpirationDate = util.GetTimeAsMsSinceEpoch(time.Now().Add(24 * time.Hour * time.Duration(admin.Filters.Preferences.DefaultUsersExpiration)))
|
||||||
|
|
|
@ -174,13 +174,15 @@ type clientMessagePage struct {
|
||||||
|
|
||||||
type clientProfilePage struct {
|
type clientProfilePage struct {
|
||||||
baseClientPage
|
baseClientPage
|
||||||
PublicKeys []string
|
PublicKeys []string
|
||||||
TLSCerts []string
|
TLSCerts []string
|
||||||
CanSubmit bool
|
CanSubmit bool
|
||||||
AllowAPIKeyAuth bool
|
AllowAPIKeyAuth bool
|
||||||
Email string
|
Email string
|
||||||
Description string
|
AdditionalEmails []string
|
||||||
Error *util.I18nError
|
AdditionalEmailsString string
|
||||||
|
Description string
|
||||||
|
Error *util.I18nError
|
||||||
}
|
}
|
||||||
|
|
||||||
type changeClientPasswordPage struct {
|
type changeClientPasswordPage struct {
|
||||||
|
@ -841,6 +843,8 @@ func (s *httpdServer) renderClientProfilePage(w http.ResponseWriter, r *http.Req
|
||||||
data.TLSCerts = user.Filters.TLSCerts
|
data.TLSCerts = user.Filters.TLSCerts
|
||||||
data.AllowAPIKeyAuth = user.Filters.AllowAPIKeyAuth
|
data.AllowAPIKeyAuth = user.Filters.AllowAPIKeyAuth
|
||||||
data.Email = user.Email
|
data.Email = user.Email
|
||||||
|
data.AdditionalEmails = user.Filters.AdditionalEmails
|
||||||
|
data.AdditionalEmailsString = strings.Join(data.AdditionalEmails, ", ")
|
||||||
data.Description = user.Description
|
data.Description = user.Description
|
||||||
data.CanSubmit = userMerged.CanUpdateProfile()
|
data.CanSubmit = userMerged.CanUpdateProfile()
|
||||||
renderClientTemplate(w, templateClientProfile, data)
|
renderClientTemplate(w, templateClientProfile, data)
|
||||||
|
@ -1661,6 +1665,15 @@ func (s *httpdServer) handleWebClientProfilePost(w http.ResponseWriter, r *http.
|
||||||
if userMerged.CanChangeInfo() {
|
if userMerged.CanChangeInfo() {
|
||||||
user.Email = strings.TrimSpace(r.Form.Get("email"))
|
user.Email = strings.TrimSpace(r.Form.Get("email"))
|
||||||
user.Description = r.Form.Get("description")
|
user.Description = r.Form.Get("description")
|
||||||
|
for k := range r.Form {
|
||||||
|
if hasPrefixAndSuffix(k, "additional_emails[", "][additional_email]") {
|
||||||
|
email := strings.TrimSpace(r.Form.Get(k))
|
||||||
|
if email != "" {
|
||||||
|
r.Form.Add("additional_emails", email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user.Filters.AdditionalEmails = r.Form["additional_emails"]
|
||||||
}
|
}
|
||||||
err = dataprovider.UpdateUser(&user, dataprovider.ActionExecutorSelf, ipAddr, user.Role)
|
err = dataprovider.UpdateUser(&user, dataprovider.ActionExecutorSelf, ipAddr, user.Role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -2037,6 +2037,9 @@ func checkUser(expected *dataprovider.User, actual *dataprovider.User) error {
|
||||||
if expected.Email != actual.Email {
|
if expected.Email != actual.Email {
|
||||||
return errors.New("email mismatch")
|
return errors.New("email mismatch")
|
||||||
}
|
}
|
||||||
|
if !slices.Equal(expected.Filters.AdditionalEmails, actual.Filters.AdditionalEmails) {
|
||||||
|
return errors.New("additional emails mismatch")
|
||||||
|
}
|
||||||
if expected.Filters.RequirePasswordChange != actual.Filters.RequirePasswordChange {
|
if expected.Filters.RequirePasswordChange != actual.Filters.RequirePasswordChange {
|
||||||
return errors.New("require_password_change mismatch")
|
return errors.New("require_password_change mismatch")
|
||||||
}
|
}
|
||||||
|
|
|
@ -540,6 +540,7 @@
|
||||||
"invalid_quota_size": "Invalid quota size",
|
"invalid_quota_size": "Invalid quota size",
|
||||||
"expires_in": "Expires in",
|
"expires_in": "Expires in",
|
||||||
"expires_in_help": "Account expiration as number of days from the creation. 0 means no expiration",
|
"expires_in_help": "Account expiration as number of days from the creation. 0 means no expiration",
|
||||||
|
"additional_emails": "Additional emails",
|
||||||
"tls_certs": "TLS certificates",
|
"tls_certs": "TLS certificates",
|
||||||
"tls_certs_help": "TLS certificates can be used for FTP and/or WebDAV authentication",
|
"tls_certs_help": "TLS certificates can be used for FTP and/or WebDAV authentication",
|
||||||
"tls_cert_help": "Paste a PEM encoded TLS certificate here",
|
"tls_cert_help": "Paste a PEM encoded TLS certificate here",
|
||||||
|
|
|
@ -540,6 +540,7 @@
|
||||||
"invalid_quota_size": "Quota (dimensione) non valida",
|
"invalid_quota_size": "Quota (dimensione) non valida",
|
||||||
"expires_in": "Scadenza",
|
"expires_in": "Scadenza",
|
||||||
"expires_in_help": "Scadenza dell'account espressa in numero di giorni dalla creazione. 0 significa nessuna scadenza",
|
"expires_in_help": "Scadenza dell'account espressa in numero di giorni dalla creazione. 0 significa nessuna scadenza",
|
||||||
|
"additional_emails": "Email aggiuntive",
|
||||||
"tls_certs": "Certificati TLS",
|
"tls_certs": "Certificati TLS",
|
||||||
"tls_certs_help": "I certificati TLS possono essere utilizzati per l'autenticazione FTP e/o WebDAV",
|
"tls_certs_help": "I certificati TLS possono essere utilizzati per l'autenticazione FTP e/o WebDAV",
|
||||||
"tls_cert_help": "Incolla qui un tuo certificato TLS codificato PEM",
|
"tls_cert_help": "Incolla qui un tuo certificato TLS codificato PEM",
|
||||||
|
|
|
@ -461,6 +461,70 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-10">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h3 data-i18n="user.additional_emails" class="card-title section-title-inner">Additional emails</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="additional_emails">
|
||||||
|
<div class="form-group">
|
||||||
|
<div data-repeater-list="additional_emails">
|
||||||
|
{{- range $idx, $val := .User.Filters.AdditionalEmails}}
|
||||||
|
<div data-repeater-item>
|
||||||
|
<div class="form-group row">
|
||||||
|
<div class="col-md-10 mt-3 mt-md-8">
|
||||||
|
<input type="email" class="form-control" placeholder="" name="additional_email" value="{{$val}}" maxlength="255" autocomplete="off" spellcheck="false" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 mt-3 mt-md-8">
|
||||||
|
<a href="#" data-repeater-delete
|
||||||
|
class="btn btn-light-danger">
|
||||||
|
<i class="ki-duotone ki-trash fs-5">
|
||||||
|
<span class="path1"></span>
|
||||||
|
<span class="path2"></span>
|
||||||
|
<span class="path3"></span>
|
||||||
|
<span class="path4"></span>
|
||||||
|
<span class="path5"></span>
|
||||||
|
</i>
|
||||||
|
<span data-i18n="general.delete">Delete</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{- else}}
|
||||||
|
<div data-repeater-item>
|
||||||
|
<div class="form-group row">
|
||||||
|
<div class="col-md-10 mt-3 mt-md-8">
|
||||||
|
<input type="email" class="form-control" placeholder="" name="additional_email" value="" maxlength="255" autocomplete="off" spellcheck="false" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 mt-3 mt-md-8">
|
||||||
|
<a href="#" data-repeater-delete
|
||||||
|
class="btn btn-light-danger">
|
||||||
|
<i class="ki-duotone ki-trash fs-5">
|
||||||
|
<span class="path1"></span>
|
||||||
|
<span class="path2"></span>
|
||||||
|
<span class="path3"></span>
|
||||||
|
<span class="path4"></span>
|
||||||
|
<span class="path5"></span>
|
||||||
|
</i>
|
||||||
|
<span data-i18n="general.delete">Delete</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{- end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mt-5">
|
||||||
|
<a href="#" data-repeater-create class="btn btn-light-primary">
|
||||||
|
<i class="ki-duotone ki-plus fs-3"></i>
|
||||||
|
<span data-i18n="general.add">Add</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{- template "user_group_profile" .User.Filters}}
|
{{- template "user_group_profile" .User.Filters}}
|
||||||
|
|
||||||
<div class="form-group row mt-10">
|
<div class="form-group row mt-10">
|
||||||
|
@ -789,6 +853,7 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
|
||||||
initRepeater('#src_bandwidth_limits');
|
initRepeater('#src_bandwidth_limits');
|
||||||
initRepeater('#tls_certs');
|
initRepeater('#tls_certs');
|
||||||
initRepeater('#access_time_restrictions');
|
initRepeater('#access_time_restrictions');
|
||||||
|
initRepeater('#additional_emails');
|
||||||
initRepeaterItems();
|
initRepeaterItems();
|
||||||
//{{- if .Error}}
|
//{{- if .Error}}
|
||||||
//{{- if ne .LoggedUser.Filters.Preferences.VisibleUserPageSections 0}}
|
//{{- if ne .LoggedUser.Filters.Preferences.VisibleUserPageSections 0}}
|
||||||
|
|
|
@ -31,6 +31,80 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{- if .LoggedUser.CanChangeInfo}}
|
||||||
|
<div class="card mt-10">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h3 data-i18n="user.additional_emails" class="card-title section-title-inner">Additional emails</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="additional_emails">
|
||||||
|
<div class="form-group">
|
||||||
|
<div data-repeater-list="additional_emails">
|
||||||
|
{{- range $idx, $val := .AdditionalEmails}}
|
||||||
|
<div data-repeater-item>
|
||||||
|
<div class="form-group row">
|
||||||
|
<div class="col-md-10 mt-3 mt-md-8">
|
||||||
|
<input type="email" class="form-control" placeholder="" name="additional_email" value="{{$val}}" maxlength="255" autocomplete="off" spellcheck="false" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 mt-3 mt-md-8">
|
||||||
|
<a href="#" data-repeater-delete
|
||||||
|
class="btn btn-light-danger">
|
||||||
|
<i class="ki-duotone ki-trash fs-5">
|
||||||
|
<span class="path1"></span>
|
||||||
|
<span class="path2"></span>
|
||||||
|
<span class="path3"></span>
|
||||||
|
<span class="path4"></span>
|
||||||
|
<span class="path5"></span>
|
||||||
|
</i>
|
||||||
|
<span data-i18n="general.delete">Delete</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{- else}}
|
||||||
|
<div data-repeater-item>
|
||||||
|
<div class="form-group row">
|
||||||
|
<div class="col-md-10 mt-3 mt-md-8">
|
||||||
|
<input type="email" class="form-control" placeholder="" name="additional_email" value="" maxlength="255" autocomplete="off" spellcheck="false" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 mt-3 mt-md-8">
|
||||||
|
<a href="#" data-repeater-delete
|
||||||
|
class="btn btn-light-danger">
|
||||||
|
<i class="ki-duotone ki-trash fs-5">
|
||||||
|
<span class="path1"></span>
|
||||||
|
<span class="path2"></span>
|
||||||
|
<span class="path3"></span>
|
||||||
|
<span class="path4"></span>
|
||||||
|
<span class="path5"></span>
|
||||||
|
</i>
|
||||||
|
<span data-i18n="general.delete">Delete</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{- end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mt-5">
|
||||||
|
<a href="#" data-repeater-create class="btn btn-light-primary">
|
||||||
|
<i class="ki-duotone ki-plus fs-3"></i>
|
||||||
|
<span data-i18n="general.add">Add</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{- else}}
|
||||||
|
<div class="form-group row mt-10">
|
||||||
|
<label for="idAdditionalEmails" data-i18n="user.additional_emails" class="col-md-3 col-form-label">Additional emails</label>
|
||||||
|
<div class="col-md-9">
|
||||||
|
<input type="text" id="idAdditionalEmails" name="description" placeholder="" value="{{.AdditionalEmailsString}}"
|
||||||
|
class="form-control-plaintext readonly-input" readonly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
<div class="form-group row mt-10">
|
<div class="form-group row mt-10">
|
||||||
<label for="idDescription" data-i18n="general.description" class="col-md-3 col-form-label">Description</label>
|
<label for="idDescription" data-i18n="general.description" class="col-md-3 col-form-label">Description</label>
|
||||||
<div class="col-md-9">
|
<div class="col-md-9">
|
||||||
|
@ -217,6 +291,9 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
|
||||||
//{{- if .LoggedUser.CanManageTLSCerts}}
|
//{{- if .LoggedUser.CanManageTLSCerts}}
|
||||||
initRepeater('#tls_certs');
|
initRepeater('#tls_certs');
|
||||||
//{{- end}}
|
//{{- end}}
|
||||||
|
//{{- if .LoggedUser.CanChangeInfo}}
|
||||||
|
initRepeater('#additional_emails');
|
||||||
|
//{{- end}}
|
||||||
initRepeaterItems();
|
initRepeaterItems();
|
||||||
//{{- end}}
|
//{{- end}}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue