فهرست منبع

WebClient: allow to pass args for localized errors from the backend

Signed-off-by: Nicola Murino <[email protected]>
Nicola Murino 1 سال پیش
والد
کامیت
61fe7c39a7

+ 6 - 6
go.mod

@@ -4,7 +4,7 @@ go 1.21
 
 require (
 	cloud.google.com/go/storage v1.35.1
-	github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.0
+	github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.1
 	github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.2.0
 	github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5
 	github.com/alexedwards/argon2id v1.0.0
@@ -140,7 +140,7 @@ require (
 	github.com/mitchellh/go-testing-interface v1.14.1 // indirect
 	github.com/mitchellh/mapstructure v1.5.0 // indirect
 	github.com/oklog/run v1.1.0 // indirect
-	github.com/pelletier/go-toml/v2 v2.1.0 // indirect
+	github.com/pelletier/go-toml/v2 v2.1.1 // indirect
 	github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
 	github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b // indirect
 	github.com/prometheus/client_model v0.5.0 // indirect
@@ -169,10 +169,10 @@ require (
 	golang.org/x/tools v0.16.0 // indirect
 	golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
 	google.golang.org/appengine v1.6.8 // indirect
-	google.golang.org/genproto v0.0.0-20231127180814-3a041ad873d4 // indirect
-	google.golang.org/genproto/googleapis/api v0.0.0-20231127180814-3a041ad873d4 // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4 // indirect
-	google.golang.org/grpc v1.59.0 // indirect
+	google.golang.org/genproto v0.0.0-20231211222908-989df2bf70f3 // indirect
+	google.golang.org/genproto/googleapis/api v0.0.0-20231211222908-989df2bf70f3 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20231211222908-989df2bf70f3 // indirect
+	google.golang.org/grpc v1.60.0 // indirect
 	google.golang.org/protobuf v1.31.0 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect

+ 12 - 12
go.sum

@@ -12,8 +12,8 @@ cloud.google.com/go/kms v1.15.5/go.mod h1:cU2H5jnp6G2TDpUGZyqTCoy1n16fbubHZjmVXS
 cloud.google.com/go/storage v1.35.1 h1:B59ahL//eDfx2IIKFBeT5Atm9wnNmj3+8xG/W4WB//w=
 cloud.google.com/go/storage v1.35.1/go.mod h1:M6M/3V/D3KpzMTJyPOR/HU6n2Si5QdaXYEsng2xgOs8=
 github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU=
-github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.0 h1:fb8kj/Dh4CSwgsOzHeZY4Xh68cFVbzXx+ONXGMY//4w=
-github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.0/go.mod h1:uReU2sSxZExRPBAg3qKzmAucSi51+SP1OhohieR821Q=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.1 h1:lGlwhPtrX6EVml1hO0ivjkUxsSyl4dsiw9qcA1k/3IQ=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.1/go.mod h1:RKUqNu35KJYcVG/fqTRqmuXJZYNhYkBrnC/hX7yGbTA=
 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.4.0 h1:BMAjVKJM0U/CYF27gA0ZMmXGkOcvfFtD0oHVZ1TIPRI=
 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.4.0/go.mod h1:1fXstnBMas5kzG+S3q8UoJcmyU6nUeunJcMDHcRYHhs=
 github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.1 h1:6oNBlSdi1QqM1PNW7FPA6xOGA5UNsXnkaYZz9vdPGhA=
@@ -302,8 +302,8 @@ github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU=
 github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w=
 github.com/otiai10/mint v1.5.1 h1:XaPLeE+9vGbuyEHem1JNk3bYc7KKqyI/na0/mLd/Kks=
 github.com/otiai10/mint v1.5.1/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM=
-github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
-github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
+github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI=
+github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
 github.com/pires/go-proxyproto v0.7.0 h1:IukmRewDQFWC7kfnb66CSomk2q/seBuilHBYFwyq0Hs=
 github.com/pires/go-proxyproto v0.7.0/go.mod h1:Vz/1JPY/OACxWGQNIRY2BeyDmpoaWmEP40O9LbuiFR4=
 github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
@@ -524,19 +524,19 @@ google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJ
 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
 google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
 google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
-google.golang.org/genproto v0.0.0-20231127180814-3a041ad873d4 h1:W12Pwm4urIbRdGhMEg2NM9O3TWKjNcxQhs46V0ypf/k=
-google.golang.org/genproto v0.0.0-20231127180814-3a041ad873d4/go.mod h1:5RBcpGRxr25RbDzY5w+dmaqpSEvl8Gwl1x2CICf60ic=
-google.golang.org/genproto/googleapis/api v0.0.0-20231127180814-3a041ad873d4 h1:ZcOkrmX74HbKFYnpPY8Qsw93fC29TbJXspYKaBkSXDQ=
-google.golang.org/genproto/googleapis/api v0.0.0-20231127180814-3a041ad873d4/go.mod h1:k2dtGpRrbsSyKcNPKKI5sstZkrNCZwpU/ns96JoHbGg=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4 h1:DC7wcm+i+P1rN3Ff07vL+OndGg5OhNddHyTA+ocPqYE=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20231127180814-3a041ad873d4/go.mod h1:eJVxU6o+4G1PSczBr85xmyvSNYAKvAYgkub40YGomFM=
+google.golang.org/genproto v0.0.0-20231211222908-989df2bf70f3 h1:1hfbdAfFbkmpg41000wDVqr7jUpK/Yo+LPnIxxGzmkg=
+google.golang.org/genproto v0.0.0-20231211222908-989df2bf70f3/go.mod h1:5RBcpGRxr25RbDzY5w+dmaqpSEvl8Gwl1x2CICf60ic=
+google.golang.org/genproto/googleapis/api v0.0.0-20231211222908-989df2bf70f3 h1:EWIeHfGuUf00zrVZGEgYFxok7plSAXBGcH7NNdMAWvA=
+google.golang.org/genproto/googleapis/api v0.0.0-20231211222908-989df2bf70f3/go.mod h1:k2dtGpRrbsSyKcNPKKI5sstZkrNCZwpU/ns96JoHbGg=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20231211222908-989df2bf70f3 h1:kzJAXnzZoFbe5bhZd4zjUuHos/I31yH4thfMb/13oVY=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20231211222908-989df2bf70f3/go.mod h1:eJVxU6o+4G1PSczBr85xmyvSNYAKvAYgkub40YGomFM=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
 google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
 google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
 google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
-google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk=
-google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98=
+google.golang.org/grpc v1.60.0 h1:6FQAR0kM31P6MRdeluor2w2gPaS4SVNrD/DNTxrQ15k=
+google.golang.org/grpc v1.60.0/go.mod h1:OlCHIeLYqSSsLi6i49B5QGdzaMZK9+M7LXN2FKz4eGM=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
 google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=

+ 41 - 7
internal/httpd/flash.go

@@ -16,18 +16,47 @@ package httpd
 
 import (
 	"encoding/base64"
+	"encoding/json"
 	"net/http"
 	"time"
+
+	"github.com/drakkan/sftpgo/v2/internal/util"
 )
 
 const (
 	flashCookieName = "message"
 )
 
-func setFlashMessage(w http.ResponseWriter, r *http.Request, value string) {
+func newFlashMessage(errorStrig, i18nMessage string) flashMessage {
+	return flashMessage{
+		ErrorString: errorStrig,
+		I18nMessage: i18nMessage,
+	}
+}
+
+type flashMessage struct {
+	ErrorString string `json:"error"`
+	I18nMessage string `json:"message"`
+}
+
+func (m *flashMessage) getI18nError() *util.I18nError {
+	if m.ErrorString == "" && m.I18nMessage == "" {
+		return nil
+	}
+	return util.NewI18nError(
+		util.NewGenericError(m.ErrorString),
+		m.I18nMessage,
+	)
+}
+
+func setFlashMessage(w http.ResponseWriter, r *http.Request, message flashMessage) {
+	value, err := json.Marshal(message)
+	if err != nil {
+		return
+	}
 	http.SetCookie(w, &http.Cookie{
 		Name:     flashCookieName,
-		Value:    base64.URLEncoding.EncodeToString([]byte(value)),
+		Value:    base64.URLEncoding.EncodeToString(value),
 		Path:     "/",
 		Expires:  time.Now().Add(60 * time.Second),
 		MaxAge:   60,
@@ -38,10 +67,11 @@ func setFlashMessage(w http.ResponseWriter, r *http.Request, value string) {
 	w.Header().Add("Cache-Control", `no-cache="Set-Cookie"`)
 }
 
-func getFlashMessage(w http.ResponseWriter, r *http.Request) string {
+func getFlashMessage(w http.ResponseWriter, r *http.Request) flashMessage {
+	var msg flashMessage
 	cookie, err := r.Cookie(flashCookieName)
 	if err != nil {
-		return ""
+		return msg
 	}
 	http.SetCookie(w, &http.Cookie{
 		Name:     flashCookieName,
@@ -53,9 +83,13 @@ func getFlashMessage(w http.ResponseWriter, r *http.Request) string {
 		Secure:   isTLS(r),
 		SameSite: http.SameSiteLaxMode,
 	})
-	message, err := base64.URLEncoding.DecodeString(cookie.Value)
+	value, err := base64.URLEncoding.DecodeString(cookie.Value)
+	if err != nil {
+		return msg
+	}
+	err = json.Unmarshal(value, &msg)
 	if err != nil {
-		return ""
+		return flashMessage{}
 	}
-	return string(message)
+	return msg
 }

+ 2 - 2
internal/httpd/flash_test.go

@@ -30,10 +30,10 @@ func TestFlashMessages(t *testing.T) {
 	req, err := http.NewRequest(http.MethodGet, "/url", nil)
 	require.NoError(t, err)
 	message := "test message"
-	setFlashMessage(rr, req, message)
+	setFlashMessage(rr, req, flashMessage{ErrorString: message})
 	req.Header.Set("Cookie", fmt.Sprintf("%v=%v", flashCookieName, base64.URLEncoding.EncodeToString([]byte(message))))
 	msg := getFlashMessage(rr, req)
-	assert.Equal(t, message, msg)
+	assert.Equal(t, message, msg.ErrorString)
 	req.Header.Set("Cookie", fmt.Sprintf("%v=%v", flashCookieName, "a"))
 	msg = getFlashMessage(rr, req)
 	assert.Empty(t, msg)

+ 10 - 1
internal/httpd/internal_test.go

@@ -3529,6 +3529,12 @@ func TestI18NErrors(t *testing.T) {
 	assert.ErrorIs(t, errI18n, util.ErrValidation)
 	assert.Equal(t, err.Error(), errI18n.Error())
 	assert.Equal(t, util.I18nError500Message, getI18NErrorString(errI18n, ""))
+	assert.Equal(t, util.I18nError500Message, errI18n.Message)
+	assert.Equal(t, "{}", errI18n.Args())
+	var e1 *util.ValidationError
+	assert.ErrorAs(t, errI18n, &e1)
+	var e2 *util.I18nError
+	assert.ErrorAs(t, errI18n, &e2)
 	err2 := util.NewI18nError(fs.ErrNotExist, util.I18nError500Message)
 	assert.ErrorIs(t, err2, &util.I18nError{})
 	assert.ErrorIs(t, err2, fs.ErrNotExist)
@@ -3537,7 +3543,10 @@ func TestI18NErrors(t *testing.T) {
 	errorString := getI18NErrorString(nil, util.I18nError500Message)
 	assert.Equal(t, util.I18nError500Message, errorString)
 	errI18nWrap := util.NewI18nError(errI18n, util.I18nError404Message)
-	assert.Equal(t, util.I18nError500Message, errI18nWrap.I18nMessage)
+	assert.Equal(t, util.I18nError500Message, errI18nWrap.Message)
+	errI18n = util.NewI18nError(err, util.I18nError500Message, util.I18nErrorArgs(map[string]any{"a": "b"}))
+	assert.Equal(t, util.I18nError500Message, errI18n.Message)
+	assert.Equal(t, `{"a":"b"}`, errI18n.Args())
 }
 
 func isSharedProviderSupported() bool {

+ 5 - 1
internal/httpd/middleware.go

@@ -233,11 +233,15 @@ func (s *httpdServer) checkAuthRequirements(next http.Handler) http.Handler {
 		if tokenClaims.MustSetTwoFactorAuth || tokenClaims.MustChangePassword {
 			var err error
 			if tokenClaims.MustSetTwoFactorAuth {
+				protocols := strings.Join(tokenClaims.RequiredTwoFactorProtocols, ", ")
 				err = util.NewI18nError(
 					util.NewGenericError(
 						fmt.Sprintf("Two-factor authentication requirements not met, please configure two-factor authentication for the following protocols: %v",
-							strings.Join(tokenClaims.RequiredTwoFactorProtocols, ", "))),
+							protocols)),
 					util.I18nError2FARequired,
+					util.I18nErrorArgs(map[string]any{
+						"val": protocols,
+					}),
 				)
 			} else {
 				err = util.NewI18nError(

+ 24 - 13
internal/httpd/oidc.go

@@ -497,7 +497,7 @@ func (s *httpdServer) validateOIDCToken(w http.ResponseWriter, r *http.Request,
 		defer cancel()
 
 		if err = token.refresh(ctx, s.binding.OIDC.oauth2Config, s.binding.OIDC.getVerifier(ctx), r); err != nil {
-			setFlashMessage(w, r, "Your OpenID token is expired, please log-in again")
+			setFlashMessage(w, r, newFlashMessage("Your OpenID token is expired, please log-in again", util.I18nOIDCTokenExpired))
 			doRedirect()
 			return oidcToken{}, errInvalidToken
 		}
@@ -507,7 +507,10 @@ func (s *httpdServer) validateOIDCToken(w http.ResponseWriter, r *http.Request,
 	if isAdmin {
 		if !token.isAdmin() {
 			logger.Debug(logSender, "", "oidc token associated with cookie %q is not valid for admin users", token.Cookie)
-			setFlashMessage(w, r, "Your OpenID token is not valid for the SFTPGo Web Admin UI. Please logout from your OpenID server and log-in as an SFTPGo admin")
+			setFlashMessage(w, r, newFlashMessage(
+				"Your OpenID token is not valid for the SFTPGo Web Admin UI. Please logout from your OpenID server and log-in as an SFTPGo admin",
+				util.I18nOIDCTokenInvalidAdmin,
+			))
 			doRedirect()
 			return oidcToken{}, errInvalidToken
 		}
@@ -515,7 +518,10 @@ func (s *httpdServer) validateOIDCToken(w http.ResponseWriter, r *http.Request,
 	}
 	if token.isAdmin() {
 		logger.Debug(logSender, "", "oidc token associated with cookie %q is valid for admin users", token.Cookie)
-		setFlashMessage(w, r, "Your OpenID token is not valid for the SFTPGo Web Client UI. Please logout from your OpenID server and log-in as an SFTPGo user")
+		setFlashMessage(w, r, newFlashMessage(
+			"Your OpenID token is not valid for the SFTPGo Web Client UI. Please logout from your OpenID server and log-in as an SFTPGo user",
+			util.I18nOIDCTokenInvalidUser,
+		))
 		doRedirect()
 		return oidcToken{}, errInvalidToken
 	}
@@ -541,7 +547,7 @@ func (s *httpdServer) oidcTokenAuthenticator(audience tokenAudience) func(next h
 			}
 			_, tokenString, err := jwtTokenClaims.createToken(s.tokenAuth, audience, util.GetIPFromRemoteAddress(r.RemoteAddr))
 			if err != nil {
-				setFlashMessage(w, r, "Unable to create cookie")
+				setFlashMessage(w, r, newFlashMessage("Unable to create cookie", util.I18nError500Message))
 				if audience == tokenAudienceWebAdmin {
 					http.Redirect(w, r, webAdminLoginPath, http.StatusFound)
 				} else {
@@ -610,14 +616,14 @@ func (s *httpdServer) handleOIDCRedirect(w http.ResponseWriter, r *http.Request)
 	oauth2Token, err := s.binding.OIDC.oauth2Config.Exchange(ctx, r.URL.Query().Get("code"))
 	if err != nil {
 		logger.Debug(logSender, "", "failed to exchange oidc token: %v", err)
-		setFlashMessage(w, r, "Failed to exchange OpenID token")
+		setFlashMessage(w, r, newFlashMessage("Failed to exchange OpenID token", util.I18nOIDCErrTokenExchange))
 		doRedirect()
 		return
 	}
 	rawIDToken, ok := oauth2Token.Extra("id_token").(string)
 	if !ok {
 		logger.Debug(logSender, "", "no id_token field in OAuth2 OpenID token")
-		setFlashMessage(w, r, "No id_token field in OAuth2 OpenID token")
+		setFlashMessage(w, r, newFlashMessage("No id_token field in OAuth2 OpenID token", util.I18nOIDCTokenInvalid))
 		doRedirect()
 		return
 	}
@@ -625,14 +631,14 @@ func (s *httpdServer) handleOIDCRedirect(w http.ResponseWriter, r *http.Request)
 	idToken, err := s.binding.OIDC.getVerifier(ctx).Verify(ctx, rawIDToken)
 	if err != nil {
 		logger.Debug(logSender, "", "failed to verify oidc token: %v", err)
-		setFlashMessage(w, r, "Failed to verify OpenID token")
+		setFlashMessage(w, r, newFlashMessage("Failed to verify OpenID token", util.I18nOIDCTokenInvalid))
 		doRedirect()
 		doLogout(rawIDToken)
 		return
 	}
 	if idToken.Nonce != authReq.Nonce {
 		logger.Debug(logSender, "", "oidc authentication nonce did not match")
-		setFlashMessage(w, r, "OpenID authentication nonce did not match")
+		setFlashMessage(w, r, newFlashMessage("OpenID authentication nonce did not match", util.I18nOIDCTokenInvalid))
 		doRedirect()
 		doLogout(rawIDToken)
 		return
@@ -642,7 +648,7 @@ func (s *httpdServer) handleOIDCRedirect(w http.ResponseWriter, r *http.Request)
 	err = idToken.Claims(&claims)
 	if err != nil {
 		logger.Debug(logSender, "", "unable to get oidc token claims: %v", err)
-		setFlashMessage(w, r, "Unable to get OpenID token claims")
+		setFlashMessage(w, r, newFlashMessage("Unable to get OpenID token claims", util.I18nOIDCTokenInvalid))
 		doRedirect()
 		doLogout(rawIDToken)
 		return
@@ -663,7 +669,7 @@ func (s *httpdServer) handleOIDCRedirect(w http.ResponseWriter, r *http.Request)
 		s.binding.OIDC.CustomFields, s.binding.OIDC.getForcedRole(authReq.Audience))
 	if err != nil {
 		logger.Debug(logSender, "", "unable to parse oidc token claims: %v", err)
-		setFlashMessage(w, r, fmt.Sprintf("Unable to parse OpenID token claims: %v", err))
+		setFlashMessage(w, r, newFlashMessage(fmt.Sprintf("Unable to parse OpenID token claims: %v", err), util.I18nOIDCTokenInvalid))
 		doRedirect()
 		doLogout(rawIDToken)
 		return
@@ -672,7 +678,9 @@ func (s *httpdServer) handleOIDCRedirect(w http.ResponseWriter, r *http.Request)
 	case tokenAudienceWebAdmin:
 		if !token.isAdmin() {
 			logger.Debug(logSender, "", "wrong oidc token role, the mapped user is not an SFTPGo admin")
-			setFlashMessage(w, r, "Wrong OpenID role, the logged in user is not an SFTPGo admin")
+			setFlashMessage(w, r, newFlashMessage(
+				"Wrong OpenID role, the logged in user is not an SFTPGo admin",
+				util.I18nOIDCTokenInvalidRoleAdmin))
 			doRedirect()
 			doLogout(rawIDToken)
 			return
@@ -680,7 +688,10 @@ func (s *httpdServer) handleOIDCRedirect(w http.ResponseWriter, r *http.Request)
 	case tokenAudienceWebClient:
 		if token.isAdmin() {
 			logger.Debug(logSender, "", "wrong oidc token role, the mapped user is an SFTPGo admin")
-			setFlashMessage(w, r, "Wrong OpenID role, the logged in user is an SFTPGo admin")
+			setFlashMessage(w, r, newFlashMessage(
+				"Wrong OpenID role, the logged in user is an SFTPGo admin",
+				util.I18nOIDCTokenInvalidRoleUser,
+			))
 			doRedirect()
 			doLogout(rawIDToken)
 			return
@@ -689,7 +700,7 @@ func (s *httpdServer) handleOIDCRedirect(w http.ResponseWriter, r *http.Request)
 	err = token.getUser(r)
 	if err != nil {
 		logger.Debug(logSender, "", "unable to get the sftpgo user associated with oidc token: %v", err)
-		setFlashMessage(w, r, "Unable to get the user associated with the OpenID token")
+		setFlashMessage(w, r, newFlashMessage("Unable to get the user associated with the OpenID token", util.I18nOIDCErrGetUser))
 		doRedirect()
 		doLogout(rawIDToken)
 		return

+ 48 - 35
internal/httpd/server.go

@@ -160,12 +160,12 @@ func (s *httpdServer) refreshCookie(next http.Handler) http.Handler {
 	})
 }
 
-func (s *httpdServer) renderClientLoginPage(w http.ResponseWriter, r *http.Request, error, ip string) {
-	data := loginPage{
+func (s *httpdServer) renderClientLoginPage(w http.ResponseWriter, r *http.Request, err *util.I18nError, ip string) {
+	data := clientLoginPage{
 		commonBasePage: getCommonBasePage(r),
 		Title:          util.I18nLoginTitle,
 		CurrentURL:     webClientLoginPath,
-		Error:          error,
+		Error:          err,
 		CSRFToken:      createCSRFToken(ip),
 		Branding:       s.binding.Branding.WebClient,
 		FormDisabled:   s.binding.isWebClientLoginFormDisabled(),
@@ -198,7 +198,7 @@ func (s *httpdServer) handleWebClientLogout(w http.ResponseWriter, r *http.Reque
 func (s *httpdServer) handleWebClientChangePwdPost(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	if err := r.ParseForm(); err != nil {
-		s.renderClientChangePasswordPage(w, r, util.I18nErrorInvalidForm)
+		s.renderClientChangePasswordPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm))
 		return
 	}
 	if err := verifyCSRFToken(r.Form.Get(csrfFormToken), util.GetIPFromRemoteAddress(r.RemoteAddr)); err != nil {
@@ -208,7 +208,7 @@ func (s *httpdServer) handleWebClientChangePwdPost(w http.ResponseWriter, r *htt
 	err := doChangeUserPassword(r, strings.TrimSpace(r.Form.Get("current_password")),
 		strings.TrimSpace(r.Form.Get("new_password1")), strings.TrimSpace(r.Form.Get("new_password2")))
 	if err != nil {
-		s.renderClientChangePasswordPage(w, r, getI18NErrorString(err, util.I18nErrorChangePwdGeneric))
+		s.renderClientChangePasswordPage(w, r, util.NewI18nError(err, util.I18nErrorChangePwdGeneric))
 		return
 	}
 	s.handleWebClientLogout(w, r)
@@ -220,7 +220,8 @@ func (s *httpdServer) handleClientWebLogin(w http.ResponseWriter, r *http.Reques
 		http.Redirect(w, r, webAdminSetupPath, http.StatusFound)
 		return
 	}
-	s.renderClientLoginPage(w, r, getFlashMessage(w, r), util.GetIPFromRemoteAddress(r.RemoteAddr))
+	msg := getFlashMessage(w, r)
+	s.renderClientLoginPage(w, r, msg.getI18nError(), util.GetIPFromRemoteAddress(r.RemoteAddr))
 }
 
 func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Request) {
@@ -228,7 +229,7 @@ func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Re
 
 	ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
 	if err := r.ParseForm(); err != nil {
-		s.renderClientLoginPage(w, r, util.I18nErrorInvalidForm, ipAddr)
+		s.renderClientLoginPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm), ipAddr)
 		return
 	}
 	protocol := common.ProtocolHTTP
@@ -237,33 +238,35 @@ func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Re
 	if username == "" || password == "" {
 		updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}},
 			dataprovider.LoginMethodPassword, ipAddr, common.ErrNoCredentials)
-		s.renderClientLoginPage(w, r, util.I18nErrorInvalidCredentials, ipAddr)
+		s.renderClientLoginPage(w, r,
+			util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials), ipAddr)
 		return
 	}
 	if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
 		updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}},
 			dataprovider.LoginMethodPassword, ipAddr, err)
-		s.renderClientLoginPage(w, r, util.I18nErrorInvalidCSRF, ipAddr)
+		s.renderClientLoginPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF), ipAddr)
 		return
 	}
 
 	if err := common.Config.ExecutePostConnectHook(ipAddr, protocol); err != nil {
 		updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}},
 			dataprovider.LoginMethodPassword, ipAddr, err)
-		s.renderClientLoginPage(w, r, util.I18nError403Message, ipAddr)
+		s.renderClientLoginPage(w, r, util.NewI18nError(err, util.I18nError403Message), ipAddr)
 		return
 	}
 
 	user, err := dataprovider.CheckUserAndPass(username, password, ipAddr, protocol)
 	if err != nil {
 		updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, err)
-		s.renderClientLoginPage(w, r, util.I18nErrorInvalidCredentials, ipAddr)
+		s.renderClientLoginPage(w, r,
+			util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials), ipAddr)
 		return
 	}
 	connectionID := fmt.Sprintf("%v_%v", protocol, xid.New().String())
 	if err := checkHTTPClientUser(&user, r, connectionID, true); err != nil {
 		updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, err)
-		s.renderClientLoginPage(w, r, getI18NErrorString(err, util.I18nError403Message), ipAddr)
+		s.renderClientLoginPage(w, r, util.NewI18nError(err, util.I18nError403Message), ipAddr)
 		return
 	}
 
@@ -272,7 +275,7 @@ func (s *httpdServer) handleWebClientLoginPost(w http.ResponseWriter, r *http.Re
 	if err != nil {
 		logger.Warn(logSender, connectionID, "unable to check fs root: %v", err)
 		updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, common.ErrInternalFailure)
-		s.renderClientLoginPage(w, r, getI18NErrorString(err, util.I18nErrorFsGeneric), ipAddr)
+		s.renderClientLoginPage(w, r, util.NewI18nError(err, util.I18nErrorFsGeneric), ipAddr)
 		return
 	}
 	s.loginUser(w, r, &user, connectionID, ipAddr, false, s.renderClientLoginPage)
@@ -284,7 +287,7 @@ func (s *httpdServer) handleWebClientPasswordResetPost(w http.ResponseWriter, r
 	ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
 	err := r.ParseForm()
 	if err != nil {
-		s.renderClientResetPwdPage(w, r, util.I18nErrorInvalidForm, ipAddr)
+		s.renderClientResetPwdPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm), ipAddr)
 		return
 	}
 	if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
@@ -294,18 +297,20 @@ func (s *httpdServer) handleWebClientPasswordResetPost(w http.ResponseWriter, r
 	newPassword := strings.TrimSpace(r.Form.Get("password"))
 	confirmPassword := strings.TrimSpace(r.Form.Get("confirm_password"))
 	if newPassword != confirmPassword {
-		s.renderClientResetPwdPage(w, r, util.I18nErrorChangePwdNoMatch, ipAddr)
+		s.renderClientResetPwdPage(w, r, util.NewI18nError(
+			errors.New("the two password fields do not match"),
+			util.I18nErrorChangePwdNoMatch), ipAddr)
 		return
 	}
 	_, user, err := handleResetPassword(r, strings.TrimSpace(r.Form.Get("code")),
 		newPassword, false)
 	if err != nil {
-		s.renderClientResetPwdPage(w, r, getI18NErrorString(err, util.I18nErrorChangePwdGeneric), ipAddr)
+		s.renderClientResetPwdPage(w, r, util.NewI18nError(err, util.I18nErrorChangePwdGeneric), ipAddr)
 		return
 	}
 	connectionID := fmt.Sprintf("%v_%v", getProtocolFromRequest(r), xid.New().String())
 	if err := checkHTTPClientUser(user, r, connectionID, true); err != nil {
-		s.renderClientResetPwdPage(w, r, getI18NErrorString(err, util.I18nErrorDirList403), ipAddr)
+		s.renderClientResetPwdPage(w, r, util.NewI18nError(err, util.I18nErrorDirList403), ipAddr)
 		return
 	}
 
@@ -313,7 +318,7 @@ func (s *httpdServer) handleWebClientPasswordResetPost(w http.ResponseWriter, r
 	err = user.CheckFsRoot(connectionID)
 	if err != nil {
 		logger.Warn(logSender, connectionID, "unable to check fs root: %v", err)
-		s.renderClientResetPwdPage(w, r, util.I18nErrorLoginAfterReset, ipAddr)
+		s.renderClientResetPwdPage(w, r, util.NewI18nError(err, util.I18nErrorLoginAfterReset), ipAddr)
 		return
 	}
 	s.loginUser(w, r, user, connectionID, ipAddr, false, s.renderClientResetPwdPage)
@@ -328,17 +333,18 @@ func (s *httpdServer) handleWebClientTwoFactorRecoveryPost(w http.ResponseWriter
 	}
 	ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
 	if err := r.ParseForm(); err != nil {
-		s.renderClientTwoFactorRecoveryPage(w, r, util.I18nErrorInvalidForm, ipAddr)
+		s.renderClientTwoFactorRecoveryPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm), ipAddr)
 		return
 	}
 	username := claims.Username
 	recoveryCode := strings.TrimSpace(r.Form.Get("recovery_code"))
 	if username == "" || recoveryCode == "" {
-		s.renderClientTwoFactorRecoveryPage(w, r, util.I18nErrorInvalidCredentials, ipAddr)
+		s.renderClientTwoFactorRecoveryPage(w, r,
+			util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials), ipAddr)
 		return
 	}
 	if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
-		s.renderClientTwoFactorRecoveryPage(w, r, util.I18nErrorInvalidCSRF, ipAddr)
+		s.renderClientTwoFactorRecoveryPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF), ipAddr)
 		return
 	}
 	user, userMerged, err := dataprovider.GetUserVariants(username, "")
@@ -346,11 +352,13 @@ func (s *httpdServer) handleWebClientTwoFactorRecoveryPost(w http.ResponseWriter
 		if errors.Is(err, util.ErrNotFound) {
 			handleDefenderEventLoginFailed(ipAddr, err) //nolint:errcheck
 		}
-		s.renderClientTwoFactorRecoveryPage(w, r, util.I18nErrorInvalidCredentials, ipAddr)
+		s.renderClientTwoFactorRecoveryPage(w, r,
+			util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials), ipAddr)
 		return
 	}
 	if !userMerged.Filters.TOTPConfig.Enabled || !util.Contains(userMerged.Filters.TOTPConfig.Protocols, common.ProtocolHTTP) {
-		s.renderClientTwoFactorPage(w, r, "Two factory authentication is not enabled", ipAddr)
+		s.renderClientTwoFactorPage(w, r, util.NewI18nError(
+			util.NewValidationError("two factory authentication is not enabled"), util.I18n2FADisabled), ipAddr)
 		return
 	}
 	for idx, code := range user.Filters.RecoveryCodes {
@@ -360,7 +368,8 @@ func (s *httpdServer) handleWebClientTwoFactorRecoveryPost(w http.ResponseWriter
 		}
 		if code.Secret.GetPayload() == recoveryCode {
 			if code.Used {
-				s.renderClientTwoFactorRecoveryPage(w, r, util.I18nErrorInvalidCredentials, ipAddr)
+				s.renderClientTwoFactorRecoveryPage(w, r,
+					util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials), ipAddr)
 				return
 			}
 			user.Filters.RecoveryCodes[idx].Used = true
@@ -377,7 +386,8 @@ func (s *httpdServer) handleWebClientTwoFactorRecoveryPost(w http.ResponseWriter
 		}
 	}
 	handleDefenderEventLoginFailed(ipAddr, dataprovider.ErrInvalidCredentials) //nolint:errcheck
-	s.renderClientTwoFactorRecoveryPage(w, r, util.I18nErrorInvalidCredentials, ipAddr)
+	s.renderClientTwoFactorRecoveryPage(w, r,
+		util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials), ipAddr)
 }
 
 func (s *httpdServer) handleWebClientTwoFactorPost(w http.ResponseWriter, r *http.Request) {
@@ -389,7 +399,7 @@ func (s *httpdServer) handleWebClientTwoFactorPost(w http.ResponseWriter, r *htt
 	}
 	ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
 	if err := r.ParseForm(); err != nil {
-		s.renderClientTwoFactorPage(w, r, util.I18nErrorInvalidForm, ipAddr)
+		s.renderClientTwoFactorPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm), ipAddr)
 		return
 	}
 	username := claims.Username
@@ -397,25 +407,26 @@ func (s *httpdServer) handleWebClientTwoFactorPost(w http.ResponseWriter, r *htt
 	if username == "" || passcode == "" {
 		updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}},
 			dataprovider.LoginMethodPassword, ipAddr, common.ErrNoCredentials)
-		s.renderClientTwoFactorPage(w, r, util.I18nErrorInvalidCredentials, ipAddr)
+		s.renderClientTwoFactorPage(w, r,
+			util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials), ipAddr)
 		return
 	}
 	if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
 		updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}},
 			dataprovider.LoginMethodPassword, ipAddr, err)
-		s.renderClientTwoFactorPage(w, r, util.I18nErrorInvalidCSRF, ipAddr)
+		s.renderClientTwoFactorPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF), ipAddr)
 		return
 	}
 	user, err := dataprovider.GetUserWithGroupSettings(username, "")
 	if err != nil {
 		updateLoginMetrics(&dataprovider.User{BaseUser: sdk.BaseUser{Username: username}},
 			dataprovider.LoginMethodPassword, ipAddr, err)
-		s.renderClientTwoFactorPage(w, r, util.I18nErrorInvalidCredentials, ipAddr)
+		s.renderClientTwoFactorPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCredentials), ipAddr)
 		return
 	}
 	if !user.Filters.TOTPConfig.Enabled || !util.Contains(user.Filters.TOTPConfig.Protocols, common.ProtocolHTTP) {
 		updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, common.ErrInternalFailure)
-		s.renderClientTwoFactorPage(w, r, util.I18n2FADisabled, ipAddr)
+		s.renderClientTwoFactorPage(w, r, util.NewI18nError(common.ErrInternalFailure, util.I18n2FADisabled), ipAddr)
 		return
 	}
 	err = user.Filters.TOTPConfig.Secret.Decrypt()
@@ -428,7 +439,8 @@ func (s *httpdServer) handleWebClientTwoFactorPost(w http.ResponseWriter, r *htt
 		user.Filters.TOTPConfig.Secret.GetPayload())
 	if !match || err != nil {
 		updateLoginMetrics(&user, dataprovider.LoginMethodPassword, ipAddr, dataprovider.ErrInvalidCredentials)
-		s.renderClientTwoFactorPage(w, r, util.I18nErrorInvalidCredentials, ipAddr)
+		s.renderClientTwoFactorPage(w, r,
+			util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials), ipAddr)
 		return
 	}
 	connectionID := fmt.Sprintf("%s_%s", getProtocolFromRequest(r), xid.New().String())
@@ -601,7 +613,7 @@ func (s *httpdServer) handleWebAdminLogin(w http.ResponseWriter, r *http.Request
 		http.Redirect(w, r, webAdminSetupPath, http.StatusFound)
 		return
 	}
-	s.renderAdminLoginPage(w, r, getFlashMessage(w, r), util.GetIPFromRemoteAddress(r.RemoteAddr))
+	s.renderAdminLoginPage(w, r, getFlashMessage(w, r).ErrorString, util.GetIPFromRemoteAddress(r.RemoteAddr))
 }
 
 func (s *httpdServer) handleWebAdminLogout(w http.ResponseWriter, r *http.Request) {
@@ -649,7 +661,8 @@ func (s *httpdServer) handleWebAdminPasswordResetPost(w http.ResponseWriter, r *
 	admin, _, err := handleResetPassword(r, strings.TrimSpace(r.Form.Get("code")),
 		strings.TrimSpace(r.Form.Get("password")), true)
 	if err != nil {
-		if e, ok := err.(*util.ValidationError); ok {
+		var e *util.ValidationError
+		if errors.As(err, &e) {
 			s.renderResetPwdPage(w, r, e.GetErrorString(), ipAddr)
 			return
 		}
@@ -712,7 +725,7 @@ func (s *httpdServer) handleWebAdminSetupPost(w http.ResponseWriter, r *http.Req
 
 func (s *httpdServer) loginUser(
 	w http.ResponseWriter, r *http.Request, user *dataprovider.User, connectionID, ipAddr string,
-	isSecondFactorAuth bool, errorFunc func(w http.ResponseWriter, r *http.Request, error, ip string),
+	isSecondFactorAuth bool, errorFunc func(w http.ResponseWriter, r *http.Request, err *util.I18nError, ip string),
 ) {
 	c := jwtTokenClaims{
 		Username:                   user.Username,
@@ -734,7 +747,7 @@ func (s *httpdServer) loginUser(
 	if err != nil {
 		logger.Warn(logSender, connectionID, "unable to set user login cookie %v", err)
 		updateLoginMetrics(user, dataprovider.LoginMethodPassword, ipAddr, common.ErrInternalFailure)
-		errorFunc(w, r, util.I18nError500Message, ipAddr)
+		errorFunc(w, r, util.NewI18nError(err, util.I18nError500Message), ipAddr)
 		return
 	}
 	if isSecondFactorAuth {

+ 1 - 1
internal/httpd/web.go

@@ -144,7 +144,7 @@ func i18nFsMsg(status int) string {
 func getI18NErrorString(err error, fallback string) string {
 	var errI18n *util.I18nError
 	if errors.As(err, &errI18n) {
-		return errI18n.I18nMessage
+		return errI18n.Message
 	}
 	return fallback
 }

+ 2 - 1
internal/httpd/webadmin.go

@@ -2652,7 +2652,8 @@ func (s *httpdServer) handleWebAdminForgotPwdPost(w http.ResponseWriter, r *http
 	}
 	err = handleForgotPassword(r, r.Form.Get("username"), true)
 	if err != nil {
-		if e, ok := err.(*util.ValidationError); ok {
+		var e *util.ValidationError
+		if errors.As(err, &e) {
 			s.renderForgotPwdPage(w, r, e.GetErrorString(), ipAddr)
 			return
 		}

+ 164 - 82
internal/httpd/webclient.go

@@ -141,7 +141,7 @@ type filesPage struct {
 	CanDownload        bool
 	CanShare           bool
 	ShareUploadBaseURL string
-	Error              string
+	Error              *util.I18nError
 	Paths              []dirMapping
 	QuotaUsage         *userQuotaUsage
 }
@@ -149,7 +149,7 @@ type filesPage struct {
 type shareLoginPage struct {
 	commonBasePage
 	CurrentURL string
-	Error      string
+	Error      *util.I18nError
 	CSRFToken  string
 	Title      string
 	Branding   UIBranding
@@ -168,7 +168,7 @@ type shareUploadPage struct {
 
 type clientMessagePage struct {
 	baseClientPage
-	Error   string
+	Error   *util.I18nError
 	Success string
 }
 
@@ -179,23 +179,24 @@ type clientProfilePage struct {
 	AllowAPIKeyAuth bool
 	Email           string
 	Description     string
-	Error           string
+	Error           *util.I18nError
 }
 
 type changeClientPasswordPage struct {
 	baseClientPage
-	Error string
+	Error *util.I18nError
 }
 
 type clientMFAPage struct {
 	baseClientPage
-	TOTPConfigs     []string
-	TOTPConfig      dataprovider.UserTOTPConfig
-	GenerateTOTPURL string
-	ValidateTOTPURL string
-	SaveTOTPURL     string
-	RecCodesURL     string
-	Protocols       []string
+	TOTPConfigs       []string
+	TOTPConfig        dataprovider.UserTOTPConfig
+	GenerateTOTPURL   string
+	ValidateTOTPURL   string
+	SaveTOTPURL       string
+	RecCodesURL       string
+	Protocols         []string
+	RequiredProtocols []string
 }
 
 type clientSharesPage struct {
@@ -207,10 +208,55 @@ type clientSharesPage struct {
 type clientSharePage struct {
 	baseClientPage
 	Share *dataprovider.Share
-	Error string
+	Error *util.I18nError
 	IsAdd bool
 }
 
+// TODO: merge with loginPage once the WebAdmin supports localization
+type clientLoginPage struct {
+	commonBasePage
+	CurrentURL     string
+	Error          *util.I18nError
+	CSRFToken      string
+	AltLoginURL    string
+	AltLoginName   string
+	ForgotPwdURL   string
+	OpenIDLoginURL string
+	Title          string
+	Branding       UIBranding
+	FormDisabled   bool
+}
+
+type clientResetPwdPage struct {
+	commonBasePage
+	CurrentURL string
+	Error      *util.I18nError
+	CSRFToken  string
+	LoginURL   string
+	Title      string
+	Branding   UIBranding
+}
+
+type clientTwoFactorPage struct {
+	commonBasePage
+	CurrentURL  string
+	Error       *util.I18nError
+	CSRFToken   string
+	RecoveryURL string
+	Title       string
+	Branding    UIBranding
+}
+
+type clientForgotPwdPage struct {
+	commonBasePage
+	CurrentURL string
+	Error      *util.I18nError
+	CSRFToken  string
+	LoginURL   string
+	Title      string
+	Branding   UIBranding
+}
+
 type userQuotaUsage struct {
 	QuotaSize                int64
 	QuotaFiles               int
@@ -553,11 +599,11 @@ func (s *httpdServer) getBaseClientPageData(title, currentURL string, r *http.Re
 	return data
 }
 
-func (s *httpdServer) renderClientForgotPwdPage(w http.ResponseWriter, r *http.Request, error, ip string) {
-	data := forgotPwdPage{
+func (s *httpdServer) renderClientForgotPwdPage(w http.ResponseWriter, r *http.Request, err *util.I18nError, ip string) {
+	data := clientForgotPwdPage{
 		commonBasePage: getCommonBasePage(r),
 		CurrentURL:     webClientForgotPwdPath,
-		Error:          error,
+		Error:          err,
 		CSRFToken:      createCSRFToken(ip),
 		LoginURL:       webClientLoginPath,
 		Title:          util.I18nForgotPwdTitle,
@@ -566,11 +612,11 @@ func (s *httpdServer) renderClientForgotPwdPage(w http.ResponseWriter, r *http.R
 	renderClientTemplate(w, templateForgotPassword, data)
 }
 
-func (s *httpdServer) renderClientResetPwdPage(w http.ResponseWriter, r *http.Request, error, ip string) {
-	data := resetPwdPage{
+func (s *httpdServer) renderClientResetPwdPage(w http.ResponseWriter, r *http.Request, err *util.I18nError, ip string) {
+	data := clientResetPwdPage{
 		commonBasePage: getCommonBasePage(r),
 		CurrentURL:     webClientResetPwdPath,
-		Error:          error,
+		Error:          err,
 		CSRFToken:      createCSRFToken(ip),
 		LoginURL:       webClientLoginPath,
 		Title:          util.I18nResetPwdTitle,
@@ -579,12 +625,12 @@ func (s *httpdServer) renderClientResetPwdPage(w http.ResponseWriter, r *http.Re
 	renderClientTemplate(w, templateResetPassword, data)
 }
 
-func (s *httpdServer) renderShareLoginPage(w http.ResponseWriter, r *http.Request, error, ip string) {
+func (s *httpdServer) renderShareLoginPage(w http.ResponseWriter, r *http.Request, err *util.I18nError, ip string) {
 	data := shareLoginPage{
 		commonBasePage: getCommonBasePage(r),
 		Title:          util.I18nShareLoginTitle,
 		CurrentURL:     r.RequestURI,
-		Error:          error,
+		Error:          err,
 		CSRFToken:      createCSRFToken(ip),
 		Branding:       s.binding.Branding.WebClient,
 	}
@@ -599,13 +645,13 @@ func renderClientTemplate(w http.ResponseWriter, tmplName string, data any) {
 }
 
 func (s *httpdServer) renderClientMessagePage(w http.ResponseWriter, r *http.Request, title string, statusCode int, err error, message string) {
-	var errString string
+	var i18nErr *util.I18nError
 	if err != nil {
-		errString = getI18NErrorString(err, util.I18nError500Message)
+		i18nErr = util.NewI18nError(err, util.I18nError500Message)
 	}
 	data := clientMessagePage{
 		baseClientPage: s.getBaseClientPageData(title, "", r),
-		Error:          errString,
+		Error:          i18nErr,
 		Success:        message,
 	}
 	w.WriteHeader(statusCode)
@@ -628,16 +674,16 @@ func (s *httpdServer) renderClientForbiddenPage(w http.ResponseWriter, r *http.R
 }
 
 func (s *httpdServer) renderClientNotFoundPage(w http.ResponseWriter, r *http.Request, err error) {
-	s.renderClientMessagePage(w, r, util.I18nError400Title, http.StatusNotFound,
-		util.NewI18nError(err, util.I18nError400Message), "")
+	s.renderClientMessagePage(w, r, util.I18nError404Title, http.StatusNotFound,
+		util.NewI18nError(err, util.I18nError404Message), "")
 }
 
-func (s *httpdServer) renderClientTwoFactorPage(w http.ResponseWriter, r *http.Request, errorString, ip string) {
-	data := twoFactorPage{
+func (s *httpdServer) renderClientTwoFactorPage(w http.ResponseWriter, r *http.Request, err *util.I18nError, ip string) {
+	data := clientTwoFactorPage{
 		commonBasePage: getCommonBasePage(r),
 		Title:          pageTwoFactorTitle,
 		CurrentURL:     webClientTwoFactorPath,
-		Error:          errorString,
+		Error:          err,
 		CSRFToken:      createCSRFToken(ip),
 		RecoveryURL:    webClientTwoFactorRecoveryPath,
 		Branding:       s.binding.Branding.WebClient,
@@ -648,12 +694,12 @@ func (s *httpdServer) renderClientTwoFactorPage(w http.ResponseWriter, r *http.R
 	renderClientTemplate(w, templateClientTwoFactor, data)
 }
 
-func (s *httpdServer) renderClientTwoFactorRecoveryPage(w http.ResponseWriter, r *http.Request, errorString, ip string) {
-	data := twoFactorPage{
+func (s *httpdServer) renderClientTwoFactorRecoveryPage(w http.ResponseWriter, r *http.Request, err *util.I18nError, ip string) {
+	data := clientTwoFactorPage{
 		commonBasePage: getCommonBasePage(r),
 		Title:          pageTwoFactorRecoveryTitle,
 		CurrentURL:     webClientTwoFactorRecoveryPath,
-		Error:          errorString,
+		Error:          err,
 		CSRFToken:      createCSRFToken(ip),
 		Branding:       s.binding.Branding.WebClient,
 	}
@@ -676,6 +722,7 @@ func (s *httpdServer) renderClientMFAPage(w http.ResponseWriter, r *http.Request
 		return
 	}
 	data.TOTPConfig = user.Filters.TOTPConfig
+	data.RequiredProtocols = user.Filters.TwoFactorAuthProtocols
 	renderClientTemplate(w, templateClientMFA, data)
 }
 
@@ -698,7 +745,7 @@ func (s *httpdServer) renderEditFilePage(w http.ResponseWriter, r *http.Request,
 }
 
 func (s *httpdServer) renderAddUpdateSharePage(w http.ResponseWriter, r *http.Request, share *dataprovider.Share,
-	errorString string, isAdd bool) {
+	err *util.I18nError, isAdd bool) {
 	currentURL := webClientSharePath
 	title := util.I18nShareAddTitle
 	if !isAdd {
@@ -708,7 +755,7 @@ func (s *httpdServer) renderAddUpdateSharePage(w http.ResponseWriter, r *http.Re
 	data := clientSharePage{
 		baseClientPage: s.getBaseClientPageData(title, currentURL, r),
 		Share:          share,
-		Error:          errorString,
+		Error:          err,
 		IsAdd:          isAdd,
 	}
 
@@ -736,8 +783,8 @@ func getDirMapping(dirName, baseWebPath string) []dirMapping {
 	return paths
 }
 
-func (s *httpdServer) renderSharedFilesPage(w http.ResponseWriter, r *http.Request, dirName, error string,
-	share dataprovider.Share,
+func (s *httpdServer) renderSharedFilesPage(w http.ResponseWriter, r *http.Request, dirName string,
+	err *util.I18nError, share dataprovider.Share,
 ) {
 	currentURL := path.Join(webClientPubSharesPath, share.ShareID, "browse")
 	baseData := s.getBaseClientPageData(util.I18nSharedFilesTitle, currentURL, r)
@@ -746,7 +793,7 @@ func (s *httpdServer) renderSharedFilesPage(w http.ResponseWriter, r *http.Reque
 
 	data := filesPage{
 		baseClientPage: baseData,
-		Error:          error,
+		Error:          err,
 		CurrentDir:     url.QueryEscape(dirName),
 		DownloadURL:    path.Join(baseSharePath, "partial"),
 		// dirName must be escaped because the router expects the full path as single argument
@@ -785,10 +832,11 @@ func (s *httpdServer) renderUploadToSharePage(w http.ResponseWriter, r *http.Req
 	renderClientTemplate(w, templateUploadToShare, data)
 }
 
-func (s *httpdServer) renderFilesPage(w http.ResponseWriter, r *http.Request, dirName, error string, user *dataprovider.User) {
+func (s *httpdServer) renderFilesPage(w http.ResponseWriter, r *http.Request, dirName string,
+	err *util.I18nError, user *dataprovider.User) {
 	data := filesPage{
 		baseClientPage:     s.getBaseClientPageData(util.I18nFilesTitle, webClientFilesPath, r),
-		Error:              error,
+		Error:              err,
 		CurrentDir:         url.QueryEscape(dirName),
 		DownloadURL:        webClientDownloadZipPath,
 		ViewPDFURL:         webClientViewPDFPath,
@@ -808,14 +856,14 @@ func (s *httpdServer) renderFilesPage(w http.ResponseWriter, r *http.Request, di
 	renderClientTemplate(w, templateClientFiles, data)
 }
 
-func (s *httpdServer) renderClientProfilePage(w http.ResponseWriter, r *http.Request, error string) {
+func (s *httpdServer) renderClientProfilePage(w http.ResponseWriter, r *http.Request, err *util.I18nError) {
 	data := clientProfilePage{
 		baseClientPage: s.getBaseClientPageData(util.I18nProfileTitle, webClientProfilePath, r),
-		Error:          error,
+		Error:          err,
 	}
-	user, userMerged, err := dataprovider.GetUserVariants(data.LoggedUser.Username, "")
-	if err != nil {
-		s.renderClientInternalServerErrorPage(w, r, err)
+	user, userMerged, errUser := dataprovider.GetUserVariants(data.LoggedUser.Username, "")
+	if errUser != nil {
+		s.renderClientInternalServerErrorPage(w, r, errUser)
 		return
 	}
 	data.PublicKeys = user.PublicKeys
@@ -826,10 +874,10 @@ func (s *httpdServer) renderClientProfilePage(w http.ResponseWriter, r *http.Req
 	renderClientTemplate(w, templateClientProfile, data)
 }
 
-func (s *httpdServer) renderClientChangePasswordPage(w http.ResponseWriter, r *http.Request, error string) {
+func (s *httpdServer) renderClientChangePasswordPage(w http.ResponseWriter, r *http.Request, err *util.I18nError) {
 	data := changeClientPasswordPage{
 		baseClientPage: s.getBaseClientPageData(util.I18nChangePwdTitle, webChangeClientPwdPath, r),
-		Error:          error,
+		Error:          err,
 	}
 
 	renderClientTemplate(w, templateClientChangePwd, data)
@@ -1023,7 +1071,8 @@ func (s *httpdServer) handleShareGetFiles(w http.ResponseWriter, r *http.Request
 	}
 
 	if err = common.Connections.Add(connection); err != nil {
-		s.renderSharedFilesPage(w, r, path.Dir(share.GetRelativePath(name)), util.I18nError429Message, share)
+		s.renderSharedFilesPage(w, r, path.Dir(share.GetRelativePath(name)),
+			util.NewI18nError(err, util.I18nError429Message), share)
 		return
 	}
 	defer common.Connections.Remove(connection.GetID())
@@ -1035,18 +1084,20 @@ func (s *httpdServer) handleShareGetFiles(w http.ResponseWriter, r *http.Request
 		info, err = connection.Stat(name, 1)
 	}
 	if err != nil {
-		s.renderSharedFilesPage(w, r, path.Dir(share.GetRelativePath(name)), i18nFsMsg(getRespStatus(err)), share)
+		s.renderSharedFilesPage(w, r, path.Dir(share.GetRelativePath(name)),
+			util.NewI18nError(err, i18nFsMsg(getRespStatus(err))), share)
 		return
 	}
 	if info.IsDir() {
-		s.renderSharedFilesPage(w, r, share.GetRelativePath(name), "", share)
+		s.renderSharedFilesPage(w, r, share.GetRelativePath(name), nil, share)
 		return
 	}
 	dataprovider.UpdateShareLastUse(&share, 1) //nolint:errcheck
 	if status, err := downloadFile(w, r, connection, name, info, false, &share); err != nil {
 		dataprovider.UpdateShareLastUse(&share, -1) //nolint:errcheck
 		if status > 0 {
-			s.renderSharedFilesPage(w, r, path.Dir(share.GetRelativePath(name)), i18nFsMsg(getRespStatus(err)), share)
+			s.renderSharedFilesPage(w, r, path.Dir(share.GetRelativePath(name)),
+				util.NewI18nError(err, i18nFsMsg(getRespStatus(err))), share)
 		}
 	}
 }
@@ -1228,11 +1279,11 @@ func (s *httpdServer) handleClientGetFiles(w http.ResponseWriter, r *http.Reques
 		info, err = connection.Stat(name, 0)
 	}
 	if err != nil {
-		s.renderFilesPage(w, r, path.Dir(name), i18nFsMsg(getRespStatus(err)), &user)
+		s.renderFilesPage(w, r, path.Dir(name), util.NewI18nError(err, i18nFsMsg(getRespStatus(err))), &user)
 		return
 	}
 	if info.IsDir() {
-		s.renderFilesPage(w, r, name, "", &user)
+		s.renderFilesPage(w, r, name, nil, &user)
 		return
 	}
 	if status, err := downloadFile(w, r, connection, name, info, false, nil); err != nil && status != 0 {
@@ -1242,7 +1293,7 @@ func (s *httpdServer) handleClientGetFiles(w http.ResponseWriter, r *http.Reques
 					util.NewI18nError(err, util.I18nError416Message), "")
 				return
 			}
-			s.renderFilesPage(w, r, path.Dir(name), i18nFsMsg(status), &user)
+			s.renderFilesPage(w, r, path.Dir(name), util.NewI18nError(err, i18nFsMsg(status)), &user)
 		}
 	}
 }
@@ -1365,7 +1416,7 @@ func (s *httpdServer) handleClientAddShareGet(w http.ResponseWriter, r *http.Req
 		}
 	}
 
-	s.renderAddUpdateSharePage(w, r, share, "", true)
+	s.renderAddUpdateSharePage(w, r, share, nil, true)
 }
 
 func (s *httpdServer) handleClientUpdateShareGet(w http.ResponseWriter, r *http.Request) {
@@ -1379,7 +1430,7 @@ func (s *httpdServer) handleClientUpdateShareGet(w http.ResponseWriter, r *http.
 	share, err := dataprovider.ShareExists(shareID, claims.Username)
 	if err == nil {
 		share.HideConfidentialData()
-		s.renderAddUpdateSharePage(w, r, &share, "", false)
+		s.renderAddUpdateSharePage(w, r, &share, nil, false)
 	} else if errors.Is(err, util.ErrNotFound) {
 		s.renderClientNotFoundPage(w, r, err)
 	} else {
@@ -1396,7 +1447,7 @@ func (s *httpdServer) handleClientAddSharePost(w http.ResponseWriter, r *http.Re
 	}
 	share, err := getShareFromPostFields(r)
 	if err != nil {
-		s.renderAddUpdateSharePage(w, r, share, getI18NErrorString(err, util.I18nError500Message), true)
+		s.renderAddUpdateSharePage(w, r, share, util.NewI18nError(err, util.I18nError500Message), true)
 		return
 	}
 	ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
@@ -1410,24 +1461,39 @@ func (s *httpdServer) handleClientAddSharePost(w http.ResponseWriter, r *http.Re
 	share.Username = claims.Username
 	if share.Password == "" {
 		if util.Contains(claims.Permissions, sdk.WebClientShareNoPasswordDisabled) {
-			s.renderAddUpdateSharePage(w, r, share, util.I18nErrorShareNoPwd, true)
+			s.renderAddUpdateSharePage(w, r, share,
+				util.NewI18nError(util.NewValidationError("You are not allowed to share files/folders without password"), util.I18nErrorShareNoPwd),
+				true)
 			return
 		}
 	}
 	user, err := dataprovider.GetUserWithGroupSettings(claims.Username, "")
 	if err != nil {
-		s.renderAddUpdateSharePage(w, r, share, util.I18nErrorGetUser, true)
+		s.renderAddUpdateSharePage(w, r, share, util.NewI18nError(err, util.I18nErrorGetUser), true)
 		return
 	}
 	if err := user.CheckMaxShareExpiration(util.GetTimeFromMsecSinceEpoch(share.ExpiresAt)); err != nil {
-		s.renderAddUpdateSharePage(w, r, share, util.I18nErrorShareExpirationOutOfRange, true)
+		s.renderAddUpdateSharePage(w, r, share, util.NewI18nError(
+			err,
+			util.I18nErrorShareExpirationOutOfRange,
+			util.I18nErrorArgs(
+				map[string]any{
+					"val": time.Now().Add(24 * time.Hour * time.Duration(user.Filters.MaxSharesExpiration+1)).UnixMilli(),
+					"formatParams": map[string]string{
+						"year":  "numeric",
+						"month": "numeric",
+						"day":   "numeric",
+					},
+				},
+			),
+		), true)
 		return
 	}
 	err = dataprovider.AddShare(share, claims.Username, ipAddr, claims.Role)
 	if err == nil {
 		http.Redirect(w, r, webClientSharesPath, http.StatusSeeOther)
 	} else {
-		s.renderAddUpdateSharePage(w, r, share, getI18NErrorString(err, util.I18nErrorShareGeneric), true)
+		s.renderAddUpdateSharePage(w, r, share, util.NewI18nError(err, util.I18nErrorShareGeneric), true)
 	}
 }
 
@@ -1449,7 +1515,7 @@ func (s *httpdServer) handleClientUpdateSharePost(w http.ResponseWriter, r *http
 	}
 	updatedShare, err := getShareFromPostFields(r)
 	if err != nil {
-		s.renderAddUpdateSharePage(w, r, updatedShare, getI18NErrorString(err, util.I18nError500Message), false)
+		s.renderAddUpdateSharePage(w, r, updatedShare, util.NewI18nError(err, util.I18nError500Message), false)
 		return
 	}
 	ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
@@ -1464,24 +1530,39 @@ func (s *httpdServer) handleClientUpdateSharePost(w http.ResponseWriter, r *http
 	}
 	if updatedShare.Password == "" {
 		if util.Contains(claims.Permissions, sdk.WebClientShareNoPasswordDisabled) {
-			s.renderAddUpdateSharePage(w, r, updatedShare, util.I18nErrorShareNoPwd, false)
+			s.renderAddUpdateSharePage(w, r, updatedShare,
+				util.NewI18nError(util.NewValidationError("You are not allowed to share files/folders without password"), util.I18nErrorShareNoPwd),
+				false)
 			return
 		}
 	}
 	user, err := dataprovider.GetUserWithGroupSettings(claims.Username, "")
 	if err != nil {
-		s.renderAddUpdateSharePage(w, r, updatedShare, util.I18nErrorGetUser, false)
+		s.renderAddUpdateSharePage(w, r, updatedShare, util.NewI18nError(err, util.I18nErrorGetUser), false)
 		return
 	}
 	if err := user.CheckMaxShareExpiration(util.GetTimeFromMsecSinceEpoch(updatedShare.ExpiresAt)); err != nil {
-		s.renderAddUpdateSharePage(w, r, updatedShare, util.I18nErrorShareExpirationOutOfRange, false)
+		s.renderAddUpdateSharePage(w, r, updatedShare, util.NewI18nError(
+			err,
+			util.I18nErrorShareExpirationOutOfRange,
+			util.I18nErrorArgs(
+				map[string]any{
+					"val": time.Now().Add(24 * time.Hour * time.Duration(user.Filters.MaxSharesExpiration+1)).UnixMilli(),
+					"formatParams": map[string]string{
+						"year":  "numeric",
+						"month": "numeric",
+						"day":   "numeric",
+					},
+				},
+			),
+		), false)
 		return
 	}
 	err = dataprovider.UpdateShare(updatedShare, claims.Username, ipAddr, claims.Role)
 	if err == nil {
 		http.Redirect(w, r, webClientSharesPath, http.StatusSeeOther)
 	} else {
-		s.renderAddUpdateSharePage(w, r, updatedShare, getI18NErrorString(err, util.I18nErrorShareGeneric), false)
+		s.renderAddUpdateSharePage(w, r, updatedShare, util.NewI18nError(err, util.I18nErrorShareGeneric), false)
 	}
 }
 
@@ -1522,19 +1603,19 @@ func (s *httpdServer) handleClientGetShares(w http.ResponseWriter, r *http.Reque
 
 func (s *httpdServer) handleClientGetProfile(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
-	s.renderClientProfilePage(w, r, "")
+	s.renderClientProfilePage(w, r, nil)
 }
 
 func (s *httpdServer) handleWebClientChangePwd(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
-	s.renderClientChangePasswordPage(w, r, "")
+	s.renderClientChangePasswordPage(w, r, nil)
 }
 
 func (s *httpdServer) handleWebClientProfilePost(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	err := r.ParseForm()
 	if err != nil {
-		s.renderClientProfilePage(w, r, util.I18nErrorInvalidForm)
+		s.renderClientProfilePage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm))
 		return
 	}
 	ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
@@ -1549,7 +1630,7 @@ func (s *httpdServer) handleWebClientProfilePost(w http.ResponseWriter, r *http.
 	}
 	user, userMerged, err := dataprovider.GetUserVariants(claims.Username, "")
 	if err != nil {
-		s.renderClientProfilePage(w, r, util.I18nErrorGetUser)
+		s.renderClientProfilePage(w, r, util.NewI18nError(err, util.I18nErrorGetUser))
 		return
 	}
 	if !userMerged.CanManagePublicKeys() && !userMerged.CanChangeAPIKeyAuth() && !userMerged.CanChangeInfo() {
@@ -1576,7 +1657,7 @@ func (s *httpdServer) handleWebClientProfilePost(w http.ResponseWriter, r *http.
 	}
 	err = dataprovider.UpdateUser(&user, dataprovider.ActionExecutorSelf, ipAddr, user.Role)
 	if err != nil {
-		s.renderClientProfilePage(w, r, getI18NErrorString(err, util.I18nError500Message))
+		s.renderClientProfilePage(w, r, util.NewI18nError(err, util.I18nError500Message))
 		return
 	}
 	s.renderClientMessagePage(w, r, util.I18nProfileTitle, http.StatusOK, nil, util.I18nProfileUpdated)
@@ -1589,12 +1670,12 @@ func (s *httpdServer) handleWebClientMFA(w http.ResponseWriter, r *http.Request)
 
 func (s *httpdServer) handleWebClientTwoFactor(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
-	s.renderClientTwoFactorPage(w, r, "", util.GetIPFromRemoteAddress(r.RemoteAddr))
+	s.renderClientTwoFactorPage(w, r, nil, util.GetIPFromRemoteAddress(r.RemoteAddr))
 }
 
 func (s *httpdServer) handleWebClientTwoFactorRecovery(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
-	s.renderClientTwoFactorRecoveryPage(w, r, "", util.GetIPFromRemoteAddress(r.RemoteAddr))
+	s.renderClientTwoFactorRecoveryPage(w, r, nil, util.GetIPFromRemoteAddress(r.RemoteAddr))
 }
 
 func getShareFromPostFields(r *http.Request) (*dataprovider.Share, error) {
@@ -1646,7 +1727,7 @@ func (s *httpdServer) handleWebClientForgotPwd(w http.ResponseWriter, r *http.Re
 		s.renderClientNotFoundPage(w, r, errors.New("this page does not exist"))
 		return
 	}
-	s.renderClientForgotPwdPage(w, r, "", util.GetIPFromRemoteAddress(r.RemoteAddr))
+	s.renderClientForgotPwdPage(w, r, nil, util.GetIPFromRemoteAddress(r.RemoteAddr))
 }
 
 func (s *httpdServer) handleWebClientForgotPwdPost(w http.ResponseWriter, r *http.Request) {
@@ -1655,7 +1736,7 @@ func (s *httpdServer) handleWebClientForgotPwdPost(w http.ResponseWriter, r *htt
 	ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
 	err := r.ParseForm()
 	if err != nil {
-		s.renderClientForgotPwdPage(w, r, util.I18nErrorInvalidForm, ipAddr)
+		s.renderClientForgotPwdPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm), ipAddr)
 		return
 	}
 	if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
@@ -1665,7 +1746,7 @@ func (s *httpdServer) handleWebClientForgotPwdPost(w http.ResponseWriter, r *htt
 	username := strings.TrimSpace(r.Form.Get("username"))
 	err = handleForgotPassword(r, username, false)
 	if err != nil {
-		s.renderClientForgotPwdPage(w, r, getI18NErrorString(err, util.I18nErrorPwdResetGeneric), ipAddr)
+		s.renderClientForgotPwdPage(w, r, util.NewI18nError(err, util.I18nErrorPwdResetGeneric), ipAddr)
 		return
 	}
 	http.Redirect(w, r, webClientResetPwdPath, http.StatusFound)
@@ -1677,7 +1758,7 @@ func (s *httpdServer) handleWebClientPasswordReset(w http.ResponseWriter, r *htt
 		s.renderClientNotFoundPage(w, r, errors.New("this page does not exist"))
 		return
 	}
-	s.renderClientResetPwdPage(w, r, "", util.GetIPFromRemoteAddress(r.RemoteAddr))
+	s.renderClientResetPwdPage(w, r, nil, util.GetIPFromRemoteAddress(r.RemoteAddr))
 }
 
 func (s *httpdServer) handleClientViewPDF(w http.ResponseWriter, r *http.Request) {
@@ -1780,29 +1861,30 @@ func (s *httpdServer) ensurePDF(w http.ResponseWriter, r *http.Request, name str
 
 func (s *httpdServer) handleClientShareLoginGet(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize)
-	s.renderShareLoginPage(w, r, "", util.GetIPFromRemoteAddress(r.RemoteAddr))
+	s.renderShareLoginPage(w, r, nil, util.GetIPFromRemoteAddress(r.RemoteAddr))
 }
 
 func (s *httpdServer) handleClientShareLoginPost(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxLoginBodySize)
 	ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
 	if err := r.ParseForm(); err != nil {
-		s.renderShareLoginPage(w, r, util.I18nErrorInvalidForm, ipAddr)
+		s.renderShareLoginPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidForm), ipAddr)
 		return
 	}
 	if err := verifyCSRFToken(r.Form.Get(csrfFormToken), ipAddr); err != nil {
-		s.renderShareLoginPage(w, r, util.I18nErrorInvalidCSRF, ipAddr)
+		s.renderShareLoginPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCSRF), ipAddr)
 		return
 	}
 	shareID := getURLParam(r, "id")
 	share, err := dataprovider.ShareExists(shareID, "")
 	if err != nil {
-		s.renderShareLoginPage(w, r, util.I18nErrorInvalidCredentials, ipAddr)
+		s.renderShareLoginPage(w, r, util.NewI18nError(err, util.I18nErrorInvalidCredentials), ipAddr)
 		return
 	}
 	match, err := share.CheckCredentials(strings.TrimSpace(r.Form.Get("share_password")))
 	if !match || err != nil {
-		s.renderShareLoginPage(w, r, util.I18nErrorInvalidCredentials, ipAddr)
+		s.renderShareLoginPage(w, r, util.NewI18nError(dataprovider.ErrInvalidCredentials, util.I18nErrorInvalidCredentials),
+			ipAddr)
 		return
 	}
 	c := jwtTokenClaims{
@@ -1810,7 +1892,7 @@ func (s *httpdServer) handleClientShareLoginPost(w http.ResponseWriter, r *http.
 	}
 	err = c.createAndSetCookie(w, r, s.tokenAuth, tokenAudienceWebShare, ipAddr)
 	if err != nil {
-		s.renderShareLoginPage(w, r, util.I18nError500Message, ipAddr)
+		s.renderShareLoginPage(w, r, util.NewI18nError(err, util.I18nError500Message), ipAddr)
 		return
 	}
 	next := path.Clean(r.URL.Query().Get("next"))

+ 53 - 6
internal/util/i18n.go

@@ -15,6 +15,7 @@
 package util
 
 import (
+	"encoding/json"
 	"errors"
 )
 
@@ -40,6 +41,7 @@ const (
 	I18nInvalidAuthReqTitle            = "title.invalid_auth_request"
 	I18nError403Title                  = "title.error403"
 	I18nError400Title                  = "title.error400"
+	I18nError404Title                  = "title.error404"
 	I18nError416Title                  = "title.error416"
 	I18nError429Title                  = "title.error429"
 	I18nError500Title                  = "title.error500"
@@ -136,24 +138,48 @@ const (
 	I18nProfileUpdated                 = "general.profile_updated"
 	I18nShareLoginOK                   = "general.share_ok"
 	I18n2FADisabled                    = "2fa.disabled"
+	I18nOIDCTokenExpired               = "oidc.token_expired"
+	I18nOIDCTokenInvalidAdmin          = "oidc.token_invalid_webadmin"
+	I18nOIDCTokenInvalidUser           = "oidc.token_invalid_webclient"
+	I18nOIDCErrTokenExchange           = "oidc.token_exchange_err"
+	I18nOIDCTokenInvalid               = "oidc.token_invalid"
+	I18nOIDCTokenInvalidRoleAdmin      = "oidc.role_admin_err"
+	I18nOIDCTokenInvalidRoleUser       = "oidc.role_user_err"
+	I18nOIDCErrGetUser                 = "oidc.get_user_err"
 )
 
 // NewI18nError returns a I18nError wrappring the provided error
-func NewI18nError(err error, message string) *I18nError {
+func NewI18nError(err error, message string, options ...I18nErrorOption) *I18nError {
 	var errI18n *I18nError
 	if errors.As(err, &errI18n) {
 		return errI18n
 	}
-	return &I18nError{
-		err:         err,
-		I18nMessage: message,
+	errI18n = &I18nError{
+		err:     err,
+		Message: message,
+		args:    nil,
+	}
+	for _, opt := range options {
+		opt(errI18n)
+	}
+	return errI18n
+}
+
+// I18nErrorOption defines a functional option type that allows to configure the I18nError.
+type I18nErrorOption func(*I18nError)
+
+// I18nErrorArgs is a functional option to set I18nError arguments.
+func I18nErrorArgs(args map[string]any) I18nErrorOption {
+	return func(e *I18nError) {
+		e.args = args
 	}
 }
 
 // I18nError is an error wrapper that add a message to use for localization.
 type I18nError struct {
-	err         error
-	I18nMessage string
+	err     error
+	Message string
+	args    map[string]any
 }
 
 // Error returns the wrapped error string.
@@ -161,6 +187,11 @@ func (e *I18nError) Error() string {
 	return e.err.Error()
 }
 
+// Unwrap returns the underlying error
+func (e *I18nError) Unwrap() error {
+	return e.err
+}
+
 // Is reports if target matches
 func (e *I18nError) Is(target error) bool {
 	if errors.Is(e.err, target) {
@@ -169,3 +200,19 @@ func (e *I18nError) Is(target error) bool {
 	_, ok := target.(*I18nError)
 	return ok
 }
+
+// HasArgs returns true if the error has i18n args.
+func (e *I18nError) HasArgs() bool {
+	return len(e.args) > 0
+}
+
+// Args returns the provided args in JSON format
+func (e *I18nError) Args() string {
+	if len(e.args) > 0 {
+		data, err := json.Marshal(e.args)
+		if err == nil {
+			return string(data)
+		}
+	}
+	return "{}"
+}

+ 17 - 6
static/locales/en/translation.json

@@ -19,10 +19,11 @@
       "download_shared_file": "Download shared file",
       "share_access_error": "Unable to access the share",
       "invalid_auth_request": "Invalid authentication request",
-      "error429": "Too Many Requests",
-      "error403": "Forbidden",
       "error400": "Bad Request",
+      "error403": "Forbidden",
+      "error404": "Not Found",
       "error416": "Requested Range Not Satisfiable",
+      "error429": "Too Many Requests",
       "error500": "Internal Server Error",
       "errorPDF": "Unable to show PDF file",
       "error_editor": "Cannot open file editor"
@@ -52,7 +53,7 @@
       "reset_pwd_err_generic": "Unexpected error while resetting password",
       "reset_ok_login_error": "The password reset completed successfully but an unexpected error occurred while signing in",
       "ip_not_allowed": "Login is not allowed from this IP address",
-      "two_factor_required": "Two-factor authentication is required, set it up"
+      "two_factor_required": "Set up two-factor authentication, it is required for the following protocols: {{val}}"
    },
    "theme": {
       "light": "Light",
@@ -281,9 +282,9 @@
       "auth_code_invalid": "Failed to validate the provided authentication code",
       "auth_secret_gen_err": "Failed to generate authentication secret",
       "save_err": "Failed to save configuration",
-      "save_err_proto": "$t(2fa.save_err). Make sure the protocols enabled comply with company policy",
       "auth_code_required": "The authentication code is required",
-      "no_protocol": "Please select at least a protocol"
+      "no_protocol": "Please select at least a protocol",
+      "required_protocols": "The following protocols are required: {{val}}"
    },
    "share": {
       "scope": "Scope",
@@ -309,7 +310,7 @@
       "max_tokens_invalid": "Invalid max tokens",
       "expiration_invalid": "Invalid expiration",
       "err_no_password": "You are not allowed to share files/folders without password",
-      "expiration_out_of_range": "Set an expiration date and ensure it complies with company policy, e.g. is not too far in the future",
+      "expiration_out_of_range": "Set an expiration date and make sure it is less than or equal to {{- val, datetime}}",
       "generic": "Unexpected error saving share",
       "path_required": "At least a path is required",
       "path_write_scope": "The write scope requires exactly one path",
@@ -366,5 +367,15 @@
       "file_pattern_invalid": "Invalid file name pattern filters",
       "disable_active_2fa": "Two-factor authentication cannot be disabled for a user with an active configuration",
       "pwd_change_conflict": "It is not possible to request a password change and at the same time prevent the password from being changed"
+   },
+   "oidc": {
+      "token_expired": "Your OpenID token has expired, please log in again",
+      "token_invalid_webadmin": "Your OpenID token is not valid for the WebAdmin UI. Log out of your OpenID server and log in to WebAdmin",
+      "token_invalid_webclient": "Your OpenID token is not valid for the WebClient UI. Log out of your OpenID server and log in to the WebClient",
+      "token_exchange_err": "Failed to exchange OpenID token",
+      "token_invalid": "Invalid OpenID token",
+      "role_admin_err": "Incorrect OpenID role, logged in user is not an administrator",
+      "role_user_err": "Incorrect OpenID role, logged in user is an administrator",
+      "get_user_err": "Failed to get user associated with OpenID token"
    }
 }

+ 17 - 6
static/locales/it/translation.json

@@ -19,10 +19,11 @@
         "download_shared_file": "Scarica file condiviso",
         "share_access_error": "Impossibile accedere alla condivisione",
         "invalid_auth_request": "Richiesta di autenticazione non valida",
-        "error429": "Troppe richieste",
-        "error403": "Non permesso",
         "error400": "Richiesta non valida",
+        "error403": "Non permesso",
+        "error404": "Non trovato",
         "error416": "Impossibile tornare l'intervallo richiesto",
+        "error429": "Troppe richieste",
         "error500": "Errore interno del server",
         "errorPDF": "Impossibile mostrare il file PDF",
         "error_editor": "Impossibile aprire l'editor di file"
@@ -52,7 +53,7 @@
         "reset_pwd_err_generic": "Errore imprevisto durante la reimpostazione della password",
         "reset_ok_login_error": "La reimpostazione della password è stata completata correttamente ma si è verificato un errore imprevisto durante l'accesso",
         "ip_not_allowed": "L'accesso non è consentito da questo indirizzo IP",
-        "two_factor_required": "È richiesta l'autenticazione a due fattori, configurala"
+        "two_factor_required": "Configura l'autenticazione a due fattori, è obbligatoria per i seguenti protocolli: {{val}}"
     },
     "theme": {
         "light": "Chiaro",
@@ -281,9 +282,9 @@
         "auth_code_invalid": "Impossibile convalidare il codice di autenticazione fornito",
         "auth_secret_gen_err": "Impossibile generare il segreto di autenticazione",
         "save_err": "Impossibile salvare la configurazione",
-        "save_err_proto": "$t(2fa.save_err). Assicurati che i protocolli abilitati siano conformi alla politica aziendale",
         "auth_code_required": "Il codice di autenticazione è obbligatorio",
-        "no_protocol": "Seleziona almeno un protocollo"
+        "no_protocol": "Seleziona almeno un protocollo",
+        "required_protocols": "I seguenti protocolli sono obbligatori: {{val}}"
     },
     "share": {
         "scope": "Ambito",
@@ -309,7 +310,7 @@
         "max_tokens_invalid": "Token massimi non validi",
         "expiration_invalid": "Scadenza non valida",
         "err_no_password": "Non sei autorizzato a condividere file/cartelle senza password",
-        "expiration_out_of_range": "Imposta una data di scadenza e assicurati che sia conforme alla politica aziendale, ad es. non è troppo lontan nel futuro",
+        "expiration_out_of_range": "Imposta una data di scadenza e assicurati che sia inferiore o uguale al {{- val, datetime}}",
         "generic": "Errore imprevisto durante il salvataggio della condivisione",
         "path_required": "È necessario almeno un percorso",
         "path_write_scope": "L'ambito di scrittura richiede esattamente un percorso",
@@ -366,5 +367,15 @@
         "file_pattern_invalid": "Filtri su modelli di nome file non validi",
         "disable_active_2fa": "L'autenticazione a due fattori non può essere disabilitata per un utente con una configurazione attiva",
         "pwd_change_conflict": "Non è possibile richiedere la modifica della password e allo stesso tempo impedire la modifica della password"
+    },
+    "oidc": {
+        "token_expired": "Il tuo token OpenID è scaduto, effettua nuovamente l'accesso",
+        "token_invalid_webadmin": "Il tuo token OpenID non è valido per l'interfaccia utente WebAdmin. Esci dal tuo server OpenID e accedi a WebAdmin",
+        "token_invalid_webclient": "Il tuo token OpenID non è valido per l'interfaccia utente WebClient. Esci dal tuo server OpenID e accedi al WebClient",
+        "token_exchange_err": "Impossibile scambiare il token OpenID",
+        "token_invalid": "Token OpenID non valido",
+        "role_admin_err": "Ruolo OpenID errato, l'utente che ha effettuato l'accesso non è un amministratore",
+        "role_user_err": "Ruolo OpenID errato, l'utente che ha effettuato l'accesso è un amministratore",
+        "get_user_err": "Impossibile ottenere l'utente associato al token OpenID"
     }
 }

+ 4 - 3
templates/common/base.html

@@ -21,7 +21,7 @@ explicit grant from the SFTPGo Team ([email protected]).
         <span class="path3"></span>
     </i>
     <div class="text-gray-800 fw-bold fs-5 d-flex flex-column pe-0 pe-sm-10">
-		<span data-i18n="{{.}}" id="errorTxt"></span>
+		<span {{if .}}data-i18n="{{.Message}}" {{if .HasArgs}}data-i18n-options="{{.Args}}"{{end}}{{end}} id="errorTxt"></span>
 	</div>
     <button id="id_dismiss_error_msg" type="button" class="position-absolute position-sm-relative m-2 m-sm-0 top-0 end-0 btn btn-icon btn-sm btn-active-light-primary ms-sm-auto">
         <i class="ki-duotone ki-cross fs-2x text-primary">
@@ -202,9 +202,10 @@ explicit grant from the SFTPGo Team ([email protected]).
             });
     }
 
-    function setI18NData(el, value) {
+    function setI18NData(el, value, options) {
+        el.removeAttr("data-i18n-options");
         el.attr("data-i18n", value);
-        el.localize();
+        el.localize(options);
     }
 
     KTUtil.onDOMContentLoaded(function () {

+ 3 - 3
templates/webclient/editfile.html

@@ -151,11 +151,11 @@ explicit grant from the SFTPGo Team ([email protected]).
     //{{- end}}
 
     $(document).on("i18nload", function(){
+        let message = 'fs.edit_file';
         //{{- if .ReadOnly}}
-        $('#card_title').text($.t('fs.view_file', { path: '{{.Path}}'}));
-        //{{- else}}
-        $('#card_title').text($.t('fs.edit_file', { path: '{{.Path}}'}));
+        message = 'fs.view_file';
         //{{- end}}
+        setI18NData($('#card_title'), message, { path: '{{.Path}}'});
     });
 
     $(document).on("i18nshow", function(){

+ 2 - 4
templates/webclient/files.html

@@ -1409,8 +1409,7 @@ explicit grant from the SFTPGo Team ([email protected]).
                     if (!errorMessage){
                         errorMessage = "fs.delete.err_generic";
                     }
-                    errTxtEl.removeAttr("data-i18n")
-                    errTxtEl.text($.t(errorMessage, {name: itemName}));
+                    setI18NData(errTxtEl, errorMessage, {name: itemName});
                     errDivEl.removeClass("d-none");
                 });
             }
@@ -1484,8 +1483,7 @@ explicit grant from the SFTPGo Team ([email protected]).
             if (!errorMessage){
                 errorMessage = "fs.rename.err_generic";
             }
-            errTxtEl.removeAttr("data-i18n")
-            errTxtEl.text($.t(errorMessage, {name: oldName}));
+            setI18NData(errTxtEl, errorMessage, {name: oldName});
             errDivEl.removeClass("d-none");
         });
     }

+ 1 - 1
templates/webclient/forgot-password.html

@@ -41,7 +41,7 @@ explicit grant from the SFTPGo Team ([email protected]).
             </span>
         </div>
     </div>
-    {{template "errmsg" .Error}}
+    {{- template "errmsg" .Error}}
     <div class="fv-row mb-10">
         <input data-i18n="[placeholder]login.your_username" class="form-control form-control-lg form-control-solid" type="text" placeholder="Your username" name="username" spellcheck="false" required />
     </div>

+ 1 - 1
templates/webclient/login.html

@@ -29,7 +29,7 @@ explicit grant from the SFTPGo Team ([email protected]).
 									</div>
 								</div>
 							</div>
-							{{template "errmsg" .Error}}
+							{{- template "errmsg" .Error}}
 							{{- if not .FormDisabled}}
 							<div class="fv-row mb-10">
 								<input data-i18n="[placeholder]login.username" class="form-control form-control-lg form-control-solid" type="text" name="username" placeholder="Username" spellcheck="false" required />

+ 1 - 1
templates/webclient/message.html

@@ -53,7 +53,7 @@ explicit grant from the SFTPGo Team ([email protected]).
                 <div class="d-flex flex-stack flex-grow-1 ">
                     <div class=" fw-semibold">
                         <div class="fs-5 text-gray-800">
-                            <span data-i18n="{{.Error}}"></span>
+                            <span data-i18n="{{.Error.Message}}" {{if .Error.HasArgs}}data-i18n-options="{{.Error.Args}}"{{end}}></span>
                         </div>
                     </div>
                 </div>

+ 13 - 9
templates/webclient/mfa.html

@@ -273,8 +273,12 @@ explicit grant from the SFTPGo Team ([email protected]).
 
 {{- define "extra_js"}}
 <script type="text/javascript" {{- if .CSPNonce}} nonce="{{.CSPNonce}}"{{- end}}>
-     const qrModal = new bootstrap.Modal('#qrcode_modal');
-     const recCodesModal = new bootstrap.Modal('#recovery_codes_modal');
+    const qrModal = new bootstrap.Modal('#qrcode_modal');
+    const recCodesModal = new bootstrap.Modal('#recovery_codes_modal');
+    const requiredProtocols = [];
+    {{- range .RequiredProtocols}}
+    requiredProtocols.push('{{.}}');
+    {{- end}}
 
     function onConfigChanged() {
         let selectedConfig = $('#id_config option:selected').val();
@@ -537,6 +541,13 @@ explicit grant from the SFTPGo Team ([email protected]).
             errDivEl.removeClass("d-none");
             return;
         }
+        for (let i = 0; i < requiredProtocols.length > 0; i++){
+            if (!protocolsArray.includes(requiredProtocols[i])){
+                setI18NData(errTxtEl, '2fa.required_protocols', {val: requiredProtocols.join(', ')});
+                errDivEl.removeClass("d-none");
+                return;
+            }
+        }
         let postData = {
             protocols: protocolsArray
         }
@@ -587,13 +598,6 @@ explicit grant from the SFTPGo Team ([email protected]).
             }).catch(function (error) {
                 el.removeAttribute('data-kt-indicator');
                 el.disabled = false;
-                if (error && error.response) {
-                    switch (error.response.status) {
-                        case 400:
-                            errorMessage = "2fa.save_err_proto";
-                            break;
-                    }
-                }
                 setI18NData(errTxtEl, errorMessage);
                 errDivEl.removeClass("d-none");
             });

+ 1 - 1
templates/webclient/reset-password.html

@@ -41,7 +41,7 @@ explicit grant from the SFTPGo Team ([email protected]).
             </span>
         </div>
     </div>
-    {{template "errmsg" .Error}}
+    {{- template "errmsg" .Error}}
     <div class="fv-row mb-10">
         <input data-i18n="[placeholder]login.confirm_code" class="form-control form-control-lg form-control-solid" type="text" placeholder="Confirmation code" name="code" spellcheck="false" required />
     </div>

+ 1 - 1
templates/webclient/sharelogin.html

@@ -29,7 +29,7 @@ explicit grant from the SFTPGo Team ([email protected]).
             </div>
         </div>
     </div>
-    {{template "errmsg" .Error}}
+    {{- template "errmsg" .Error}}
     <div class="fv-row mb-10">
         <input data-i18n="[placeholder]login.password" class="form-control form-control-lg form-control-solid" type="password" name="share_password" placeholder="Password" spellcheck="false" required />
     </div>

+ 2 - 2
templates/webclient/shares.html

@@ -237,7 +237,7 @@ explicit grant from the SFTPGo Team ([email protected]).
                                 let info = "";
                                 if (row[5] > 0){
                                     info+= $.t('share.expiration_date', {
-                                        val: new Date(parseInt(row[5], 10)),
+                                        val: parseInt(row[5], 10),
                                         formatParams: {
                                             val: { year: 'numeric', month: 'numeric', day: 'numeric' },
                                         }
@@ -245,7 +245,7 @@ explicit grant from the SFTPGo Team ([email protected]).
                                 }
                                 if (row[6] > 0){
                                     info+= $.t('share.last_use', {
-                                        val: new Date(parseInt(row[6], 10)),
+                                        val: parseInt(row[6], 10),
                                         formatParams: {
                                             val: { year: 'numeric', month: 'numeric', day: 'numeric' },
                                         }

+ 1 - 1
templates/webclient/twofactor-recovery.html

@@ -29,7 +29,7 @@ explicit grant from the SFTPGo Team ([email protected]).
             </div>
         </div>
     </div>
-    {{template "errmsg" .Error}}
+    {{- template "errmsg" .Error}}
     <div class="fv-row mb-10">
         <input data-i18n="[placeholder]login.recovery_code" class="form-control form-control-lg form-control-solid" type="text" name="recovery_code" placeholder="Recovery code" spellcheck="false" required />
     </div>

+ 1 - 1
templates/webclient/twofactor.html

@@ -29,7 +29,7 @@ explicit grant from the SFTPGo Team ([email protected]).
             </div>
         </div>
     </div>
-    {{template "errmsg" .Error}}
+    {{- template "errmsg" .Error}}
     <div class="fv-row mb-10">
         <input data-i18n="[placeholder]login.auth_code" class="form-control form-control-lg form-control-solid" type="text" placeholder="Authentication code" name="passcode" spellcheck="false" required />
     </div>