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 config.PasswordValidation.Admins.MinEntropy > 0 {
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 {
@ -397,7 +397,7 @@ func (a *Admin) validate() error {
return util.NewI18nError(err, util.I18nError2FAInvalid)
}
if err := a.validateRecoveryCodes(); err != nil {
return err
return util.NewI18nError(err, util.I18nErrorRecoveryCodesInvalid)
}
if config.NamingRules&1 == 0 && !usernameRegex.MatchString(a.Username) {
return util.NewI18nError(

View file

@ -1723,6 +1723,8 @@ func (s *httpdServer) setupWebAdminRoutes() {
Get(webStatusPath, s.handleWebGetStatus)
router.With(s.checkPerm(dataprovider.PermAdminManageAdmins), s.refreshCookie).
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).
Get(webAdminPath, s.handleWebAddAdminGet)
router.With(s.checkPerm(dataprovider.PermAdminManageAdmins), s.refreshCookie).

View file

@ -98,7 +98,6 @@ const (
templateMaintenance = "maintenance.html"
templateMFA = "mfa.html"
templateSetup = "adminsetup.html"
pageAdminsTitle = "Admins"
pageStatusTitle = "Status"
pageEventRulesTitle = "Event rules"
pageEventActionsTitle = "Event actions"
@ -162,11 +161,6 @@ type basePage struct {
Branding UIBranding
}
type adminsPage struct {
basePage
Admins []dataprovider.Admin
}
type eventRulesPage struct {
basePage
Rules []dataprovider.EventRule
@ -215,7 +209,7 @@ type adminPage struct {
Admin *dataprovider.Admin
Groups []dataprovider.Group
Roles []dataprovider.Role
Error string
Error *util.I18nError
IsAdd bool
}
@ -379,12 +373,12 @@ func loadAdminTemplates(templatesPath string) {
filepath.Join(templatesPath, templateAdminDir, templateUser),
}
adminsPaths := []string{
filepath.Join(templatesPath, templateCommonDir, templateCommonCSS),
filepath.Join(templatesPath, templateCommonDir, templateCommonBase),
filepath.Join(templatesPath, templateAdminDir, templateBase),
filepath.Join(templatesPath, templateAdminDir, templateAdmins),
}
adminPaths := []string{
filepath.Join(templatesPath, templateCommonDir, templateCommonCSS),
filepath.Join(templatesPath, templateCommonDir, templateCommonBase),
filepath.Join(templatesPath, templateAdminDir, templateBase),
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,
error string, isAdd bool) {
groups, err := s.getWebGroups(w, r, defaultQueryLimit, true)
if err != nil {
err error, isAdd bool) {
groups, errGroups := s.getWebGroups(w, r, defaultQueryLimit, true)
if errGroups != nil {
return
}
roles, err := s.getWebRoles(w, r, 10, true)
if err != nil {
roles, errRoles := s.getWebRoles(w, r, 10, true)
if errRoles != nil {
return
}
currentURL := webAdminPath
title := "Add a new admin"
title := util.I18nAddAdminTitle
if !isAdd {
currentURL = fmt.Sprintf("%v/%v", webAdminPath, url.PathEscape(admin.Username))
title = "Update admin"
title = util.I18nUpdateAdminTitle
}
data := adminPage{
basePage: s.getBasePageData(title, currentURL, r),
Admin: admin,
Groups: groups,
Roles: roles,
Error: error,
Error: getI18nError(err),
IsAdd: isAdd,
}
@ -1777,14 +1771,14 @@ func getAdminFromPostFields(r *http.Request) (dataprovider.Admin, error) {
admin.Filters.Preferences.DefaultUsersExpiration = defaultUsersExpiration
}
for k := range r.Form {
if strings.HasPrefix(k, "group") {
if hasPrefixAndSuffix(k, "groups[", "][group]") {
groupName := strings.TrimSpace(r.Form.Get(k))
if groupName != "" {
idx := strings.TrimPrefix(k, "group")
addAsGroupType := r.Form.Get(fmt.Sprintf("add_as_group_type%s", idx))
group := dataprovider.AdminGroupMapping{
Name: groupName,
}
base, _ := strings.CutSuffix(k, "[group]")
addAsGroupType := strings.TrimSpace(r.Form.Get(base + "[group_type]"))
switch addAsGroupType {
case "1":
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)
}
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)
limit := defaultQueryLimit
if _, ok := r.URL.Query()["qlimit"]; ok {
var err error
limit, err = strconv.Atoi(r.URL.Query().Get("qlimit"))
if err != nil {
limit = defaultQueryLimit
claims, err := getTokenClaims(r)
if err != nil || claims.Username == "" {
sendAPIResponse(w, r, nil, util.I18nErrorDirList403, http.StatusForbidden)
return
}
}
admins := make([]dataprovider.Admin, 0, limit)
admins := make([]dataprovider.Admin, 0, 50)
for {
a, err := dataprovider.GetAdmins(limit, len(admins), dataprovider.OrderASC)
a, err := dataprovider.GetAdmins(defaultQueryLimit, len(admins), dataprovider.OrderASC)
if err != nil {
s.renderInternalServerErrorPage(w, r, err)
sendAPIResponse(w, r, err, getI18NErrorString(err, util.I18nError500Message), http.StatusInternalServerError)
return
}
admins = append(admins, a...)
if len(a) < limit {
if len(a) < defaultQueryLimit {
break
}
}
data := adminsPage{
basePage: s.getBasePageData(pageAdminsTitle, webAdminsPath, r),
Admins: admins,
render.JSON(w, r, 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)
}
@ -2847,7 +2841,7 @@ func (s *httpdServer) handleWebAddAdminGet(w http.ResponseWriter, r *http.Reques
Status: 1,
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) {
@ -2855,7 +2849,7 @@ func (s *httpdServer) handleWebUpdateAdminGet(w http.ResponseWriter, r *http.Req
username := getURLParam(r, "username")
admin, err := dataprovider.AdminExists(username)
if err == nil {
s.renderAddUpdateAdminPage(w, r, &admin, "", false)
s.renderAddUpdateAdminPage(w, r, &admin, nil, false)
} else if errors.Is(err, util.ErrNotFound) {
s.renderNotFoundPage(w, r, err)
} else {
@ -2872,7 +2866,7 @@ func (s *httpdServer) handleWebAddAdminPost(w http.ResponseWriter, r *http.Reque
}
admin, err := getAdminFromPostFields(r)
if err != nil {
s.renderAddUpdateAdminPage(w, r, &admin, err.Error(), true)
s.renderAddUpdateAdminPage(w, r, &admin, err, true)
return
}
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)
if err != nil {
s.renderAddUpdateAdminPage(w, r, &admin, err.Error(), true)
s.renderAddUpdateAdminPage(w, r, &admin, err, true)
return
}
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)
if err != nil {
s.renderAddUpdateAdminPage(w, r, &updatedAdmin, err.Error(), false)
s.renderAddUpdateAdminPage(w, r, &updatedAdmin, err, false)
return
}
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
claims, err := getTokenClaims(r)
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
}
if username == claims.Username {
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
}
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
}
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
}
}
err = dataprovider.UpdateAdmin(&updatedAdmin, claims.Username, ipAddr, claims.Role)
if err != nil {
s.renderAddUpdateAdminPage(w, r, &updatedAdmin, err.Error(), false)
s.renderAddUpdateAdminPage(w, r, &updatedAdmin, err, false)
return
}
http.Redirect(w, r, webAdminsPath, http.StatusSeeOther)

View file

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

View file

@ -56,7 +56,9 @@
"oauth2_error": "Unable to complete OAuth2 flow",
"oauth2_success": "OAuth2 flow completed",
"add_role": "Add role",
"update_role": "Update role"
"update_role": "Update role",
"add_admin": "Add admin",
"update_admin": "Update admin"
},
"setup": {
"desc": "To start using SFTPGo you need to create an administrator user",
@ -225,7 +227,9 @@
"protocol": "Protocol",
"refresh": "Refresh",
"members": "Members",
"members_summary": "Users: {{users}}. Admins: {{admins}}"
"members_summary": "Users: {{users}}. Admins: {{admins}}",
"status": "Status",
"last_login": "Last login"
},
"fs": {
"view_file": "View file \"{{- path}}\"",
@ -468,8 +472,6 @@
"file_pattern_invalid": "Invalid file name pattern filters",
"disable_active_2fa": "Two-factor authentication cannot be disabled for a user with an active configuration",
"pwd_change_conflict": "It is not possible to request a password change and at the same time prevent the password from being changed",
"status": "Status",
"last_login": "Last login",
"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_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"
},
"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": {
"view_manage": "View and manage connections",

View file

@ -56,7 +56,9 @@
"oauth2_error": "Impossibile completare il flusso OAuth2",
"oauth2_success": "OAuth2 completato",
"add_role": "Aggiungi ruolo",
"update_role": "Aggiorna ruolo"
"update_role": "Aggiorna ruolo",
"add_admin": "Aggiungi amministratore",
"update_admin": "Aggiorna amministratore"
},
"setup": {
"desc": "Per iniziare a utilizzare SFTPGo devi creare un utente amministratore",
@ -225,7 +227,9 @@
"protocol": "Protocollo",
"refresh": "Aggiorna",
"members": "Membri",
"members_summary": "Utenti: {{users}}. Amministratori: {{admins}}"
"members_summary": "Utenti: {{users}}. Amministratori: {{admins}}",
"status": "Stato",
"last_login": "Ultimo accesso"
},
"fs": {
"view_file": "Visualizza file \"{{- path}}\"",
@ -468,8 +472,6 @@
"file_pattern_invalid": "Filtri su modelli di nome file non validi",
"disable_active_2fa": "L'autenticazione a due fattori non può essere disabilitata per un utente con una configurazione attiva",
"pwd_change_conflict": "Non è possibile richiedere la modifica della password e allo stesso tempo impedire la modifica della password",
"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",
"require_pwd_change": "Richiedi modifica password",
"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"
},
"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": {
"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
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, version 3.
This WebUI uses the KeenThemes Mega Bundle, a proprietary theme:
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
https://keenthemes.com/products/templates-mega-bundle
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
KeenThemes HTML/CSS/JS components are allowed for use only within the
SFTPGo product and restricted to be used in a resealable HTML template
that can compete with KeenThemes products anyhow.
This WebUI is allowed for use only within the SFTPGo product and
therefore cannot be used in derivative works/products without an
explicit grant from the SFTPGo Team (support@sftpgo.com).
-->
{{template "base" .}}
{{define "title"}}{{.Title}}{{end}}
{{define "extra_css"}}
<link href="{{.StaticURL}}/vendor/bootstrap-select/css/bootstrap-select.min.css" rel="stylesheet">
{{end}}
{{define "page_body"}}
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">{{if .IsAdd}}Add a new admin{{else}}Edit admin{{end}}</h6>
{{- define "page_body"}}
<div class="card shadow-sm">
<div class="card-header bg-light">
<h3 data-i18n="{{.Title}}" class="card-title section-title"></h3>
</div>
<div class="card-body">
{{if .Error}}
<div class="alert alert-warning alert-dismissible fade show" role="alert">
{{.Error}}
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
{{end}}
{{- template "errmsg" .Error}}
<form id="admin_form" action="{{.CurrentURL}}" method="POST" autocomplete="off">
<div class="form-group row">
<label for="idUsername" class="col-sm-2 col-form-label">Username</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="idUsername" name="username" placeholder=""
value="{{.Admin.Username}}" maxlength="255" autocomplete="nope" spellcheck="false" required {{if not .IsAdd}}readonly{{end}}>
<label for="idUsername" data-i18n="login.username" class="col-md-3 col-form-label">Username</label>
<div class="col-md-9">
<input id="idUsername" type="text" placeholder="" name="username" value="{{.Admin.Username}}" maxlength="255" autocomplete="off"
spellcheck="false" required {{if .IsAdd}}class="form-control"{{else}}class="form-control-plaintext readonly-input" readonly{{end}} />
</div>
</div>
<div class="form-group row">
<label for="idStatus" class="col-sm-2 col-form-label">Status</label>
<div class="col-sm-10">
<select class="form-control selectpicker" id="idStatus" name="status">
<option value="1" {{if eq .Admin.Status 1 }}selected{{end}}>Active</option>
<option value="0" {{if eq .Admin.Status 0 }}selected{{end}}>Inactive</option>
<div class="form-group row mt-10">
<label for="idPassword" data-i18n="login.password" class="col-md-3 col-form-label">Password</label>
<div class="col-md-9">
<input id="idPassword" type="password" class="form-control" name="password" autocomplete="new-password"
spellcheck="false" value="" {{if not .IsAdd}}aria-describedby="idPasswordHelp"{{end}} />
{{- 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>
</div>
</div>
<div class="form-group row">
<label for="idEmail" class="col-sm-2 col-form-label">Email</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="idEmail" name="email" placeholder=""
value="{{.Admin.Email}}" maxlength="255" spellcheck="false">
</div>
</div>
<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}}
<div class="form-group row mt-10">
<label for="idPermissions" data-i18n="general.permissions" class="col-md-3 col-form-label">Permissions</label>
<div class="col-md-9">
<select id="idPermissions" name="permissions" class="form-select" data-control="i18n-select2" data-hide-search="true" data-close-on-select="false" multiple>
{{- range $validPerm := .Admin.GetValidPerms}}
<option value="{{$validPerm}}" {{- range $perm :=$.Admin.Permissions }}{{- if eq $perm $validPerm}} selected{{- end}}{{- end}}>{{$validPerm}}</option>
{{- end}}
</select>
</div>
</div>
<div class="card bg-light mb-3">
<div class="card-header">
<b>Role</b>
{{- if .Roles}}
<div class="card mt-10">
<div class="card-header bg-light">
<h3 data-i18n="general.role" class="card-title section-title-inner">Role</h3>
</div>
<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>
<div class="form-group row">
<label for="idRole" class="col-sm-2 col-form-label">Role</label>
<div class="col-sm-10">
<select class="form-control selectpicker" data-live-search="true" id="idRole" name="role">
<p data-i18n="admin.role_help" class="fs-5 fw-semibold mb-4"></p>
<div class="form-group row mt-10">
<label for="idRole" data-i18n="general.role" class="col-md-3 col-form-label">Role</label>
<div class="col-md-9">
<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>
{{- range .Roles}}
<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>
{{- end}}
<div class="card bg-light mb-3">
<div class="card-header">
<b>Groups for users</b>
{{- if .Groups}}
<div class="card mt-10">
<div class="card-header bg-light">
<h3 data-i18n="admin.users_groups" class="card-title section-title-inner">Groups for users</h3>
</div>
<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 class="form-group row">
<div class="col-md-12 form_field_groups_outer">
<div id="groups">
<p class="fs-5 fw-semibold mb-4" data-i18n="admin.users_groups_help"></p>
<div class="form-group">
<div data-repeater-list="groups">
{{range $idx, $val := .Admin.Groups}}
<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="idGroup{{$idx}}" name="group{{$idx}}">
<div data-repeater-item>
<div data-repeater-item>
<div class="form-group row">
<div class="col-md-6 mt-3 mt-md-8">
<select name="group" data-i18n="[data-placeholder]general.group_placeholder" class="form-select select-repetear" data-allow-clear="true">
<option value=""></option>
{{- range $.Groups}}
<option value="{{.Name}}" {{if eq $val.Name .Name}}selected{{end}}>{{.Name}}</option>
<option value="{{.Name}}" {{- if eq $val.Name .Name}} selected{{- end}}>{{.Name}}</option>
{{- end}}
</select>
</div>
<div class="form-group col-md-4">
<select class="form-control selectpicker" id="idAddAsGroupType{{$idx}}" name="add_as_group_type{{$idx}}">
<option value="0" {{if eq $val.Options.AddToUsersAs 0}}selected{{end}}>Add as membership</option>
<option value="1" {{if eq $val.Options.AddToUsersAs 1}}selected{{end}}>Add as primary</option>
<option value="2" {{if eq $val.Options.AddToUsersAs 2}}selected{{end}}>Add as secondary</option>
<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="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 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>
{{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">
</div>
</div>
{{- else}}
<div data-repeater-item>
<div class="form-group row">
<div class="col-md-6 mt-3 mt-md-8">
<select name="group" data-i18n="[data-placeholder]general.group_placeholder" class="form-select select-repetear" data-allow-clear="true">
<option value=""></option>
{{- range .Groups}}
{{- 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>
<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="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 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>
{{end}}
</div>
{{- end}}
</div>
</div>
<div class="row mx-1">
<button type="button" class="btn btn-secondary add_new_group_field_btn">
<i class="fas fa-plus"></i> Add group
</button>
<div class="form-group mt-5">
<a href="#" data-repeater-create class="btn btn-light-primary">
<i class="ki-duotone ki-plus fs-3"></i>
<span data-i18n="general.add">Add</span>
</a>
</div>
</div>
</div>
</div>
{{- end}}
<div class="card bg-light mb-3">
<div class="card-header">
<b>User page preferences</b>
<div class="card mt-10">
<div class="card-header bg-light">
<h3 data-i18n="admin.user_page_pref" class="card-title section-title-inner">User page preferences</h3>
</div>
<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>
<div class="form-group row">
<label for="idUserPageHiddenSections" class="col-sm-2 col-form-label">Hide sections</label>
<div class="col-sm-10">
<select class="form-control selectpicker" id="idUserPageHiddenSections" name="user_page_hidden_sections" multiple>
<option value="1" {{if .Admin.Filters.Preferences.HideGroups}}selected{{end}}>Groups</option>
<option value="2" {{if .Admin.Filters.Preferences.HideFilesystem}}selected{{end}}>Filesystem</option>
<option value="3" {{if .Admin.Filters.Preferences.HideVirtualFolders}}selected{{end}}>Virtual Folders</option>
<option value="4" {{if .Admin.Filters.Preferences.HideProfile}}selected{{end}}>Profile</option>
<option value="5" {{if .Admin.Filters.Preferences.HideACLs}}selected{{end}}>ACLs</option>
<option value="6" {{if .Admin.Filters.Preferences.HideDiskQuotaAndBandwidthLimits}}selected{{end}}>Disk quota and bandwidth limits</option>
<option value="7" {{if .Admin.Filters.Preferences.HideAdvancedSettings}}selected{{end}}>Advanced settings</option>
<p data-i18n="admin.user_page_pref_help" class="fs-5 fw-semibold mb-4"></p>
<div class="form-group row mt-10">
<label for="idUserPageHiddenSections" data-i18n="admin.hide_sections" class="col-md-3 col-form-label">Hide sections</label>
<div class="col-md-9">
<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="1" data-i18n="title.groups" {{if .Admin.Filters.Preferences.HideGroups}}selected{{end}}>Groups</option>
<option value="2" data-i18n="storage.title" {{if .Admin.Filters.Preferences.HideFilesystem}}selected{{end}}>Filesystem</option>
<option value="3" data-i18n="title.folders" {{if .Admin.Filters.Preferences.HideVirtualFolders}}selected{{end}}>Virtual Folders</option>
<option value="4" data-i18n="title.profile" {{if .Admin.Filters.Preferences.HideProfile}}selected{{end}}>Profile</option>
<option value="5" data-i18n="general.acls" {{if .Admin.Filters.Preferences.HideACLs}}selected{{end}}>ACLs</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>
</div>
</div>
<div class="form-group row">
<label for="idDefaultUsersExpiration" class="col-sm-2 col-form-label">Default users expiration</label>
<div class="col-sm-10">
<input type="number" class="form-control" id="idDefaultUsersExpiration" name="default_users_expiration"
value="{{.Admin.Filters.Preferences.DefaultUsersExpiration}}" min="0" aria-describedby="defaultUsersExpirationHelpBlock">
<small id="defaultUsersExpirationHelpBlock" class="form-text text-muted">
Default expiration for newly created users as number of days
</small>
<div class="form-group row mt-10">
<label for="idDefaultUsersExpiration" data-i18n="admin.default_users_expiration" class="col-md-3 col-form-label">Default users expiration</label>
<div class="col-md-9">
<input id="idDefaultUsersExpiration" type="number" min="0" class="form-control" name="default_users_expiration" value="{{.Admin.Filters.Preferences.DefaultUsersExpiration}}" aria-describedby="idDefaultUsersExpirationHelp"/>
<div id="idDefaultUsersExpirationHelp" class="form-text" data-i18n="admin.default_users_expiration_help"></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 class="form-group row">
<label for="idAllowedIP" class="col-sm-2 col-form-label">Allowed IP/Mask</label>
<div class="col-sm-10">
<textarea class="form-control" id="idAllowedIP" name="allowed_ip" rows="3" placeholder=""
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 class="form-group">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="idAllowAPIKeyAuth" name="allow_api_key_auth"
{{if .Admin.Filters.AllowAPIKeyAuth}}checked{{end}} aria-describedby="allowAPIKeyAuthHelpBlock">
<label for="idAllowAPIKeyAuth" class="form-check-label">Allow API key authentication</label>
<small id="allowAPIKeyAuthHelpBlock" class="form-text text-muted">
Allow to impersonate this admin, in REST API, with an API key
</small>
</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 class="form-group row mt-10">
<label for="idAdditionalInfo" data-i18n="general.additional_info" class="col-md-3 col-form-label">Additional info</label>
<div class="col-md-9">
<textarea id="idAdditionalInfo" class="form-control" name="additional_info" rows="3">{{.Admin.AdditionalInfo}}</textarea>
</div>
</div>
<div class="d-flex justify-content-end mt-12">
<input type="hidden" name="_form_token" value="{{.CSRFToken}}">
<button type="submit" class="btn btn-primary float-right mt-3 px-5">Submit</button>
<button type="submit" id="form_submit" class="btn btn-primary px-10" name="form_action" value="submit">
<span data-i18n="general.submit" class="indicator-label">
Submit
</span>
<span data-i18n="general.wait" class="indicator-progress">
Please wait...
<span class="spinner-border spinner-border-sm align-middle ms-2"></span>
</span>
</button>
</div>
</form>
</div>
</div>
{{end}}
{{define "extra_js"}}
<script src="{{.StaticURL}}/vendor/bootstrap-select/js/bootstrap-select.min.js"></script>
<script type="text/javascript">
$("body").on("click", ".add_new_group_field_btn", function () {
let index = $(".form_field_groups_outer").find(".form_field_groups_outer_row").length;
while (document.getElementById("idGroup"+index) != null){
index++;
}
$(".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();
{{- define "extra_js"}}
<script {{- if .CSPNonce}} nonce="{{.CSPNonce}}"{{- end}} src="{{.StaticURL}}/assets/plugins/custom/formrepeater/formrepeater.bundle.js"></script>
<script type="text/javascript" {{- if .CSPNonce}} nonce="{{.CSPNonce}}"{{- end}}>
$(document).on("i18nload", function(){
initRepeater('#groups');
initRepeaterItems();
});
$("body").on("click", ".remove_group_btn_frm_field", function () {
$(this).closest(".form_field_groups_outer_row").remove();
$(document).on("i18nshow", function(){
$('#admin_form').submit(function (event) {
let submitButton = document.querySelector('#form_submit');
submitButton.setAttribute('data-kt-indicator', 'on');
submitButton.disabled = true;
});
});
</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
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, version 3.
This WebUI uses the KeenThemes Mega Bundle, a proprietary theme:
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
https://keenthemes.com/products/templates-mega-bundle
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
KeenThemes HTML/CSS/JS components are allowed for use only within the
SFTPGo product and restricted to be used in a resealable HTML template
that can compete with KeenThemes products anyhow.
This WebUI is allowed for use only within the SFTPGo product and
therefore cannot be used in derivative works/products without an
explicit grant from the SFTPGo Team (support@sftpgo.com).
-->
{{template "base" .}}
{{define "title"}}{{.Title}}{{end}}
{{- define "extra_css"}}
<link href="{{.StaticURL}}/assets/plugins/custom/datatables/datatables.bundle.css" rel="stylesheet" type="text/css"/>
{{- end}}
{{define "extra_css"}}
<link href="{{.StaticURL}}/vendor/datatables/dataTables.bootstrap4.min.css" rel="stylesheet">
<link href="{{.StaticURL}}/vendor/datatables/buttons.bootstrap4.min.css" rel="stylesheet">
<link href="{{.StaticURL}}/vendor/datatables/fixedHeader.bootstrap4.min.css" rel="stylesheet">
<link href="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.css" rel="stylesheet">
<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"}}
{{- template "errmsg" ""}}
<div class="card shadow-sm">
<div class="card-header bg-light">
<h3 data-i18n="admin.view_manage" class="card-title section-title">View and manage admins</h3>
</div>
<div id="card_body" class="card-body">
<div id="loader" class="align-items-center text-center my-10">
<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>
{{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>
<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>
<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 class="card-body">
<div class="table-responsive">
<table class="table table-hover nowrap" id="dataTable" width="100%" cellspacing="0">
<table id="dataTable" class="table align-middle table-row-dashed fs-6 gy-5">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Status</th>
<th>Permissions</th>
<th>Last login</th>
<th>MFA</th>
<th>Allow list</th>
<th>Email</th>
<th>Description</th>
<th>Groups for users</th>
<th>Role</th>
<tr class="text-start text-muted fw-bold fs-6 gs-0">
<th data-i18n="login.username">Username</th>
<th data-i18n="general.status">Status</th>
<th data-i18n="general.last_login">Last login</th>
<th data-i18n="general.email">Email</th>
<th data-i18n="general.role">Role</th>
<th data-i18n="title.two_factor_auth_short">2FA</th>
<th data-i18n="general.description">Description</th>
<th class="min-w-100px"></th>
</tr>
</thead>
<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>
<tbody id="table_body" class="text-gray-800 fw-semibold"></tbody>
</table>
</div>
</div>
</div>
{{end}}
</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);
{{define "dialog"}}
<div class="modal fade" id="deleteModal" tabindex="-1" role="dialog" aria-labelledby="deleteModalLabel"
aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteModalLabel">
Confirmation required
</h5>
<button class="close" type="button" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">Do you want to delete the selected admin?</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-dismiss="modal">
Cancel
</button>
<a class="btn btn-warning" href="#" onclick="deleteAction()">
Delete
</a>
</div>
</div>
</div>
</div>
{{end}}
{{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}}'},
axios.delete(path, {
timeout: 15000,
success: function (result) {
window.location.href = '{{.AdminsURL}}';
headers: {
'X-CSRF-TOKEN': '{{.CSRFToken}}'
},
validateStatus: function (status) {
return status == 200;
}
}).then(function(response){
location.reload();
}).catch(function(error){
KTApp.hidePageLoading();
let errorMessage;
if (error && error.response) {
switch (error.response.status) {
case 400:
if (error.response.data && error.response.data.error){
if (error.response.data.error.includes("delete yourself")){
errorMessage = "admin.self_delete";
}
}
break;
case 403:
errorMessage = "general.delete_error_403";
break;
case 404:
errorMessage = "general.delete_error_404";
break;
}
}
if (!errorMessage){
errorMessage = "general.delete_error_generic";
}
ModalAlert.fire({
text: $.t(errorMessage),
icon: "warning",
confirmButtonText: $.t('general.ok'),
customClass: {
confirmButton: "btn btn-primary"
}
});
});
}
});
}
var datatable = function(){
var dt;
var initDatatable = function () {
$('#errorMsg').addClass("d-none");
dt = $('#dataTable').DataTable({
ajax: {
url: "{{.AdminsURL}}/json",
dataSrc: "",
error: function ($xhr, textStatus, errorThrown) {
var txt = "Unable to delete the selected admin";
$(".dataTables_processing").hide();
let txt = "";
if ($xhr) {
var json = $xhr.responseJSON;
let json = $xhr.responseJSON;
if (json) {
if (json.message){
txt += ": " + json.message;
} else {
txt += ": " + json.error;
txt = json.message;
}
}
}
$('#errorTxt').text(txt);
$('#errorMsg').show();
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' },
}
});
}
$(document).ready(function () {
$.fn.dataTable.ext.buttons.add = {
text: '<i class="fas fa-plus"></i>',
name: 'add',
titleAttr: "Add",
action: function (e, dt, node, config) {
window.location.href = '{{.AdminURL}}';
return ""
}
return data;
}
};
$.fn.dataTable.ext.buttons.edit = {
text: '<i class="fas fa-pen"></i>',
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)"
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 "";
}
},
],
"columnDefs": [
{
"targets": [0],
"visible": false,
"searchable": false,
"className": "noVis"
deferRender: true,
stateSave: true,
stateDuration: 0,
colReorder: {
enable: true,
fixedColumnsLeft: 1
},
{
"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,
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();
}
],
"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);
dt.on('draw', drawAction);
dt.on('column-reorder', function(e, settings, details){
drawAction();
});
{{end}}
}
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();
}
}
}();
$(document).on("i18nshow", function(){
datatable.init();
});
</script>
{{end}}
{{- end}}

View file

@ -370,7 +370,7 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
<div class="accordion-body">
<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">
<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>

View file

@ -45,15 +45,15 @@ explicit grant from the SFTPGo Team (support@sftpgo.com).
</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="checkColMembers" />
<label class="form-check-label" for="checkColMembers">
<span data-i18n="user.status" class="text-gray-800 fs-6">Status</span>
<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="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>
</div>
<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>
<tr class="text-start text-muted fw-bold fs-6 gs-0">
<th data-i18n="login.username">Username</th>
<th data-i18n="user.status">Status</th>
<th data-i18n="user.last_login">Last login</th>
<th data-i18n="general.status">Status</th>
<th data-i18n="general.last_login">Last login</th>
<th data-i18n="general.email">Email</th>
<th data-i18n="storage.label">Storage</th>
<th data-i18n="general.role">Role</th>