WIP new WebAdmin: admin/admins pages

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
This commit is contained in:
Nicola Murino 2024-01-22 20:22:41 +01:00
parent d67f00546a
commit d381304136
No known key found for this signature in database
GPG key ID: 935D2952DEC4EECF
10 changed files with 743 additions and 545 deletions

View file

@ -286,7 +286,7 @@ func (a *Admin) hashPassword() error {
if a.Password != "" && !util.IsStringPrefixInSlice(a.Password, internalHashPwdPrefixes) { if a.Password != "" && !util.IsStringPrefixInSlice(a.Password, internalHashPwdPrefixes) {
if config.PasswordValidation.Admins.MinEntropy > 0 { if config.PasswordValidation.Admins.MinEntropy > 0 {
if err := passwordvalidator.Validate(a.Password, config.PasswordValidation.Admins.MinEntropy); err != nil { if err := passwordvalidator.Validate(a.Password, config.PasswordValidation.Admins.MinEntropy); err != nil {
return util.NewValidationError(err.Error()) return util.NewI18nError(util.NewValidationError(err.Error()), util.I18nErrorPasswordComplexity)
} }
} }
if config.PasswordHashing.Algo == HashingAlgoBcrypt { if config.PasswordHashing.Algo == HashingAlgoBcrypt {
@ -397,7 +397,7 @@ func (a *Admin) validate() error {
return util.NewI18nError(err, util.I18nError2FAInvalid) return util.NewI18nError(err, util.I18nError2FAInvalid)
} }
if err := a.validateRecoveryCodes(); err != nil { if err := a.validateRecoveryCodes(); err != nil {
return err return util.NewI18nError(err, util.I18nErrorRecoveryCodesInvalid)
} }
if config.NamingRules&1 == 0 && !usernameRegex.MatchString(a.Username) { if config.NamingRules&1 == 0 && !usernameRegex.MatchString(a.Username) {
return util.NewI18nError( return util.NewI18nError(

View file

@ -1723,6 +1723,8 @@ func (s *httpdServer) setupWebAdminRoutes() {
Get(webStatusPath, s.handleWebGetStatus) Get(webStatusPath, s.handleWebGetStatus)
router.With(s.checkPerm(dataprovider.PermAdminManageAdmins), s.refreshCookie). router.With(s.checkPerm(dataprovider.PermAdminManageAdmins), s.refreshCookie).
Get(webAdminsPath, s.handleGetWebAdmins) Get(webAdminsPath, s.handleGetWebAdmins)
router.With(s.checkPerm(dataprovider.PermAdminManageAdmins), compressor.Handler, s.refreshCookie).
Get(webAdminsPath+jsonAPISuffix, getAllAdmins)
router.With(s.checkPerm(dataprovider.PermAdminManageAdmins), s.refreshCookie). router.With(s.checkPerm(dataprovider.PermAdminManageAdmins), s.refreshCookie).
Get(webAdminPath, s.handleWebAddAdminGet) Get(webAdminPath, s.handleWebAddAdminGet)
router.With(s.checkPerm(dataprovider.PermAdminManageAdmins), s.refreshCookie). router.With(s.checkPerm(dataprovider.PermAdminManageAdmins), s.refreshCookie).

View file

@ -98,7 +98,6 @@ const (
templateMaintenance = "maintenance.html" templateMaintenance = "maintenance.html"
templateMFA = "mfa.html" templateMFA = "mfa.html"
templateSetup = "adminsetup.html" templateSetup = "adminsetup.html"
pageAdminsTitle = "Admins"
pageStatusTitle = "Status" pageStatusTitle = "Status"
pageEventRulesTitle = "Event rules" pageEventRulesTitle = "Event rules"
pageEventActionsTitle = "Event actions" pageEventActionsTitle = "Event actions"
@ -162,11 +161,6 @@ type basePage struct {
Branding UIBranding Branding UIBranding
} }
type adminsPage struct {
basePage
Admins []dataprovider.Admin
}
type eventRulesPage struct { type eventRulesPage struct {
basePage basePage
Rules []dataprovider.EventRule Rules []dataprovider.EventRule
@ -215,7 +209,7 @@ type adminPage struct {
Admin *dataprovider.Admin Admin *dataprovider.Admin
Groups []dataprovider.Group Groups []dataprovider.Group
Roles []dataprovider.Role Roles []dataprovider.Role
Error string Error *util.I18nError
IsAdd bool IsAdd bool
} }
@ -379,12 +373,12 @@ func loadAdminTemplates(templatesPath string) {
filepath.Join(templatesPath, templateAdminDir, templateUser), filepath.Join(templatesPath, templateAdminDir, templateUser),
} }
adminsPaths := []string{ adminsPaths := []string{
filepath.Join(templatesPath, templateCommonDir, templateCommonCSS), filepath.Join(templatesPath, templateCommonDir, templateCommonBase),
filepath.Join(templatesPath, templateAdminDir, templateBase), filepath.Join(templatesPath, templateAdminDir, templateBase),
filepath.Join(templatesPath, templateAdminDir, templateAdmins), filepath.Join(templatesPath, templateAdminDir, templateAdmins),
} }
adminPaths := []string{ adminPaths := []string{
filepath.Join(templatesPath, templateCommonDir, templateCommonCSS), filepath.Join(templatesPath, templateCommonDir, templateCommonBase),
filepath.Join(templatesPath, templateAdminDir, templateBase), filepath.Join(templatesPath, templateAdminDir, templateBase),
filepath.Join(templatesPath, templateAdminDir, templateAdmin), filepath.Join(templatesPath, templateAdminDir, templateAdmin),
} }
@ -893,27 +887,27 @@ func (s *httpdServer) renderAdminSetupPage(w http.ResponseWriter, r *http.Reques
} }
func (s *httpdServer) renderAddUpdateAdminPage(w http.ResponseWriter, r *http.Request, admin *dataprovider.Admin, func (s *httpdServer) renderAddUpdateAdminPage(w http.ResponseWriter, r *http.Request, admin *dataprovider.Admin,
error string, isAdd bool) { err error, isAdd bool) {
groups, err := s.getWebGroups(w, r, defaultQueryLimit, true) groups, errGroups := s.getWebGroups(w, r, defaultQueryLimit, true)
if err != nil { if errGroups != nil {
return return
} }
roles, err := s.getWebRoles(w, r, 10, true) roles, errRoles := s.getWebRoles(w, r, 10, true)
if err != nil { if errRoles != nil {
return return
} }
currentURL := webAdminPath currentURL := webAdminPath
title := "Add a new admin" title := util.I18nAddAdminTitle
if !isAdd { if !isAdd {
currentURL = fmt.Sprintf("%v/%v", webAdminPath, url.PathEscape(admin.Username)) currentURL = fmt.Sprintf("%v/%v", webAdminPath, url.PathEscape(admin.Username))
title = "Update admin" title = util.I18nUpdateAdminTitle
} }
data := adminPage{ data := adminPage{
basePage: s.getBasePageData(title, currentURL, r), basePage: s.getBasePageData(title, currentURL, r),
Admin: admin, Admin: admin,
Groups: groups, Groups: groups,
Roles: roles, Roles: roles,
Error: error, Error: getI18nError(err),
IsAdd: isAdd, IsAdd: isAdd,
} }
@ -1777,14 +1771,14 @@ func getAdminFromPostFields(r *http.Request) (dataprovider.Admin, error) {
admin.Filters.Preferences.DefaultUsersExpiration = defaultUsersExpiration admin.Filters.Preferences.DefaultUsersExpiration = defaultUsersExpiration
} }
for k := range r.Form { for k := range r.Form {
if strings.HasPrefix(k, "group") { if hasPrefixAndSuffix(k, "groups[", "][group]") {
groupName := strings.TrimSpace(r.Form.Get(k)) groupName := strings.TrimSpace(r.Form.Get(k))
if groupName != "" { if groupName != "" {
idx := strings.TrimPrefix(k, "group")
addAsGroupType := r.Form.Get(fmt.Sprintf("add_as_group_type%s", idx))
group := dataprovider.AdminGroupMapping{ group := dataprovider.AdminGroupMapping{
Name: groupName, Name: groupName,
} }
base, _ := strings.CutSuffix(k, "[group]")
addAsGroupType := strings.TrimSpace(r.Form.Get(base + "[group_type]"))
switch addAsGroupType { switch addAsGroupType {
case "1": case "1":
group.Options.AddToUsersAs = dataprovider.GroupAddToUsersAsPrimary group.Options.AddToUsersAs = dataprovider.GroupAddToUsersAsPrimary
@ -2803,32 +2797,32 @@ func (s *httpdServer) handleWebRestore(w http.ResponseWriter, r *http.Request) {
s.renderMessagePage(w, r, util.I18nMaintenanceTitle, http.StatusOK, nil, util.I18nBackupOK) s.renderMessagePage(w, r, util.I18nMaintenanceTitle, http.StatusOK, nil, util.I18nBackupOK)
} }
func (s *httpdServer) handleGetWebAdmins(w http.ResponseWriter, r *http.Request) { func getAllAdmins(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
limit := defaultQueryLimit claims, err := getTokenClaims(r)
if _, ok := r.URL.Query()["qlimit"]; ok { if err != nil || claims.Username == "" {
var err error sendAPIResponse(w, r, nil, util.I18nErrorDirList403, http.StatusForbidden)
limit, err = strconv.Atoi(r.URL.Query().Get("qlimit")) return
if err != nil {
limit = defaultQueryLimit
}
} }
admins := make([]dataprovider.Admin, 0, limit) admins := make([]dataprovider.Admin, 0, 50)
for { for {
a, err := dataprovider.GetAdmins(limit, len(admins), dataprovider.OrderASC) a, err := dataprovider.GetAdmins(defaultQueryLimit, len(admins), dataprovider.OrderASC)
if err != nil { if err != nil {
s.renderInternalServerErrorPage(w, r, err) sendAPIResponse(w, r, err, getI18NErrorString(err, util.I18nError500Message), http.StatusInternalServerError)
return return
} }
admins = append(admins, a...) admins = append(admins, a...)
if len(a) < limit { if len(a) < defaultQueryLimit {
break break
} }
} }
data := adminsPage{ render.JSON(w, r, admins)
basePage: s.getBasePageData(pageAdminsTitle, webAdminsPath, r), }
Admins: admins,
} func (s *httpdServer) handleGetWebAdmins(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
data := s.getBasePageData(util.I18nAdminsTitle, webAdminsPath, r)
renderAdminTemplate(w, templateAdmins, data) renderAdminTemplate(w, templateAdmins, data)
} }
@ -2847,7 +2841,7 @@ func (s *httpdServer) handleWebAddAdminGet(w http.ResponseWriter, r *http.Reques
Status: 1, Status: 1,
Permissions: []string{dataprovider.PermAdminAny}, Permissions: []string{dataprovider.PermAdminAny},
} }
s.renderAddUpdateAdminPage(w, r, admin, "", true) s.renderAddUpdateAdminPage(w, r, admin, nil, true)
} }
func (s *httpdServer) handleWebUpdateAdminGet(w http.ResponseWriter, r *http.Request) { func (s *httpdServer) handleWebUpdateAdminGet(w http.ResponseWriter, r *http.Request) {
@ -2855,7 +2849,7 @@ func (s *httpdServer) handleWebUpdateAdminGet(w http.ResponseWriter, r *http.Req
username := getURLParam(r, "username") username := getURLParam(r, "username")
admin, err := dataprovider.AdminExists(username) admin, err := dataprovider.AdminExists(username)
if err == nil { if err == nil {
s.renderAddUpdateAdminPage(w, r, &admin, "", false) s.renderAddUpdateAdminPage(w, r, &admin, nil, false)
} else if errors.Is(err, util.ErrNotFound) { } else if errors.Is(err, util.ErrNotFound) {
s.renderNotFoundPage(w, r, err) s.renderNotFoundPage(w, r, err)
} else { } else {
@ -2872,7 +2866,7 @@ func (s *httpdServer) handleWebAddAdminPost(w http.ResponseWriter, r *http.Reque
} }
admin, err := getAdminFromPostFields(r) admin, err := getAdminFromPostFields(r)
if err != nil { if err != nil {
s.renderAddUpdateAdminPage(w, r, &admin, err.Error(), true) s.renderAddUpdateAdminPage(w, r, &admin, err, true)
return return
} }
if admin.Password == "" && s.binding.isWebAdminLoginFormDisabled() { if admin.Password == "" && s.binding.isWebAdminLoginFormDisabled() {
@ -2885,7 +2879,7 @@ func (s *httpdServer) handleWebAddAdminPost(w http.ResponseWriter, r *http.Reque
} }
err = dataprovider.AddAdmin(&admin, claims.Username, ipAddr, claims.Role) err = dataprovider.AddAdmin(&admin, claims.Username, ipAddr, claims.Role)
if err != nil { if err != nil {
s.renderAddUpdateAdminPage(w, r, &admin, err.Error(), true) s.renderAddUpdateAdminPage(w, r, &admin, err, true)
return return
} }
http.Redirect(w, r, webAdminsPath, http.StatusSeeOther) http.Redirect(w, r, webAdminsPath, http.StatusSeeOther)
@ -2906,7 +2900,7 @@ func (s *httpdServer) handleWebUpdateAdminPost(w http.ResponseWriter, r *http.Re
updatedAdmin, err := getAdminFromPostFields(r) updatedAdmin, err := getAdminFromPostFields(r)
if err != nil { if err != nil {
s.renderAddUpdateAdminPage(w, r, &updatedAdmin, err.Error(), false) s.renderAddUpdateAdminPage(w, r, &updatedAdmin, err, false)
return return
} }
ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr) ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
@ -2923,26 +2917,36 @@ func (s *httpdServer) handleWebUpdateAdminPost(w http.ResponseWriter, r *http.Re
updatedAdmin.Filters.RecoveryCodes = admin.Filters.RecoveryCodes updatedAdmin.Filters.RecoveryCodes = admin.Filters.RecoveryCodes
claims, err := getTokenClaims(r) claims, err := getTokenClaims(r)
if err != nil || claims.Username == "" { if err != nil || claims.Username == "" {
s.renderAddUpdateAdminPage(w, r, &updatedAdmin, "Invalid token claims", false) s.renderAddUpdateAdminPage(w, r, &updatedAdmin, util.NewI18nError(errInvalidTokenClaims, util.I18nErrorInvalidToken), false)
return return
} }
if username == claims.Username { if username == claims.Username {
if claims.isCriticalPermRemoved(updatedAdmin.Permissions) { if claims.isCriticalPermRemoved(updatedAdmin.Permissions) {
s.renderAddUpdateAdminPage(w, r, &updatedAdmin, "You cannot remove these permissions to yourself", false) s.renderAddUpdateAdminPage(w, r, &updatedAdmin,
util.NewI18nError(errors.New("you cannot remove these permissions to yourself"),
util.I18nErrorAdminSelfPerms,
), false)
return return
} }
if updatedAdmin.Status == 0 { if updatedAdmin.Status == 0 {
s.renderAddUpdateAdminPage(w, r, &updatedAdmin, "You cannot disable yourself", false) s.renderAddUpdateAdminPage(w, r, &updatedAdmin,
util.NewI18nError(errors.New("you cannot disable yourself"),
util.I18nErrorAdminSelfDisable,
), false)
return return
} }
if updatedAdmin.Role != claims.Role { if updatedAdmin.Role != claims.Role {
s.renderAddUpdateAdminPage(w, r, &updatedAdmin, "You cannot add/change your role", false) s.renderAddUpdateAdminPage(w, r, &updatedAdmin,
util.NewI18nError(
errors.New("you cannot add/change your role"),
util.I18nErrorAdminSelfRole,
), false)
return return
} }
} }
err = dataprovider.UpdateAdmin(&updatedAdmin, claims.Username, ipAddr, claims.Role) err = dataprovider.UpdateAdmin(&updatedAdmin, claims.Username, ipAddr, claims.Role)
if err != nil { if err != nil {
s.renderAddUpdateAdminPage(w, r, &updatedAdmin, err.Error(), false) s.renderAddUpdateAdminPage(w, r, &updatedAdmin, err, false)
return return
} }
http.Redirect(w, r, webAdminsPath, http.StatusSeeOther) http.Redirect(w, r, webAdminsPath, http.StatusSeeOther)

View file

@ -53,6 +53,8 @@ const (
I18nErrorEditorTitle = "title.error_editor" I18nErrorEditorTitle = "title.error_editor"
I18nAddUserTitle = "title.add_user" I18nAddUserTitle = "title.add_user"
I18nUpdateUserTitle = "title.update_user" I18nUpdateUserTitle = "title.update_user"
I18nAddAdminTitle = "title.add_admin"
I18nUpdateAdminTitle = "title.update_admin"
I18nTemplateUserTitle = "title.template_user" I18nTemplateUserTitle = "title.template_user"
I18nMaintenanceTitle = "title.maintenance" I18nMaintenanceTitle = "title.maintenance"
I18nConfigsTitle = "title.configs" I18nConfigsTitle = "title.configs"
@ -60,6 +62,7 @@ const (
I18nOAuth2ErrorTitle = "title.oauth2_error" I18nOAuth2ErrorTitle = "title.oauth2_error"
I18nSessionsTitle = "title.connections" I18nSessionsTitle = "title.connections"
I18nRolesTitle = "title.roles" I18nRolesTitle = "title.roles"
I18nAdminsTitle = "title.admins"
I18nErrorSetupInstallCode = "setup.install_code_mismatch" I18nErrorSetupInstallCode = "setup.install_code_mismatch"
I18nInvalidAuth = "general.invalid_auth_request" I18nInvalidAuth = "general.invalid_auth_request"
I18nError429Message = "general.error429" I18nError429Message = "general.error429"
@ -212,6 +215,9 @@ const (
I18nOAuth2ErrTokenExchange = "oauth2.token_exchange_err" I18nOAuth2ErrTokenExchange = "oauth2.token_exchange_err"
I18nOAuth2ErrNoRefreshToken = "oauth2.no_refresh_token" I18nOAuth2ErrNoRefreshToken = "oauth2.no_refresh_token"
I18nOAuth2OK = "oauth2.success" I18nOAuth2OK = "oauth2.success"
I18nErrorAdminSelfPerms = "admin.self_permissions"
I18nErrorAdminSelfDisable = "admin.self_disable"
I18nErrorAdminSelfRole = "admin.self_role"
) )
// NewI18nError returns a I18nError wrappring the provided error // NewI18nError returns a I18nError wrappring the provided error

View file

@ -56,7 +56,9 @@
"oauth2_error": "Unable to complete OAuth2 flow", "oauth2_error": "Unable to complete OAuth2 flow",
"oauth2_success": "OAuth2 flow completed", "oauth2_success": "OAuth2 flow completed",
"add_role": "Add role", "add_role": "Add role",
"update_role": "Update role" "update_role": "Update role",
"add_admin": "Add admin",
"update_admin": "Update admin"
}, },
"setup": { "setup": {
"desc": "To start using SFTPGo you need to create an administrator user", "desc": "To start using SFTPGo you need to create an administrator user",
@ -225,7 +227,9 @@
"protocol": "Protocol", "protocol": "Protocol",
"refresh": "Refresh", "refresh": "Refresh",
"members": "Members", "members": "Members",
"members_summary": "Users: {{users}}. Admins: {{admins}}" "members_summary": "Users: {{users}}. Admins: {{admins}}",
"status": "Status",
"last_login": "Last login"
}, },
"fs": { "fs": {
"view_file": "View file \"{{- path}}\"", "view_file": "View file \"{{- path}}\"",
@ -468,8 +472,6 @@
"file_pattern_invalid": "Invalid file name pattern filters", "file_pattern_invalid": "Invalid file name pattern filters",
"disable_active_2fa": "Two-factor authentication cannot be disabled for a user with an active configuration", "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", "pwd_change_conflict": "It is not possible to request a password change and at the same time prevent the password from being changed",
"status": "Status",
"last_login": "Last login",
"role_help": "Users with a role can be managed by global administrators and administrators with the same role", "role_help": "Users with a role can be managed by global administrators and administrators with the same role",
"require_pwd_change": "Require password change", "require_pwd_change": "Require password change",
"require_pwd_change_help": "The user will need to change the password from WebClient to activate the account", "require_pwd_change_help": "The user will need to change the password from WebClient to activate the account",
@ -673,7 +675,24 @@
"external_auth_cache_time_help": "Cache time, in seconds, for users authenticated using an external auth hook. 0 means no cache" "external_auth_cache_time_help": "Cache time, in seconds, for users authenticated using an external auth hook. 0 means no cache"
}, },
"admin": { "admin": {
"role_permissions": "A role admin cannot have the following permissions: {{val}}" "role_permissions": "A role admin cannot have the following permissions: {{val}}",
"view_manage": "View and manage admins",
"self_delete": "You cannot delete yourself",
"self_permissions": "You cannot remove these permissions to yourself",
"self_disable": "You cannot disable yourself",
"self_role": "You cannot add/change your role",
"password_help": "If blank the current password will not be changed",
"role_help": "Setting a role limit the administrator to only manage users with the same role. Administrators with a role cannot have the following permissions: \"manage_admins\", \"manage_roles\", \"manage_event_rules\", \"manage_apikeys\", \"manage_system\", \"manage_ip_lists\"",
"users_groups": "Groups for users",
"users_groups_help": "Groups automatically selected for new users created by this admin. The admin will still be able to choose different groups. These settings are only used for this admin UI and they will be ignored in REST API/hooks",
"group_membership": "Add as membership",
"group_primary": "Add as primary",
"group_secondary": "Add as secondary",
"user_page_pref": "User page preferences",
"user_page_pref_help": "You can 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 add/update user page",
"hide_sections": "Hide sections",
"default_users_expiration": "Default users expiration",
"default_users_expiration_help": "Default expiration for new users as number of days"
}, },
"connections": { "connections": {
"view_manage": "View and manage connections", "view_manage": "View and manage connections",

View file

@ -56,7 +56,9 @@
"oauth2_error": "Impossibile completare il flusso OAuth2", "oauth2_error": "Impossibile completare il flusso OAuth2",
"oauth2_success": "OAuth2 completato", "oauth2_success": "OAuth2 completato",
"add_role": "Aggiungi ruolo", "add_role": "Aggiungi ruolo",
"update_role": "Aggiorna ruolo" "update_role": "Aggiorna ruolo",
"add_admin": "Aggiungi amministratore",
"update_admin": "Aggiorna amministratore"
}, },
"setup": { "setup": {
"desc": "Per iniziare a utilizzare SFTPGo devi creare un utente amministratore", "desc": "Per iniziare a utilizzare SFTPGo devi creare un utente amministratore",
@ -225,7 +227,9 @@
"protocol": "Protocollo", "protocol": "Protocollo",
"refresh": "Aggiorna", "refresh": "Aggiorna",
"members": "Membri", "members": "Membri",
"members_summary": "Utenti: {{users}}. Amministratori: {{admins}}" "members_summary": "Utenti: {{users}}. Amministratori: {{admins}}",
"status": "Stato",
"last_login": "Ultimo accesso"
}, },
"fs": { "fs": {
"view_file": "Visualizza file \"{{- path}}\"", "view_file": "Visualizza file \"{{- path}}\"",
@ -468,8 +472,6 @@
"file_pattern_invalid": "Filtri su modelli di nome file non validi", "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", "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", "pwd_change_conflict": "Non è possibile richiedere la modifica della password e allo stesso tempo impedire la modifica della password",
"status": "Stato",
"last_login": "Ultimo accesso",
"role_help": "Gli utenti con un ruolo possono essere gestiti da amministratori globali e amministratori con lo stesso ruolo", "role_help": "Gli utenti con un ruolo possono essere gestiti da amministratori globali e amministratori con lo stesso ruolo",
"require_pwd_change": "Richiedi modifica password", "require_pwd_change": "Richiedi modifica password",
"require_pwd_change_help": "L'utente dovrà modificare la password dal WebClient per attivare l'account", "require_pwd_change_help": "L'utente dovrà modificare la password dal WebClient per attivare l'account",
@ -673,7 +675,24 @@
"external_auth_cache_time_help": "Tempo di memorizzazione nella cache, in secondi, per gli utenti autenticati utilizzando un hook di autenticazione esterno. 0 significa nessuna cache" "external_auth_cache_time_help": "Tempo di memorizzazione nella cache, in secondi, per gli utenti autenticati utilizzando un hook di autenticazione esterno. 0 significa nessuna cache"
}, },
"admin": { "admin": {
"role_permissions": "Un amministratore di ruolo non può avere le seguenti autorizzazioni: {{val}}" "role_permissions": "Un amministratore di ruolo non può avere le seguenti autorizzazioni: {{val}}",
"view_manage": "Visualizza e gestisci amministratori",
"self_delete": "Non puoi eliminare te stesso",
"self_permissions": "Non puoi rimuovere questi permessi a te stesso",
"self_disable": "Non puoi disabilitare te stesso",
"self_role": "Non puoi aggiungere/modificare il tuo ruolo",
"password_help": "Se vuoto la password corrente non verrà modificata",
"role_help": "L'impostazione di un ruolo limita l'amministratore a gestire solo gli utenti con lo stesso ruolo. Gli amministratori con un ruolo non possono avere le seguenti autorizzazioni: \"manage_admins\", \"manage_roles\", \"manage_event_rules\", \"manage_apikeys\", \"manage_system\", \"manage_ip_lists\"",
"users_groups": "Gruppi per gli utenti",
"users_groups_help": "Gruppi selezionati automaticamente per i nuovi utenti creati da questo amministratore. L'amministratore potrà comunque scegliere gruppi differenti. Queste impostazioni vengono utilizzate solo per questa interfaccia utente di amministrazione e verranno ignorate negli hook/API REST",
"group_membership": "Aggiungi come di appartenenza",
"group_primary": "Aggiungi come primario",
"group_secondary": "Aggiungi come secondario",
"user_page_pref": "Preferenze della pagina utente",
"user_page_pref_help": "Puoi nascondere alcune sezioni dalla pagina utente. Queste non sono impostazioni di sicurezza e non vengono verificate lato server. Hanno il solo scopo di semplificare la pagina di creazione/modifica utenti",
"hide_sections": "Nascondi sezioni",
"default_users_expiration": "Scadenza predefinita utenti",
"default_users_expiration_help": "Scadenza predefinita per i nuovi utenti espressa in numero di giorni"
}, },
"connections": { "connections": {
"view_manage": "Visualizza e gestisci connessioni attive", "view_manage": "Visualizza e gestisci connessioni attive",

View file

@ -1,114 +1,79 @@
<!-- <!--
Copyright (C) 2019 Nicola Murino Copyright (C) 2024 Nicola Murino
This program is free software: you can redistribute it and/or modify This WebUI uses the KeenThemes Mega Bundle, a proprietary theme:
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, version 3.
This program is distributed in the hope that it will be useful, https://keenthemes.com/products/templates-mega-bundle
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License KeenThemes HTML/CSS/JS components are allowed for use only within the
along with this program. If not, see <https://www.gnu.org/licenses/>. SFTPGo product and restricted to be used in a resealable HTML template
that can compete with KeenThemes products anyhow.
This WebUI is allowed for use only within the SFTPGo product and
therefore cannot be used in derivative works/products without an
explicit grant from the SFTPGo Team (support@sftpgo.com).
--> -->
{{template "base" .}} {{template "base" .}}
{{define "title"}}{{.Title}}{{end}} {{- define "page_body"}}
<div class="card shadow-sm">
{{define "extra_css"}} <div class="card-header bg-light">
<link href="{{.StaticURL}}/vendor/bootstrap-select/css/bootstrap-select.min.css" rel="stylesheet"> <h3 data-i18n="{{.Title}}" class="card-title section-title"></h3>
{{end}}
{{define "page_body"}}
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">{{if .IsAdd}}Add a new admin{{else}}Edit admin{{end}}</h6>
</div> </div>
<div class="card-body"> <div class="card-body">
{{if .Error}} {{- template "errmsg" .Error}}
<div class="alert alert-warning alert-dismissible fade show" role="alert">
{{.Error}}
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
{{end}}
<form id="admin_form" action="{{.CurrentURL}}" method="POST" autocomplete="off"> <form id="admin_form" action="{{.CurrentURL}}" method="POST" autocomplete="off">
<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" data-i18n="login.username" class="col-md-3 col-form-label">Username</label>
<div class="col-sm-10"> <div class="col-md-9">
<input type="text" class="form-control" id="idUsername" name="username" placeholder="" <input id="idUsername" type="text" placeholder="" name="username" value="{{.Admin.Username}}" maxlength="255" autocomplete="off"
value="{{.Admin.Username}}" maxlength="255" autocomplete="nope" spellcheck="false" required {{if not .IsAdd}}readonly{{end}}> spellcheck="false" required {{if .IsAdd}}class="form-control"{{else}}class="form-control-plaintext readonly-input" readonly{{end}} />
</div> </div>
</div> </div>
<div class="form-group row"> <div class="form-group row mt-10">
<label for="idStatus" class="col-sm-2 col-form-label">Status</label> <label for="idPassword" data-i18n="login.password" class="col-md-3 col-form-label">Password</label>
<div class="col-sm-10"> <div class="col-md-9">
<select class="form-control selectpicker" id="idStatus" name="status"> <input id="idPassword" type="password" class="form-control" name="password" autocomplete="new-password"
<option value="1" {{if eq .Admin.Status 1 }}selected{{end}}>Active</option> spellcheck="false" value="" {{if not .IsAdd}}aria-describedby="idPasswordHelp"{{end}} />
<option value="0" {{if eq .Admin.Status 0 }}selected{{end}}>Inactive</option> {{- if not .IsAdd}}
<div id="idPasswordHelp" class="form-text" data-i18n="admin.password_help"></div>
{{- end}}
</div>
</div>
<div class="form-group row mt-10">
<label for="idStatus" data-i18n="general.status" class="col-md-3 col-form-label">Status</label>
<div class="col-md-9">
<select id="idStatus" name="status" class="form-select" data-control="i18n-select2" data-hide-search="true">
<option data-i18n="general.active" value="1" {{- if eq .Admin.Status 1 }} selected{{- end}}>Active</option>
<option data-i18n="general.inactive" value="0" {{- if eq .Admin.Status 0 }} selected{{- end}}>Inactive</option>
</select> </select>
</div> </div>
</div> </div>
<div class="form-group row"> <div class="form-group row mt-10">
<label for="idEmail" class="col-sm-2 col-form-label">Email</label> <label for="idPermissions" data-i18n="general.permissions" class="col-md-3 col-form-label">Permissions</label>
<div class="col-sm-10"> <div class="col-md-9">
<input type="text" class="form-control" id="idEmail" name="email" placeholder="" <select id="idPermissions" name="permissions" class="form-select" data-control="i18n-select2" data-hide-search="true" data-close-on-select="false" multiple>
value="{{.Admin.Email}}" maxlength="255" spellcheck="false"> {{- range $validPerm := .Admin.GetValidPerms}}
</div> <option value="{{$validPerm}}" {{- range $perm :=$.Admin.Permissions }}{{- if eq $perm $validPerm}} selected{{- end}}{{- end}}>{{$validPerm}}</option>
</div> {{- end}}
<div class="form-group row">
<label for="idDescription" class="col-sm-2 col-form-label">Description</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="idDescription" name="description" placeholder=""
value="{{.Admin.Description}}" maxlength="255" aria-describedby="descriptionHelpBlock">
<small id="descriptionHelpBlock" class="form-text text-muted">
Optional description, for example the admin full name
</small>
</div>
</div>
<div class="form-group row">
<label for="idPassword" class="col-sm-2 col-form-label">Password</label>
<div class="col-sm-10">
<input type="password" class="form-control" id="idPassword" name="password" placeholder="" autocomplete="new-password" spellcheck="false"
{{if not .IsAdd}}aria-describedby="pwdHelpBlock" {{end}}>
{{if not .IsAdd}}
<small id="pwdHelpBlock" class="form-text text-muted">
If empty the current password will not be changed
</small>
{{end}}
</div>
</div>
<div class="form-group row">
<label for="idPermissions" class="col-sm-2 col-form-label">Permissions</label>
<div class="col-sm-10">
<select class="form-control selectpicker" id="idPermissions" name="permissions" required multiple>
{{range $validPerm := .Admin.GetValidPerms}}
<option value="{{$validPerm}}" {{range $perm :=$.Admin.Permissions }}
{{if eq $perm $validPerm}}selected{{end}}{{end}}>{{$validPerm}}
</option>
{{end}}
</select> </select>
</div> </div>
</div> </div>
<div class="card bg-light mb-3"> {{- if .Roles}}
<div class="card-header"> <div class="card mt-10">
<b>Role</b> <div class="card-header bg-light">
<h3 data-i18n="general.role" class="card-title section-title-inner">Role</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<h6 class="card-title mb-4">Setting a role limit the administrator to only manage users with the same role. Role administrators cannot have the following permissions: "manage_admins", "manage_roles", "manage_event_rules", "manage_apikeys", "manage_system", "manage_ip_lists"</h6> <p data-i18n="admin.role_help" class="fs-5 fw-semibold mb-4"></p>
<div class="form-group row"> <div class="form-group row mt-10">
<label for="idRole" class="col-sm-2 col-form-label">Role</label> <label for="idRole" data-i18n="general.role" class="col-md-3 col-form-label">Role</label>
<div class="col-sm-10"> <div class="col-md-9">
<select class="form-control selectpicker" data-live-search="true" id="idRole" name="role"> <select id="idRole" name="role" data-i18n="[data-placeholder]general.role_placeholder" class="form-select" data-control="i18n-select2" data-placeholder="Select a role" data-allow-clear="true">
<option value=""></option> <option value=""></option>
{{- range .Roles}} {{- range .Roles}}
<option value="{{.Name}}" {{if eq $.Admin.Role .Name}}selected{{end}}>{{.Name}}</option> <option value="{{.Name}}" {{if eq $.Admin.Role .Name}}selected{{end}}>{{.Name}}</option>
@ -118,185 +83,207 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
</div> </div>
</div> </div>
</div> </div>
{{- end}}
<div class="card bg-light mb-3"> {{- if .Groups}}
<div class="card-header"> <div class="card mt-10">
<b>Groups for users</b> <div class="card-header bg-light">
<h3 data-i18n="admin.users_groups" class="card-title section-title-inner">Groups for users</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<h6 class="card-title mb-4">Groups automatically selected for new users created by this admin. The admin will still be able to choose different groups. These settings are only used for this admin UI and they will be ignored in REST API/hooks.</h6> <div id="groups">
<div class="form-group row"> <p class="fs-5 fw-semibold mb-4" data-i18n="admin.users_groups_help"></p>
<div class="col-md-12 form_field_groups_outer"> <div class="form-group">
{{range $idx, $val := .Admin.Groups}} <div data-repeater-list="groups">
<div class="row form_field_groups_outer_row"> {{range $idx, $val := .Admin.Groups}}
<div class="form-group col-md-7"> <div data-repeater-item>
<select class="form-control selectpicker" data-live-search="true" id="idGroup{{$idx}}" name="group{{$idx}}"> <div data-repeater-item>
<option value=""></option> <div class="form-group row">
{{- range $.Groups}} <div class="col-md-6 mt-3 mt-md-8">
<option value="{{.Name}}" {{if eq $val.Name .Name}}selected{{end}}>{{.Name}}</option> <select name="group" data-i18n="[data-placeholder]general.group_placeholder" class="form-select select-repetear" data-allow-clear="true">
{{- end}} <option value=""></option>
</select> {{- range $.Groups}}
<option value="{{.Name}}" {{- if eq $val.Name .Name}} selected{{- end}}>{{.Name}}</option>
{{- end}}
</select>
</div>
<div class="col-md-5 mt-3 mt-md-8">
<select name="group_type" class="form-select select-repetear">
<option value="0" data-i18n="admin.group_membership" {{if eq $val.Options.AddToUsersAs 0}}selected{{end}}>Add as membership</option>
<option value="1" data-i18n="admin.group_primary" {{if eq $val.Options.AddToUsersAs 1}}selected{{end}}>Add as primary</option>
<option value="2" data-i18n="admin.group_secondary" {{if eq $val.Options.AddToUsersAs 2}}selected{{end}}>Add as secondary</option>
</select>
</div>
<div class="col-md-1 mt-3 mt-md-8">
<a href="#" data-repeater-delete
class="btn btn-light-danger ps-5 pe-4">
<i class="ki-duotone ki-trash fs-2">
<span class="path1"></span>
<span class="path2"></span>
<span class="path3"></span>
<span class="path4"></span>
<span class="path5"></span>
</i>
</a>
</div>
</div>
</div>
</div> </div>
<div class="form-group col-md-4"> {{- else}}
<select class="form-control selectpicker" id="idAddAsGroupType{{$idx}}" name="add_as_group_type{{$idx}}"> <div data-repeater-item>
<option value="0" {{if eq $val.Options.AddToUsersAs 0}}selected{{end}}>Add as membership</option> <div class="form-group row">
<option value="1" {{if eq $val.Options.AddToUsersAs 1}}selected{{end}}>Add as primary</option> <div class="col-md-6 mt-3 mt-md-8">
<option value="2" {{if eq $val.Options.AddToUsersAs 2}}selected{{end}}>Add as secondary</option> <select name="group" data-i18n="[data-placeholder]general.group_placeholder" class="form-select select-repetear" data-allow-clear="true">
</select> <option value=""></option>
</div> {{- range $.Groups}}
<div class="form-group col-md-1"> <option value="{{.Name}}">{{.Name}}</option>
<button class="btn btn-circle btn-danger remove_group_btn_frm_field"> {{- end}}
<i class="fas fa-trash"></i> </select>
</button> </div>
<div class="col-md-5 mt-3 mt-md-8">
<select name="group_type" class="form-select select-repetear select-first">
<option value="0" data-i18n="admin.group_membership">Add as membership</option>
<option value="1" data-i18n="admin.group_primary">Add as primary</option>
<option value="2" data-i18n="admin.group_secondary">Add as secondary</option>
</select>
</div>
<div class="col-md-1 mt-3 mt-md-8">
<a href="#" data-repeater-delete
class="btn btn-light-danger ps-5 pe-4">
<i class="ki-duotone ki-trash fs-2">
<span class="path1"></span>
<span class="path2"></span>
<span class="path3"></span>
<span class="path4"></span>
<span class="path5"></span>
</i>
</a>
</div>
</div>
</div> </div>
{{- end}}
</div> </div>
{{else}}
<div class="row form_field_groups_outer_row">
<div class="form-group col-md-7">
<select class="form-control selectpicker" data-live-search="true" id="idGroup0" name="group0">
<option value=""></option>
{{- range .Groups}}
<option value="{{.Name}}">{{.Name}}</option>
{{- end}}
</select>
</div>
<div class="form-group col-md-4">
<select class="form-control selectpicker" id="idAddAsGroupType0" name="add_as_group_type0">
<option value="0">Add as membership</option>
<option value="1">Add as primary</option>
<option value="2">Add as secondary</option>
</select>
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_group_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
{{end}}
</div> </div>
</div>
<div class="row mx-1"> <div class="form-group mt-5">
<button type="button" class="btn btn-secondary add_new_group_field_btn"> <a href="#" data-repeater-create class="btn btn-light-primary">
<i class="fas fa-plus"></i> Add group <i class="ki-duotone ki-plus fs-3"></i>
</button> <span data-i18n="general.add">Add</span>
</a>
</div>
</div> </div>
</div> </div>
</div> </div>
{{- end}}
<div class="card bg-light mb-3"> <div class="card mt-10">
<div class="card-header"> <div class="card-header bg-light">
<b>User page preferences</b> <h3 data-i18n="admin.user_page_pref" class="card-title section-title-inner">User page preferences</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<h6 class="card-title mb-4">You can 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.</h6> <p data-i18n="admin.user_page_pref_help" class="fs-5 fw-semibold mb-4"></p>
<div class="form-group row">
<label for="idUserPageHiddenSections" class="col-sm-2 col-form-label">Hide sections</label> <div class="form-group row mt-10">
<div class="col-sm-10"> <label for="idUserPageHiddenSections" data-i18n="admin.hide_sections" class="col-md-3 col-form-label">Hide sections</label>
<select class="form-control selectpicker" id="idUserPageHiddenSections" name="user_page_hidden_sections" multiple> <div class="col-md-9">
<option value="1" {{if .Admin.Filters.Preferences.HideGroups}}selected{{end}}>Groups</option> <select id="idUserPageHiddenSections" name="user_page_hidden_sections" class="form-select" data-control="i18n-select2" data-hide-search="true" data-close-on-select="false" multiple>
<option value="2" {{if .Admin.Filters.Preferences.HideFilesystem}}selected{{end}}>Filesystem</option> <option value="1" data-i18n="title.groups" {{if .Admin.Filters.Preferences.HideGroups}}selected{{end}}>Groups</option>
<option value="3" {{if .Admin.Filters.Preferences.HideVirtualFolders}}selected{{end}}>Virtual Folders</option> <option value="2" data-i18n="storage.title" {{if .Admin.Filters.Preferences.HideFilesystem}}selected{{end}}>Filesystem</option>
<option value="4" {{if .Admin.Filters.Preferences.HideProfile}}selected{{end}}>Profile</option> <option value="3" data-i18n="title.folders" {{if .Admin.Filters.Preferences.HideVirtualFolders}}selected{{end}}>Virtual Folders</option>
<option value="5" {{if .Admin.Filters.Preferences.HideACLs}}selected{{end}}>ACLs</option> <option value="4" data-i18n="title.profile" {{if .Admin.Filters.Preferences.HideProfile}}selected{{end}}>Profile</option>
<option value="6" {{if .Admin.Filters.Preferences.HideDiskQuotaAndBandwidthLimits}}selected{{end}}>Disk quota and bandwidth limits</option> <option value="5" data-i18n="general.acls" {{if .Admin.Filters.Preferences.HideACLs}}selected{{end}}>ACLs</option>
<option value="7" {{if .Admin.Filters.Preferences.HideAdvancedSettings}}selected{{end}}>Advanced settings</option> <option value="6" data-i18n="general.quota_limits" {{if .Admin.Filters.Preferences.HideDiskQuotaAndBandwidthLimits}}selected{{end}}>Disk quota and bandwidth limits</option>
<option value="7" data-i18n="general.advanced_settings" {{if .Admin.Filters.Preferences.HideAdvancedSettings}}selected{{end}}>Advanced settings</option>
</select> </select>
</div> </div>
</div> </div>
<div class="form-group row">
<label for="idDefaultUsersExpiration" class="col-sm-2 col-form-label">Default users expiration</label> <div class="form-group row mt-10">
<div class="col-sm-10"> <label for="idDefaultUsersExpiration" data-i18n="admin.default_users_expiration" class="col-md-3 col-form-label">Default users expiration</label>
<input type="number" class="form-control" id="idDefaultUsersExpiration" name="default_users_expiration" <div class="col-md-9">
value="{{.Admin.Filters.Preferences.DefaultUsersExpiration}}" min="0" aria-describedby="defaultUsersExpirationHelpBlock"> <input id="idDefaultUsersExpiration" type="number" min="0" class="form-control" name="default_users_expiration" value="{{.Admin.Filters.Preferences.DefaultUsersExpiration}}" aria-describedby="idDefaultUsersExpirationHelp"/>
<small id="defaultUsersExpirationHelpBlock" class="form-text text-muted"> <div id="idDefaultUsersExpirationHelp" class="form-text" data-i18n="admin.default_users_expiration_help"></div>
Default expiration for newly created users as number of days
</small>
</div> </div>
</div> </div>
</div>
</div>
<div class="form-group row mt-10">
<label for="idEmail" data-i18n="general.email" class="col-md-3 col-form-label">Email</label>
<div class="col-md-9">
<input id="idEmail" type="text" class="form-control" placeholder="" name="email" value="{{.Admin.Email}}"
maxlength="255" autocomplete="off" spellcheck="false" />
</div>
</div>
<div class="form-group row mt-10">
<label for="idDescription" data-i18n="general.description" class="col-md-3 col-form-label">Description</label>
<div class="col-md-9">
<input id="idDescription" type="text" class="form-control" name="description" value="{{.Admin.Description}}" maxlength="255">
</div>
</div>
<div class="form-group row mt-10">
<label for="idAllowedIP" data-i18n="general.allowed_ip_mask" class="col-md-3 col-form-label">Allowed IP/Mask</label>
<div class="col-md-9">
<textarea class="form-control" id="idAllowedIP" name="allowed_ip" aria-describedby="idAllowedIPHelp"
rows="3">{{.Admin.GetAllowedIPAsString}}</textarea>
<div id="idAllowedIPHelp" class="form-text" data-i18n="general.ip_mask_help"></div>
</div>
</div>
<div class="form-group row align-items-center mt-10">
<label data-i18n="general.api_key_auth" class="col-md-3 col-form-label" for="idAllowAPIKeyAuth">API key authentication</label>
<div class="col-md-9">
<div class="form-check form-switch form-check-custom form-check-solid">
<input class="form-check-input" type="checkbox" id="idAllowAPIKeyAuth" name="allow_api_key_auth" {{if .Admin.Filters.AllowAPIKeyAuth}}checked{{end}}/>
<label data-i18n="filters.api_key_auth_help" class="form-check-label fw-semibold text-gray-800" for="idAllowAPIKeyAuth">
Allow to impersonate yourself, in REST API, with an API key
</label>
</div>
</div> </div>
</div> </div>
<div class="form-group row"> <div class="form-group row mt-10">
<label for="idAllowedIP" class="col-sm-2 col-form-label">Allowed IP/Mask</label> <label for="idAdditionalInfo" data-i18n="general.additional_info" class="col-md-3 col-form-label">Additional info</label>
<div class="col-sm-10"> <div class="col-md-9">
<textarea class="form-control" id="idAllowedIP" name="allowed_ip" rows="3" placeholder="" <textarea id="idAdditionalInfo" class="form-control" name="additional_info" rows="3">{{.Admin.AdditionalInfo}}</textarea>
aria-describedby="allowedIPHelpBlock">{{.Admin.GetAllowedIPAsString}}</textarea>
<small id="allowedIPHelpBlock" class="form-text text-muted">
Comma separated IP/Mask in CIDR format, for example "192.168.1.0/24,10.8.0.100/32"
</small>
</div> </div>
</div> </div>
<div class="form-group"> <div class="d-flex justify-content-end mt-12">
<div class="form-check"> <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
<input type="checkbox" class="form-check-input" id="idAllowAPIKeyAuth" name="allow_api_key_auth" <button type="submit" id="form_submit" class="btn btn-primary px-10" name="form_action" value="submit">
{{if .Admin.Filters.AllowAPIKeyAuth}}checked{{end}} aria-describedby="allowAPIKeyAuthHelpBlock"> <span data-i18n="general.submit" class="indicator-label">
<label for="idAllowAPIKeyAuth" class="form-check-label">Allow API key authentication</label> Submit
<small id="allowAPIKeyAuthHelpBlock" class="form-text text-muted"> </span>
Allow to impersonate this admin, in REST API, with an API key <span data-i18n="general.wait" class="indicator-progress">
</small> Please wait...
</div> <span class="spinner-border spinner-border-sm align-middle ms-2"></span>
</span>
</button>
</div> </div>
<div class="form-group row">
<label for="idAdditionalInfo" class="col-sm-2 col-form-label">Additional info</label>
<div class="col-sm-10">
<textarea class="form-control" id="idAdditionalInfo" name="additional_info" rows="3"
aria-describedby="additionalInfoHelpBlock">{{.Admin.AdditionalInfo}}</textarea>
<small id="additionalInfoHelpBlock" class="form-text text-muted">
Free form text field
</small>
</div>
</div>
<input type="hidden" name="_form_token" value="{{.CSRFToken}}">
<button type="submit" class="btn btn-primary float-right mt-3 px-5">Submit</button>
</form> </form>
</div> </div>
</div> </div>
{{end}} {{- end}}
{{define "extra_js"}} {{- define "extra_js"}}
<script src="{{.StaticURL}}/vendor/bootstrap-select/js/bootstrap-select.min.js"></script> <script {{- if .CSPNonce}} nonce="{{.CSPNonce}}"{{- end}} src="{{.StaticURL}}/assets/plugins/custom/formrepeater/formrepeater.bundle.js"></script>
<script type="text/javascript"> <script type="text/javascript" {{- if .CSPNonce}} nonce="{{.CSPNonce}}"{{- end}}>
$("body").on("click", ".add_new_group_field_btn", function () {
let index = $(".form_field_groups_outer").find(".form_field_groups_outer_row").length; $(document).on("i18nload", function(){
while (document.getElementById("idGroup"+index) != null){ initRepeater('#groups');
index++; initRepeaterItems();
}
$(".form_field_groups_outer").append(`
<div class="row form_field_groups_outer_row">
<div class="form-group col-md-7">
<select class="form-control" id="idGroup${index}" name="group${index}">
<option value=""></option>
</select>
</div>
<div class="form-group col-md-4">
<select class="form-control" id="idAddAsGroupType${index}" name="add_as_group_type${index}">
<option value="0">Add as membership</option>
<option value="1">Add as primary</option>
<option value="2">Add as secondary</option>
</select>
</div>
<div class="form-group col-md-1">
<button class="btn btn-circle btn-danger remove_group_btn_frm_field">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
`);
{{- range .Groups}}
$("#idGroup"+index).append($('<option>').val('{{.Name}}').text('{{.Name}}'));
{{- end}}
$("#idGroup"+index).selectpicker({'liveSearch': true});
$("#idAddAsGroupType"+index).selectpicker();
}); });
$("body").on("click", ".remove_group_btn_frm_field", function () { $(document).on("i18nshow", function(){
$(this).closest(".form_field_groups_outer_row").remove(); $('#admin_form').submit(function (event) {
let submitButton = document.querySelector('#form_submit');
submitButton.setAttribute('data-kt-indicator', 'on');
submitButton.disabled = true;
});
}); });
</script> </script>
{{end}} {{- end}}

View file

@ -1,272 +1,433 @@
<!-- <!--
Copyright (C) 2019 Nicola Murino Copyright (C) 2024 Nicola Murino
This program is free software: you can redistribute it and/or modify This WebUI uses the KeenThemes Mega Bundle, a proprietary theme:
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, version 3.
This program is distributed in the hope that it will be useful, https://keenthemes.com/products/templates-mega-bundle
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License KeenThemes HTML/CSS/JS components are allowed for use only within the
along with this program. If not, see <https://www.gnu.org/licenses/>. SFTPGo product and restricted to be used in a resealable HTML template
that can compete with KeenThemes products anyhow.
This WebUI is allowed for use only within the SFTPGo product and
therefore cannot be used in derivative works/products without an
explicit grant from the SFTPGo Team (support@sftpgo.com).
--> -->
{{template "base" .}} {{template "base" .}}
{{define "title"}}{{.Title}}{{end}} {{- define "extra_css"}}
<link href="{{.StaticURL}}/assets/plugins/custom/datatables/datatables.bundle.css" rel="stylesheet" type="text/css"/>
{{- end}}
{{define "extra_css"}} {{- define "page_body"}}
<link href="{{.StaticURL}}/vendor/datatables/dataTables.bootstrap4.min.css" rel="stylesheet"> {{- template "errmsg" ""}}
<link href="{{.StaticURL}}/vendor/datatables/buttons.bootstrap4.min.css" rel="stylesheet"> <div class="card shadow-sm">
<link href="{{.StaticURL}}/vendor/datatables/fixedHeader.bootstrap4.min.css" rel="stylesheet"> <div class="card-header bg-light">
<link href="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.css" rel="stylesheet"> <h3 data-i18n="admin.view_manage" class="card-title section-title">View and manage admins</h3>
<link href="{{.StaticURL}}/vendor/datatables/select.bootstrap4.min.css" rel="stylesheet">
<link href="{{.StaticURL}}/vendor/datatables/colReorder.bootstrap4.min.css" rel="stylesheet">
{{end}}
{{define "page_body"}}
<div id="errorMsg" class="alert alert-warning fade show" style="display: none;" role="alert">
<span id="errorTxt"></span>
<button type="button" class="close" aria-label="Close" onclick="dismissErrorMsg();">
<span aria-hidden="true">&times;</span>
</button>
</div>
<script type="text/javascript">
function dismissErrorMsg(){
$('#errorMsg').hide();
}
</script>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">View and manage admins</h6>
</div> </div>
<div class="card-body"> <div id="card_body" class="card-body">
<div class="table-responsive"> <div id="loader" class="align-items-center text-center my-10">
<table class="table table-hover nowrap" id="dataTable" width="100%" cellspacing="0"> <span class="spinner-border w-15px h-15px text-muted align-middle me-2"></span>
<span data-i18n="general.loading" class="text-gray-700">Loading...</span>
</div>
<div id="card_content" class="d-none">
<div class="d-flex flex-stack flex-wrap mb-5">
<div class="d-flex align-items-center position-relative my-2">
<i class="ki-solid ki-magnifier fs-1 position-absolute ms-6"></i>
<input name="search" data-i18n="[placeholder]general.search" type="text" data-table-filter="search"
class="form-control rounded-1 w-250px ps-15 me-5" placeholder="Search" />
</div>
<div class="d-flex justify-content-end my-2" data-table-toolbar="base">
<button type="button" class="btn btn-light-primary rotate" data-kt-menu-trigger="click" data-kt-menu-placement="bottom" data-kt-menu-permanent="true">
<span data-i18n="general.colvis">Column visibility</span>
<i class="ki-duotone ki-down fs-3 rotate-180 ms-3 me-0"></i>
</button>
<div class="menu menu-sub menu-sub-dropdown menu-column menu-rounded menu-gray-800 menu-state-bg-light-primary fw-semibold w-auto min-w-200 mw-300px py-4" data-kt-menu="true">
<div class="menu-item px-3 py-2 form-check form-check-sm form-check-custom form-check-solid">
<input type="checkbox" class="form-check-input" value="" id="checkColStatus" />
<label class="form-check-label" for="checkColStatus">
<span data-i18n="general.status" class="text-gray-800 fs-6">Status</span>
</label>
</div>
<div class="menu-item px-3 py-2 form-check form-check-sm form-check-custom form-check-solid">
<input type="checkbox" class="form-check-input" value="" id="checkColLastLogin" />
<label class="form-check-label" for="checkColLastLogin">
<span data-i18n="general.last_login" class="text-gray-800 fs-6">Last login</span>
</label>
</div>
<div class="menu-item px-3 py-2 form-check form-check-sm form-check-custom form-check-solid">
<input type="checkbox" class="form-check-input" value="" id="checkColEmail" />
<label class="form-check-label" for="checkColEmail">
<span data-i18n="general.email" class="text-gray-800 fs-6">Email</span>
</label>
</div>
<div class="menu-item px-3 py-2 form-check form-check-sm form-check-custom form-check-solid">
<input type="checkbox" class="form-check-input" value="" id="checkColRole" />
<label class="form-check-label" for="checkColRole">
<span data-i18n="general.role" class="text-gray-800 fs-6">Role</span>
</label>
</div>
<div class="menu-item px-3 py-2 form-check form-check-sm form-check-custom form-check-solid">
<input type="checkbox" class="form-check-input" value="" id="checkCol2FA" />
<label class="form-check-label" for="checkCol2FA">
<span data-i18n="title.two_factor_auth_short" class="text-gray-800 fs-6">2FA</span>
</label>
</div>
<div class="menu-item px-3 py-2 form-check form-check-sm form-check-custom form-check-solid">
<input type="checkbox" class="form-check-input" value="" id="checkColDesc" />
<label class="form-check-label" for="checkColDesc">
<span data-i18n="general.description" class="text-gray-800 fs-6">Description</span>
</label>
</div>
</div>
{{- if .LoggedUser.HasPermission "manage_admins"}}
<a href="{{.AdminURL}}" class="btn btn-primary ms-5">
<i class="ki-duotone ki-plus fs-2"></i>
<span data-i18n="general.add">Add</span>
</a>
{{- end}}
</div>
</div>
<table id="dataTable" class="table align-middle table-row-dashed fs-6 gy-5">
<thead> <thead>
<tr> <tr class="text-start text-muted fw-bold fs-6 gs-0">
<th>ID</th> <th data-i18n="login.username">Username</th>
<th>Username</th> <th data-i18n="general.status">Status</th>
<th>Status</th> <th data-i18n="general.last_login">Last login</th>
<th>Permissions</th> <th data-i18n="general.email">Email</th>
<th>Last login</th> <th data-i18n="general.role">Role</th>
<th>MFA</th> <th data-i18n="title.two_factor_auth_short">2FA</th>
<th>Allow list</th> <th data-i18n="general.description">Description</th>
<th>Email</th> <th class="min-w-100px"></th>
<th>Description</th>
<th>Groups for users</th>
<th>Role</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="table_body" class="text-gray-800 fw-semibold"></tbody>
{{range .Admins}}
<tr>
<td>{{.ID}}</td>
<td>{{.Username}}</td>
<td>{{if eq .Status 1 }}Active{{else}}Inactive{{end}}</td>
<td>{{.GetPermissionsAsString}}</td>
<td>{{.GetLastLoginAsString}}</td>
<td>{{if .Filters.TOTPConfig.Enabled }}Enabled{{else}}Disabled{{end}}</td>
<td>{{.GetAllowedIPAsString}}</td>
<td>{{.Email}}</td>
<td>{{.Description}}</td>
<td>{{.GetGroupsAsString}}</td>
<td>{{.Role}}</td>
</tr>
{{end}}
</tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
{{- end}}
{{- define "extra_js"}}
<script {{- if .CSPNonce}} nonce="{{.CSPNonce}}"{{- end}} src="{{.StaticURL}}/assets/plugins/custom/datatables/datatables.bundle.js"></script>
<script type="text/javascript" {{- if .CSPNonce}} nonce="{{.CSPNonce}}"{{- end}}>
function deleteAction(username) {
ModalAlert.fire({
text: $.t('general.delete_confirm_generic'),
icon: "warning",
confirmButtonText: $.t('general.delete_confirm_btn'),
cancelButtonText: $.t('general.cancel'),
customClass: {
confirmButton: "btn btn-danger",
cancelButton: 'btn btn-secondary'
}
}).then((result) => {
if (result.isConfirmed){
$('#loading_message').text("");
KTApp.showPageLoading();
let path = '{{.AdminURL}}' + "/" + encodeURIComponent(username);
{{end}} axios.delete(path, {
timeout: 15000,
{{define "dialog"}} headers: {
<div class="modal fade" id="deleteModal" tabindex="-1" role="dialog" aria-labelledby="deleteModalLabel" 'X-CSRF-TOKEN': '{{.CSRFToken}}'
aria-hidden="true"> },
<div class="modal-dialog" role="document"> validateStatus: function (status) {
<div class="modal-content"> return status == 200;
<div class="modal-header"> }
<h5 class="modal-title" id="deleteModalLabel"> }).then(function(response){
Confirmation required location.reload();
</h5> }).catch(function(error){
<button class="close" type="button" data-dismiss="modal" aria-label="Close"> KTApp.hidePageLoading();
<span aria-hidden="true">&times;</span> let errorMessage;
</button> if (error && error.response) {
</div> switch (error.response.status) {
<div class="modal-body">Do you want to delete the selected admin?</div> case 400:
<div class="modal-footer"> if (error.response.data && error.response.data.error){
<button class="btn btn-secondary" type="button" data-dismiss="modal"> if (error.response.data.error.includes("delete yourself")){
Cancel errorMessage = "admin.self_delete";
</button> }
<a class="btn btn-warning" href="#" onclick="deleteAction()"> }
Delete break;
</a> case 403:
</div> errorMessage = "general.delete_error_403";
</div> break;
</div> case 404:
</div> errorMessage = "general.delete_error_404";
{{end}} break;
{{define "extra_js"}}
<script src="{{.StaticURL}}/vendor/datatables/jquery.dataTables.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/dataTables.bootstrap4.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/dataTables.buttons.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/buttons.bootstrap4.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/buttons.colVis.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/dataTables.fixedHeader.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/dataTables.responsive.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/dataTables.select.min.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/ellipsis.js"></script>
<script src="{{.StaticURL}}/vendor/datatables/dataTables.colReorder.min.js"></script>
<script type="text/javascript">
function deleteAction() {
var table = $('#dataTable').DataTable();
table.button('delete:name').enable(false);
var username = table.row({ selected: true }).data()[1];
var path = '{{.AdminURL}}' + "/" + fixedEncodeURIComponent(username);
$('#deleteModal').modal('hide');
$('#errorMsg').hide();
$.ajax({
url: path,
type: 'DELETE',
dataType: 'json',
headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
timeout: 15000,
success: function (result) {
window.location.href = '{{.AdminsURL}}';
},
error: function ($xhr, textStatus, errorThrown) {
var txt = "Unable to delete the selected admin";
if ($xhr) {
var json = $xhr.responseJSON;
if (json) {
if (json.message){
txt += ": " + json.message;
} else {
txt += ": " + json.error;
} }
} }
} if (!errorMessage){
$('#errorTxt').text(txt); errorMessage = "general.delete_error_generic";
$('#errorMsg').show(); }
ModalAlert.fire({
text: $.t(errorMessage),
icon: "warning",
confirmButtonText: $.t('general.ok'),
customClass: {
confirmButton: "btn btn-primary"
}
});
});
} }
}); });
} }
$(document).ready(function () { var datatable = function(){
$.fn.dataTable.ext.buttons.add = { var dt;
text: '<i class="fas fa-plus"></i>',
name: 'add', var initDatatable = function () {
titleAttr: "Add", $('#errorMsg').addClass("d-none");
action: function (e, dt, node, config) { dt = $('#dataTable').DataTable({
window.location.href = '{{.AdminURL}}'; ajax: {
url: "{{.AdminsURL}}/json",
dataSrc: "",
error: function ($xhr, textStatus, errorThrown) {
$(".dataTables_processing").hide();
let txt = "";
if ($xhr) {
let json = $xhr.responseJSON;
if (json) {
if (json.message){
txt = json.message;
}
}
}
if (!txt){
txt = "general.error500";
}
setI18NData($('#errorTxt'), txt);
$('#errorMsg').removeClass("d-none");
}
},
columns: [
{
data: "username",
render: function(data, type, row) {
if (type === 'display') {
return escapeHTML(data);
}
return data;
}
},
{
data: "status",
render: function(data, type, row) {
if (type === 'display') {
switch (data){
case 1:
return $.t('general.active');
default:
return $.t('general.inactive');
}
}
return data;
}
},
{
data: "last_login",
defaultContent: "",
render: function(data, type, row) {
if (type === 'display') {
if (data > 0){
return $.t('general.datetime', {
val: parseInt(data, 10),
formatParams: {
val: { year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric' },
}
});
}
return ""
}
return data;
}
},
{
data: "email",
visible: false,
defaultContent: "",
render: function(data, type, row) {
if (type === 'display') {
if (data){
return escapeHTML(data);
}
return "";
}
return data;
}
},
{
data: "role",
visible: false,
defaultContent: "",
render: function(data, type, row) {
if (type === 'display') {
if (data){
return escapeHTML(data);
}
return ""
}
return data;
}
},
{
data: "filters.totp_config.enabled",
visible: false,
defaultContent: false,
render: function(data, type, row) {
if (type === 'display') {
if (data){
return $.t('general.active');
}
return $.t('general.inactive')
}
return data;
}
},
{
data: "description",
visible: false,
defaultContent: "",
render: function(data, type, row) {
if (type === 'display') {
if (data){
return escapeHTML(data);
}
return ""
}
return data;
}
},
{
data: "id",
searchable: false,
orderable: false,
className: 'text-end',
render: function (data, type, row) {
if (type === 'display') {
//{{- if .LoggedUser.HasPermission "manage_admins"}}
return `<button class="btn btn-light btn-active-light-primary btn-flex btn-center btn-sm rotate" data-kt-menu-trigger="click" data-kt-menu-placement="bottom-end">
<span data-i18n="general.actions" class="fs-6">Actions</span>
<i class="ki-duotone ki-down fs-5 ms-1 rotate-180"></i>
</button>
<div class="menu menu-sub menu-sub-dropdown menu-column menu-rounded menu-gray-700 menu-state-bg-light-primary fw-semibold fs-6 w-200px py-4" data-kt-menu="true">
<div class="menu-item px-3">
<a data-i18n="general.edit" href="#" class="menu-link px-3" data-table-action="edit_row">Edit</a>
</div>
<div class="menu-item px-3">
<a data-i18n="general.delete" href="#" class="menu-link text-danger px-3" data-table-action="delete_row">Delete</a>
</div>
</div>`;
//{{- end}}
}
return "";
}
},
],
deferRender: true,
stateSave: true,
stateDuration: 0,
colReorder: {
enable: true,
fixedColumnsLeft: 1
},
stateLoadParams: function (settings, data) {
if (data.search.search){
const filterSearch = document.querySelector('[data-table-filter="search"]');
filterSearch.value = data.search.search;
}
},
language: {
info: $.t('datatable.info'),
infoEmpty: $.t('datatable.info_empty'),
infoFiltered: $.t('datatable.info_filtered'),
loadingRecords: "",
processing: $.t('datatable.processing'),
zeroRecords: "",
emptyTable: $.t('datatable.no_records')
},
order: [[0, 'asc']],
initComplete: function(settings, json) {
$('#loader').addClass("d-none");
$('#card_content').removeClass("d-none");
let api = $.fn.dataTable.Api(settings);
api.columns.adjust().draw("page");
drawAction();
}
});
dt.on('draw', drawAction);
dt.on('column-reorder', function(e, settings, details){
drawAction();
});
}
function drawAction() {
KTMenu.createInstances();
handleRowActions();
$('#table_body').localize();
}
function handleColVisibilityCheckbox(el, index) {
el.off("change");
el.prop('checked', dt.column(index).visible());
el.on("change", function(e){
dt.column(index).visible($(this).is(':checked'));
dt.draw('page');
});
}
var handleDatatableActions = function () {
const filterSearch = $(document.querySelector('[data-table-filter="search"]'));
filterSearch.off("keyup");
filterSearch.on('keyup', function (e) {
dt.rows().deselect();
dt.search(e.target.value, true, false).draw();
});
handleColVisibilityCheckbox($('#checkColStatus'), 1);
handleColVisibilityCheckbox($('#checkColLastLogin'), 2);
handleColVisibilityCheckbox($('#checkColEmail'), 3);
handleColVisibilityCheckbox($('#checkColRole'), 4);
handleColVisibilityCheckbox($('#checkCol2FA'), 5);
handleColVisibilityCheckbox($('#checkColDesc'), 6);
}
function handleRowActions() {
const editButtons = document.querySelectorAll('[data-table-action="edit_row"]');
editButtons.forEach(d => {
let el = $(d);
el.off("click");
el.on("click", function(e){
e.preventDefault();
let rowData = dt.row(e.target.closest('tr')).data();
window.location.replace('{{.AdminURL}}' + "/" + encodeURIComponent(rowData['username']));
});
});
const deleteButtons = document.querySelectorAll('[data-table-action="delete_row"]');
deleteButtons.forEach(d => {
let el = $(d);
el.off("click");
el.on("click", function(e){
e.preventDefault();
const parent = e.target.closest('tr');
deleteAction(dt.row(parent).data()['username']);
});
});
}
return {
init: function () {
initDatatable();
handleDatatableActions();
} }
}; }
}();
$.fn.dataTable.ext.buttons.edit = { $(document).on("i18nshow", function(){
text: '<i class="fas fa-pen"></i>', datatable.init();
name: 'edit',
titleAttr: "Edit",
action: function (e, dt, node, config) {
var username = dt.row({ selected: true }).data()[1];
var path = '{{.AdminURL}}' + "/" + fixedEncodeURIComponent(username);
window.location.href = path;
},
enabled: false
};
$.fn.dataTable.ext.buttons.delete = {
text: '<i class="fas fa-trash"></i>',
name: 'delete',
titleAttr: "Delete",
action: function (e, dt, node, config) {
$('#deleteModal').modal('show');
},
enabled: false
};
var table = $('#dataTable').DataTable({
"select": {
"style": "single",
"blurable": true
},
"colReorder": {
"enable": true,
"fixedColumnsLeft": 2
},
"stateSave": true,
"stateDuration": 0,
"buttons": [
{
"text": "Column visibility",
"extend": "colvis",
"columns": ":not(.noVis)"
}
],
"columnDefs": [
{
"targets": [0],
"visible": false,
"searchable": false,
"className": "noVis"
},
{
"targets": [1],
"className": "noVis"
},
{
"targets": [3],
"render": $.fn.dataTable.render.ellipsis(70, true)
},
{
"targets": [4],
"render": $.fn.dataTable.render.datetime()
},
{
"targets": [6],
"render": $.fn.dataTable.render.ellipsis(60, true),
"visible": false,
},
{
"targets": [5,7,8,10],
"visible": false,
},
{
"targets": [9],
"render": $.fn.dataTable.render.ellipsis(50, true),
"visible": false,
}
],
"scrollX": false,
"scrollY": false,
"responsive": true,
"order": [[1, 'asc']]
});
new $.fn.dataTable.FixedHeader( table );
{{if .LoggedAdmin.HasPermission "manage_admins"}}
table.button().add(0,'delete');
table.button().add(0,'edit');
table.button().add(0,'add');
table.buttons().container().appendTo('.col-md-6:eq(0)', table.table().container());
table.on('select deselect', function () {
var selectedRows = table.rows({ selected: true }).count();
table.button('edit:name').enable(selectedRows == 1);
table.button('delete:name').enable(selectedRows == 1);
});
{{end}}
}); });
</script> </script>
{{end}} {{- end}}

View file

@ -370,7 +370,7 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
<div class="accordion-body"> <div class="accordion-body">
<div class="form-group row"> <div class="form-group row">
<label for="idStatus" data-i18n="user.status" class="col-md-3 col-form-label">Status</label> <label for="idStatus" data-i18n="general.status" class="col-md-3 col-form-label">Status</label>
<div class="col-md-9"> <div class="col-md-9">
<select id="idStatus" name="status" class="form-select" data-control="i18n-select2" data-hide-search="true"> <select id="idStatus" name="status" class="form-select" data-control="i18n-select2" data-hide-search="true">
<option data-i18n="general.active" value="1" {{- if eq .User.Status 1 }} selected{{- end}}>Active</option> <option data-i18n="general.active" value="1" {{- if eq .User.Status 1 }} selected{{- end}}>Active</option>

View file

@ -45,15 +45,15 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
</button> </button>
<div class="menu menu-sub menu-sub-dropdown menu-column menu-rounded menu-gray-800 menu-state-bg-light-primary fw-semibold w-auto min-w-200 mw-300px py-4" data-kt-menu="true"> <div class="menu menu-sub menu-sub-dropdown menu-column menu-rounded menu-gray-800 menu-state-bg-light-primary fw-semibold w-auto min-w-200 mw-300px py-4" data-kt-menu="true">
<div class="menu-item px-3 py-2 form-check form-check-sm form-check-custom form-check-solid"> <div class="menu-item px-3 py-2 form-check form-check-sm form-check-custom form-check-solid">
<input type="checkbox" class="form-check-input" value="" id="checkColMembers" /> <input type="checkbox" class="form-check-input" value="" id="checkColStatus" />
<label class="form-check-label" for="checkColMembers"> <label class="form-check-label" for="checkColStatus">
<span data-i18n="user.status" class="text-gray-800 fs-6">Status</span> <span data-i18n="general.status" class="text-gray-800 fs-6">Status</span>
</label> </label>
</div> </div>
<div class="menu-item px-3 py-2 form-check form-check-sm form-check-custom form-check-solid"> <div class="menu-item px-3 py-2 form-check form-check-sm form-check-custom form-check-solid">
<input type="checkbox" class="form-check-input" value="" id="checkColLastLogin" /> <input type="checkbox" class="form-check-input" value="" id="checkColLastLogin" />
<label class="form-check-label" for="checkColLastLogin"> <label class="form-check-label" for="checkColLastLogin">
<span data-i18n="user.last_login" class="text-gray-800 fs-6">Last login</span> <span data-i18n="general.last_login" class="text-gray-800 fs-6">Last login</span>
</label> </label>
</div> </div>
<div class="menu-item px-3 py-2 form-check form-check-sm form-check-custom form-check-solid"> <div class="menu-item px-3 py-2 form-check form-check-sm form-check-custom form-check-solid">
@ -112,8 +112,8 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
<thead> <thead>
<tr class="text-start text-muted fw-bold fs-6 gs-0"> <tr class="text-start text-muted fw-bold fs-6 gs-0">
<th data-i18n="login.username">Username</th> <th data-i18n="login.username">Username</th>
<th data-i18n="user.status">Status</th> <th data-i18n="general.status">Status</th>
<th data-i18n="user.last_login">Last login</th> <th data-i18n="general.last_login">Last login</th>
<th data-i18n="general.email">Email</th> <th data-i18n="general.email">Email</th>
<th data-i18n="storage.label">Storage</th> <th data-i18n="storage.label">Storage</th>
<th data-i18n="general.role">Role</th> <th data-i18n="general.role">Role</th>