mirror of
https://github.com/drakkan/sftpgo.git
synced 2024-11-21 15:10:23 +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/xid v1.6.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/spf13/afero v1.11.0
|
||||
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/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||
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.20241002160417-3a2e25af00c1/go.mod h1:Isl0IEzS/Muvh8Fr4X+NWFsOS/fZQHRD4oPQpoY7C4g=
|
||||
github.com/sftpgo/sdk v0.1.9-0.20241011171103-64fc18a344f9 h1:wlXBnaNfJJJRZjHO2AerSS5gp0ckkYUgBzSXivUo0Wo=
|
||||
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/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||
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"
|
||||
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",
|
||||
user.Username, err, time.Since(startTime))
|
||||
return err
|
||||
|
@ -2554,6 +2554,9 @@ func preserveUserProfile(user, newUser *dataprovider.User) {
|
|||
if user.Email != "" {
|
||||
newUser.Email = user.Email
|
||||
}
|
||||
if len(user.Filters.AdditionalEmails) > 0 {
|
||||
newUser.Filters.AdditionalEmails = user.Filters.AdditionalEmails
|
||||
}
|
||||
}
|
||||
if newUser.CanChangeAPIKeyAuth() {
|
||||
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
|
||||
user.Password = "secret"
|
||||
user.Email = "example@example.com"
|
||||
user.Filters.AdditionalEmails = []string{"alias@example.com"}
|
||||
user.Description = "some desc"
|
||||
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"}
|
||||
|
@ -1432,6 +1433,7 @@ func TestIDPAccountCheckRule(t *testing.T) {
|
|||
assert.Len(t, user.PublicKeys, 1)
|
||||
assert.Len(t, user.Filters.TLSCerts, 1)
|
||||
assert.NotEmpty(t, user.Email)
|
||||
assert.Len(t, user.Filters.AdditionalEmails, 1)
|
||||
assert.NotEmpty(t, user.Description)
|
||||
|
||||
err = dataprovider.DeleteUser(username, "", "", "")
|
||||
|
|
|
@ -7709,6 +7709,7 @@ func TestEventRulePasswordExpiration(t *testing.T) {
|
|||
_, _, err = httpdtest.UpdateEventRule(rule1, http.StatusOK)
|
||||
assert.NoError(t, err)
|
||||
user.Email = "user@example.net"
|
||||
user.Filters.AdditionalEmails = []string{"additional@example.net"}
|
||||
_, _, err = httpdtest.UpdateUser(user, http.StatusOK, "")
|
||||
assert.NoError(t, err)
|
||||
conn, client, err = getSftpClient(user)
|
||||
|
@ -7724,8 +7725,9 @@ func TestEventRulePasswordExpiration(t *testing.T) {
|
|||
return lastReceivedEmail.get().From != ""
|
||||
}, 1500*time.Millisecond, 100*time.Millisecond)
|
||||
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.Filters.AdditionalEmails[0])
|
||||
assert.Contains(t, email.Data, "your SFTPGo password expires in 5 days")
|
||||
err = client.RemoveDirectory(dirName)
|
||||
assert.NoError(t, err)
|
||||
|
|
|
@ -3230,6 +3230,24 @@ func validateCombinedUserFilters(user *User) error {
|
|||
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 {
|
||||
if user.Username == "" {
|
||||
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 {
|
||||
return util.NewI18nError(err, util.I18nErrorReservedUsername)
|
||||
}
|
||||
if user.Email != "" && !util.IsEmailValid(user.Email) {
|
||||
return util.NewI18nError(
|
||||
util.NewValidationError(fmt.Sprintf("email %q is not valid", user.Email)),
|
||||
util.I18nErrorInvalidEmail,
|
||||
)
|
||||
if err := validateEmails(user); err != nil {
|
||||
return err
|
||||
}
|
||||
if config.NamingRules&1 == 0 && !usernameRegex.MatchString(user.Username) {
|
||||
return util.NewI18nError(
|
||||
|
|
|
@ -124,6 +124,8 @@ type UserFilters struct {
|
|||
sdk.BaseUserFilters
|
||||
// User must change password from WebClient/REST API at next login.
|
||||
RequirePasswordChange bool `json:"require_password_change,omitempty"`
|
||||
// AdditionalEmails defines additional email addresses
|
||||
AdditionalEmails []string `json:"additional_emails,omitempty"`
|
||||
// Time-based one time passwords configuration
|
||||
TOTPConfig UserTOTPConfig `json:"totp_config,omitempty"`
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
func (u *User) GetSubDirPermissions() []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.Protocols = make([]string, len(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))
|
||||
for _, code := range u.Filters.RecoveryCodes {
|
||||
if code.Secret == nil {
|
||||
|
|
|
@ -469,8 +469,9 @@ func getUserProfile(w http.ResponseWriter, r *http.Request) {
|
|||
Description: user.Description,
|
||||
AllowAPIKeyAuth: user.Filters.AllowAPIKeyAuth,
|
||||
},
|
||||
PublicKeys: user.PublicKeys,
|
||||
TLSCerts: user.Filters.TLSCerts,
|
||||
AdditionalEmails: user.Filters.AdditionalEmails,
|
||||
PublicKeys: user.PublicKeys,
|
||||
TLSCerts: user.Filters.TLSCerts,
|
||||
}
|
||||
render.JSON(w, r, resp)
|
||||
}
|
||||
|
@ -508,6 +509,7 @@ func updateUserProfile(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
if userMerged.CanChangeInfo() {
|
||||
user.Email = req.Email
|
||||
user.Filters.AdditionalEmails = req.AdditionalEmails
|
||||
user.Description = req.Description
|
||||
}
|
||||
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 {
|
||||
baseProfile
|
||||
PublicKeys []string `json:"public_keys,omitempty"`
|
||||
TLSCerts []string `json:"tls_certs,omitempty"`
|
||||
AdditionalEmails []string `json:"additional_emails,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) {
|
||||
|
@ -786,7 +787,8 @@ func getActiveUser(username string, r *http.Request) (dataprovider.User, 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 admin dataprovider.Admin
|
||||
var user dataprovider.User
|
||||
|
@ -796,11 +798,13 @@ func handleForgotPassword(r *http.Request, username string, isAdmin bool) error
|
|||
}
|
||||
if isAdmin {
|
||||
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)
|
||||
} else {
|
||||
user, err = getActiveUser(username, r)
|
||||
email = user.Email
|
||||
emails = user.GetEmailAddresses()
|
||||
subject = fmt.Sprintf("Email Verification Code for user %q", username)
|
||||
if err == nil {
|
||||
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)
|
||||
}
|
||||
if email == "" {
|
||||
if len(emails) == 0 {
|
||||
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.I18nErrorPwdResetNoEmail,
|
||||
|
@ -836,7 +840,7 @@ func handleForgotPassword(r *http.Request, username string, isAdmin bool) error
|
|||
return util.NewGenericError("Unable to render password reset template")
|
||||
}
|
||||
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",
|
||||
err, time.Since(startTime))
|
||||
return util.NewI18nError(
|
||||
|
@ -844,8 +848,8 @@ func handleForgotPassword(r *http.Request, username string, isAdmin bool) error
|
|||
util.I18nErrorPwdResetSendEmail,
|
||||
)
|
||||
}
|
||||
logger.Debug(logSender, middleware.GetReqID(r.Context()), "reset code sent via email to %q, email: %q, is admin? %v, elapsed: %v",
|
||||
username, email, isAdmin, time.Since(startTime))
|
||||
logger.Debug(logSender, middleware.GetReqID(r.Context()), "reset code sent via email to %q, emails: %+v, is admin? %v, elapsed: %v",
|
||||
username, emails, isAdmin, time.Since(startTime))
|
||||
return resetCodesMgr.Add(c)
|
||||
}
|
||||
|
||||
|
|
|
@ -623,6 +623,7 @@ func TestInitialization(t *testing.T) {
|
|||
func TestBasicUserHandling(t *testing.T) {
|
||||
u := getTestUser()
|
||||
u.Email = "user@user.com"
|
||||
u.Filters.AdditionalEmails = []string{"email1@user.com", "email2@user.com"}
|
||||
user, resp, err := httpdtest.AddUser(u, http.StatusCreated)
|
||||
assert.NoError(t, err, string(resp))
|
||||
_, resp, err = httpdtest.AddUser(u, http.StatusConflict)
|
||||
|
@ -663,6 +664,12 @@ func TestBasicUserHandling(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
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)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
@ -11298,6 +11305,7 @@ func TestWebAPIChangeUserProfileMock(t *testing.T) {
|
|||
checkResponseCode(t, http.StatusBadRequest, rr)
|
||||
|
||||
email := "userapi@example.com"
|
||||
additionalEmails := []string{"userapi1@example.com"}
|
||||
description := "user API description"
|
||||
profileReq := make(map[string]any)
|
||||
profileReq["allow_api_key_auth"] = true
|
||||
|
@ -11305,6 +11313,7 @@ func TestWebAPIChangeUserProfileMock(t *testing.T) {
|
|||
profileReq["description"] = description
|
||||
profileReq["public_keys"] = []string{testPubKey, testPubKey1}
|
||||
profileReq["tls_certs"] = []string{httpsCert}
|
||||
profileReq["additional_emails"] = additionalEmails
|
||||
asJSON, err := json.Marshal(profileReq)
|
||||
assert.NoError(t, err)
|
||||
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)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, email, profileReq["email"].(string))
|
||||
assert.Len(t, profileReq["additional_emails"].([]interface{}), 1)
|
||||
assert.Equal(t, description, profileReq["description"].(string))
|
||||
assert.True(t, profileReq["allow_api_key_auth"].(bool))
|
||||
val, ok := profileReq["public_keys"].([]any)
|
||||
|
@ -11343,6 +11353,17 @@ func TestWebAPIChangeUserProfileMock(t *testing.T) {
|
|||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusBadRequest, rr)
|
||||
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
|
||||
profileReq = make(map[string]any)
|
||||
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[1][public_key]", testPubKey1)
|
||||
form.Set("tls_certs[0][tls_cert]", httpsCert)
|
||||
form.Set("additional_emails[0][additional_email]", "email1@user.com")
|
||||
// no csrf token
|
||||
req, err := http.NewRequest(http.MethodPost, webClientProfilePath, bytes.NewBuffer([]byte(form.Encode())))
|
||||
assert.NoError(t, err)
|
||||
|
@ -19885,6 +19907,9 @@ func TestWebUserProfile(t *testing.T) {
|
|||
assert.Len(t, user.Filters.TLSCerts, 1)
|
||||
assert.Equal(t, email, user.Email)
|
||||
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
|
||||
form.Set("email", "not an email")
|
||||
|
@ -21268,6 +21293,7 @@ func TestWebUserAddMock(t *testing.T) {
|
|||
user.AdditionalInfo = "info"
|
||||
user.Description = "user dsc"
|
||||
user.Email = "test@test.com"
|
||||
user.Filters.AdditionalEmails = []string{"example1@test.com", "example2@test.com"}
|
||||
mappedDir := filepath.Join(os.TempDir(), "mapped")
|
||||
folderName := filepath.Base(mappedDir)
|
||||
f := vfs.BaseVirtualFolder{
|
||||
|
@ -21285,6 +21311,8 @@ func TestWebUserAddMock(t *testing.T) {
|
|||
form.Set(csrfFormToken, csrfToken)
|
||||
form.Set("username", user.Username)
|
||||
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("osfs_read_buffer_size", "2")
|
||||
form.Set("osfs_write_buffer_size", "3")
|
||||
|
@ -21611,6 +21639,7 @@ func TestWebUserAddMock(t *testing.T) {
|
|||
assert.True(t, newUser.Filters.DisableFsChecks)
|
||||
assert.False(t, newUser.Filters.AllowAPIKeyAuth)
|
||||
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, 0, newUser.Filters.FTPSecurity)
|
||||
assert.Equal(t, 10, newUser.Filters.DefaultSharesExpiration)
|
||||
|
|
|
@ -1981,6 +1981,13 @@ func updateRepeaterFormFields(r *http.Request) {
|
|||
}
|
||||
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]") {
|
||||
base, _ := strings.CutSuffix(k, "[vfolder_path]")
|
||||
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{
|
||||
BaseUserFilters: filters,
|
||||
RequirePasswordChange: r.Form.Get("require_password_change") != "",
|
||||
AdditionalEmails: r.Form["additional_emails"],
|
||||
},
|
||||
VirtualFolders: getVirtualFoldersFromPostFields(r),
|
||||
FsConfig: fsConfig,
|
||||
|
@ -3317,6 +3325,7 @@ func (s *httpdServer) handleWebTemplateUserGet(w http.ResponseWriter, r *http.Re
|
|||
user.SetEmptySecrets()
|
||||
user.PublicKeys = nil
|
||||
user.Email = ""
|
||||
user.Filters.AdditionalEmails = nil
|
||||
user.Description = ""
|
||||
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)))
|
||||
|
|
|
@ -174,13 +174,15 @@ type clientMessagePage struct {
|
|||
|
||||
type clientProfilePage struct {
|
||||
baseClientPage
|
||||
PublicKeys []string
|
||||
TLSCerts []string
|
||||
CanSubmit bool
|
||||
AllowAPIKeyAuth bool
|
||||
Email string
|
||||
Description string
|
||||
Error *util.I18nError
|
||||
PublicKeys []string
|
||||
TLSCerts []string
|
||||
CanSubmit bool
|
||||
AllowAPIKeyAuth bool
|
||||
Email string
|
||||
AdditionalEmails []string
|
||||
AdditionalEmailsString string
|
||||
Description string
|
||||
Error *util.I18nError
|
||||
}
|
||||
|
||||
type changeClientPasswordPage struct {
|
||||
|
@ -841,6 +843,8 @@ func (s *httpdServer) renderClientProfilePage(w http.ResponseWriter, r *http.Req
|
|||
data.TLSCerts = user.Filters.TLSCerts
|
||||
data.AllowAPIKeyAuth = user.Filters.AllowAPIKeyAuth
|
||||
data.Email = user.Email
|
||||
data.AdditionalEmails = user.Filters.AdditionalEmails
|
||||
data.AdditionalEmailsString = strings.Join(data.AdditionalEmails, ", ")
|
||||
data.Description = user.Description
|
||||
data.CanSubmit = userMerged.CanUpdateProfile()
|
||||
renderClientTemplate(w, templateClientProfile, data)
|
||||
|
@ -1661,6 +1665,15 @@ func (s *httpdServer) handleWebClientProfilePost(w http.ResponseWriter, r *http.
|
|||
if userMerged.CanChangeInfo() {
|
||||
user.Email = strings.TrimSpace(r.Form.Get("email"))
|
||||
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)
|
||||
if err != nil {
|
||||
|
|
|
@ -2037,6 +2037,9 @@ func checkUser(expected *dataprovider.User, actual *dataprovider.User) error {
|
|||
if expected.Email != actual.Email {
|
||||
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 {
|
||||
return errors.New("require_password_change mismatch")
|
||||
}
|
||||
|
|
|
@ -540,6 +540,7 @@
|
|||
"invalid_quota_size": "Invalid quota size",
|
||||
"expires_in": "Expires in",
|
||||
"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_help": "TLS certificates can be used for FTP and/or WebDAV authentication",
|
||||
"tls_cert_help": "Paste a PEM encoded TLS certificate here",
|
||||
|
|
|
@ -540,6 +540,7 @@
|
|||
"invalid_quota_size": "Quota (dimensione) non valida",
|
||||
"expires_in": "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_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",
|
||||
|
|
|
@ -461,6 +461,70 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
|
|||
</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}}
|
||||
|
||||
<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('#tls_certs');
|
||||
initRepeater('#access_time_restrictions');
|
||||
initRepeater('#additional_emails');
|
||||
initRepeaterItems();
|
||||
//{{- if .Error}}
|
||||
//{{- if ne .LoggedUser.Filters.Preferences.VisibleUserPageSections 0}}
|
||||
|
|
|
@ -31,6 +31,80 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
|
|||
</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">
|
||||
<label for="idDescription" data-i18n="general.description" class="col-md-3 col-form-label">Description</label>
|
||||
<div class="col-md-9">
|
||||
|
@ -217,6 +291,9 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
|
|||
//{{- if .LoggedUser.CanManageTLSCerts}}
|
||||
initRepeater('#tls_certs');
|
||||
//{{- end}}
|
||||
//{{- if .LoggedUser.CanChangeInfo}}
|
||||
initRepeater('#additional_emails');
|
||||
//{{- end}}
|
||||
initRepeaterItems();
|
||||
//{{- end}}
|
||||
|
||||
|
|
Loading…
Reference in a new issue