diff --git a/internal/dataprovider/admin.go b/internal/dataprovider/admin.go index 2cda9026..2bc2b125 100644 --- a/internal/dataprovider/admin.go +++ b/internal/dataprovider/admin.go @@ -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( diff --git a/internal/httpd/server.go b/internal/httpd/server.go index 3f317ea7..d46d1277 100644 --- a/internal/httpd/server.go +++ b/internal/httpd/server.go @@ -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). diff --git a/internal/httpd/webadmin.go b/internal/httpd/webadmin.go index 1f40f52e..2f16c4a8 100644 --- a/internal/httpd/webadmin.go +++ b/internal/httpd/webadmin.go @@ -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) diff --git a/internal/util/i18n.go b/internal/util/i18n.go index bf60d076..1c602f15 100644 --- a/internal/util/i18n.go +++ b/internal/util/i18n.go @@ -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 diff --git a/static/locales/en/translation.json b/static/locales/en/translation.json index 7c488442..6528ef1c 100644 --- a/static/locales/en/translation.json +++ b/static/locales/en/translation.json @@ -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", diff --git a/static/locales/it/translation.json b/static/locales/it/translation.json index ab4dfe76..cda3c547 100644 --- a/static/locales/it/translation.json +++ b/static/locales/it/translation.json @@ -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", diff --git a/templates/webadmin/admin.html b/templates/webadmin/admin.html index e1bc28cd..9b929fe1 100644 --- a/templates/webadmin/admin.html +++ b/templates/webadmin/admin.html @@ -1,114 +1,79 @@ {{template "base" .}} -{{define "title"}}{{.Title}}{{end}} - -{{define "extra_css"}} - -{{end}} - -{{define "page_body"}} -