web ui: allow to create multiple users from a template

This commit is contained in:
Nicola Murino 2021-01-25 21:31:33 +01:00
parent 5fcbf2528f
commit 54321c5240
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
14 changed files with 532 additions and 86 deletions

View file

@ -397,7 +397,7 @@ func (p *BoltProvider) userExists(username string) (User, error) {
} }
func (p *BoltProvider) addUser(user *User) error { func (p *BoltProvider) addUser(user *User) error {
err := validateUser(user) err := ValidateUser(user)
if err != nil { if err != nil {
return err return err
} }
@ -437,7 +437,7 @@ func (p *BoltProvider) addUser(user *User) error {
} }
func (p *BoltProvider) updateUser(user *User) error { func (p *BoltProvider) updateUser(user *User) error {
err := validateUser(user) err := ValidateUser(user)
if err != nil { if err != nil {
return err return err
} }
@ -1003,7 +1003,7 @@ func updateV4BoltCompatUser(dbHandle *bolt.DB, user compatUserV4) error {
} }
func updateV4BoltUser(dbHandle *bolt.DB, user User) error { func updateV4BoltUser(dbHandle *bolt.DB, user User) error {
err := validateUser(&user) err := ValidateUser(&user)
if err != nil { if err != nil {
return err return err
} }

View file

@ -1323,7 +1323,9 @@ func validateFolder(folder *vfs.BaseVirtualFolder) error {
return nil return nil
} }
func validateUser(user *User) error { // ValidateUser returns an error if the user is not valid
// FIXME: this should be defined as User struct method
func ValidateUser(user *User) error {
user.SetEmptySecretsIfNil() user.SetEmptySecretsIfNil()
buildUserHomeDir(user) buildUserHomeDir(user)
if err := validateBaseParams(user); err != nil { if err := validateBaseParams(user); err != nil {

View file

@ -185,7 +185,7 @@ func (p *MemoryProvider) addUser(user *User) error {
if p.dbHandle.isClosed { if p.dbHandle.isClosed {
return errMemoryProviderClosed return errMemoryProviderClosed
} }
err := validateUser(user) err := ValidateUser(user)
if err != nil { if err != nil {
return err return err
} }
@ -211,7 +211,7 @@ func (p *MemoryProvider) updateUser(user *User) error {
if p.dbHandle.isClosed { if p.dbHandle.isClosed {
return errMemoryProviderClosed return errMemoryProviderClosed
} }
err := validateUser(user) err := ValidateUser(user)
if err != nil { if err != nil {
return err return err
} }

View file

@ -306,7 +306,7 @@ func sqlCommonUpdateLastLogin(username string, dbHandle *sql.DB) error {
} }
func sqlCommonAddUser(user *User, dbHandle *sql.DB) error { func sqlCommonAddUser(user *User, dbHandle *sql.DB) error {
err := validateUser(user) err := ValidateUser(user)
if err != nil { if err != nil {
return err return err
} }
@ -360,7 +360,7 @@ func sqlCommonAddUser(user *User, dbHandle *sql.DB) error {
} }
func sqlCommonUpdateUser(user *User, dbHandle *sql.DB) error { func sqlCommonUpdateUser(user *User, dbHandle *sql.DB) error {
err := validateUser(user) err := ValidateUser(user)
if err != nil { if err != nil {
return err return err
} }

View file

@ -261,6 +261,16 @@ func (u *User) HideConfidentialData() {
} }
} }
// SetEmptySecrets sets to empty any user secret
func (u *User) SetEmptySecrets() {
u.FsConfig.S3Config.AccessSecret = kms.NewEmptySecret()
u.FsConfig.GCSConfig.Credentials = kms.NewEmptySecret()
u.FsConfig.AzBlobConfig.AccountKey = kms.NewEmptySecret()
u.FsConfig.CryptConfig.Passphrase = kms.NewEmptySecret()
u.FsConfig.SFTPConfig.Password = kms.NewEmptySecret()
u.FsConfig.SFTPConfig.PrivateKey = kms.NewEmptySecret()
}
// DecryptSecrets tries to decrypts kms secrets // DecryptSecrets tries to decrypts kms secrets
func (u *User) DecryptSecrets() error { func (u *User) DecryptSecrets() error {
switch u.FsConfig.Provider { switch u.FsConfig.Provider {
@ -801,12 +811,12 @@ func (u *User) GetExpirationDateAsString() string {
} }
// GetAllowedIPAsString returns the allowed IP as comma separated string // GetAllowedIPAsString returns the allowed IP as comma separated string
func (u User) GetAllowedIPAsString() string { func (u *User) GetAllowedIPAsString() string {
return strings.Join(u.Filters.AllowedIP, ",") return strings.Join(u.Filters.AllowedIP, ",")
} }
// GetDeniedIPAsString returns the denied IP as comma separated string // GetDeniedIPAsString returns the denied IP as comma separated string
func (u User) GetDeniedIPAsString() string { func (u *User) GetDeniedIPAsString() string {
return strings.Join(u.Filters.DeniedIP, ",") return strings.Join(u.Filters.DeniedIP, ",")
} }

View file

@ -60,6 +60,7 @@ const (
webScanVFolderPath = "/web/folder-quota-scans" webScanVFolderPath = "/web/folder-quota-scans"
webQuotaScanPath = "/web/quota-scans" webQuotaScanPath = "/web/quota-scans"
webChangeAdminPwdPath = "/web/changepwd/admin" webChangeAdminPwdPath = "/web/changepwd/admin"
webTemplateUser = "/web/template/user"
webStaticFilesPath = "/static" webStaticFilesPath = "/static"
// MaxRestoreSize defines the max size for the loaddata input file // MaxRestoreSize defines the max size for the loaddata input file
MaxRestoreSize = 10485760 // 10 MB MaxRestoreSize = 10485760 // 10 MB

View file

@ -76,6 +76,7 @@ const (
webMaintenancePath = "/web/maintenance" webMaintenancePath = "/web/maintenance"
webRestorePath = "/web/restore" webRestorePath = "/web/restore"
webChangeAdminPwdPath = "/web/changepwd/admin" webChangeAdminPwdPath = "/web/changepwd/admin"
webTemplateUser = "/web/template/user"
httpBaseURL = "http://127.0.0.1:8081" httpBaseURL = "http://127.0.0.1:8081"
configDir = ".." configDir = ".."
httpsCert = `-----BEGIN CERTIFICATE----- httpsCert = `-----BEGIN CERTIFICATE-----
@ -2214,11 +2215,16 @@ func TestProviderErrors(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
err = os.Remove(backupFilePath) err = os.Remove(backupFilePath)
assert.NoError(t, err) assert.NoError(t, err)
req, err := http.NewRequest(http.MethodGet, webUserPath+"?cloneFrom=user", nil) req, err := http.NewRequest(http.MethodGet, webUserPath+"?clone-from=user", nil)
assert.NoError(t, err) assert.NoError(t, err)
setJWTCookieForReq(req, testServerToken) setJWTCookieForReq(req, testServerToken)
rr := executeRequest(req) rr := executeRequest(req)
checkResponseCode(t, http.StatusInternalServerError, rr) checkResponseCode(t, http.StatusInternalServerError, rr)
req, err = http.NewRequest(http.MethodGet, webTemplateUser+"?from=auser", nil)
assert.NoError(t, err)
setJWTCookieForReq(req, testServerToken)
rr = executeRequest(req)
checkResponseCode(t, http.StatusInternalServerError, rr)
err = config.LoadConfig(configDir, "") err = config.LoadConfig(configDir, "")
assert.NoError(t, err) assert.NoError(t, err)
providerConf := config.GetProviderConf() providerConf := config.GetProviderConf()
@ -4439,49 +4445,163 @@ func TestWebUserUpdateMock(t *testing.T) {
checkResponseCode(t, http.StatusOK, rr) checkResponseCode(t, http.StatusOK, rr)
} }
func TestRenderUserTemplateMock(t *testing.T) {
token, err := getJWTTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
req, err := http.NewRequest(http.MethodGet, webTemplateUser, nil)
assert.NoError(t, err)
setJWTCookieForReq(req, token)
rr := executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodGet, webTemplateUser+fmt.Sprintf("?from=%v", user.Username), nil)
assert.NoError(t, err)
setJWTCookieForReq(req, token)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
req, err = http.NewRequest(http.MethodGet, webTemplateUser+"?from=unknown", nil)
assert.NoError(t, err)
setJWTCookieForReq(req, token)
rr = executeRequest(req)
checkResponseCode(t, http.StatusNotFound, rr)
_, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err)
}
func TestRenderWebCloneUserMock(t *testing.T) { func TestRenderWebCloneUserMock(t *testing.T) {
token, err := getJWTTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) token, err := getJWTTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err) assert.NoError(t, err)
user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated)
assert.NoError(t, err) assert.NoError(t, err)
req, err := http.NewRequest(http.MethodGet, webUserPath+fmt.Sprintf("?cloneFrom=%v", user.Username), nil) req, err := http.NewRequest(http.MethodGet, webUserPath+fmt.Sprintf("?clone-from=%v", user.Username), nil)
assert.NoError(t, err) assert.NoError(t, err)
setJWTCookieForReq(req, token) setJWTCookieForReq(req, token)
rr := executeRequest(req) rr := executeRequest(req)
checkResponseCode(t, http.StatusOK, rr) checkResponseCode(t, http.StatusOK, rr)
req, err = http.NewRequest(http.MethodGet, webUserPath+fmt.Sprintf("?cloneFrom=%v", altAdminPassword), nil) req, err = http.NewRequest(http.MethodGet, webUserPath+fmt.Sprintf("?clone-from=%v", altAdminPassword), nil)
assert.NoError(t, err) assert.NoError(t, err)
setJWTCookieForReq(req, token) setJWTCookieForReq(req, token)
rr = executeRequest(req) rr = executeRequest(req)
checkResponseCode(t, http.StatusNotFound, rr) checkResponseCode(t, http.StatusNotFound, rr)
if config.GetProviderConf().Driver != "memory" {
user.FsConfig = dataprovider.Filesystem{
Provider: dataprovider.CryptedFilesystemProvider,
CryptConfig: vfs.CryptFsConfig{
Passphrase: kms.NewPlainSecret("secret"),
},
}
err = user.FsConfig.CryptConfig.Passphrase.Encrypt()
assert.NoError(t, err)
user.FsConfig.CryptConfig.Passphrase.SetStatus(kms.SecretStatusAWS)
user.Password = defaultPassword
err = dataprovider.UpdateUser(&user)
assert.NoError(t, err)
req, err = http.NewRequest(http.MethodGet, webUserPath+fmt.Sprintf("?cloneFrom=%v", user.Username), nil)
assert.NoError(t, err)
setJWTCookieForReq(req, token)
rr = executeRequest(req)
checkResponseCode(t, http.StatusInternalServerError, rr)
}
_, err = httpdtest.RemoveUser(user, http.StatusOK) _, err = httpdtest.RemoveUser(user, http.StatusOK)
assert.NoError(t, err) assert.NoError(t, err)
} }
func TestUserTemplateMock(t *testing.T) {
token, err := getJWTTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err)
user := getTestUser()
user.FsConfig.Provider = dataprovider.S3FilesystemProvider
user.FsConfig.S3Config.Bucket = "test"
user.FsConfig.S3Config.Region = "eu-central-1"
user.FsConfig.S3Config.AccessKey = "%username%"
user.FsConfig.S3Config.KeyPrefix = "somedir/subdir/"
user.FsConfig.S3Config.UploadPartSize = 5
user.FsConfig.S3Config.UploadConcurrency = 4
form := make(url.Values)
form.Set("username", user.Username)
form.Set("home_dir", filepath.Join(os.TempDir(), "%username%"))
form.Set("uid", "0")
form.Set("gid", strconv.FormatInt(int64(user.GID), 10))
form.Set("max_sessions", strconv.FormatInt(int64(user.MaxSessions), 10))
form.Set("quota_size", strconv.FormatInt(user.QuotaSize, 10))
form.Set("quota_files", strconv.FormatInt(int64(user.QuotaFiles), 10))
form.Set("upload_bandwidth", "0")
form.Set("download_bandwidth", "0")
form.Set("permissions", "*")
form.Set("sub_dirs_permissions", "")
form.Set("status", strconv.Itoa(user.Status))
form.Set("expiration_date", "2020-01-01 00:00:00")
form.Set("allowed_ip", "")
form.Set("denied_ip", "")
form.Set("fs_provider", "1")
form.Set("s3_bucket", user.FsConfig.S3Config.Bucket)
form.Set("s3_region", user.FsConfig.S3Config.Region)
form.Set("s3_access_key", "%username%")
form.Set("s3_access_secret", "%password%")
form.Set("s3_key_prefix", "base/%username%")
form.Set("allowed_extensions", "/dir1::.jpg,.png")
form.Set("denied_extensions", "/dir2::.zip")
form.Set("max_upload_file_size", "0")
// test invalid s3_upload_part_size
form.Set("s3_upload_part_size", "a")
b, contentType, _ := getMultipartFormData(form, "", "")
req, _ := http.NewRequest(http.MethodPost, path.Join(webTemplateUser), &b)
setJWTCookieForReq(req, token)
req.Header.Set("Content-Type", contentType)
rr := executeRequest(req)
checkResponseCode(t, http.StatusBadRequest, rr)
form.Set("s3_upload_part_size", strconv.FormatInt(user.FsConfig.S3Config.UploadPartSize, 10))
form.Set("s3_upload_concurrency", strconv.Itoa(user.FsConfig.S3Config.UploadConcurrency))
b, contentType, _ = getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, path.Join(webTemplateUser), &b)
setJWTCookieForReq(req, token)
req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
checkResponseCode(t, http.StatusBadRequest, rr)
form.Set("users", "user1::password1::invalid-pkey")
b, contentType, _ = getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, path.Join(webTemplateUser), &b)
setJWTCookieForReq(req, token)
req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
checkResponseCode(t, http.StatusBadRequest, rr)
require.Contains(t, rr.Body.String(), "Error validating user")
form.Set("users", "user1:password1")
b, contentType, _ = getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, path.Join(webTemplateUser), &b)
setJWTCookieForReq(req, token)
req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
checkResponseCode(t, http.StatusBadRequest, rr)
require.Contains(t, rr.Body.String(), "No valid users found, export is not possible")
form.Set("users", "user1::password1\nuser2::password2::"+testPubKey+"\nuser3::::")
b, contentType, _ = getMultipartFormData(form, "", "")
req, _ = http.NewRequest(http.MethodPost, path.Join(webTemplateUser), &b)
setJWTCookieForReq(req, token)
req.Header.Set("Content-Type", contentType)
rr = executeRequest(req)
checkResponseCode(t, http.StatusOK, rr)
var dump dataprovider.BackupData
err = json.Unmarshal(rr.Body.Bytes(), &dump)
require.NoError(t, err)
require.Len(t, dump.Users, 2)
user1 := dump.Users[0]
user2 := dump.Users[1]
require.Equal(t, "user1", user1.Username)
require.Equal(t, dataprovider.S3FilesystemProvider, user1.FsConfig.Provider)
require.Equal(t, "user2", user2.Username)
require.Equal(t, dataprovider.S3FilesystemProvider, user2.FsConfig.Provider)
require.Len(t, user2.PublicKeys, 1)
require.Equal(t, filepath.Join(os.TempDir(), user1.Username), user1.HomeDir)
require.Equal(t, filepath.Join(os.TempDir(), user2.Username), user2.HomeDir)
require.Equal(t, user1.Username, user1.FsConfig.S3Config.AccessKey)
require.Equal(t, user2.Username, user2.FsConfig.S3Config.AccessKey)
require.Equal(t, path.Join("base", user1.Username)+"/", user1.FsConfig.S3Config.KeyPrefix)
require.Equal(t, path.Join("base", user2.Username)+"/", user2.FsConfig.S3Config.KeyPrefix)
require.True(t, user1.FsConfig.S3Config.AccessSecret.IsEncrypted())
err = user1.FsConfig.S3Config.AccessSecret.Decrypt()
require.NoError(t, err)
require.Equal(t, "password1", user1.FsConfig.S3Config.AccessSecret.GetPayload())
require.True(t, user2.FsConfig.S3Config.AccessSecret.IsEncrypted())
err = user2.FsConfig.S3Config.AccessSecret.Decrypt()
require.NoError(t, err)
require.Equal(t, "password2", user2.FsConfig.S3Config.AccessSecret.GetPayload())
}
func TestWebUserS3Mock(t *testing.T) { func TestWebUserS3Mock(t *testing.T) {
token, err := getJWTTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) token, err := getJWTTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
assert.NoError(t, err) assert.NoError(t, err)

View file

@ -29,7 +29,9 @@ import (
"github.com/drakkan/sftpgo/common" "github.com/drakkan/sftpgo/common"
"github.com/drakkan/sftpgo/dataprovider" "github.com/drakkan/sftpgo/dataprovider"
"github.com/drakkan/sftpgo/kms"
"github.com/drakkan/sftpgo/utils" "github.com/drakkan/sftpgo/utils"
"github.com/drakkan/sftpgo/vfs"
) )
const ( const (
@ -705,3 +707,51 @@ func TestVerifyTLSConnection(t *testing.T) {
certMgr = oldCertMgr certMgr = oldCertMgr
} }
func TestGetUserFromTemplate(t *testing.T) {
user := dataprovider.User{
Status: 1,
}
user.VirtualFolders = append(user.VirtualFolders, vfs.VirtualFolder{
BaseVirtualFolder: vfs.BaseVirtualFolder{
MappedPath: "dir%username%",
},
})
username := "userTemplate"
password := "pwdTemplate"
templateFields := userTemplateFields{
Username: username,
Password: password,
}
userTemplate := getUserFromTemplate(user, templateFields)
require.Len(t, userTemplate.VirtualFolders, 1)
require.Equal(t, "dir"+username, userTemplate.VirtualFolders[0].MappedPath)
user.FsConfig.Provider = dataprovider.CryptedFilesystemProvider
user.FsConfig.CryptConfig.Passphrase = kms.NewPlainSecret("%password%")
userTemplate = getUserFromTemplate(user, templateFields)
require.Equal(t, password, userTemplate.FsConfig.CryptConfig.Passphrase.GetPayload())
user.FsConfig.Provider = dataprovider.GCSFilesystemProvider
user.FsConfig.GCSConfig.KeyPrefix = "%username%%password%"
userTemplate = getUserFromTemplate(user, templateFields)
require.Equal(t, username+password, userTemplate.FsConfig.GCSConfig.KeyPrefix)
user.FsConfig.Provider = dataprovider.AzureBlobFilesystemProvider
user.FsConfig.AzBlobConfig.KeyPrefix = "a%username%"
user.FsConfig.AzBlobConfig.AccountKey = kms.NewPlainSecret("pwd%password%%username%")
userTemplate = getUserFromTemplate(user, templateFields)
require.Equal(t, "a"+username, userTemplate.FsConfig.AzBlobConfig.KeyPrefix)
require.Equal(t, "pwd"+password+username, userTemplate.FsConfig.AzBlobConfig.AccountKey.GetPayload())
user.FsConfig.Provider = dataprovider.SFTPFilesystemProvider
user.FsConfig.SFTPConfig.Prefix = "%username%"
user.FsConfig.SFTPConfig.Username = "sftp_%username%"
user.FsConfig.SFTPConfig.Password = kms.NewPlainSecret("sftp%password%")
userTemplate = getUserFromTemplate(user, templateFields)
require.Equal(t, username, userTemplate.FsConfig.SFTPConfig.Prefix)
require.Equal(t, "sftp_"+username, userTemplate.FsConfig.SFTPConfig.Username)
require.Equal(t, "sftp"+password, userTemplate.FsConfig.SFTPConfig.Password.GetPayload())
}

View file

@ -369,6 +369,9 @@ func (s *httpdServer) initializeRouter() {
router.With(checkPerm(dataprovider.PermAdminManageSystem)).Get(webMaintenancePath, handleWebMaintenance) router.With(checkPerm(dataprovider.PermAdminManageSystem)).Get(webMaintenancePath, handleWebMaintenance)
router.With(checkPerm(dataprovider.PermAdminManageSystem)).Get(webBackupPath, dumpData) router.With(checkPerm(dataprovider.PermAdminManageSystem)).Get(webBackupPath, dumpData)
router.With(checkPerm(dataprovider.PermAdminManageSystem)).Post(webRestorePath, handleWebRestore) router.With(checkPerm(dataprovider.PermAdminManageSystem)).Post(webRestorePath, handleWebRestore)
router.With(checkPerm(dataprovider.PermAdminManageSystem), s.refreshCookie).
Get(webTemplateUser, handleWebTemplateUserGet)
router.With(checkPerm(dataprovider.PermAdminManageSystem)).Post(webTemplateUser, handleWebTemplateUserPost)
}) })
router.Group(func(router chi.Router) { router.Group(func(router chi.Router) {

View file

@ -13,6 +13,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/go-chi/render"
"github.com/drakkan/sftpgo/common" "github.com/drakkan/sftpgo/common"
"github.com/drakkan/sftpgo/dataprovider" "github.com/drakkan/sftpgo/dataprovider"
"github.com/drakkan/sftpgo/kms" "github.com/drakkan/sftpgo/kms"
@ -21,6 +23,14 @@ import (
"github.com/drakkan/sftpgo/vfs" "github.com/drakkan/sftpgo/vfs"
) )
type userPageMode int
const (
userPageModeAdd userPageMode = iota + 1
userPageModeUpdate
userPageModeTemplate
)
const ( const (
templateBase = "base.html" templateBase = "base.html"
templateUsers = "users.html" templateUsers = "users.html"
@ -62,6 +72,7 @@ type basePage struct {
CurrentURL string CurrentURL string
UsersURL string UsersURL string
UserURL string UserURL string
UserTemplateURL string
AdminsURL string AdminsURL string
AdminURL string AdminURL string
QuotaScanURL string QuotaScanURL string
@ -110,7 +121,7 @@ type statusPage struct {
type userPage struct { type userPage struct {
basePage basePage
User dataprovider.User User *dataprovider.User
RootPerms []string RootPerms []string
Error string Error string
ValidPerms []string ValidPerms []string
@ -118,7 +129,7 @@ type userPage struct {
ValidProtocols []string ValidProtocols []string
RootDirPerms []string RootDirPerms []string
RedactedSecret string RedactedSecret string
IsAdd bool Mode userPageMode
} }
type adminPage struct { type adminPage struct {
@ -158,6 +169,12 @@ type loginPage struct {
Error string Error string
} }
type userTemplateFields struct {
Username string
Password string
PublicKey string
}
func loadTemplates(templatesPath string) { func loadTemplates(templatesPath string) {
usersPaths := []string{ usersPaths := []string{
filepath.Join(templatesPath, templateBase), filepath.Join(templatesPath, templateBase),
@ -239,6 +256,7 @@ func getBasePageData(title, currentURL string, r *http.Request) basePage {
CurrentURL: currentURL, CurrentURL: currentURL,
UsersURL: webUsersPath, UsersURL: webUsersPath,
UserURL: webUserPath, UserURL: webUserPath,
UserTemplateURL: webTemplateUser,
AdminsURL: webAdminsPath, AdminsURL: webAdminsPath,
AdminURL: webAdminPath, AdminURL: webAdminPath,
FoldersURL: webFoldersPath, FoldersURL: webFoldersPath,
@ -337,27 +355,23 @@ func renderAddUpdateAdminPage(w http.ResponseWriter, r *http.Request, admin *dat
renderTemplate(w, templateAdmin, data) renderTemplate(w, templateAdmin, data)
} }
func renderAddUserPage(w http.ResponseWriter, r *http.Request, user dataprovider.User, error string) { func renderUserPage(w http.ResponseWriter, r *http.Request, user *dataprovider.User, mode userPageMode, error string) {
user.SetEmptySecretsIfNil() user.SetEmptySecretsIfNil()
data := userPage{ var title, currentURL string
basePage: getBasePageData("Add a new user", webUserPath, r), switch mode {
IsAdd: true, case userPageModeAdd:
Error: error, title = "Add a new user"
User: user, currentURL = webUserPath
ValidPerms: dataprovider.ValidPerms, case userPageModeUpdate:
ValidSSHLoginMethods: dataprovider.ValidSSHLoginMethods, title = "Update user"
ValidProtocols: dataprovider.ValidProtocols, currentURL = fmt.Sprintf("%v/%v", webUserPath, url.PathEscape(user.Username))
RootDirPerms: user.GetPermissionsForPath("/"), case userPageModeTemplate:
RedactedSecret: redactedSecret, title = "User template"
currentURL = webTemplateUser
} }
renderTemplate(w, templateUser, data)
}
func renderUpdateUserPage(w http.ResponseWriter, r *http.Request, user dataprovider.User, error string) {
user.SetEmptySecretsIfNil()
data := userPage{ data := userPage{
basePage: getBasePageData("Update user", fmt.Sprintf("%v/%v", webUserPath, url.PathEscape(user.Username)), r), basePage: getBasePageData(title, currentURL, r),
IsAdd: false, Mode: mode,
Error: error, Error: error,
User: user, User: user,
ValidPerms: dataprovider.ValidPerms, ValidPerms: dataprovider.ValidPerms,
@ -378,6 +392,33 @@ func renderAddFolderPage(w http.ResponseWriter, r *http.Request, folder vfs.Base
renderTemplate(w, templateFolder, data) renderTemplate(w, templateFolder, data)
} }
func getUsersForTemplate(r *http.Request) []userTemplateFields {
var res []userTemplateFields
formValue := r.Form.Get("users")
for _, cleaned := range getSliceFromDelimitedValues(formValue, "\n") {
if strings.Contains(cleaned, "::") {
mapping := strings.Split(cleaned, "::")
if len(mapping) > 1 {
username := strings.TrimSpace(mapping[0])
password := strings.TrimSpace(mapping[1])
var publicKey string
if len(mapping) > 2 {
publicKey = strings.TrimSpace(mapping[2])
}
if username == "" || (password == "" && publicKey == "") {
continue
}
res = append(res, userTemplateFields{
Username: username,
Password: password,
PublicKey: publicKey,
})
}
}
}
return res
}
func getVirtualFoldersFromPostFields(r *http.Request) []vfs.VirtualFolder { func getVirtualFoldersFromPostFields(r *http.Request) []vfs.VirtualFolder {
var virtualFolders []vfs.VirtualFolder var virtualFolders []vfs.VirtualFolder
formValue := r.Form.Get("virtual_folders") formValue := r.Form.Get("virtual_folders")
@ -429,7 +470,7 @@ func getUserPermissionsFromPostFields(r *http.Request) map[string][]string {
perms = append(perms, cleanedPerm) perms = append(perms, cleanedPerm)
} }
} }
if len(dir) > 0 { if dir != "" {
permissions[dir] = perms permissions[dir] = perms
} }
} }
@ -442,7 +483,7 @@ func getSliceFromDelimitedValues(values, delimiter string) []string {
result := []string{} result := []string{}
for _, v := range strings.Split(values, delimiter) { for _, v := range strings.Split(values, delimiter) {
cleaned := strings.TrimSpace(v) cleaned := strings.TrimSpace(v)
if len(cleaned) > 0 { if cleaned != "" {
result = append(result, cleaned) result = append(result, cleaned)
} }
} }
@ -708,6 +749,128 @@ func getAdminFromPostFields(r *http.Request) (dataprovider.Admin, error) {
return admin, nil return admin, nil
} }
func replacePlaceholders(field string, replacements map[string]string) string {
for k, v := range replacements {
field = strings.ReplaceAll(field, k, v)
}
return field
}
func getCryptFsFromTemplate(fsConfig vfs.CryptFsConfig, replacements map[string]string) vfs.CryptFsConfig {
if fsConfig.Passphrase != nil {
if fsConfig.Passphrase.IsPlain() {
payload := replacePlaceholders(fsConfig.Passphrase.GetPayload(), replacements)
fsConfig.Passphrase = kms.NewPlainSecret(payload)
}
}
return fsConfig
}
func getS3FsFromTemplate(fsConfig vfs.S3FsConfig, replacements map[string]string) vfs.S3FsConfig {
fsConfig.KeyPrefix = replacePlaceholders(fsConfig.KeyPrefix, replacements)
fsConfig.AccessKey = replacePlaceholders(fsConfig.AccessKey, replacements)
if fsConfig.AccessSecret != nil && fsConfig.AccessSecret.IsPlain() {
payload := replacePlaceholders(fsConfig.AccessSecret.GetPayload(), replacements)
fsConfig.AccessSecret = kms.NewPlainSecret(payload)
}
return fsConfig
}
func getGCSFsFromTemplate(fsConfig vfs.GCSFsConfig, replacements map[string]string) vfs.GCSFsConfig {
fsConfig.KeyPrefix = replacePlaceholders(fsConfig.KeyPrefix, replacements)
return fsConfig
}
func getAzBlobFsFromTemplate(fsConfig vfs.AzBlobFsConfig, replacements map[string]string) vfs.AzBlobFsConfig {
fsConfig.KeyPrefix = replacePlaceholders(fsConfig.KeyPrefix, replacements)
fsConfig.AccountName = replacePlaceholders(fsConfig.AccountName, replacements)
if fsConfig.AccountKey != nil && fsConfig.AccountKey.IsPlain() {
payload := replacePlaceholders(fsConfig.AccountKey.GetPayload(), replacements)
fsConfig.AccountKey = kms.NewPlainSecret(payload)
}
return fsConfig
}
func getSFTPFsFromTemplate(fsConfig vfs.SFTPFsConfig, replacements map[string]string) vfs.SFTPFsConfig {
fsConfig.Prefix = replacePlaceholders(fsConfig.Prefix, replacements)
fsConfig.Username = replacePlaceholders(fsConfig.Username, replacements)
if fsConfig.Password != nil && fsConfig.Password.IsPlain() {
payload := replacePlaceholders(fsConfig.Password.GetPayload(), replacements)
fsConfig.Password = kms.NewPlainSecret(payload)
}
return fsConfig
}
func getUserFromTemplate(user dataprovider.User, template userTemplateFields) dataprovider.User {
user.Username = template.Username
user.Password = template.Password
user.PublicKeys = nil
if template.PublicKey != "" {
user.PublicKeys = append(user.PublicKeys, template.PublicKey)
}
replacements := make(map[string]string)
replacements["%username%"] = user.Username
user.Password = replacePlaceholders(user.Password, replacements)
replacements["%password%"] = user.Password
user.HomeDir = replacePlaceholders(user.HomeDir, replacements)
var vfolders []vfs.VirtualFolder
for _, vfolder := range user.VirtualFolders {
vfolder.MappedPath = replacePlaceholders(vfolder.MappedPath, replacements)
vfolders = append(vfolders, vfolder)
}
user.VirtualFolders = vfolders
user.AdditionalInfo = replacePlaceholders(user.AdditionalInfo, replacements)
switch user.FsConfig.Provider {
case dataprovider.CryptedFilesystemProvider:
user.FsConfig.CryptConfig = getCryptFsFromTemplate(user.FsConfig.CryptConfig, replacements)
case dataprovider.S3FilesystemProvider:
user.FsConfig.S3Config = getS3FsFromTemplate(user.FsConfig.S3Config, replacements)
case dataprovider.GCSFilesystemProvider:
user.FsConfig.GCSConfig = getGCSFsFromTemplate(user.FsConfig.GCSConfig, replacements)
case dataprovider.AzureBlobFilesystemProvider:
user.FsConfig.AzBlobConfig = getAzBlobFsFromTemplate(user.FsConfig.AzBlobConfig, replacements)
case dataprovider.SFTPFilesystemProvider:
user.FsConfig.SFTPConfig = getSFTPFsFromTemplate(user.FsConfig.SFTPConfig, replacements)
}
return user
}
/*func decryptSecretsForTemplateUser(user *dataprovider.User) error {
user.SetEmptySecretsIfNil()
switch user.FsConfig.Provider {
case dataprovider.CryptedFilesystemProvider:
if user.FsConfig.CryptConfig.Passphrase.IsEncrypted() {
return user.FsConfig.CryptConfig.Passphrase.Decrypt()
}
case dataprovider.S3FilesystemProvider:
if user.FsConfig.S3Config.AccessSecret.IsEncrypted() {
return user.FsConfig.S3Config.AccessSecret.Decrypt()
}
case dataprovider.GCSFilesystemProvider:
if user.FsConfig.GCSConfig.Credentials.IsEncrypted() {
return user.FsConfig.GCSConfig.Credentials.Decrypt()
}
case dataprovider.AzureBlobFilesystemProvider:
if user.FsConfig.AzBlobConfig.AccountKey.IsEncrypted() {
return user.FsConfig.AzBlobConfig.AccountKey.Decrypt()
}
case dataprovider.SFTPFilesystemProvider:
if user.FsConfig.SFTPConfig.Password.IsEncrypted() {
if err := user.FsConfig.SFTPConfig.Password.Decrypt(); err != nil {
return err
}
}
if user.FsConfig.SFTPConfig.PrivateKey.IsEncrypted() {
return user.FsConfig.SFTPConfig.PrivateKey.Decrypt()
}
}
return nil
}*/
func getUserFromPostFields(r *http.Request) (dataprovider.User, error) { func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
var user dataprovider.User var user dataprovider.User
err := r.ParseMultipartForm(maxRequestSize) err := r.ParseMultipartForm(maxRequestSize)
@ -1004,18 +1167,13 @@ func handleGetWebUsers(w http.ResponseWriter, r *http.Request) {
renderTemplate(w, templateUsers, data) renderTemplate(w, templateUsers, data)
} }
func handleWebAddUserGet(w http.ResponseWriter, r *http.Request) { func handleWebTemplateUserGet(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("cloneFrom") != "" { if r.URL.Query().Get("from") != "" {
username := r.URL.Query().Get("cloneFrom") username := r.URL.Query().Get("from")
user, err := dataprovider.UserExists(username) user, err := dataprovider.UserExists(username)
if err == nil { if err == nil {
user.ID = 0 user.SetEmptySecrets()
user.Username = "" renderUserPage(w, r, &user, userPageModeTemplate, "")
if err := user.DecryptSecrets(); err != nil {
renderInternalServerErrorPage(w, r, err)
return
}
renderAddUserPage(w, r, user, "")
} else if _, ok := err.(*dataprovider.RecordNotFoundError); ok { } else if _, ok := err.(*dataprovider.RecordNotFoundError); ok {
renderNotFoundPage(w, r, err) renderNotFoundPage(w, r, err)
} else { } else {
@ -1023,7 +1181,57 @@ func handleWebAddUserGet(w http.ResponseWriter, r *http.Request) {
} }
} else { } else {
user := dataprovider.User{Status: 1} user := dataprovider.User{Status: 1}
renderAddUserPage(w, r, user, "") renderUserPage(w, r, &user, userPageModeTemplate, "")
}
}
func handleWebTemplateUserPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
templateUser, err := getUserFromPostFields(r)
if err != nil {
renderMessagePage(w, r, "Error parsing user fields", "", http.StatusBadRequest, err, "")
return
}
var dump dataprovider.BackupData
dump.Version = dataprovider.DumpVersion
userTmplFields := getUsersForTemplate(r)
for _, tmpl := range userTmplFields {
u := getUserFromTemplate(templateUser, tmpl)
if err := dataprovider.ValidateUser(&u); err != nil {
renderMessagePage(w, r, fmt.Sprintf("Error validating user %#v", u.Username), "", http.StatusBadRequest, err, "")
return
}
dump.Users = append(dump.Users, u)
}
if len(dump.Users) == 0 {
renderMessagePage(w, r, "No users to export", "No valid users found, export is not possible", http.StatusBadRequest, nil, "")
return
}
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"sftpgo-%v-users-from-template.json\"", len(dump.Users)))
render.JSON(w, r, dump)
}
func handleWebAddUserGet(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("clone-from") != "" {
username := r.URL.Query().Get("clone-from")
user, err := dataprovider.UserExists(username)
if err == nil {
user.ID = 0
user.Username = ""
user.SetEmptySecrets()
renderUserPage(w, r, &user, userPageModeAdd, "")
} else if _, ok := err.(*dataprovider.RecordNotFoundError); ok {
renderNotFoundPage(w, r, err)
} else {
renderInternalServerErrorPage(w, r, err)
}
} else {
user := dataprovider.User{Status: 1}
renderUserPage(w, r, &user, userPageModeAdd, "")
} }
} }
@ -1031,7 +1239,7 @@ func handleWebUpdateUserGet(w http.ResponseWriter, r *http.Request) {
username := getURLParam(r, "username") username := getURLParam(r, "username")
user, err := dataprovider.UserExists(username) user, err := dataprovider.UserExists(username)
if err == nil { if err == nil {
renderUpdateUserPage(w, r, user, "") renderUserPage(w, r, &user, userPageModeUpdate, "")
} else if _, ok := err.(*dataprovider.RecordNotFoundError); ok { } else if _, ok := err.(*dataprovider.RecordNotFoundError); ok {
renderNotFoundPage(w, r, err) renderNotFoundPage(w, r, err)
} else { } else {
@ -1043,14 +1251,14 @@ func handleWebAddUserPost(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
user, err := getUserFromPostFields(r) user, err := getUserFromPostFields(r)
if err != nil { if err != nil {
renderAddUserPage(w, r, user, err.Error()) renderUserPage(w, r, &user, userPageModeAdd, err.Error())
return return
} }
err = dataprovider.AddUser(&user) err = dataprovider.AddUser(&user)
if err == nil { if err == nil {
http.Redirect(w, r, webUsersPath, http.StatusSeeOther) http.Redirect(w, r, webUsersPath, http.StatusSeeOther)
} else { } else {
renderAddUserPage(w, r, user, err.Error()) renderUserPage(w, r, &user, userPageModeAdd, err.Error())
} }
} }
@ -1067,7 +1275,7 @@ func handleWebUpdateUserPost(w http.ResponseWriter, r *http.Request) {
} }
updatedUser, err := getUserFromPostFields(r) updatedUser, err := getUserFromPostFields(r)
if err != nil { if err != nil {
renderUpdateUserPage(w, r, user, err.Error()) renderUserPage(w, r, &user, userPageModeUpdate, err.Error())
return return
} }
updatedUser.ID = user.ID updatedUser.ID = user.ID
@ -1087,7 +1295,7 @@ func handleWebUpdateUserPost(w http.ResponseWriter, r *http.Request) {
} }
http.Redirect(w, r, webUsersPath, http.StatusSeeOther) http.Redirect(w, r, webUsersPath, http.StatusSeeOther)
} else { } else {
renderUpdateUserPage(w, r, user, err.Error()) renderUserPage(w, r, &user, userPageModeUpdate, err.Error())
} }
} }

View file

@ -89,7 +89,7 @@ function deleteAction() {
var table = $('#dataTable').DataTable(); var table = $('#dataTable').DataTable();
table.button('delete:name').enable(false); table.button('delete:name').enable(false);
var folderPath = table.row({ selected: true }).data()[0]; var folderPath = table.row({ selected: true }).data()[0];
var path = '{{.FolderURL}}' + "?folder_path=" + encodeURIComponent(folderPath); var path = '{{.FolderURL}}' + "?folder-path=" + encodeURIComponent(folderPath);
$('#deleteModal').modal('hide'); $('#deleteModal').modal('hide');
$.ajax({ $.ajax({
url: path, url: path,

View file

@ -5,7 +5,7 @@
{{define "page_body"}} {{define "page_body"}}
<div class="card shadow mb-4"> <div class="card shadow mb-4">
<div class="card-header py-3"> <div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Restore</h6> <h6 class="m-0 font-weight-bold text-primary">Import</h6>
</div> </div>
<div class="card-body"> <div class="card-body">
{{if .Error}} {{if .Error}}
@ -20,7 +20,7 @@
<input type="file" class="form-control-file" id="idBackupFile" name="backup_file" <input type="file" class="form-control-file" id="idBackupFile" name="backup_file"
aria-describedby="BackupFileHelpBlock"> aria-describedby="BackupFileHelpBlock">
<small id="BackupFileHelpBlock" class="form-text text-muted"> <small id="BackupFileHelpBlock" class="form-text text-muted">
Restore data from a JSON backup file Import data from a JSON backup file
</small> </small>
</div> </div>
</div> </div>
@ -44,7 +44,7 @@
</select> </select>
</div> </div>
</div> </div>
<button type="submit" class="btn btn-primary float-right mt-3 px-5 px-3">Restore</button> <button type="submit" class="btn btn-primary float-right mt-3 px-5 px-3">Import</button>
</form> </form>
</div> </div>
</div> </div>

View file

@ -11,7 +11,7 @@
<!-- Page Heading --> <!-- Page Heading -->
<div class="card shadow mb-4"> <div class="card shadow mb-4">
<div class="card-header py-3"> <div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">{{if .IsAdd}}Add a new user{{else}}Edit user{{end}}</h6> <h6 class="m-0 font-weight-bold text-primary">{{.Title}}</h6>
</div> </div>
<div class="card-body"> <div class="card-body">
{{if .Error}} {{if .Error}}
@ -19,14 +19,47 @@
<div class="card-body text-form-error">{{.Error}}</div> <div class="card-body text-form-error">{{.Error}}</div>
</div> </div>
{{end}} {{end}}
<form id="user_form" enctype="multipart/form-data" action="{{.CurrentURL}}" method="POST" autocomplete="off"> {{if eq .Mode 3}}
<div class="card mb-4 border-left-info">
<div class="card-body">
Generate a data provider independent JSON file to create new users or update existing ones.
<br>
The following placeholders are supported:
<br><br>
<ul>
<li><span class="text-success">%username%</span> will be replaced with the specified username</li>
<li><span class="text-success">%password%</span> will be replaced with the specified password</li>
</ul>
The generated users file can be imported from the "Maintenance" section.
{{if .User.Username}}
<br>
Please note that no credentials were copied from user "{{.User.Username}}", you have to set them explicitly.
{{end}}
</div>
</div>
{{end}}
<form id="user_form" enctype="multipart/form-data" action="{{.CurrentURL}}" method="POST" autocomplete="off" {{if eq .Mode 3}}target="_blank"{{end}}>
{{if eq .Mode 3}}
<div class="form-group row">
<label for="idUsers" class="col-sm-2 col-form-label">Users</label>
<div class="col-sm-10">
<textarea class="form-control" id="idUsers" name="users" rows="5" required
aria-describedby="usersHelpBlock"></textarea>
<small id="usersHelpBlock" class="form-text text-muted">
Specify the username and at least one of the password and public key. Each line must be username::password::public-key
</small>
</div>
</div>
<input type="hidden" name="username" id="idUsername" value="{{.User.Username}}">
{{else}}
<div class="form-group row"> <div class="form-group row">
<label for="idUsername" class="col-sm-2 col-form-label">Username</label> <label for="idUsername" class="col-sm-2 col-form-label">Username</label>
<div class="col-sm-10"> <div class="col-sm-10">
<input type="text" class="form-control" id="idUsername" name="username" placeholder="" <input type="text" class="form-control" id="idUsername" name="username" placeholder=""
value="{{.User.Username}}" maxlength="255" autocomplete="nope" required {{if not .IsAdd}}readonly{{end}}> value="{{.User.Username}}" maxlength="255" autocomplete="nope" required {{if ge .Mode 2}}readonly{{end}}>
</div> </div>
</div> </div>
{{end}}
<div class="form-group row"> <div class="form-group row">
<label for="idStatus" class="col-sm-2 col-form-label">Status</label> <label for="idStatus" class="col-sm-2 col-form-label">Status</label>
@ -48,12 +81,12 @@
</div> </div>
</div> </div>
</div> </div>
{{if ne .Mode 3}}
<div class="form-group row"> <div class="form-group row">
<label for="idPassword" class="col-sm-2 col-form-label">Password</label> <label for="idPassword" class="col-sm-2 col-form-label">Password</label>
<div class="col-sm-10"> <div class="col-sm-10">
<input type="password" class="form-control" id="idPassword" name="password" placeholder="" {{if not .IsAdd}}aria-describedby="pwdHelpBlock" {{end}}> <input type="password" class="form-control" id="idPassword" name="password" placeholder="" {{if eq .Mode 2}}aria-describedby="pwdHelpBlock" {{end}}>
{{if not .IsAdd}} {{if eq .Mode 2}}
<small id="pwdHelpBlock" class="form-text text-muted"> <small id="pwdHelpBlock" class="form-text text-muted">
If empty the current password will not be changed If empty the current password will not be changed
</small> </small>
@ -71,7 +104,7 @@
</small> </small>
</div> </div>
</div> </div>
{{end}}
<div class="form-group row"> <div class="form-group row">
<label for="idProtocols" class="col-sm-2 col-form-label">Denied protocols</label> <label for="idProtocols" class="col-sm-2 col-form-label">Denied protocols</label>
<div class="col-sm-10"> <div class="col-sm-10">
@ -612,7 +645,7 @@
</div> </div>
</div> </div>
{{if not .IsAdd}} {{if eq .Mode 2}}
<div class="form-group"> <div class="form-group">
<div class="form-check"> <div class="form-check">
<input type="checkbox" class="form-check-input" id="idDisconnect" name="disconnect" <input type="checkbox" class="form-check-input" id="idDisconnect" name="disconnect"
@ -626,7 +659,7 @@
{{end}} {{end}}
<input type="hidden" name="expiration_date" id="hidden_start_datetime" value=""> <input type="hidden" name="expiration_date" id="hidden_start_datetime" value="">
<button type="submit" class="btn btn-primary float-right mt-3 px-5 px-3">Submit</button> <button type="submit" class="btn btn-primary float-right mt-3 px-5 px-3">{{if eq .Mode 3}}Generate and export users{{else}}Submit{{end}}</button>
</form> </form>
</div> </div>
</div> </div>

View file

@ -153,12 +153,27 @@
name: 'clone', name: 'clone',
action: function (e, dt, node, config) { action: function (e, dt, node, config) {
var username = dt.row({ selected: true }).data()[1]; var username = dt.row({ selected: true }).data()[1];
var path = '{{.UserURL}}' + "?cloneFrom=" + encodeURIComponent(username); var path = '{{.UserURL}}' + "?clone-from=" + encodeURIComponent(username);
window.location.href = path; window.location.href = path;
}, },
enabled: false enabled: false
}; };
$.fn.dataTable.ext.buttons.template = {
text: 'Template',
name: 'template',
action: function (e, dt, node, config) {
var selectedRows = table.rows({ selected: true }).count();
if (selectedRows == 1){
var username = dt.row({ selected: true }).data()[1];
var path = '{{.UserTemplateURL}}' + "?from=" + encodeURIComponent(username);
window.location.href = path;
} else {
window.location.href = '{{.UserTemplateURL}}';
}
}
};
$.fn.dataTable.ext.buttons.delete = { $.fn.dataTable.ext.buttons.delete = {
text: 'Delete', text: 'Delete',
name: 'delete', name: 'delete',
@ -240,6 +255,10 @@
table.button().add(0,'quota_scan'); table.button().add(0,'quota_scan');
{{end}} {{end}}
{{if .LoggedAdmin.HasPermission "manage_system"}}
table.button().add(0,'template');
{{end}}
{{if .LoggedAdmin.HasPermission "del_users"}} {{if .LoggedAdmin.HasPermission "del_users"}}
table.button().add(0,'delete'); table.button().add(0,'delete');
{{end}} {{end}}