Browse Source

WIP new WebAdmin: configs page

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
Nicola Murino 1 year ago
parent
commit
69da5c10c6

+ 2 - 2
go.mod

@@ -38,7 +38,7 @@ require (
 	github.com/hashicorp/go-retryablehttp v0.7.5
 	github.com/jackc/pgx/v5 v5.5.2
 	github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126
-	github.com/klauspost/compress v1.17.4
+	github.com/klauspost/compress v1.17.5
 	github.com/lestrrat-go/jwx/v2 v2.0.19
 	github.com/lithammer/shortuuid/v3 v3.0.7
 	github.com/mattn/go-sqlite3 v1.14.20
@@ -74,7 +74,7 @@ require (
 	golang.org/x/sys v0.16.0
 	golang.org/x/term v0.16.0
 	golang.org/x/time v0.5.0
-	google.golang.org/api v0.158.0
+	google.golang.org/api v0.159.0
 	gopkg.in/natefinch/lumberjack.v2 v2.2.1
 )
 

+ 4 - 4
go.sum

@@ -245,8 +245,8 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y
 github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
 github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
 github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
-github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
-github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
+github.com/klauspost/compress v1.17.5 h1:d4vBd+7CHydUqpFBgUEKkSdtSugf9YFmSkvUYPquI5E=
+github.com/klauspost/compress v1.17.5/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
 github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
 github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -522,8 +522,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
-google.golang.org/api v0.158.0 h1:7SKwlRqzrXT2ULl6a3iESb+1pOak5IOd5F+ay5ULiV4=
-google.golang.org/api v0.158.0/go.mod h1:0mu0TpK33qnydLvWqbImq2b1eQ5FHRSDCBzAxX9ZHyw=
+google.golang.org/api v0.159.0 h1:fVTj+7HHiUYz4JEZCHHoRIeQX7h5FMzrA2RF/DzDdbs=
+google.golang.org/api v0.159.0/go.mod h1:0mu0TpK33qnydLvWqbImq2b1eQ5FHRSDCBzAxX9ZHyw=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=

+ 4 - 1
internal/acme/acme.go

@@ -241,7 +241,10 @@ func (c *Configuration) Initialize(configDir string) error {
 		return nil
 	}
 	if c.Email == "" || !util.IsEmailValid(c.Email) {
-		return fmt.Errorf("invalid email address %q", c.Email)
+		return util.NewI18nError(
+			fmt.Errorf("invalid email address %q", c.Email),
+			util.I18nErrorInvalidEmail,
+		)
 	}
 	if c.RenewDays < 1 {
 		return fmt.Errorf("invalid number of days remaining before renewal: %d", c.RenewDays)

+ 20 - 18
internal/dataprovider/configs.go

@@ -17,7 +17,6 @@ package dataprovider
 import (
 	"encoding/json"
 	"fmt"
-	"strings"
 
 	"golang.org/x/crypto/ssh"
 
@@ -48,7 +47,6 @@ var (
 type SFTPDConfigs struct {
 	HostKeyAlgos   []string `json:"host_key_algos,omitempty"`
 	PublicKeyAlgos []string `json:"public_key_algos,omitempty"`
-	Moduli         []string `json:"moduli,omitempty"`
 	KexAlgorithms  []string `json:"kex_algorithms,omitempty"`
 	Ciphers        []string `json:"ciphers,omitempty"`
 	MACs           []string `json:"macs,omitempty"`
@@ -61,9 +59,6 @@ func (c *SFTPDConfigs) isEmpty() bool {
 	if len(c.PublicKeyAlgos) > 0 {
 		return false
 	}
-	if len(c.Moduli) > 0 {
-		return false
-	}
 	if len(c.KexAlgorithms) > 0 {
 		return false
 	}
@@ -101,11 +96,6 @@ func (*SFTPDConfigs) GetSupportedMACs() []string {
 	return supportedMACs
 }
 
-// GetModuliAsString returns moduli files as comma separated string
-func (c *SFTPDConfigs) GetModuliAsString() string {
-	return strings.Join(c.Moduli, ",")
-}
-
 func (c *SFTPDConfigs) validate() error {
 	var hostKeyAlgos []string
 	for _, algo := range c.HostKeyAlgos {
@@ -152,8 +142,6 @@ func (c *SFTPDConfigs) getACopy() *SFTPDConfigs {
 	copy(hostKeys, c.HostKeyAlgos)
 	publicKeys := make([]string, len(c.PublicKeyAlgos))
 	copy(publicKeys, c.PublicKeyAlgos)
-	moduli := make([]string, len(c.Moduli))
-	copy(moduli, c.Moduli)
 	kexs := make([]string, len(c.KexAlgorithms))
 	copy(kexs, c.KexAlgorithms)
 	ciphers := make([]string, len(c.Ciphers))
@@ -164,7 +152,6 @@ func (c *SFTPDConfigs) getACopy() *SFTPDConfigs {
 	return &SFTPDConfigs{
 		HostKeyAlgos:   hostKeys,
 		PublicKeyAlgos: publicKeys,
-		Moduli:         moduli,
 		KexAlgorithms:  kexs,
 		Ciphers:        ciphers,
 		MACs:           macs,
@@ -204,13 +191,22 @@ func (c *SMTPOAuth2) validate() error {
 		return util.NewValidationError("smtp oauth2: unsupported provider")
 	}
 	if c.ClientID == "" {
-		return util.NewValidationError("smtp oauth2: client id is required")
+		return util.NewI18nError(
+			util.NewValidationError("smtp oauth2: client id is required"),
+			util.I18nErrorSMTPClientIDRequired,
+		)
 	}
 	if c.ClientSecret == nil {
-		return util.NewValidationError("smtp oauth2: client secret is required")
+		return util.NewI18nError(
+			util.NewValidationError("smtp oauth2: client secret is required"),
+			util.I18nErrorSMTPClientSecretRequired,
+		)
 	}
 	if c.RefreshToken == nil {
-		return util.NewValidationError("smtp oauth2: refresh token is required")
+		return util.NewI18nError(
+			util.NewValidationError("smtp oauth2: refresh token is required"),
+			util.I18nErrorSMTPRefreshTokenRequired,
+		)
 	}
 	if err := validateSMTPSecret(c.ClientSecret, "oauth2 client secret"); err != nil {
 		return err
@@ -267,7 +263,10 @@ func (c *SMTPConfigs) validate() error {
 		}
 	}
 	if c.User == "" && c.From == "" {
-		return util.NewValidationError("smtp: from address and user cannot both be empty")
+		return util.NewI18nError(
+			util.NewValidationError("smtp: from address and user cannot both be empty"),
+			util.I18nErrorSMTPRequiredFields,
+		)
 	}
 	if c.AuthType < 0 || c.AuthType > 3 {
 		return util.NewValidationError(fmt.Sprintf("smtp: invalid auth type %d", c.AuthType))
@@ -354,7 +353,10 @@ func (c *ACMEConfigs) validate() error {
 		return nil
 	}
 	if c.Email == "" && !util.IsEmailValid(c.Email) {
-		return util.NewValidationError(fmt.Sprintf("acme: invalid email %q", c.Email))
+		return util.NewI18nError(
+			util.NewValidationError(fmt.Sprintf("acme: invalid email %q", c.Email)),
+			util.I18nErrorInvalidEmail,
+		)
 	}
 	if c.HTTP01Challenge.Port <= 0 || c.HTTP01Challenge.Port > 65535 {
 		return util.NewValidationError(fmt.Sprintf("acme: invalid HTTP-01 challenge port %d", c.HTTP01Challenge.Port))

+ 2 - 0
internal/httpd/api_configs.go

@@ -23,6 +23,7 @@ import (
 
 	"github.com/drakkan/sftpgo/v2/internal/dataprovider"
 	"github.com/drakkan/sftpgo/v2/internal/kms"
+	"github.com/drakkan/sftpgo/v2/internal/logger"
 	"github.com/drakkan/sftpgo/v2/internal/smtp"
 	"github.com/drakkan/sftpgo/v2/internal/util"
 )
@@ -72,6 +73,7 @@ func testSMTPConfig(w http.ResponseWriter, r *http.Request) {
 	}
 	if err := req.SendEmail([]string{req.Recipient}, nil, "SFTPGo - Testing Email Settings",
 		"It appears your SFTPGo email is setup correctly!", smtp.EmailContentTypeTextPlain); err != nil {
+		logger.Info(logSender, "", "unable to send test email: %v", err)
 		sendAPIResponse(w, r, err, "", http.StatusInternalServerError)
 		return
 	}

+ 5 - 12
internal/httpd/httpd_test.go

@@ -7925,7 +7925,6 @@ func TestLoaddata(t *testing.T) {
 	assert.Equal(t, configs.SMTP, configsGet.SMTP)
 	assert.Equal(t, []string{ssh.KeyAlgoRSA}, configsGet.SFTPD.HostKeyAlgos)
 	assert.Equal(t, []string{ssh.KeyAlgoDSA}, configsGet.SFTPD.PublicKeyAlgos)
-	assert.Len(t, configsGet.SFTPD.Moduli, 0)
 	assert.Len(t, configsGet.SFTPD.KexAlgorithms, 0)
 	assert.Len(t, configsGet.SFTPD.Ciphers, 0)
 	assert.Len(t, configsGet.SFTPD.MACs, 0)
@@ -8087,7 +8086,7 @@ func TestLoaddataMode(t *testing.T) {
 	folderName := filepath.Base(mappedPath)
 	configs := dataprovider.Configs{
 		SFTPD: &dataprovider.SFTPDConfigs{
-			Moduli: []string{"/moduli"},
+			PublicKeyAlgos: []string{ssh.KeyAlgoRSA},
 		},
 	}
 	role := getTestRole()
@@ -8200,7 +8199,7 @@ func TestLoaddataMode(t *testing.T) {
 	assert.NoError(t, err)
 	configs, err = dataprovider.GetConfigs()
 	assert.NoError(t, err)
-	assert.Len(t, configs.SFTPD.Moduli, 1)
+	assert.Len(t, configs.SFTPD.PublicKeyAlgos, 1)
 	folder, _, err := httpdtest.GetFolderByName(folderName, http.StatusOK)
 	assert.NoError(t, err)
 	assert.Equal(t, mappedPath+"1", folder.MappedPath)
@@ -8272,7 +8271,7 @@ func TestLoaddataMode(t *testing.T) {
 	entry, _, err = httpdtest.UpdateIPListEntry(entry, http.StatusOK)
 	assert.NoError(t, err)
 
-	configs.SFTPD.Moduli = append(configs.SFTPD.Moduli, "/moduli_new")
+	configs.SFTPD.PublicKeyAlgos = append(configs.SFTPD.PublicKeyAlgos, ssh.KeyAlgoDSA)
 	err = dataprovider.UpdateConfigs(&configs, "", "", "")
 	assert.NoError(t, err)
 	backupData.Configs = &configs
@@ -8286,7 +8285,7 @@ func TestLoaddataMode(t *testing.T) {
 	assert.NoError(t, err)
 	configs, err = dataprovider.GetConfigs()
 	assert.NoError(t, err)
-	assert.Len(t, configs.SFTPD.Moduli, 2)
+	assert.Len(t, configs.SFTPD.PublicKeyAlgos, 2)
 	group, _, err = httpdtest.GetGroupByName(group.Name, http.StatusOK)
 	assert.NoError(t, err)
 	assert.NotEqual(t, oldGroupDesc, group.Description)
@@ -8346,7 +8345,7 @@ func TestLoaddataMode(t *testing.T) {
 	assert.Equal(t, oldUploadBandwidth, user.UploadBandwidth)
 	configs, err = dataprovider.GetConfigs()
 	assert.NoError(t, err)
-	assert.Len(t, configs.SFTPD.Moduli, 1)
+	assert.Len(t, configs.SFTPD.PublicKeyAlgos, 1)
 	// the group is referenced
 	_, err = httpdtest.RemoveGroup(group, http.StatusBadRequest)
 	assert.NoError(t, err)
@@ -12772,7 +12771,6 @@ func TestWebConfigsMock(t *testing.T) {
 	form.Set("sftp_host_key_algos", ssh.KeyAlgoRSA)
 	form.Add("sftp_host_key_algos", ssh.CertAlgoDSAv01)
 	form.Set("sftp_pub_key_algos", ssh.KeyAlgoDSA)
-	form.Set("sftp_moduli", "path 1 , path 2")
 	form.Set("form_action", "sftp_submit")
 	req, err = http.NewRequest(http.MethodPost, webConfigsPath, bytes.NewBuffer([]byte(form.Encode())))
 	assert.NoError(t, err)
@@ -12800,9 +12798,6 @@ func TestWebConfigsMock(t *testing.T) {
 	assert.Contains(t, configs.SFTPD.HostKeyAlgos, ssh.KeyAlgoRSA)
 	assert.Len(t, configs.SFTPD.PublicKeyAlgos, 1)
 	assert.Contains(t, configs.SFTPD.PublicKeyAlgos, ssh.KeyAlgoDSA)
-	assert.Len(t, configs.SFTPD.Moduli, 2)
-	assert.Contains(t, configs.SFTPD.Moduli, "path 1")
-	assert.Contains(t, configs.SFTPD.Moduli, "path 2")
 	assert.Len(t, configs.SFTPD.KexAlgorithms, 1)
 	assert.Contains(t, configs.SFTPD.KexAlgorithms, "diffie-hellman-group16-sha512")
 	// invalid form action
@@ -12850,7 +12845,6 @@ func TestWebConfigsMock(t *testing.T) {
 	assert.Contains(t, configs.SFTPD.HostKeyAlgos, ssh.KeyAlgoRSA)
 	assert.Len(t, configs.SFTPD.PublicKeyAlgos, 1)
 	assert.Contains(t, configs.SFTPD.PublicKeyAlgos, ssh.KeyAlgoDSA)
-	assert.Len(t, configs.SFTPD.Moduli, 2)
 	assert.Equal(t, "mail.example.net", configs.SMTP.Host)
 	assert.Equal(t, 587, configs.SMTP.Port)
 	assert.Equal(t, "Example <info@example.net>", configs.SMTP.From)
@@ -12922,7 +12916,6 @@ func TestWebConfigsMock(t *testing.T) {
 	assert.Contains(t, configs.SFTPD.HostKeyAlgos, ssh.KeyAlgoRSA)
 	assert.Len(t, configs.SFTPD.PublicKeyAlgos, 1)
 	assert.Contains(t, configs.SFTPD.PublicKeyAlgos, ssh.KeyAlgoDSA)
-	assert.Len(t, configs.SFTPD.Moduli, 2)
 	assert.Equal(t, 80, configs.ACME.HTTP01Challenge.Port)
 	assert.Equal(t, 7, configs.ACME.Protocols)
 	assert.Empty(t, configs.ACME.Domain)

+ 9 - 10
internal/httpd/webadmin.go

@@ -101,7 +101,6 @@ const (
 	pageEventRulesTitle      = "Event rules"
 	pageEventActionsTitle    = "Event actions"
 	pageEventsTitle          = "Logs"
-	pageConfigsTitle         = "Configurations"
 	defaultQueryLimit        = 1000
 	inversePatternType       = "inverse"
 )
@@ -339,7 +338,7 @@ type configsPage struct {
 	RedactedSecret    string
 	OAuth2TokenURL    string
 	OAuth2RedirectURL string
-	Error             string
+	Error             *util.I18nError
 }
 
 type messagePage struct {
@@ -515,7 +514,7 @@ func loadAdminTemplates(templatesPath string) {
 		filepath.Join(templatesPath, templateAdminDir, templateEvents),
 	}
 	configsPaths := []string{
-		filepath.Join(templatesPath, templateCommonDir, templateCommonCSS),
+		filepath.Join(templatesPath, templateCommonDir, templateCommonBase),
 		filepath.Join(templatesPath, templateAdminDir, templateBase),
 		filepath.Join(templatesPath, templateAdminDir, templateConfigs),
 	}
@@ -840,7 +839,7 @@ func (s *httpdServer) renderMaintenancePage(w http.ResponseWriter, r *http.Reque
 }
 
 func (s *httpdServer) renderConfigsPage(w http.ResponseWriter, r *http.Request, configs dataprovider.Configs,
-	error string, section int,
+	err error, section int,
 ) {
 	configs.SetNilsToEmpty()
 	if configs.SMTP.Port == 0 {
@@ -852,13 +851,13 @@ func (s *httpdServer) renderConfigsPage(w http.ResponseWriter, r *http.Request,
 		configs.ACME.HTTP01Challenge.Port = 80
 	}
 	data := configsPage{
-		basePage:          s.getBasePageData(pageConfigsTitle, webConfigsPath, r),
+		basePage:          s.getBasePageData(util.I18nConfigsTitle, webConfigsPath, r),
 		Configs:           configs,
 		ConfigSection:     section,
 		RedactedSecret:    redactedSecret,
 		OAuth2TokenURL:    webOAuth2TokenPath,
 		OAuth2RedirectURL: webOAuth2RedirectPath,
-		Error:             error,
+		Error:             getI18nError(err),
 	}
 
 	renderAdminTemplate(w, templateConfigs, data)
@@ -2564,7 +2563,6 @@ func getSFTPConfigsFromPostFields(r *http.Request) *dataprovider.SFTPDConfigs {
 	return &dataprovider.SFTPDConfigs{
 		HostKeyAlgos:   r.Form["sftp_host_key_algos"],
 		PublicKeyAlgos: r.Form["sftp_pub_key_algos"],
-		Moduli:         getSliceFromDelimitedValues(r.Form.Get("sftp_moduli"), ","),
 		KexAlgorithms:  r.Form["sftp_kex_algos"],
 		Ciphers:        r.Form["sftp_ciphers"],
 		MACs:           r.Form["sftp_macs"],
@@ -4095,7 +4093,7 @@ func (s *httpdServer) handleWebConfigs(w http.ResponseWriter, r *http.Request) {
 		s.renderInternalServerErrorPage(w, r, err)
 		return
 	}
-	s.renderConfigsPage(w, r, configs, "", 0)
+	s.renderConfigsPage(w, r, configs, nil, 0)
 }
 
 func (s *httpdServer) handleWebConfigsPost(w http.ResponseWriter, r *http.Request) {
@@ -4131,7 +4129,8 @@ func (s *httpdServer) handleWebConfigsPost(w http.ResponseWriter, r *http.Reques
 		acmeConfigs := getACMEConfigsFromPostFields(r)
 		configs.ACME = acmeConfigs
 		if err := acme.GetCertificatesForConfig(acmeConfigs, configurationDir); err != nil {
-			s.renderConfigsPage(w, r, configs, err.Error(), configSection)
+			logger.Info(logSender, "", "unable to get ACME certificates: %v", err)
+			s.renderConfigsPage(w, r, configs, util.NewI18nError(err, util.I18nErrorACMEGeneric), configSection)
 			return
 		}
 	case "smtp_submit":
@@ -4146,7 +4145,7 @@ func (s *httpdServer) handleWebConfigsPost(w http.ResponseWriter, r *http.Reques
 
 	err = dataprovider.UpdateConfigs(&configs, claims.Username, ipAddr, claims.Role)
 	if err != nil {
-		s.renderConfigsPage(w, r, configs, err.Error(), configSection)
+		s.renderConfigsPage(w, r, configs, err, configSection)
 		return
 	}
 	if configSection == 3 {

+ 0 - 2
internal/sftpd/internal_test.go

@@ -1834,7 +1834,6 @@ func TestConfigsFromProvider(t *testing.T) {
 	configs := dataprovider.Configs{
 		SFTPD: &dataprovider.SFTPDConfigs{
 			HostKeyAlgos:   []string{ssh.KeyAlgoRSA},
-			Moduli:         []string{"/etc/ssh/moduli"},
 			KexAlgorithms:  []string{kexDHGroupExchangeSHA256},
 			Ciphers:        []string{"aes128-cbc", "aes192-cbc", "aes256-cbc"},
 			MACs:           []string{"hmac-sha2-512-etm@openssh.com"},
@@ -1854,7 +1853,6 @@ func TestConfigsFromProvider(t *testing.T) {
 	assert.Equal(t, expectedKEXs, c.KexAlgorithms)
 	assert.Equal(t, expectedCiphers, c.Ciphers)
 	assert.Equal(t, expectedMACs, c.MACs)
-	assert.Equal(t, configs.SFTPD.Moduli, c.Moduli)
 	assert.Equal(t, expectedPublicKeyAlgos, c.PublicKeyAlgorithms)
 
 	err = dataprovider.UpdateConfigs(nil, "", "", "")

+ 0 - 1
internal/sftpd/server.go

@@ -340,7 +340,6 @@ func (c *Configuration) loadFromProvider() error {
 		}
 		c.PublicKeyAlgorithms = append(c.PublicKeyAlgorithms, configs.SFTPD.PublicKeyAlgos...)
 	}
-	c.Moduli = append(c.Moduli, configs.SFTPD.Moduli...)
 	if len(configs.SFTPD.KexAlgorithms) > 0 {
 		if len(c.KexAlgorithms) == 0 {
 			c.KexAlgorithms = preferredKexAlgos

+ 5 - 0
internal/util/i18n.go

@@ -232,6 +232,11 @@ const (
 	I18nFTPTLSMixed                    = "status.tls_mixed"
 	I18nErrorBackupFile                = "maintenance.backup_invalid_file"
 	I18nErrorRestore                   = "maintenance.restore_error"
+	I18nErrorACMEGeneric               = "acme.generic_error"
+	I18nErrorSMTPRequiredFields        = "smtp.err_required_fields"
+	I18nErrorSMTPClientIDRequired      = "smtp.client_id_required"
+	I18nErrorSMTPClientSecretRequired  = "smtp.client_secret_required"
+	I18nErrorSMTPRefreshTokenRequired  = "smtp.refresh_token_required"
 )
 
 // NewI18nError returns a I18nError wrappring the provided error

+ 46 - 1
static/locales/en/translation.json

@@ -237,7 +237,11 @@
         "issuer": "Issuer",
         "data_provider": "Database",
         "driver": "Driver",
-        "mode": "Mode"
+        "mode": "Mode",
+        "port": "Port",
+        "domain": "Domain",
+        "test": "Test",
+        "get": "Get"
     },
     "fs": {
         "view_file": "View file \"{{- path}}\"",
@@ -789,5 +793,46 @@
         "quota_mode0": "No quota update",
         "quota_mode1": "Update quota",
         "quota_mode2": "Update quota for users with quota limits"
+    },
+    "acme": {
+        "title": "ACME",
+        "generic_error": "Unable to obtain TLS certificates, check the server logs for more details",
+        "help": "From this section you can request free TLS certificates for your SFTPGo services using the ACME protocol and the HTTP-01 challenge type. You must create a DNS entry under a custom domain that you own which resolves to your SFTPGo public IP address and the port 80 must be publicly reachable. You can set the configuration options for the most common use cases and single node setups here, for advanced configurations refer to the SFTPGo docs. A service restart is required to apply changes",
+        "domain_help": "Multiple domains can be specified comma or space separated. They will be included in the same certificate",
+        "email_help": "Email used for registration and recovery contact",
+        "port_help": "If different from 80 you have to configure a reverse proxy",
+        "protocols_help": "Use the obtained certificates for the specified protocols"
+    },
+    "smtp": {
+        "title": "SMTP",
+        "err_required_fields": "From address and Username cannot be both empty",
+        "client_id_required": "Client ID is required",
+        "client_secret_required": "Client Secret is required",
+        "refresh_token_required": "Refresh Token is required",
+        "help": "Set the SMTP configuration replacing the one defined using env vars or config file if any",
+        "host": "Server name",
+        "host_help": "If blank the configuration is disabled",
+        "auth": "Authentication",
+        "encryption": "Encryption",
+        "sender": "Sender",
+        "debug": "Debug logs",
+        "domain_help": "HELO domain. Leave blank to use the server hostname",
+        "test_recipient": "Address to send test emails to",
+        "oauth2_provider": "OAuth2 provider",
+        "oauth2_provider_help": "URI to redirect to after user authentication",
+        "oauth2_tenant": "OAuth2 Tenant",
+        "oauth2_tenant_help": "Azure tenant. Typical values are \"common\", \"organizations\", \"consumers\" or the tenant identifier",
+        "oauth2_client_id": "OAuth2 Client ID",
+        "oauth2_client_secret": "OAuth2 Client Secret",
+        "oauth2_token": "OAuth2 Token",
+        "recipient_required": "Specify a recipient to send a test email",
+        "test_error": "Unable to send test email, check server logs for more details",
+        "test_ok": "No errors were reported while sending the test email. Please check your inbox to make sure",
+        "oauth2_flow_error": "Unable to get the URI to start OAuth2 flow",
+        "oauth2_question": "Do you want to start the OAuth2 flow to get a token?"
+    },
+    "sftp": {
+        "help": "From this section you can enable algorithms disabled by default. You don't need to set values already defined using env vars or config file. A service restart is required to apply changes",
+        "host_key_algos": "Host Key Algorithms"
     }
 }

+ 47 - 2
static/locales/it/translation.json

@@ -54,7 +54,7 @@
         "update_folder": "Aggiorna cartella virtuale",
         "template_folder": "Modello cartella virtuale",
         "oauth2_error": "Impossibile completare il flusso OAuth2",
-        "oauth2_success": "OAuth2 completato",
+        "oauth2_success": "Flusso OAuth2 completato",
         "add_role": "Aggiungi ruolo",
         "update_role": "Aggiorna ruolo",
         "add_admin": "Aggiungi amministratore",
@@ -237,7 +237,11 @@
         "issuer": "Emittente",
         "data_provider": "Database",
         "driver": "Driver",
-        "mode": "Modalità"
+        "mode": "Modalità",
+        "port": "Porta",
+        "domain": "Dominio",
+        "test": "Test",
+        "get": "Ottieni"
     },
     "fs": {
         "view_file": "Visualizza file \"{{- path}}\"",
@@ -789,5 +793,46 @@
         "quota_mode0": "Non aggiornare quota",
         "quota_mode1": "Aggiorna quota",
         "quota_mode2": "Aggiorna quota per gli utenti con limiti di quota"
+    },
+    "acme": {
+        "title": "ACME",
+        "generic_error": "Impossibile ottenere certificati TLS, controlla i log del server per maggiori dettagli",
+        "help": "Da questa sezione puoi richiedere certificati TLS gratuiti per i tuoi servizi SFTPGo utilizzando il protocollo ACME e la tipologia di challenge HTTP-01. Devi creare una voce DNS sotto un dominio personalizzato di tua proprietà che si risolve nel tuo indirizzo IP pubblico SFTPGo e la porta 80 deve essere raggiungibile pubblicamente. Qui è possibile impostare le opzioni di configurazione per i casi d'uso più comuni e le configurazioni a nodo singolo, per le configurazioni avanzate fare riferimento alla documentazione SFTPGo. Per applicare le modifiche è necessario il riavvio del servizio",
+        "domain_help": "È possibile specificare più domini separati da virgole o spazi. Saranno inclusi nello stesso certificato",
+        "email_help": "Email utilizzata per la registrazione e il contatto di recupero",
+        "port_help": "Se diverso da 80 è necessario configurare un proxy inverso",
+        "protocols_help": "Utilizzare i certificati ottenuti per i protocolli specificati"
+    },
+    "smtp": {
+        "title": "SMTP",
+        "err_required_fields": "L'indirizzo del mittente e lo username non possono essere entrambi vuoti",
+        "client_id_required": "Il Client ID è obbligatorio",
+        "client_secret_required": "Il Client Secret è obbligatorio",
+        "refresh_token_required": "Il Refresh Token è obbligatorio",
+        "help": "Imposta la configurazione SMTP sostituendo quella definita utilizzando env vars o il file di configurazione, se presente",
+        "host": "Nome server",
+        "host_help": "Se vuoto la configurazione è disabilitata",
+        "auth": "Autenticazione",
+        "encryption": "Crittografia",
+        "sender": "Mittente",
+        "debug": "Log a livello debug",
+        "domain_help": "Dominio HELO. Lascia vuoto per utilizzare il nome host del server",
+        "test_recipient": "Indirizzo a cui inviare e-mail di test",
+        "oauth2_provider": "OAuth2 provider",
+        "oauth2_provider_help": "URI a cui reindirizzare dopo l'autenticazione dell'utente",
+        "oauth2_tenant": "OAuth2 Tenant",
+        "oauth2_tenant_help": "Azure tenant. Valori tipici sono \"common\", \"organizations\", \"consumers\" or l'ID del tenant",
+        "oauth2_client_id": "OAuth2 Client ID",
+        "oauth2_client_secret": "OAuth2 Client Secret",
+        "oauth2_token": "OAuth2 Token",
+        "recipient_required": "Specifica un destinatario per inviare un'e-mail di prova",
+        "test_error": "Impossibile inviare e-mail di prova, controlla i log del server per maggiori dettagli",
+        "test_ok": "Non si sono verificati errori durante l'invio dell'e-mail di prova. Controlla la tua casella di posta per essere sicuro",
+        "oauth2_flow_error": "Impossibile ottenere l'URI per avviare il flusso OAuth2",
+        "oauth2_question": "Vuoi avviare il flusso OAuth2 per ottenere un token?"
+    },
+    "sftp": {
+        "help": "Da questa sezione è possibile abilitare gli algoritmi disabilitati di default. Non è necessario impostare valori già definiti utilizzando env vars o il file di configurazione. Per applicare le modifiche è necessario il riavvio del servizio",
+        "host_key_algos": "Algoritmi per chiavi host"
     }
 }

+ 1 - 1
templates/webadmin/admin.html

@@ -212,7 +212,7 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
             <div class="form-group row mt-10">
                 <label for="idEmail" data-i18n="general.email" class="col-md-3 col-form-label">Email</label>
                 <div class="col-md-9">
-                    <input id="idEmail" type="text" class="form-control" placeholder="" name="email" value="{{.Admin.Email}}"
+                    <input id="idEmail" type="email" class="form-control" placeholder="" name="email" value="{{.Admin.Email}}"
                         maxlength="255" autocomplete="off" spellcheck="false" />
                 </div>
             </div>

+ 431 - 432
templates/webadmin/configs.html

@@ -1,597 +1,596 @@
 <!--
-Copyright (C) 2019 Nicola Murino
+Copyright (C) 2024 Nicola Murino
 
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as published
-by the Free Software Foundation, version 3.
+This WebUI uses the KeenThemes Mega Bundle, a proprietary theme:
 
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-GNU Affero General Public License for more details.
+https://keenthemes.com/products/templates-mega-bundle
 
-You should have received a copy of the GNU Affero General Public License
-along with this program. If not, see <https://www.gnu.org/licenses/>.
+KeenThemes HTML/CSS/JS components are allowed for use only within the
+SFTPGo product and restricted to be used in a resealable HTML template
+that can compete with KeenThemes products anyhow.
+
+This WebUI is allowed for use only within the SFTPGo product and
+therefore cannot be used in derivative works/products without an
+explicit grant from the SFTPGo Team (support@sftpgo.com).
 -->
 {{template "base" .}}
 
-{{define "title"}}{{.Title}}{{end}}
-
-{{define "extra_css"}}
-<link href="{{.StaticURL}}/vendor/bootstrap-select/css/bootstrap-select.min.css" rel="stylesheet">
-{{end}}
-
-{{define "page_body"}}
-<div class="card shadow mb-4">
-    <div class="card-header py-3">
-        <h6 class="m-0 font-weight-bold text-primary">{{.Title}}</h6>
+{{- define "page_body"}}
+<div class="card shadow-sm">
+    <div class="card-header bg-light">
+        <h3 data-i18n="{{.Title}}" class="card-title section-title"></h3>
     </div>
     <div class="card-body">
-        {{if .Error}}
-        <div class="alert alert-warning alert-dismissible fade show" role="alert">
-            {{.Error}}
-            <button type="button" class="close" data-dismiss="alert" aria-label="Close">
-                <span aria-hidden="true">&times;</span>
-            </button>
-        </div>
-        {{end}}
-        <form id="configs_form" action="{{.CurrentURL}}" method="POST" autocomplete="off">
-            <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
-            <div class="accordion" id="accordionConfigs">
-                <div class="card">
-                    <div class="card-header" id="headingSFTPD">
-                        <h2 class="mb-0">
-                            <button class="btn btn-link btn-block text-left" type="button" data-toggle="collapse"
-                                data-target="#collapseSFTPD" aria-expanded="true" aria-controls="collapseSFTPD">
-                                <h6 class="m-0 font-weight-bold text-primary">SFTP</h6>
-                            </button>
-                        </h2>
-                    </div>
-
-                    <div id="collapseSFTPD" class="collapse {{if eq .ConfigSection 1}}show{{end}}" aria-labelledby="headingSFTPD" data-parent="#accordionConfigs">
-                        <div class="card-body">
-                            <div id="configs-sftp-info" class="card mb-3 border-left-info">
-                                <div class="card-body">Here you can enable algorithms disabled by default. You don't need to set values already defined using env vars or config file. A service restart is required to apply changes.</div>
-                            </div>
-
-                            <div class="form-group row">
-                                <label for="idHostKeyAlgos" class="col-sm-2 col-form-label">Host Key Algos</label>
-                                <div class="col-sm-10">
-                                    <select class="form-control selectpicker" id="idHostKeyAlgos" name="sftp_host_key_algos" multiple>
-                                        {{range $val := .Configs.SFTPD.GetSupportedHostKeyAlgos}}
+        {{- template "errmsg" .Error}}
+        <div class="accordion" id="accordion_configs">
+            <div class="accordion-item">
+                <h2 class="accordion-header" id="accordion_header_sftp">
+                    <button class="accordion-button section-title-inner text-primary collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#accordion_sftp_body" aria-expanded="{{if eq .ConfigSection 1}}true{{else}}false{{end}}" aria-controls="accordion_sftp_body">
+                        <span data-i18n="storage.sftp">SFTP</span>
+                    </button>
+                </h2>
+                <div id="accordion_sftp_body" class="accordion-collapse collapse {{if eq .ConfigSection 1}}show{{end}}" aria-labelledby="accordion_header_sftp" data-bs-parent="#accordion_configs">
+                    <div class="accordion-body">
+                        <p data-i18n="sftp.help" class="fs-5 fw-semibold mb-4"></p>
+
+                        <form id="configs_sftp_form" action="{{.CurrentURL}}" method="POST" autocomplete="off">
+                            <div class="form-group row mt-10">
+                                <label for="idHostKeyAlgos" data-i18n="sftp.host_key_algos" class="col-md-3 col-form-label">
+                                    Host Key Algos
+                                </label>
+                                <div class="col-md-9">
+                                    <select id="idHostKeyAlgos" name="sftp_host_key_algos" class="form-select" data-control="i18n-select2" data-close-on-select="false" multiple>
+                                        {{- range $val := .Configs.SFTPD.GetSupportedHostKeyAlgos}}
                                         <option value="{{$val}}" {{range $algo :=$.Configs.SFTPD.HostKeyAlgos }}{{if eq $algo $val}}selected{{end}}{{end}}>{{$val}}</option>
-                                        {{end}}
+                                        {{- end}}
                                     </select>
                                 </div>
                             </div>
 
-                            <div class="form-group row">
-                                <label for="idPubKeyAlgos" class="col-sm-2 col-form-label">Public Key Algos</label>
-                                <div class="col-sm-10">
-                                    <select class="form-control selectpicker" id="idPubKeyAlgos" name="sftp_pub_key_algos" multiple>
-                                        {{range $val := .Configs.SFTPD.GetSupportedPublicKeyAlgos}}
+                            <div class="form-group row mt-10">
+                                <label for="idPubKeyAlgos" data-i18n="status.ssh_pub_key_algo" class="col-md-3 col-form-label">
+                                    Public Key Algos
+                                </label>
+                                <div class="col-md-9">
+                                    <select id="idPubKeyAlgos" name="sftp_pub_key_algos" class="form-select" data-control="i18n-select2" data-close-on-select="false" multiple>
+                                        {{- range $val := .Configs.SFTPD.GetSupportedPublicKeyAlgos}}
                                         <option value="{{$val}}" {{range $algo :=$.Configs.SFTPD.PublicKeyAlgos }}{{if eq $algo $val}}selected{{end}}{{end}}>{{$val}}</option>
-                                        {{end}}
+                                        {{- end}}
                                     </select>
                                 </div>
                             </div>
 
-                            <div class="form-group row">
-                                <label for="idModuli" class="col-sm-2 col-form-label">Moduli</label>
-                                <div class="col-sm-10">
-                                    <textarea class="form-control" id="idModuli" name="sftp_moduli" rows="2" placeholder=""
-                                        aria-describedby="moduliHelpBlock">{{.Configs.SFTPD.GetModuliAsString}}</textarea>
-                                    <small id="moduliHelpBlock" class="form-text text-muted">
-                                        Comma separated moduli file paths. Invalid/missing paths are silently ignored. Moduli are required to enable Diffie-Helmann Group Exchange KEX algos
-                                    </small>
-                                </div>
-                            </div>
-
-                            <div class="form-group row">
-                                <label for="idKEXAlgos" class="col-sm-2 col-form-label">KEX Algos</label>
-                                <div class="col-sm-10">
-                                    <select class="form-control selectpicker" id="idKEXAlgos" name="sftp_kex_algos" multiple>
-                                        {{range $val := .Configs.SFTPD.GetSupportedKEXAlgos}}
+                            <div class="form-group row mt-10">
+                                <label for="idKEXAlgos" data-i18n="status.ssh_kex_algo" class="col-md-3 col-form-label">
+                                    KEX Algos
+                                </label>
+                                <div class="col-md-9">
+                                    <select id="idKEXAlgos" name="sftp_kex_algos" class="form-select" data-control="i18n-select2" data-close-on-select="false" multiple>
+                                        {{- range $val := .Configs.SFTPD.GetSupportedKEXAlgos}}
                                         <option value="{{$val}}" {{range $algo :=$.Configs.SFTPD.KexAlgorithms }}{{if eq $algo $val}}selected{{end}}{{end}}>{{$val}}</option>
-                                        {{end}}
+                                        {{- end}}
                                     </select>
                                 </div>
                             </div>
 
-                            <div class="form-group row">
-                                <label for="idCiphers" class="col-sm-2 col-form-label">Ciphers</label>
-                                <div class="col-sm-10">
-                                    <select class="form-control selectpicker" id="idCiphers" name="sftp_ciphers" multiple>
-                                        {{range $val := .Configs.SFTPD.GetSupportedCiphers}}
-                                        <option value="{{$val}}" {{range $algo :=$.Configs.SFTPD.Ciphers }}{{if eq $algo $val}}selected{{end}}{{end}}>{{$val}}</option>
-                                        {{end}}
+                            <div class="form-group row mt-10">
+                                <label for="idMACAlgos" data-i18n="status.ssh_mac_algo" class="col-md-3 col-form-label">
+                                    MAC Algos
+                                </label>
+                                <div class="col-md-9">
+                                    <select id="idMACAlgos" name="sftp_macs" class="form-select" data-control="i18n-select2" data-close-on-select="false" multiple>
+                                        {{- range $val := .Configs.SFTPD.GetSupportedMACs}}
+                                        <option value="{{$val}}" {{range $algo :=$.Configs.SFTPD.MACs }}{{if eq $algo $val}}selected{{end}}{{end}}>{{$val}}</option>
+                                        {{- end}}
                                     </select>
                                 </div>
                             </div>
 
-                            <div class="form-group row">
-                                <label for="idMACAlgos" class="col-sm-2 col-form-label">MAC Algos</label>
-                                <div class="col-sm-10">
-                                    <select class="form-control selectpicker" id="idMACAlgos" name="sftp_macs" multiple>
-                                        {{range $val := .Configs.SFTPD.GetSupportedMACs}}
-                                        <option value="{{$val}}" {{range $algo :=$.Configs.SFTPD.MACs }}{{if eq $algo $val}}selected{{end}}{{end}}>{{$val}}</option>
+                            <div class="form-group row mt-10">
+                                <label for="idCiphers" data-i18n="status.ssh_cipher_algo" class="col-md-3 col-form-label">
+                                    KEX Algos
+                                </label>
+                                <div class="col-md-9">
+                                    <select id="idCiphers" name="sftp_ciphers" class="form-select" data-control="i18n-select2" data-close-on-select="false" multiple>
+                                        {{range $val := .Configs.SFTPD.GetSupportedCiphers}}
+                                        <option value="{{$val}}" {{range $algo :=$.Configs.SFTPD.Ciphers }}{{if eq $algo $val}}selected{{end}}{{end}}>{{$val}}</option>
                                         {{end}}
                                     </select>
                                 </div>
                             </div>
 
-                            <div class="col-sm-12 text-right px-0">
-                                <button type="submit" class="btn btn-primary mt-3 ml-3 px-5" name="form_action" value="sftp_submit">Submit</button>
+                            <div class="d-flex justify-content-end mt-12">
+                                <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
+                                <input type="hidden" name="form_action" value="sftp_submit">
+                                <button type="submit" id="sftp_form_submit" class="btn btn-primary px-10">
+                                    <span data-i18n="general.submit" class="indicator-label">
+                                        Submit
+                                    </span>
+                                    <span data-i18n="general.wait" class="indicator-progress">
+                                        Please wait...
+                                        <span class="spinner-border spinner-border-sm align-middle ms-2"></span>
+                                    </span>
+                                </button>
                             </div>
+                        </form>
 
-                        </div>
                     </div>
                 </div>
-                <div class="card">
-                    <div class="card-header" id="headingACME">
-                        <h2 class="mb-0">
-                            <button class="btn btn-link btn-block text-left" type="button" data-toggle="collapse"
-                                data-target="#collapseACME" aria-expanded="true" aria-controls="collapseACME">
-                                <h6 class="m-0 font-weight-bold text-primary">ACME</h6>
-                            </button>
-                        </h2>
-                    </div>
-
-                    <div id="collapseACME" class="collapse {{if eq .ConfigSection 2}}show{{end}}" aria-labelledby="headingACME" data-parent="#accordionConfigs">
-                        <div class="card-body">
-                            <div id="configs-acme-info" class="card mb-3 border-left-info">
-                                <div class="card-body">From this section you can request free TLS certificates for your SFTPGo services using the ACME protocol and the HTTP-01 challenge type. You must create a DNS entry under a custom domain that you own which resolves to your SFTPGo public IP address and the port 80 must be publicly reachable. You can set the configuration options for the most common use cases and single node setups here, for advanced configurations refer to the SFTPGo docs. A service restart is required to apply changes</div>
-                            </div>
+            </div>
 
-                            <div class="form-group row">
-                                <label for="idACMEDomain" class="col-sm-2 col-form-label">Domain</label>
-                                <div class="col-sm-10">
-                                    <input type="text" class="form-control" id="idACMEDomain" name="acme_domain" placeholder=""
-                                        value="{{.Configs.ACME.Domain}}" aria-describedby="acmeDomainHelpBlock">
-                                    <small id="acmeDomainHelpBlock" class="form-text text-muted">
-                                        Multiple domains can be specified comma or space separated. They will be included in the same certificate
-                                    </small>
+            <div class="accordion-item">
+                <h2 class="accordion-header" id="accordion_header_acme">
+                    <button class="accordion-button section-title-inner text-primary collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#accordion_acme_body" aria-expanded="{{if eq .ConfigSection 2}}true{{else}}false{{end}}" aria-controls="accordion_acme_body">
+                        <span data-i18n="acme.title">ACME</span>
+                    </button>
+                </h2>
+                <div id="accordion_acme_body" class="accordion-collapse collapse {{if eq .ConfigSection 2}}show{{end}}" aria-labelledby="accordion_header_acme" data-bs-parent="#accordion_configs">
+                    <div class="accordion-body">
+                        <p data-i18n="acme.help" class="fs-5 fw-semibold mb-4"></p>
+
+                        <form id="configs_acme_form" action="{{.CurrentURL}}" method="POST" autocomplete="off">
+
+                            <div class="form-group row mt-10">
+                                <label for="idACMEDomain" data-i18n="general.domain" class="col-md-3 col-form-label">Domain</label>
+                                <div class="col-md-9">
+                                    <input id="idACMEDomain" type="text" class="form-control" name="acme_domain" value="{{.Configs.ACME.Domain}}" aria-describedby="idACMEDomainHelp" />
+                                    <div id="idACMEDomainHelp" class="form-text" data-i18n="acme.domain_help"></div>
                                 </div>
                             </div>
 
-                            <div class="form-group row">
-                                <label for="idACMEEmail" class="col-sm-2 col-form-label">Email</label>
-                                <div class="col-sm-10">
-                                    <input type="text" class="form-control" id="idACMEEmail" name="acme_email" placeholder=""
-                                        value="{{.Configs.ACME.Email}}" spellcheck="false" aria-describedby="acmeEmailHelpBlock">
-                                    <small id="acmeEmailHelpBlock" class="form-text text-muted">
-                                        Email used for registration and recovery contact
-                                    </small>
+                            <div class="form-group row mt-10">
+                                <label for="idACMEEmail" data-i18n="general.email" class="col-md-3 col-form-label">Email</label>
+                                <div class="col-md-9">
+                                    <input id="idACMEEmail" type="email" class="form-control" placeholder="" name="acme_email" value="{{.Configs.ACME.Email}}" aria-describedby="idACMEEmailHelp"
+                                        maxlength="255" autocomplete="off" spellcheck="false" />
+                                    <div id="idACMEEmailHelp" class="form-text" data-i18n="acme.email_help"></div>
                                 </div>
                             </div>
 
-                            <div class="form-group row">
-                                <label for="idACMEPort" class="col-sm-2 col-form-label">Port</label>
-                                <div class="col-sm-10">
-                                    <input type="number" min="1" max="65535" class="form-control" id="idACMEPort" name="acme_port" placeholder=""
-                                        value="{{.Configs.ACME.HTTP01Challenge.Port}}" aria-describedby="acmePortHelpBlock">
-                                    <small id="acmePortHelpBlock" class="form-text text-muted">
-                                        If different from 80 you have to configure a reverse proxy
-                                    </small>
+                            <div class="form-group row mt-10">
+                                <label for="idACMEPort" data-i18n="general.port" class="col-md-3 col-form-label">Port</label>
+                                <div class="col-md-9">
+                                    <input id="idACMEPort" type="number" min="1" max="65535" class="form-control" name="acme_port" value="{{.Configs.ACME.HTTP01Challenge.Port}}" aria-describedby="idACMEPortHelp" />
+                                    <div id="idACMEPortHelp" class="form-text" data-i18n="acme.port_help"></div>
                                 </div>
                             </div>
 
-                            <div class="form-group row">
-                                <label for="idACMEProtocols" class="col-sm-2 col-form-label">Protocols</label>
-                                <div class="col-sm-10">
-                                    <select class="form-control selectpicker" id="idACMEProtocols" name="acme_protocols" aria-describedby="acmeProtocolsHelpBlock" multiple>
+                            <div class="form-group row mt-10">
+                                <label for="idACMEProtocols" data-i18n="ip_list.protocols" class="col-md-3 col-form-label">
+                                    Protocols
+                                </label>
+                                <div class="col-md-9">
+                                    <select id="idACMEProtocols" name="acme_protocols" class="form-select" data-control="i18n-select2" data-close-on-select="false" multiple aria-describedby="idACMEProtocolsHelp">
                                         <option value="1" {{if .Configs.ACME.HasProtocol "HTTP"}}selected{{end}}>HTTP</option>
                                         <option value="2" {{if .Configs.ACME.HasProtocol "FTP"}}selected{{end}}>FTP</option>
                                         <option value="3" {{if .Configs.ACME.HasProtocol "DAV"}}selected{{end}}>DAV</option>
                                     </select>
-                                    <small id="acmePortHelpBlock" class="form-text text-muted">
-                                        Use the obtained certificates for the specified protocols
-                                    </small>
+                                    <div id="idACMEProtocolsHelp" class="form-text" data-i18n="acme.protocols_help"></div>
                                 </div>
                             </div>
 
-                            <div class="col-sm-12 text-right px-0">
-                                <button type="submit" class="btn btn-primary mt-3 ml-3 px-5" name="form_action" value="acme_submit" onclick="showSpinner();">Submit</button>
+                            <div class="d-flex justify-content-end mt-12">
+                                <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
+                                <input type="hidden" name="form_action" value="acme_submit">
+                                <button type="submit" id="acme_form_submit" class="btn btn-primary px-10">
+                                    <span data-i18n="general.submit" class="indicator-label">
+                                        Submit
+                                    </span>
+                                    <span data-i18n="general.wait" class="indicator-progress">
+                                        Please wait...
+                                        <span class="spinner-border spinner-border-sm align-middle ms-2"></span>
+                                    </span>
+                                </button>
                             </div>
-
-                        </div>
+                        </form>
                     </div>
-
                 </div>
-                <div class="card">
-                    <div class="card-header" id="headingSMTP">
-                        <h2 class="mb-0">
-                            <button class="btn btn-link btn-block text-left" type="button" data-toggle="collapse"
-                                data-target="#collapseSMTP" aria-expanded="true" aria-controls="collapseSMTP">
-                                <h6 class="m-0 font-weight-bold text-primary">SMTP</h6>
-                            </button>
-                        </h2>
-                    </div>
-
-                    <div id="collapseSMTP" class="collapse {{if eq .ConfigSection 3}}show{{end}}" aria-labelledby="headingSMTP" data-parent="#accordionConfigs">
-                        <div class="card-body">
-                            <div id="configs-smtp-info" class="card mb-3 border-left-info">
-                                <div class="card-body">Set the SMTP configuration replacing the one defined using env vars or config file if any.</div>
-                            </div>
+            </div>
 
-                            <div class="form-group row">
-                                <label for="idSMTPHost" class="col-sm-2 col-form-label">Server name</label>
-                                <div class="col-sm-5">
-                                    <input type="text" class="form-control" id="idSMTPHost" name="smtp_host" placeholder=""
-                                        value="{{.Configs.SMTP.Host}}" maxlength="512" spellcheck="false" aria-describedby="smtpHostHelpBlock">
-                                    <small id="smtpHostHelpBlock" class="form-text text-muted">
-                                        If empty the configuration is disabled
-                                    </small>
+            <div class="accordion-item">
+                <h2 class="accordion-header" id="accordion_header_smtp">
+                    <button class="accordion-button section-title-inner text-primary collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#accordion_smtp_body" aria-expanded="{{if eq .ConfigSection 3}}true{{else}}false{{end}}" aria-controls="accordion_smtp_body">
+                        <span data-i18n="smtp.title">SMTP</span>
+                    </button>
+                </h2>
+                <div id="accordion_smtp_body" class="accordion-collapse collapse {{if eq .ConfigSection 3}}show{{end}}" aria-labelledby="accordion_header_smtp" data-bs-parent="#accordion_configs">
+                    <div class="accordion-body">
+                        <p data-i18n="smtp.help" class="fs-5 fw-semibold mb-4"></p>
+
+                        <form id="configs_smtp_form" action="{{.CurrentURL}}" method="POST" autocomplete="off">
+
+                            <div class="form-group row mt-10">
+                                <label for="idSMTPHost" data-i18n="smtp.host" class="col-md-3 col-form-label">Server name</label>
+                                <div class="col-md-5">
+                                    <input id="idSMTPHost" type="text" class="form-control" name="smtp_host" value="{{.Configs.SMTP.Host}}" maxlength="512" spellcheck="false" aria-describedby="idSMTPHostHelp" />
+                                    <div id="idSMTPHostHelp" class="form-text" data-i18n="smtp.host_help"></div>
                                 </div>
-                                <div class="col-sm-1"></div>
-                                <label for="idSMTPPort" class="col-sm-2 col-form-label">Port</label>
-                                <div class="col-sm-2">
-                                    <input type="number" min="1" max="65535" class="form-control" id="idSMTPPort" name="smtp_port" placeholder=""
-                                        value="{{.Configs.SMTP.Port}}">
+                                <div class="col-md-1"></div>
+                                <label for="idSMTPPort" data-i18n="general.port" class="col-md-1 col-form-label">Port</label>
+                                <div class="col-md-2">
+                                    <input id="idSMTPPort" type="number" min="1" max="65535" class="form-control" name="smtp_port" value="{{.Configs.SMTP.Port}}"/>
                                 </div>
                             </div>
 
-                            <div class="form-group row">
-                                <label for="idSMTPUsername" class="col-sm-2 col-form-label">Username</label>
-                                <div class="col-sm-10">
-                                    <input type="text" class="form-control" id="idSMTPUsername" name="smtp_username" placeholder=""
-                                    value="{{.Configs.SMTP.User}}" maxlength="255" spellcheck="false">
+                            <div class="form-group row mt-10">
+                                <label for="idSMTPUsername" data-i18n="login.username" class="col-md-3 col-form-label">Username</label>
+                                <div class="col-md-9">
+                                    <input id="idSMTPUsername" type="text" placeholder="" name="smtp_username" value="{{.Configs.SMTP.User}}" maxlength="255" autocomplete="off"
+                                        spellcheck="false" class="form-control" />
                                 </div>
                             </div>
 
-                            <div class="form-group row">
-                                <label for="idSMTPPassword" class="col-sm-2 col-form-label">Password</label>
-                                <div class="col-sm-10">
-                                    <input type="password" class="form-control" id="idSMTPPassword" name="smtp_password" placeholder="" autocomplete="new-password" spellcheck="false"
-                                        value="{{if .Configs.SMTP.Password.IsEncrypted}}{{.RedactedSecret}}{{else}}{{.Configs.SMTP.Password.GetPayload}}{{end}}">
+                            <div class="form-group row mt-10">
+                                <label for="idSMTPPassword" data-i18n="login.password" class="col-md-3 col-form-label">Password</label>
+                                <div class="col-md-9">
+                                    <input id="idSMTPPassword" type="password" class="form-control" name="smtp_password" autocomplete="new-password"
+                                        spellcheck="false" value="{{if .Configs.SMTP.Password.IsEncrypted}}{{.RedactedSecret}}{{else}}{{.Configs.SMTP.Password.GetPayload}}{{end}}" />
                                 </div>
                             </div>
 
-                            <div class="form-group row">
-                                <label for="idSMTPAuth" class="col-sm-2 col-form-label">Auth</label>
-                                <div class="col-sm-3">
-                                    <select class="form-control selectpicker" id="idSMTPAuth" name="smtp_auth" onchange="onSMTPAuthChanged(this.value)">
+                            <div class="form-group row mt-10">
+                                <label for="idSMTPAuth" data-i18n="smtp.auth" class="col-md-3 col-form-label">Auth</label>
+                                <div class="col-md-3">
+                                    <select id="idSMTPAuth" name="smtp_auth" class="form-select" data-control="i18n-select2" data-hide-search="true">
                                         <option value="0" {{if eq .Configs.SMTP.AuthType 0}}selected{{end}}>Plain</option>
                                         <option value="1" {{if eq .Configs.SMTP.AuthType 1}}selected{{end}}>Login</option>
                                         <option value="2" {{if eq .Configs.SMTP.AuthType 2}}selected{{end}}>CRAM-MD5</option>
                                         <option value="3" {{if eq .Configs.SMTP.AuthType 3}}selected{{end}}>OAuth2</option>
                                     </select>
                                 </div>
-                                <div class="col-sm-2"></div>
-                                <label for="idSMTPEncryption" class="col-sm-2 col-form-label">Encryption</label>
-                                <div class="col-sm-3">
-                                    <select class="form-control selectpicker" id="idSMTPEncryption" name="smtp_encryption">
-                                        <option value="0" {{if eq .Configs.SMTP.Encryption 0}}selected{{end}}>None</option>
+                                <div class="col-md-1"></div>
+                                <label for="idSMTPEncryption" data-i18n="smtp.encryption" class="col-md-2 col-form-label">Encryption</label>
+                                <div class="col-md-3">
+                                    <select id="idSMTPEncryption" name="smtp_encryption" class="form-select" data-control="i18n-select2" data-hide-search="true">
+                                        <option value="0" {{if eq .Configs.SMTP.Encryption 0}}selected{{end}}>---</option>
                                         <option value="1" {{if eq .Configs.SMTP.Encryption 1}}selected{{end}}>TLS</option>
                                         <option value="2" {{if eq .Configs.SMTP.Encryption 2}}selected{{end}}>STARTTLS</option>
                                     </select>
                                 </div>
                             </div>
 
-                            <div class="form-group row smtp-oauth2">
-                                <label for="idSMTPOAuth2Provider" class="col-sm-2 col-form-label">OAuth2 provider</label>
-                                <div class="col-sm-10">
-                                    <select class="form-control selectpicker" id="idSMTPOAuth2Provider" name="smtp_oauth2_provider"
-                                        onchange="onSMTPOAuth2ProviderChanged(this.value)" aria-describedby="smtpOauth2ProviderHelpBlock">
+                            <div class="form-group row smtp-oauth2 mt-10">
+                                <label for="idSMTPOAuth2Provider" data-i18n="smtp.oauth2_provider" class="col-md-3 col-form-label">OAuth2 provider</label>
+                                <div class="col-md-9">
+                                    <select id="idSMTPOAuth2Provider" name="smtp_oauth2_provider" class="form-select" data-control="i18n-select2" data-hide-search="true" aria-describedby="idSMTPOAuth2ProviderHelp">
                                         <option value="0" {{if eq .Configs.SMTP.OAuth2.Provider 0}}selected{{end}}>Google</option>
                                         <option value="1" {{if eq .Configs.SMTP.OAuth2.Provider 1}}selected{{end}}>Microsoft</option>
                                     </select>
-                                    <small id="smtpOauth2ProviderHelpBlock" class="form-text text-muted">
-                                    </small>
+                                    <div id="idSMTPOAuth2ProviderHelp" class="form-text"></div>
                                 </div>
                             </div>
 
-                            <div class="form-group row smtp-oauth2 smtp-oauth2-microsoft">
-                                <label for="idSMTPOauth2Tenant" class="col-sm-2 col-form-label">OAuth2 Tenant</label>
-                                <div class="col-sm-10">
-                                    <input type="text" class="form-control" id="idSMTPOauth2Tenant" name="smtp_oauth2_tenant" placeholder=""
-                                        value="{{.Configs.SMTP.OAuth2.Tenant}}" aria-describedby="smtpOauth2TenantHelpBlock">
-                                    <small id="smtpOauth2TenantHelpBlock" class="form-text text-muted">
-                                        Azure Active Directory tenant. Typical values are "common", "organizations", "consumers" or tenant identifier.
-                                    </small>
+                            <div class="form-group row smtp-oauth2 smtp-oauth2-microsoft mt-10">
+                                <label for="idSMTPOauth2Tenant" data-i18n="smtp.oauth2_tenant" class="col-md-3 col-form-label">OAuth2 Tenant</label>
+                                <div class="col-md-9">
+                                    <input id="idSMTPOauth2Tenant" type="text" class="form-control" name="smtp_oauth2_tenant" value="{{.Configs.SMTP.OAuth2.Tenant}}" aria-describedby="idSMTPOauth2TenantHelp" />
+                                    <div id="idSMTPOauth2TenantHelp" class="form-text" data-i18n="smtp.oauth2_tenant_help"></div>
                                 </div>
                             </div>
 
-                            <div class="form-group row smtp-oauth2">
-                                <label for="idSMTPOauth2ClientID" class="col-sm-2 col-form-label">OAuth2 Client ID</label>
-                                <div class="col-sm-10">
-                                    <input type="text" class="form-control" id="idSMTPOauth2ClientID" name="smtp_oauth2_client_id" placeholder=""
-                                        value="{{.Configs.SMTP.OAuth2.ClientID}}" spellcheck="false">
+                            <div class="form-group row smtp-oauth2 mt-10">
+                                <label for="idSMTPOauth2ClientID" data-i18n="smtp.oauth2_client_id" class="col-md-3 col-form-label">OAuth2 Client ID</label>
+                                <div class="col-md-9">
+                                    <input id="idSMTPOauth2ClientID" type="text" class="form-control" name="smtp_oauth2_client_id" value="{{.Configs.SMTP.OAuth2.ClientID}}" spellcheck="false" />
                                 </div>
                             </div>
 
-                            <div class="form-group row smtp-oauth2">
-                                <label for="idSMTPOAuth2ClientSecret" class="col-sm-2 col-form-label">OAuth2 Client secret</label>
-                                <div class="col-sm-10">
-                                    <input type="password" class="form-control" id="idSMTPOAuth2ClientSecret" name="smtp_oauth2_client_secret" placeholder="" autocomplete="new-password" spellcheck="false"
-                                        value="{{if .Configs.SMTP.OAuth2.ClientSecret.IsEncrypted}}{{.RedactedSecret}}{{else}}{{.Configs.SMTP.OAuth2.ClientSecret.GetPayload}}{{end}}">
+                            <div class="form-group row smtp-oauth2 mt-10">
+                                <label for="idSMTPOAuth2ClientSecret" data-i18n="smtp.oauth2_client_secret" class="col-md-3 col-form-label">OAuth2 Client secret</label>
+                                <div class="col-md-9">
+                                    <input id="idSMTPOAuth2ClientSecret" type="password" class="form-control" name="smtp_oauth2_client_secret" spellcheck="false" autocomplete="new-password"
+                                        value="{{if .Configs.SMTP.OAuth2.ClientSecret.IsEncrypted}}{{.RedactedSecret}}{{else}}{{.Configs.SMTP.OAuth2.ClientSecret.GetPayload}}{{end}}" />
                                 </div>
                             </div>
 
-                            <div class="form-group row smtp-oauth2">
-                                <label for="idSMTPOAuth2RefreshToken" class="col-sm-2 col-form-label">OAuth2 Token</label>
-                                <div class="col-sm-10">
+                            <div class="form-group row smtp-oauth2 mt-10">
+                                <label for="idSMTPOAuth2RefreshToken" data-i18n="smtp.oauth2_token" class="col-md-3 col-form-label">OAuth2 Token</label>
+                                <div class="col-md-9">
                                     <div class="input-group">
-                                        <input type="password" class="form-control" id="idSMTPOAuth2RefreshToken" name="smtp_oauth2_refresh_token" placeholder="" autocomplete="new-password" spellcheck="false"
-                                            value="{{if .Configs.SMTP.OAuth2.RefreshToken.IsEncrypted}}{{.RedactedSecret}}{{else}}{{.Configs.SMTP.OAuth2.RefreshToken.GetPayload}}{{end}}">
-                                        <div class="input-group-append">
-                                            <button class="btn btn-secondary px-5" onclick="getRefreshToken(event);">Get</button>
-                                        </div>
+                                        <input id="idSMTPOAuth2RefreshToken" type="password" class="form-control rounded-left" name="smtp_oauth2_refresh_token" spellcheck="false" autocomplete="new-password"
+                                            value="{{if .Configs.SMTP.OAuth2.RefreshToken.IsEncrypted}}{{.RedactedSecret}}{{else}}{{.Configs.SMTP.OAuth2.RefreshToken.GetPayload}}{{end}}" />
+                                        <button id="refresh_token_button" type="button" class="btn btn-primary">
+                                            <span data-i18n="general.get">Get</span>
+                                        </button>
                                     </div>
                                 </div>
                             </div>
 
-                            <div class="form-group row">
-                                <label for="idSMTPFrom" class="col-sm-2 col-form-label">From</label>
-                                <div class="col-sm-10">
-                                    <input type="text" class="form-control" id="idSMTPFrom" name="smtp_from" placeholder=""
-                                        value="{{.Configs.SMTP.From}}" maxlength="512" spellcheck="false">
+                            <div class="form-group row mt-10">
+                                <label for="idSMTPFrom" data-i18n="smtp.sender" class="col-md-3 col-form-label">From</label>
+                                <div class="col-md-9">
+                                    <input id="idSMTPFrom" type="text" placeholder="" name="smtp_from" value="{{.Configs.SMTP.From}}" maxlength="512" autocomplete="off"
+                                        spellcheck="false" class="form-control" />
                                 </div>
                             </div>
 
-                            <div class="form-group row">
-                                <label for="idSMTPDomain" class="col-sm-2 col-form-label">Domain</label>
-                                <div class="col-sm-10">
-                                    <input type="text" class="form-control" id="idSMTPDomain" name="smtp_domain" placeholder=""
-                                        value="{{.Configs.SMTP.Domain}}" aria-describedby="smtpDomainHelpBlock">
-                                    <small id="smtpDomainHelpBlock" class="form-text text-muted">
-                                        HELO domain. Leave blank to use the server hostname
-                                    </small>
+                            <div class="form-group row mt-10">
+                                <label for="idSMTPDomain" data-i18n="general.domain" class="col-md-3 col-form-label">Domain</label>
+                                <div class="col-md-9">
+                                    <input id="idSMTPDomain" type="text" placeholder="" name="smtp_domain" value="{{.Configs.SMTP.Domain}}" maxlength="512"
+                                        spellcheck="false" class="form-control" aria-describedby="idSMTPDomainHelp" />
+                                    <div id="idSMTPDomainHelp" class="form-text" data-i18n="smtp.domain_help"></div>
                                 </div>
                             </div>
 
-                            <div class="form-group">
-                                <div class="form-check">
-                                    <input type="checkbox" class="form-check-input" id="idSMTPDebug" name="smtp_debug"
-                                        {{if gt .Configs.SMTP.Debug 0}}checked{{end}}>
-                                    <label for="idSMTPDebug" class="form-check-label">Debug logs</label>
+                            <div class="form-group row align-items-center mt-10">
+                                <label data-i18n="smtp.debug" class="col-md-3 col-form-label" for="idSMTPDebug">Debug logs</label>
+                                <div class="col-md-9">
+                                    <div class="form-check form-switch form-check-custom form-check-solid">
+                                        <input class="form-check-input" type="checkbox" id="idSMTPDebug" name="smtp_debug" />
+                                    </div>
                                 </div>
                             </div>
 
-                            <div class="form-group row">
-                                <div class="col-sm-12">
+                            <div class="form-group row mt-10">
+                                <div class="col-md-12">
                                     <div class="input-group">
-                                        <input type="email" class="form-control float-right" id="idSMTPRecipient" placeholder="Test email recipient" aria-label="Test email recipient">
-                                        <div class="input-group-append">
-                                          <button class="btn btn-secondary px-5" onclick="testSMTP(event);">Test</button>
-                                        </div>
+                                        <input id="idSMTPRecipient" type="email" class="form-control rounded-left" name="smtp_oauth2_refresh_token"
+                                            data-i18n="[placeholder]smtp.test_recipient" spellcheck="false" />
+                                        <button id="smtp_test_button" type="button" class="btn btn-primary">
+                                            <span data-i18n="general.test">Test</span>
+                                        </button>
                                     </div>
                                 </div>
                             </div>
 
-                            <div class="col-sm-12 text-right px-0">
-                                <button type="submit" class="btn btn-primary mt-3 px-5" name="form_action" value="smtp_submit">Submit</button>
+                            <div class="d-flex justify-content-end mt-12">
+                                <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
+                                <input type="hidden" name="form_action" value="smtp_submit">
+                                <button type="submit" id="smtp_form_submit" class="btn btn-primary px-10">
+                                    <span data-i18n="general.submit" class="indicator-label">
+                                        Submit
+                                    </span>
+                                    <span data-i18n="general.wait" class="indicator-progress">
+                                        Please wait...
+                                        <span class="spinner-border spinner-border-sm align-middle ms-2"></span>
+                                    </span>
+                                </button>
                             </div>
-
-                        </div>
+                        </form>
                     </div>
                 </div>
             </div>
-        </form>
-    </div>
-</div>
-{{end}}
-{{define "dialog"}}
-<div class="modal fade" id="spinnerModal" tabindex="-1" role="dialog" data-keyboard="false" data-backdrop="static">
-    <div class="modal-dialog modal-dialog-centered justify-content-center" role="document">
-        <span style="color: #333333;" class="fa fa-spinner fa-spin fa-3x"></span>
-    </div>
-</div>
 
-<div class="modal fade" id="smtpTestResultModal" tabindex="-1" role="dialog" aria-labelledby="smtpTestResultModalLabel"
-    aria-hidden="true">
-    <div class="modal-dialog" role="document">
-        <div class="modal-content">
-            <div class="modal-header">
-                <h5 class="modal-title" id="smtpTestResultModal">
-                    SMTP test result
-                </h5>
-                <button class="close" type="button" data-dismiss="modal" aria-label="Close">
-                    <span aria-hidden="true">&times;</span>
-                </button>
-            </div>
-            <div class="modal-body">
-                <div id="smtpSuccessMsg" class="card mb-4 border-left-success" style="display: none;">
-                    <div id="successTxt" class="card-body">No errors were reported while sending the test email. Please check your inbox to make sure.</div>
-                </div>
-                <div id="smtpErrorMsg" class="card mb-4 border-left-warning" style="display: none;">
-                    <div id="smtpErrorTxt" class="card-body text-form-error"></div>
-                </div>
-            </div>
-            <div class="modal-footer">
-                <button class="btn btn-primary" type="button" data-dismiss="modal">
-                    OK
-                </button>
-            </div>
         </div>
     </div>
 </div>
+{{- end}}
 
-<div class="modal fade" id="smtpOAuthFlowModal" tabindex="-1" role="dialog" aria-labelledby="smtpOAuthFlowModalLabel"
-    aria-hidden="true">
-    <div class="modal-dialog" role="document">
-        <div class="modal-content">
-            <div class="modal-header">
-                <h5 class="modal-title" id="smtpOAuthFlowModal">
-                    OAuth2 flow
-                </h5>
-                <button class="close" type="button" data-dismiss="modal" aria-label="Close">
-                    <span aria-hidden="true">&times;</span>
-                </button>
-            </div>
-            <div class="modal-body">
-                <div id="oauth2SuccessMsg" class="card mb-4 border-left-success" style="display: none;">
-                    <div id="oauth2SuccessTxt" class="card-body">To start the OAuth2 flow and get a token follow this <a id="oauth2link" href="#" onclick="dismissOAuthModal();"  target="_blank">link</a>.</div>
-                </div>
-                <div id="oauth2ErrorMsg" class="card mb-4 border-left-warning" style="display: none;">
-                    <div id="oauth2ErrorTxt" class="card-body text-form-error"></div>
-                </div>
-            </div>
-        </div>
-    </div>
-</div>
-{{end}}
-{{define "extra_js"}}
-<script src="{{.StaticURL}}/vendor/bootstrap-select/js/bootstrap-select.min.js"></script>
-<script type="text/javascript">
-    var spinnerDone = false;
-
-    function showSpinner(){
-        $('#spinnerModal').modal('show');
+{{- define "extra_js"}}
+<script type="text/javascript" {{- if .CSPNonce}} nonce="{{.CSPNonce}}"{{- end}}>
+    function onSMTPOAuth2ProviderChanged(val){
+        if (val == '1'){
+            $('.smtp-oauth2-microsoft').show();
+            return;
+        }
+        $('.smtp-oauth2-microsoft').hide();
     }
 
-    function dismissOAuthModal(){
-        setTimeout(function () {
-            $('#smtpOAuthFlowModal').modal('hide');
-        }, 2000);
+    function onSMTPAuthChanged(val){
+        if (val == '3'){
+            $('.smtp-oauth2').show();
+            onSMTPOAuth2ProviderChanged($('#idSMTPOAuth2Provider').val());
+            return;
+        }
+        $('.smtp-oauth2').hide();
     }
 
-    function getCurrentURI(){
-        let port = window.location.port;
-        if (port){
-            return window.location.protocol+"//"+window.location.hostname+":"+port;
+    function getRefreshToken() {
+        let clientID = $('#idSMTPOauth2ClientID').val();
+        let clientSecret = $('#idSMTPOAuth2ClientSecret').val();
+        if (!clientID){
+            ModalAlert.fire({
+                text: $.t('smtp.client_id_required'),
+                icon: "warning",
+                confirmButtonText: $.t('general.ok'),
+                customClass: {
+                    confirmButton: "btn btn-primary"
+                }
+            });
+            return;
+        }
+        if (!clientSecret){
+            ModalAlert.fire({
+                text: $.t('smtp.client_secret_required'),
+                icon: "warning",
+                confirmButtonText: $.t('general.ok'),
+                customClass: {
+                    confirmButton: "btn btn-primary"
+                }
+            });
+            return;
         }
-        return window.location.protocol+"//"+window.location.hostname;
-    }
 
-    function getRefreshToken(event){
-        event.preventDefault();
-        $('#oauth2SuccessMsg').hide();
-        $('#oauth2ErrorMsg').hide();
-        showSpinner();
+        $('#loading_message').text("");
+        KTApp.showPageLoading();
 
         let data = {"base_redirect_url": getCurrentURI(), "provider": parseInt($('#idSMTPOAuth2Provider').val()),
-            "tenant": $('#idSMTPOauth2Tenant').val(), "client_id": $('#idSMTPOauth2ClientID').val(),
-            "client_secret": $('#idSMTPOAuth2ClientSecret').val()};
-
-        $.ajax({
-            url: "{{.OAuth2TokenURL}}",
-            type: 'POST',
-            headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
-            data: JSON.stringify(data),
-            dataType: 'json',
-            contentType: 'application/json',
+            "tenant": $('#idSMTPOauth2Tenant').val(), "client_id": clientID,
+            "client_secret": clientSecret};
+
+        axios.post("{{.OAuth2TokenURL}}", data, {
             timeout: 15000,
-            success: function (result) {
-                $('#spinnerModal').modal('hide');
-                spinnerDone = true;
-                if (result && result.message){
-                    $('#oauth2link').attr("href", result.message);
-                    $('#oauth2SuccessMsg').show();
-                    $('#smtpOAuthFlowModal').modal('show');
-                } else {
-                    $('#oauth2ErrorTxt').text("Unable to get the URI to start OAuth2 flow");
-                    $('#oauth2ErrorMsg').show();
-                    $('#smtpOAuthFlowModal').modal('show');
-                }
+            headers: {
+                'X-CSRF-TOKEN': '{{.CSRFToken}}'
             },
-            error: function ($xhr, textStatus, errorThrown) {
-                $('#spinnerModal').modal('hide');
-                spinnerDone = true;
-                let txt = "Unable to get the URI to start OAuth2 flow";
-                if ($xhr) {
-                    let json = $xhr.responseJSON;
-                    if (json) {
-                        if (json.message){
-                            txt += ": " + json.message;
-                        } else {
-                            txt += ": " + json.error;
-                        }
+            validateStatus: function (status) {
+                return status == 200;
+            }
+        }).then(function (response){
+            KTApp.hidePageLoading();
+            if (response.data && response.data.message){
+                ModalAlert.fire({
+                    text: $.t('smtp.oauth2_question'),
+                    icon: "success",
+                    confirmButtonText: $.t('general.confirm'),
+                    cancelButtonText: $.t('general.cancel'),
+                    customClass: {
+                        confirmButton: "btn btn-danger",
+                        cancelButton: 'btn btn-secondary'
                     }
-                }
-                $('#oauth2ErrorTxt').text(txt);
-                $('#oauth2ErrorMsg').show();
-                $('#smtpOAuthFlowModal').modal('show');
+                }).then((result) => {
+                    if (result.isConfirmed){
+                        window.open(response.data.message,'_blank');
+                    }
+                });
+            } else {
+                ModalAlert.fire({
+                    text: $.t("smtp.oauth2_flow_error"),
+                    icon: "warning",
+                    confirmButtonText: $.t('general.ok'),
+                    customClass: {
+                        confirmButton: "btn btn-primary"
+                    }
+                });
             }
+        }).catch(function (error){
+            KTApp.hidePageLoading();
+            ModalAlert.fire({
+                text: $.t("smtp.oauth2_flow_error"),
+                icon: "warning",
+                confirmButtonText: $.t('general.ok'),
+                customClass: {
+                    confirmButton: "btn btn-primary"
+                }
+            });
         });
     }
 
-    function testSMTP(event){
-        event.preventDefault();
+    function testSMTP() {
         let recipient = $('#idSMTPRecipient').val();
         if (!recipient){
-            $('#smtpErrorTxt').text('Set a recipient to send the test mail to.');
-            $('#smtpErrorMsg').show();
-            $('#smtpTestResultModal').modal('show');
+            ModalAlert.fire({
+                text: $.t('smtp.recipient_required'),
+                icon: "warning",
+                confirmButtonText: $.t('general.ok'),
+                customClass: {
+                    confirmButton: "btn btn-primary"
+                }
+            });
             return;
         }
+
+        let authType = parseInt($('#idSMTPAuth').val());
+        let clientID = $('#idSMTPOauth2ClientID').val();
+        let clientSecret = $('#idSMTPOAuth2ClientSecret').val();
+        let refreshToken = $('#idSMTPOAuth2RefreshToken').val();
+        if (authType === 3){
+            let message;
+            if (!clientID){
+                message = "smtp.client_id_required";
+            }
+            if (!clientSecret){
+                message = "smtp.client_secret_required";
+            }
+            if (!refreshToken){
+                message = "smtp.refresh_token_required"
+            }
+            if (message){
+                ModalAlert.fire({
+                    text: $.t(message),
+                    icon: "warning",
+                    confirmButtonText: $.t('general.ok'),
+                    customClass: {
+                        confirmButton: "btn btn-primary"
+                    }
+                });
+                return;
+            }
+        }
+
+        $('#loading_message').text("");
+        KTApp.showPageLoading();
         let debug = 0;
         if ($('#idSMTPDebug').is(':checked')){
             debug = 1;
         }
-        $('#smtpSuccessMsg').hide();
-        $('#smtpErrorMsg').hide();
-        showSpinner();
 
         let data = {"host": $('#idSMTPHost').val(),"port": parseInt($('#idSMTPPort').val()),
             "from": $('#idSMTPFrom').val(),"user": $('#idSMTPUsername').val(),"password": $('#idSMTPPassword').val(),
-            "auth_type": parseInt($('#idSMTPAuth').val()),"encryption": parseInt($('#idSMTPEncryption').val()),
+            "auth_type": authType,"encryption": parseInt($('#idSMTPEncryption').val()),
             "domain": $('#idSMTPDomain').val(),"debug": debug, "oauth2": {"provider": parseInt($('#idSMTPOAuth2Provider').val()),
-            "tenant": $('#idSMTPOauth2Tenant').val(), "client_id": $('#idSMTPOauth2ClientID').val(),
-            "client_secret": $('#idSMTPOAuth2ClientSecret').val(), "refresh_token": $('#idSMTPOAuth2RefreshToken').val()},
+            "tenant": $('#idSMTPOauth2Tenant').val(), "client_id": clientID,
+            "client_secret": clientSecret, "refresh_token": refreshToken},
             "recipient": recipient};
 
-        $.ajax({
-            url: "{{.ConfigsURL}}/smtp/test",
-            type: 'POST',
-            headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
-            data: JSON.stringify(data),
-            dataType: 'json',
-            contentType: 'application/json',
+        axios.post("{{.ConfigsURL}}/smtp/test", data, {
             timeout: 15000,
-            success: function (result) {
-                $('#spinnerModal').modal('hide');
-                spinnerDone = true;
-                $('#smtpSuccessMsg').show();
-                $('#smtpTestResultModal').modal('show');
+            headers: {
+                'X-CSRF-TOKEN': '{{.CSRFToken}}'
             },
-            error: function ($xhr, textStatus, errorThrown) {
-                $('#spinnerModal').modal('hide');
-                spinnerDone = true;
-                let txt = "SMTP connection failed";
-                if ($xhr) {
-                    let json = $xhr.responseJSON;
-                    if (json) {
-                        if (json.message){
-                            txt += ": " + json.message;
-                        } else {
-                            txt += ": " + json.error;
-                        }
-                    }
-                }
-                $('#smtpErrorTxt').text(txt);
-                $('#smtpErrorMsg').show();
-                $('#smtpTestResultModal').modal('show');
+            validateStatus: function (status) {
+                return status == 200;
             }
+        }).then(function (response){
+            KTApp.hidePageLoading();
+            ModalAlert.fire({
+                text: $.t('smtp.test_ok'),
+                icon: "success",
+                confirmButtonText: $.t('general.ok'),
+                customClass: {
+                    confirmButton: 'btn btn-primary'
+                }
+            });
+        }).catch(function (error){
+            KTApp.hidePageLoading();
+            ModalAlert.fire({
+                text: $.t('smtp.test_error'),
+                icon: "warning",
+                confirmButtonText: $.t('general.ok'),
+                customClass: {
+                    confirmButton: "btn btn-primary"
+                }
+            });
         });
     }
 
-    function onSMTPAuthChanged(val){
-        if (val == '3'){
-            $('.smtp-oauth2').show();
-            onSMTPOAuth2ProviderChanged($('#idSMTPOAuth2Provider').val());
-            return;
-        }
-        $('.smtp-oauth2').hide();
-    }
+    $(document).on("i18nload", function(){
+        $('#idSMTPAuth').on("change", function(){
+            onSMTPAuthChanged(this.value);
+        });
 
-    function onSMTPOAuth2ProviderChanged(val){
-        if (val == '1'){
-            $('.smtp-oauth2-microsoft').show();
-            return;
-        }
-        $('.smtp-oauth2-microsoft').hide();
-    }
+        $('#idSMTPOAuth2Provider').on("change", function(){
+            onSMTPOAuth2ProviderChanged(this.value);
+        });
 
-    $(document).ready(function () {
-        $('#spinnerModal').on('shown.bs.modal', function () {
-            if (spinnerDone){
-                $('#spinnerModal').modal('hide');
-            }
+        $('#refresh_token_button').on("click", function(e){
+            e.preventDefault();
+            this.blur();
+            getRefreshToken();
         });
+
+        $('#smtp_test_button').on("click", function(e){
+            e.preventDefault();
+            this.blur();
+            testSMTP();
+        });
+
+        let currentURI = getCurrentURI();
+        let providerHelpText = $.t('smtp.oauth2_provider_help') + " \""+currentURI+"{{.OAuth2RedirectURL}}\"";
+        $('#idSMTPOAuth2ProviderHelp').text(providerHelpText);
         onSMTPAuthChanged('{{.Configs.SMTP.AuthType}}');
         onSMTPOAuth2ProviderChanged('{{.Configs.SMTP.OAuth2.Provider}}');
-        $('#smtpOauth2ProviderHelpBlock').text('The URI to redirect to after user authentication is '+getCurrentURI()+'{{.OAuth2RedirectURL}}');
+    });
+
+    $(document).on("i18nload", function(){
+        $('#configs_sftp_form').submit(function (event) {
+			let submitButton = document.querySelector('#sftp_form_submit');
+			submitButton.setAttribute('data-kt-indicator', 'on');
+			submitButton.disabled = true;
+        });
+
+        $('#configs_acme_form').submit(function (event) {
+			let submitButton = document.querySelector('#acme_form_submit');
+			submitButton.setAttribute('data-kt-indicator', 'on');
+			submitButton.disabled = true;
+        });
+
+        $('#configs_smtp_form').submit(function (event) {
+			let submitButton = document.querySelector('#smtp_form_submit');
+			submitButton.setAttribute('data-kt-indicator', 'on');
+			submitButton.disabled = true;
+        });
     });
 </script>
-{{end}}
+{{- end}}

+ 1 - 1
templates/webadmin/profile.html

@@ -26,7 +26,7 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
             <div class="form-group row">
                 <label for="idEmail" data-i18n="general.email" class="col-md-3 col-form-label">Email</label>
                 <div class="col-md-9">
-                    <input type="text" class="form-control" id="idEmail" name="email" placeholder="" spellcheck="false"
+                    <input type="email" class="form-control" id="idEmail" name="email" placeholder="" spellcheck="false"
                         value="{{.Email}}" maxlength="255" autocomplete="off">
                 </div>
             </div>

+ 1 - 1
templates/webadmin/user.html

@@ -392,7 +392,7 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
                             <div class="form-group row mt-10">
                                 <label for="idEmail" data-i18n="general.email" class="col-md-3 col-form-label">Email</label>
                                 <div class="col-md-9">
-                                    <input id="idEmail" type="text" class="form-control" placeholder="" name="email" value="{{.User.Email}}"
+                                    <input id="idEmail" type="email" class="form-control" placeholder="" name="email" value="{{.User.Email}}"
                                         maxlength="255" autocomplete="off" spellcheck="false" />
                                 </div>
                             </div>

+ 1 - 1
templates/webclient/profile.html

@@ -26,7 +26,7 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
             <div class="form-group row">
                 <label for="idEmail" data-i18n="general.email" class="col-md-3 col-form-label">Email</label>
                 <div class="col-md-9">
-                    <input type="text" id="idEmail" name="email" placeholder="" spellcheck="false" value="{{.Email}}" maxlength="255"
+                    <input type="email" id="idEmail" name="email" placeholder="" spellcheck="false" value="{{.Email}}" maxlength="255"
                         autocomplete="off" {{if not .LoggedUser.CanChangeInfo}}class="form-control-plaintext readonly-input" readonly{{else}}class="form-control"{{end}}>
                 </div>
             </div>