Sfoglia il codice sorgente

allow to set a default expiration for newly created users

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
Nicola Murino 2 anni fa
parent
commit
048591553a

+ 1 - 1
go.mod

@@ -52,7 +52,7 @@ require (
 	github.com/rs/cors v1.8.3-0.20220619195839-da52b0701de5
 	github.com/rs/xid v1.4.0
 	github.com/rs/zerolog v1.28.0
-	github.com/sftpgo/sdk v0.1.2
+	github.com/sftpgo/sdk v0.1.3-0.20221105153737-bae9afc6b356
 	github.com/shirou/gopsutil/v3 v3.22.10
 	github.com/spf13/afero v1.9.2
 	github.com/spf13/cobra v1.6.1

+ 2 - 2
go.sum

@@ -1457,8 +1457,8 @@ github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg
 github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo=
 github.com/seccomp/libseccomp-golang v0.9.2-0.20210429002308-3879420cc921/go.mod h1:JA8cRccbGaA1s33RQf7Y1+q9gHmZX1yB/z9WDN1C6fg=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
-github.com/sftpgo/sdk v0.1.2 h1:j4V63RuVcYfJAOWV0zRUofa1PlQvKU2ujly0lB7quVA=
-github.com/sftpgo/sdk v0.1.2/go.mod h1:PTp1TfXa+95wHw9yuZu7BA3vmzLqbRkz3gBmMNnwFQg=
+github.com/sftpgo/sdk v0.1.3-0.20221105153737-bae9afc6b356 h1:VwFpy5W/pP0X+082xKU2yu4OAwuk8Qqa8j2ofImJ1bM=
+github.com/sftpgo/sdk v0.1.3-0.20221105153737-bae9afc6b356/go.mod h1:Giy5vj7Gmju0nGlmBNd28DwPo0G0o1nr9XkE+vu3i+o=
 github.com/shirou/gopsutil/v3 v3.22.10 h1:4KMHdfBRYXGF9skjDWiL4RA2N+E8dRdodU/bOZpPoVg=
 github.com/shirou/gopsutil/v3 v3.22.10/go.mod h1:QNza6r4YQoydyCfo6rH0blGfKahgibh4dQmV5xdFkQk=
 github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=

+ 6 - 2
internal/dataprovider/admin.go

@@ -123,6 +123,9 @@ type AdminPreferences struct {
 	//
 	// The settings can be combined
 	HideUserPageSections int `json:"hide_user_page_sections,omitempty"`
+	// Defines the default expiration for newly created users as number of days.
+	// 0 means no expiration
+	DefaultUsersExpiration int `json:"default_users_expiration,omitempty"`
 }
 
 // HideGroups returns true if the groups section should be hidden
@@ -365,7 +368,7 @@ func (a *Admin) validate() error {
 		return err
 	}
 	if config.NamingRules&1 == 0 && !usernameRegex.MatchString(a.Username) {
-		return util.NewValidationError(fmt.Sprintf("username %#v is not valid, the following characters are allowed: a-zA-Z0-9-_.~", a.Username))
+		return util.NewValidationError(fmt.Sprintf("username %q is not valid, the following characters are allowed: a-zA-Z0-9-_.~", a.Username))
 	}
 	if err := a.hashPassword(); err != nil {
 		return err
@@ -574,7 +577,8 @@ func (a *Admin) getACopy() Admin {
 		})
 	}
 	filters.Preferences = AdminPreferences{
-		HideUserPageSections: a.Filters.Preferences.HideUserPageSections,
+		HideUserPageSections:   a.Filters.Preferences.HideUserPageSections,
+		DefaultUsersExpiration: a.Filters.Preferences.DefaultUsersExpiration,
 	}
 	groups := make([]AdminGroupMapping, 0, len(a.Groups))
 	for _, g := range a.Groups {

+ 9 - 0
internal/httpd/api_user.go

@@ -19,6 +19,7 @@ import (
 	"fmt"
 	"net/http"
 	"strconv"
+	"time"
 
 	"github.com/go-chi/render"
 	"github.com/sftpgo/sdk"
@@ -75,7 +76,15 @@ func addUser(w http.ResponseWriter, r *http.Request) {
 		sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
 		return
 	}
+	admin, err := dataprovider.AdminExists(claims.Username)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return
+	}
 	var user dataprovider.User
+	if admin.Filters.Preferences.DefaultUsersExpiration > 0 {
+		user.ExpirationDate = util.GetTimeAsMsSinceEpoch(time.Now().Add(24 * time.Hour * time.Duration(admin.Filters.Preferences.DefaultUsersExpiration)))
+	}
 	err = render.DecodeJSON(r.Body, &user)
 	if err != nil {
 		sendAPIResponse(w, r, err, "", http.StatusBadRequest)

+ 99 - 0
internal/httpd/httpd_test.go

@@ -2722,6 +2722,7 @@ func TestBasicAdminHandling(t *testing.T) {
 
 	admin.Username = altAdminUsername
 	admin.Filters.Preferences.HideUserPageSections = 1 + 4 + 8
+	admin.Filters.Preferences.DefaultUsersExpiration = 30
 	admin, _, err = httpdtest.AddAdmin(admin, http.StatusCreated)
 	assert.NoError(t, err)
 
@@ -2972,6 +2973,74 @@ func TestAdminPasswordHashing(t *testing.T) {
 	assert.NoError(t, err)
 }
 
+func TestDefaultUsersExpiration(t *testing.T) {
+	a := getTestAdmin()
+	a.Username = altAdminUsername
+	a.Password = altAdminPassword
+	a.Filters.Preferences.DefaultUsersExpiration = 30
+	admin, _, err := httpdtest.AddAdmin(a, http.StatusCreated)
+	assert.NoError(t, err)
+
+	token, _, err := httpdtest.GetToken(altAdminUsername, altAdminPassword)
+	assert.NoError(t, err)
+	httpdtest.SetJWTToken(token)
+
+	_, _, err = httpdtest.AddUser(getTestUser(), http.StatusCreated)
+	assert.Error(t, err)
+
+	user, _, err := httpdtest.GetUserByUsername(defaultUsername, http.StatusOK)
+	assert.NoError(t, err)
+	assert.Greater(t, user.ExpirationDate, int64(0))
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+
+	u := getTestUser()
+	u.ExpirationDate = util.GetTimeAsMsSinceEpoch(time.Now().Add(1 * time.Minute))
+
+	_, _, err = httpdtest.AddUser(u, http.StatusCreated)
+	assert.NoError(t, err)
+
+	user, _, err = httpdtest.GetUserByUsername(defaultUsername, http.StatusOK)
+	assert.NoError(t, err)
+	assert.Equal(t, u.ExpirationDate, user.ExpirationDate)
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+
+	httpdtest.SetJWTToken("")
+	_, _, err = httpdtest.AddUser(getTestUser(), http.StatusCreated)
+	assert.NoError(t, err)
+
+	// render the user template page
+	webToken, err := getJWTWebTokenFromTestServer(altAdminUsername, altAdminPassword)
+	assert.NoError(t, err)
+
+	req, err := http.NewRequest(http.MethodGet, webTemplateUser, nil)
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	rr := executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	req, err = http.NewRequest(http.MethodGet, webTemplateUser+fmt.Sprintf("?from=%s", user.Username), nil)
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, webToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
+	_, err = httpdtest.RemoveUser(user, http.StatusOK)
+	assert.NoError(t, err)
+
+	_, err = httpdtest.RemoveAdmin(admin, http.StatusOK)
+	assert.NoError(t, err)
+
+	httpdtest.SetJWTToken(token)
+	_, _, err = httpdtest.AddUser(u, http.StatusNotFound)
+	assert.NoError(t, err)
+
+	httpdtest.SetJWTToken("")
+}
+
 func TestAdminInvalidCredentials(t *testing.T) {
 	req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%v%v", httpBaseURL, tokenPath), nil)
 	assert.NoError(t, err)
@@ -6139,6 +6208,11 @@ func TestProviderErrors(t *testing.T) {
 	setJWTCookieForReq(req, testServerToken)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusInternalServerError, rr)
+	req, err = http.NewRequest(http.MethodGet, webTemplateUser, nil)
+	assert.NoError(t, err)
+	setJWTCookieForReq(req, testServerToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusInternalServerError, rr)
 	req, err = http.NewRequest(http.MethodGet, webGroupsPath+"?qlimit=a", nil)
 	assert.NoError(t, err)
 	setJWTCookieForReq(req, testServerToken)
@@ -16420,6 +16494,7 @@ func TestWebAdminBasicMock(t *testing.T) {
 	form.Add("user_page_hidden_sections", "5")
 	form.Add("user_page_hidden_sections", "6")
 	form.Add("user_page_hidden_sections", "7")
+	form.Set("default_users_expiration", "10")
 	req, _ := http.NewRequest(http.MethodPost, webAdminPath, bytes.NewBuffer([]byte(form.Encode())))
 	req.RemoteAddr = defaultRemoteAddr
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@@ -16438,6 +16513,16 @@ func TestWebAdminBasicMock(t *testing.T) {
 	checkResponseCode(t, http.StatusOK, rr)
 
 	form.Set("status", "1")
+	form.Set("default_users_expiration", "a")
+	req, _ = http.NewRequest(http.MethodPost, webAdminPath, bytes.NewBuffer([]byte(form.Encode())))
+	req.RemoteAddr = defaultRemoteAddr
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	setJWTCookieForReq(req, token)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+	assert.Contains(t, rr.Body.String(), "invalid default users expiration")
+
+	form.Set("default_users_expiration", "10")
 	req, _ = http.NewRequest(http.MethodPost, webAdminPath, bytes.NewBuffer([]byte(form.Encode())))
 	req.RemoteAddr = defaultRemoteAddr
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@@ -16488,6 +16573,7 @@ func TestWebAdminBasicMock(t *testing.T) {
 	secretPayload := admin.Filters.TOTPConfig.Secret.GetPayload()
 	assert.NotEmpty(t, secretPayload)
 	assert.Equal(t, 1+2+4+8+16+32+64, admin.Filters.Preferences.HideUserPageSections)
+	assert.Equal(t, 10, admin.Filters.Preferences.DefaultUsersExpiration)
 
 	adminTOTPConfig = dataprovider.AdminTOTPConfig{
 		Enabled:    true,
@@ -16610,6 +16696,12 @@ func TestWebAdminBasicMock(t *testing.T) {
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusNotFound, rr)
 
+	req, _ = http.NewRequest(http.MethodGet, webUserPath, nil)
+	req.RemoteAddr = defaultRemoteAddr
+	setJWTCookieForReq(req, altToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusOK, rr)
+
 	req, _ = http.NewRequest(http.MethodDelete, path.Join(webAdminPath, altAdminUsername), nil)
 	req.RemoteAddr = defaultRemoteAddr
 	setJWTCookieForReq(req, token)
@@ -16617,6 +16709,13 @@ func TestWebAdminBasicMock(t *testing.T) {
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
 
+	req, _ = http.NewRequest(http.MethodGet, webUserPath, nil)
+	req.RemoteAddr = defaultRemoteAddr
+	setJWTCookieForReq(req, altToken)
+	rr = executeRequest(req)
+	checkResponseCode(t, http.StatusInternalServerError, rr)
+	assert.Contains(t, rr.Body.String(), "unable to get the admin")
+
 	_, err = httpdtest.RemoveAdmin(admin, http.StatusNotFound)
 	assert.NoError(t, err)
 

+ 1 - 0
internal/httpd/internal_test.go

@@ -758,6 +758,7 @@ func TestUpdateWebAdminInvalidClaims(t *testing.T) {
 	form := make(url.Values)
 	form.Set(csrfFormToken, createCSRFToken(""))
 	form.Set("status", "1")
+	form.Set("default_users_expiration", "30")
 	req, _ := http.NewRequest(http.MethodPost, path.Join(webAdminPath, "admin"), bytes.NewBuffer([]byte(form.Encode())))
 	rctx := chi.NewRouteContext()
 	rctx.URLParams.Add("username", "admin")

+ 39 - 15
internal/httpd/webadmin.go

@@ -792,7 +792,7 @@ func (s *httpdServer) renderAddUpdateAdminPage(w http.ResponseWriter, r *http.Re
 }
 
 func (s *httpdServer) renderUserPage(w http.ResponseWriter, r *http.Request, user *dataprovider.User,
-	mode userPageMode, error string,
+	mode userPageMode, error string, admin *dataprovider.Admin,
 ) {
 	folders, err := s.getWebVirtualFolders(w, r, defaultQueryLimit, true)
 	if err != nil {
@@ -825,12 +825,7 @@ func (s *httpdServer) renderUserPage(w http.ResponseWriter, r *http.Request, use
 	}
 	user.FsConfig.RedactedSecret = redactedSecret
 	basePage := s.getBasePageData(title, currentURL, r)
-	if (mode == userPageModeAdd || mode == userPageModeTemplate) && len(user.Groups) == 0 {
-		admin, err := dataprovider.AdminExists(basePage.LoggedAdmin.Username)
-		if err != nil {
-			s.renderInternalServerErrorPage(w, r, err)
-			return
-		}
+	if (mode == userPageModeAdd || mode == userPageModeTemplate) && len(user.Groups) == 0 && admin != nil {
 		for _, group := range admin.Groups {
 			user.Groups = append(user.Groups, sdk.GroupMapping{
 				Name: group.Name,
@@ -1587,6 +1582,14 @@ func getAdminFromPostFields(r *http.Request) (dataprovider.Admin, error) {
 	admin.AdditionalInfo = r.Form.Get("additional_info")
 	admin.Description = r.Form.Get("description")
 	admin.Filters.Preferences.HideUserPageSections = getAdminHiddenUserPageSections(r)
+	admin.Filters.Preferences.DefaultUsersExpiration = 0
+	if val := r.Form.Get("default_users_expiration"); val != "" {
+		defaultUsersExpiration, err := strconv.ParseInt(r.Form.Get("default_users_expiration"), 10, 64)
+		if err != nil {
+			return admin, fmt.Errorf("invalid default users expiration: %w", err)
+		}
+		admin.Filters.Preferences.DefaultUsersExpiration = int(defaultUsersExpiration)
+	}
 	for k := range r.Form {
 		if strings.HasPrefix(k, "group") {
 			groupName := strings.TrimSpace(r.Form.Get(k))
@@ -2646,6 +2649,12 @@ func (s *httpdServer) handleWebTemplateFolderPost(w http.ResponseWriter, r *http
 
 func (s *httpdServer) handleWebTemplateUserGet(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	tokenAdmin := getAdminFromToken(r)
+	admin, err := dataprovider.AdminExists(tokenAdmin.Username)
+	if err != nil {
+		s.renderInternalServerErrorPage(w, r, fmt.Errorf("unable to get the admin %q: %w", tokenAdmin.Username, err))
+		return
+	}
 	if r.URL.Query().Get("from") != "" {
 		username := r.URL.Query().Get("from")
 		user, err := dataprovider.UserExists(username)
@@ -2654,7 +2663,10 @@ func (s *httpdServer) handleWebTemplateUserGet(w http.ResponseWriter, r *http.Re
 			user.PublicKeys = nil
 			user.Email = ""
 			user.Description = ""
-			s.renderUserPage(w, r, &user, userPageModeTemplate, "")
+			if user.ExpirationDate == 0 && admin.Filters.Preferences.DefaultUsersExpiration > 0 {
+				user.ExpirationDate = util.GetTimeAsMsSinceEpoch(time.Now().Add(24 * time.Hour * time.Duration(admin.Filters.Preferences.DefaultUsersExpiration)))
+			}
+			s.renderUserPage(w, r, &user, userPageModeTemplate, "", &admin)
 		} else if _, ok := err.(*util.RecordNotFoundError); ok {
 			s.renderNotFoundPage(w, r, err)
 		} else {
@@ -2667,7 +2679,10 @@ func (s *httpdServer) handleWebTemplateUserGet(w http.ResponseWriter, r *http.Re
 				"/": {dataprovider.PermAny},
 			},
 		}}
-		s.renderUserPage(w, r, &user, userPageModeTemplate, "")
+		if admin.Filters.Preferences.DefaultUsersExpiration > 0 {
+			user.ExpirationDate = util.GetTimeAsMsSinceEpoch(time.Now().Add(24 * time.Hour * time.Duration(admin.Filters.Preferences.DefaultUsersExpiration)))
+		}
+		s.renderUserPage(w, r, &user, userPageModeTemplate, "", &admin)
 	}
 }
 
@@ -2729,13 +2744,22 @@ func (s *httpdServer) handleWebTemplateUserPost(w http.ResponseWriter, r *http.R
 
 func (s *httpdServer) handleWebAddUserGet(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	tokenAdmin := getAdminFromToken(r)
+	admin, err := dataprovider.AdminExists(tokenAdmin.Username)
+	if err != nil {
+		s.renderInternalServerErrorPage(w, r, fmt.Errorf("unable to get the admin %q: %w", tokenAdmin.Username, err))
+		return
+	}
 	user := dataprovider.User{BaseUser: sdk.BaseUser{
 		Status: 1,
 		Permissions: map[string][]string{
 			"/": {dataprovider.PermAny},
 		}},
 	}
-	s.renderUserPage(w, r, &user, userPageModeAdd, "")
+	if admin.Filters.Preferences.DefaultUsersExpiration > 0 {
+		user.ExpirationDate = util.GetTimeAsMsSinceEpoch(time.Now().Add(24 * time.Hour * time.Duration(admin.Filters.Preferences.DefaultUsersExpiration)))
+	}
+	s.renderUserPage(w, r, &user, userPageModeAdd, "", &admin)
 }
 
 func (s *httpdServer) handleWebUpdateUserGet(w http.ResponseWriter, r *http.Request) {
@@ -2743,7 +2767,7 @@ func (s *httpdServer) handleWebUpdateUserGet(w http.ResponseWriter, r *http.Requ
 	username := getURLParam(r, "username")
 	user, err := dataprovider.UserExists(username)
 	if err == nil {
-		s.renderUserPage(w, r, &user, userPageModeUpdate, "")
+		s.renderUserPage(w, r, &user, userPageModeUpdate, "", nil)
 	} else if _, ok := err.(*util.RecordNotFoundError); ok {
 		s.renderNotFoundPage(w, r, err)
 	} else {
@@ -2760,7 +2784,7 @@ func (s *httpdServer) handleWebAddUserPost(w http.ResponseWriter, r *http.Reques
 	}
 	user, err := getUserFromPostFields(r)
 	if err != nil {
-		s.renderUserPage(w, r, &user, userPageModeAdd, err.Error())
+		s.renderUserPage(w, r, &user, userPageModeAdd, err.Error(), nil)
 		return
 	}
 	ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
@@ -2775,7 +2799,7 @@ func (s *httpdServer) handleWebAddUserPost(w http.ResponseWriter, r *http.Reques
 	})
 	err = dataprovider.AddUser(&user, claims.Username, ipAddr)
 	if err != nil {
-		s.renderUserPage(w, r, &user, userPageModeAdd, err.Error())
+		s.renderUserPage(w, r, &user, userPageModeAdd, err.Error(), nil)
 		return
 	}
 	http.Redirect(w, r, webUsersPath, http.StatusSeeOther)
@@ -2799,7 +2823,7 @@ func (s *httpdServer) handleWebUpdateUserPost(w http.ResponseWriter, r *http.Req
 	}
 	updatedUser, err := getUserFromPostFields(r)
 	if err != nil {
-		s.renderUserPage(w, r, &user, userPageModeUpdate, err.Error())
+		s.renderUserPage(w, r, &user, userPageModeUpdate, err.Error(), nil)
 		return
 	}
 	ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
@@ -2828,7 +2852,7 @@ func (s *httpdServer) handleWebUpdateUserPost(w http.ResponseWriter, r *http.Req
 
 	err = dataprovider.UpdateUser(&updatedUser, claims.Username, ipAddr)
 	if err != nil {
-		s.renderUserPage(w, r, &updatedUser, userPageModeUpdate, err.Error())
+		s.renderUserPage(w, r, &updatedUser, userPageModeUpdate, err.Error(), nil)
 		return
 	}
 	if r.Form.Get("disconnect") != "" {

+ 3 - 0
internal/httpdtest/httpdtest.go

@@ -1645,6 +1645,9 @@ func compareAdminFilters(expected, actual dataprovider.AdminFilters) error {
 	if expected.Preferences.HideUserPageSections != actual.Preferences.HideUserPageSections {
 		return errors.New("hide user page sections mismatch")
 	}
+	if expected.Preferences.DefaultUsersExpiration != actual.Preferences.DefaultUsersExpiration {
+		return errors.New("default users expiration mismatch")
+	}
 	return nil
 }
 

+ 3 - 16
openapi/openapi.yaml

@@ -2545,22 +2545,6 @@ paths:
           application/json:
             schema:
               $ref: '#/components/schemas/Admin'
-            examples:
-              example-1:
-                value:
-                  id: 1
-                  status: 0
-                  username: string
-                  description: string
-                  password: pa$$word
-                  email: user@example.com
-                  permissions:
-                    - '*'
-                  filters:
-                    allow_list:
-                      - 192.0.2.0/24
-                      - '2001:db8::/32'
-                  additional_info: string
       responses:
         '201':
           description: successful operation
@@ -5233,6 +5217,9 @@ components:
         hide_user_page_sections:
           type: integer
           description: 'Allow to hide some sections from the user page. These are not security settings and are not enforced server side in any way. They are only intended to simplify the user page in the WebAdmin UI. 1 means hide groups section, 2 means hide filesystem section, "users_base_dir" must be set in the config file otherwise this setting is ignored, 4 means hide virtual folders section, 8 means hide profile section, 16 means hide ACLs section, 32 means hide disk and bandwidth quota limits section, 64 means hide advanced settings section. The settings can be combined'
+        default_users_expiration:
+          type: integer
+          description: 'Defines the default expiration for newly created users as number of days. 0 means no expiration'
     AdminFilters:
       type: object
       properties:

+ 10 - 0
templates/webadmin/admin.html

@@ -182,6 +182,16 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                             </select>
                         </div>
                     </div>
+                    <div class="form-group row">
+                        <label for="idDefaultUsersExpiration" class="col-sm-2 col-form-label">Default users expiration</label>
+                        <div class="col-sm-10">
+                            <input type="number" class="form-control" id="idDefaultUsersExpiration" name="default_users_expiration"
+                                value="{{.Admin.Filters.Preferences.DefaultUsersExpiration}}" min="0" aria-describedby="defaultUsersExpirationHelpBlock">
+                            <small id="defaultUsersExpirationHelpBlock" class="form-text text-muted">
+                                Default expiration for newly created users as number of days
+                            </small>
+                        </div>
+                    </div>
                 </div>
             </div>
 

+ 4 - 0
templates/webadmin/user.html

@@ -1057,6 +1057,10 @@ along with this program.  If not, see <https://www.gnu.org/licenses/>.
                 showClear: false,
                 showClose: true,
                 showToday: false
+            },
+            widgetPositioning: {
+                horizontal: 'auto',
+                vertical: 'bottom'
             }
         });