diff --git a/Makefile b/Makefile index 1d808fd750..adf201ec20 100644 --- a/Makefile +++ b/Makefile @@ -160,7 +160,7 @@ test-server: start-docker prepare-enterprise echo "mode: count" > cover.out $(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=340s -covermode=count -coverprofile=capi.out ./api || exit 1 - $(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=12s -covermode=count -coverprofile=cmodel.out ./model || exit 1 + $(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=60s -covermode=count -coverprofile=cmodel.out ./model || exit 1 $(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=180s -covermode=count -coverprofile=cstore.out ./store || exit 1 $(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=120s -covermode=count -coverprofile=cutils.out ./utils || exit 1 $(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=120s -covermode=count -coverprofile=cweb.out ./web || exit 1 diff --git a/api/user.go b/api/user.go index 4d4518824b..de7a560bf3 100644 --- a/api/user.go +++ b/api/user.go @@ -704,6 +704,7 @@ func RevokeSessionById(c *Context, sessionId string) { } } +// IF YOU UPDATE THIS PLEASE UPDATE BELOW func RevokeAllSession(c *Context, userId string) { if result := <-Srv.Store.Session().GetSessions(userId); result.Err != nil { c.Err = result.Err @@ -726,6 +727,28 @@ func RevokeAllSession(c *Context, userId string) { } } +// UGH... +// If you update this please update above +func RevokeAllSessionsNoContext(userId string) *model.AppError { + if result := <-Srv.Store.Session().GetSessions(userId); result.Err != nil { + return result.Err + } else { + sessions := result.Data.([]*model.Session) + + for _, session := range sessions { + if session.IsOAuth { + RevokeAccessToken(session.Token) + } else { + sessionCache.Remove(session.Token) + if result := <-Srv.Store.Session().Remove(session.Id); result.Err != nil { + return result.Err + } + } + } + } + return nil +} + func getSessions(c *Context, w http.ResponseWriter, r *http.Request) { params := mux.Vars(r) diff --git a/config/config.json b/config/config.json index 582a7244ca..726cb0d8cb 100644 --- a/config/config.json +++ b/config/config.json @@ -147,6 +147,7 @@ "UsernameAttribute": "", "NicknameAttribute": "", "IdAttribute": "", + "SyncIntervalMinutes": 60, "SkipCertificateVerification": false, "QueryTimeout": 60, "LoginFieldName": "" diff --git a/einterfaces/ldap.go b/einterfaces/ldap.go index 25d591ce20..4f1b56119e 100644 --- a/einterfaces/ldap.go +++ b/einterfaces/ldap.go @@ -13,6 +13,8 @@ type LdapInterface interface { CheckPassword(id string, password string) *model.AppError SwitchToLdap(userId, ldapId, ldapPassword string) *model.AppError ValidateFilter(filter string) *model.AppError + Syncronize() *model.AppError + StartLdapSyncJob() } var theLdapInterface LdapInterface diff --git a/i18n/en.json b/i18n/en.json index 3666abd447..2a51826fad 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2027,6 +2027,14 @@ "id": "ent.ldap.validate_filter.app_error", "translation": "Invalid LDAP Filter" }, + { + "id": "ent.ldap.syncronize.get_all.app_error", + "translation": "Unable to get all users using LDAP" + }, + { + "id": "ent.ldap.syncdone.info", + "translation": "LDAP Synchronization completed" + }, { "id": "ent.mfa.activate.authenticate.app_error", "translation": "Error attempting to authenticate MFA token" @@ -2411,6 +2419,10 @@ "id": "model.config.is_valid.ldap_security.app_error", "translation": "Invalid connection security for LDAP settings. Must be '', 'TLS', or 'STARTTLS'" }, + { + "id": "model.config.is_valid.ldap_sync_interval.app_error", + "translation": "Invalid sync interval time. Must be at least one minute." + }, { "id": "model.config.is_valid.listen_address.app_error", "translation": "Invalid listen address for service settings Must be set." @@ -3555,6 +3567,10 @@ "id": "store.sql_user.get_by_auth.other.app_error", "translation": "We encountered an error trying to find the account by authentication type." }, + { + "id": "store.sql_user.get_all_using_auth_service.other.app_error", + "translation": "We encountered an error trying to find all the accounts using a specific authentication type." + }, { "id": "store.sql_user.get_by_username.app_error", "translation": "We couldn't find an existing account matching your username for this team. This team may require an invite from the team owner to join." diff --git a/mattermost.go b/mattermost.go index bebb55c3bc..ddf20f19ec 100644 --- a/mattermost.go +++ b/mattermost.go @@ -55,6 +55,7 @@ var flagCmdPermanentDeleteUser bool var flagCmdPermanentDeleteTeam bool var flagCmdPermanentDeleteAllUsers bool var flagCmdResetDatabase bool +var flagCmdRunLdapSync bool var flagUsername string var flagCmdUploadLicense bool var flagConfigFile string @@ -125,8 +126,12 @@ func main() { setDiagnosticId() go runSecurityAndDiagnosticsJob() - if einterfaces.GetComplianceInterface() != nil { - einterfaces.GetComplianceInterface().StartComplianceDailyJob() + if complianceI := einterfaces.GetComplianceInterface(); complianceI != nil { + complianceI.StartComplianceDailyJob() + } + + if ldapI := einterfaces.GetLdapInterface(); ldapI != nil { + ldapI.StartLdapSyncJob() } // wait for kill signal before attempting to gracefully shutdown @@ -154,96 +159,97 @@ func setDiagnosticId() { } } -func runSecurityAndDiagnosticsJob() { - for { - if *utils.Cfg.ServiceSettings.EnableSecurityFixAlert { - if result := <-api.Srv.Store.System().Get(); result.Err == nil { - props := result.Data.(model.StringMap) - lastSecurityTime, _ := strconv.ParseInt(props[model.SYSTEM_LAST_SECURITY_TIME], 10, 0) - currentTime := model.GetMillis() +func doSecurityAndDiagnostics() { + if *utils.Cfg.ServiceSettings.EnableSecurityFixAlert { + if result := <-api.Srv.Store.System().Get(); result.Err == nil { + props := result.Data.(model.StringMap) + lastSecurityTime, _ := strconv.ParseInt(props[model.SYSTEM_LAST_SECURITY_TIME], 10, 0) + currentTime := model.GetMillis() - if (currentTime - lastSecurityTime) > 1000*60*60*24*1 { - l4g.Debug(utils.T("mattermost.security_checks.debug")) + if (currentTime - lastSecurityTime) > 1000*60*60*24*1 { + l4g.Debug(utils.T("mattermost.security_checks.debug")) - v := url.Values{} + v := url.Values{} - v.Set(utils.PROP_DIAGNOSTIC_ID, utils.CfgDiagnosticId) - v.Set(utils.PROP_DIAGNOSTIC_BUILD, model.CurrentVersion+"."+model.BuildNumber) - v.Set(utils.PROP_DIAGNOSTIC_ENTERPRISE_READY, model.BuildEnterpriseReady) - v.Set(utils.PROP_DIAGNOSTIC_DATABASE, utils.Cfg.SqlSettings.DriverName) - v.Set(utils.PROP_DIAGNOSTIC_OS, runtime.GOOS) - v.Set(utils.PROP_DIAGNOSTIC_CATEGORY, utils.VAL_DIAGNOSTIC_CATEGORY_DEFAULT) + v.Set(utils.PROP_DIAGNOSTIC_ID, utils.CfgDiagnosticId) + v.Set(utils.PROP_DIAGNOSTIC_BUILD, model.CurrentVersion+"."+model.BuildNumber) + v.Set(utils.PROP_DIAGNOSTIC_ENTERPRISE_READY, model.BuildEnterpriseReady) + v.Set(utils.PROP_DIAGNOSTIC_DATABASE, utils.Cfg.SqlSettings.DriverName) + v.Set(utils.PROP_DIAGNOSTIC_OS, runtime.GOOS) + v.Set(utils.PROP_DIAGNOSTIC_CATEGORY, utils.VAL_DIAGNOSTIC_CATEGORY_DEFAULT) - if len(props[model.SYSTEM_RAN_UNIT_TESTS]) > 0 { - v.Set(utils.PROP_DIAGNOSTIC_UNIT_TESTS, "1") - } else { - v.Set(utils.PROP_DIAGNOSTIC_UNIT_TESTS, "0") - } + if len(props[model.SYSTEM_RAN_UNIT_TESTS]) > 0 { + v.Set(utils.PROP_DIAGNOSTIC_UNIT_TESTS, "1") + } else { + v.Set(utils.PROP_DIAGNOSTIC_UNIT_TESTS, "0") + } - systemSecurityLastTime := &model.System{Name: model.SYSTEM_LAST_SECURITY_TIME, Value: strconv.FormatInt(currentTime, 10)} - if lastSecurityTime == 0 { - <-api.Srv.Store.System().Save(systemSecurityLastTime) - } else { - <-api.Srv.Store.System().Update(systemSecurityLastTime) - } + systemSecurityLastTime := &model.System{Name: model.SYSTEM_LAST_SECURITY_TIME, Value: strconv.FormatInt(currentTime, 10)} + if lastSecurityTime == 0 { + <-api.Srv.Store.System().Save(systemSecurityLastTime) + } else { + <-api.Srv.Store.System().Update(systemSecurityLastTime) + } - if ucr := <-api.Srv.Store.User().GetTotalUsersCount(); ucr.Err == nil { - v.Set(utils.PROP_DIAGNOSTIC_USER_COUNT, strconv.FormatInt(ucr.Data.(int64), 10)) - } + if ucr := <-api.Srv.Store.User().GetTotalUsersCount(); ucr.Err == nil { + v.Set(utils.PROP_DIAGNOSTIC_USER_COUNT, strconv.FormatInt(ucr.Data.(int64), 10)) + } - if ucr := <-api.Srv.Store.User().GetTotalActiveUsersCount(); ucr.Err == nil { - v.Set(utils.PROP_DIAGNOSTIC_ACTIVE_USER_COUNT, strconv.FormatInt(ucr.Data.(int64), 10)) - } + if ucr := <-api.Srv.Store.User().GetTotalActiveUsersCount(); ucr.Err == nil { + v.Set(utils.PROP_DIAGNOSTIC_ACTIVE_USER_COUNT, strconv.FormatInt(ucr.Data.(int64), 10)) + } - res, err := http.Get(utils.DIAGNOSTIC_URL + "/security?" + v.Encode()) - if err != nil { - l4g.Error(utils.T("mattermost.security_info.error")) - return - } + res, err := http.Get(utils.DIAGNOSTIC_URL + "/security?" + v.Encode()) + if err != nil { + l4g.Error(utils.T("mattermost.security_info.error")) + return + } - bulletins := model.SecurityBulletinsFromJson(res.Body) + bulletins := model.SecurityBulletinsFromJson(res.Body) - for _, bulletin := range bulletins { - if bulletin.AppliesToVersion == model.CurrentVersion { - if props["SecurityBulletin_"+bulletin.Id] == "" { - if results := <-api.Srv.Store.User().GetSystemAdminProfiles(); results.Err != nil { - l4g.Error(utils.T("mattermost.system_admins.error")) + for _, bulletin := range bulletins { + if bulletin.AppliesToVersion == model.CurrentVersion { + if props["SecurityBulletin_"+bulletin.Id] == "" { + if results := <-api.Srv.Store.User().GetSystemAdminProfiles(); results.Err != nil { + l4g.Error(utils.T("mattermost.system_admins.error")) + return + } else { + users := results.Data.(map[string]*model.User) + + resBody, err := http.Get(utils.DIAGNOSTIC_URL + "/bulletins/" + bulletin.Id) + if err != nil { + l4g.Error(utils.T("mattermost.security_bulletin.error")) return - } else { - users := results.Data.(map[string]*model.User) - - resBody, err := http.Get(utils.DIAGNOSTIC_URL + "/bulletins/" + bulletin.Id) - if err != nil { - l4g.Error(utils.T("mattermost.security_bulletin.error")) - return - } - - body, err := ioutil.ReadAll(resBody.Body) - res.Body.Close() - if err != nil || resBody.StatusCode != 200 { - l4g.Error(utils.T("mattermost.security_bulletin_read.error")) - return - } - - for _, user := range users { - l4g.Info(utils.T("mattermost.send_bulletin.info"), bulletin.Id, user.Email) - utils.SendMail(user.Email, utils.T("mattermost.bulletin.subject"), string(body)) - } } - bulletinSeen := &model.System{Name: "SecurityBulletin_" + bulletin.Id, Value: bulletin.Id} - <-api.Srv.Store.System().Save(bulletinSeen) + body, err := ioutil.ReadAll(resBody.Body) + res.Body.Close() + if err != nil || resBody.StatusCode != 200 { + l4g.Error(utils.T("mattermost.security_bulletin_read.error")) + return + } + + for _, user := range users { + l4g.Info(utils.T("mattermost.send_bulletin.info"), bulletin.Id, user.Email) + utils.SendMail(user.Email, utils.T("mattermost.bulletin.subject"), string(body)) + } } + + bulletinSeen := &model.System{Name: "SecurityBulletin_" + bulletin.Id, Value: bulletin.Id} + <-api.Srv.Store.System().Save(bulletinSeen) } } } } } - - time.Sleep(time.Hour * 4) } } +func runSecurityAndDiagnosticsJob() { + doSecurityAndDiagnostics() + model.CreateRecurringTask("Security and Diagnostics", doSecurityAndDiagnostics, time.Hour*4) +} + func parseCmds() { flag.Usage = func() { fmt.Fprintln(os.Stderr, usage) @@ -272,6 +278,7 @@ func parseCmds() { flag.BoolVar(&flagCmdPermanentDeleteTeam, "permanent_delete_team", false, "") flag.BoolVar(&flagCmdPermanentDeleteAllUsers, "permanent_delete_all_users", false, "") flag.BoolVar(&flagCmdResetDatabase, "reset_database", false, "") + flag.BoolVar(&flagCmdRunLdapSync, "ldap_sync", false, "") flag.BoolVar(&flagCmdUploadLicense, "upload_license", false, "") flag.Parse() @@ -290,6 +297,7 @@ func parseCmds() { flagCmdPermanentDeleteTeam || flagCmdPermanentDeleteAllUsers || flagCmdResetDatabase || + flagCmdRunLdapSync || flagCmdUploadLicense) } @@ -308,6 +316,7 @@ func runCmds() { cmdPermDeleteAllUsers() cmdResetDatabase() cmdUploadLicense() + cmdRunLdapSync() } type TeamForUpgrade struct { @@ -1130,6 +1139,21 @@ func cmdResetDatabase() { } +func cmdRunLdapSync() { + if flagCmdRunLdapSync { + if ldapI := einterfaces.GetLdapInterface(); ldapI != nil { + if err := ldapI.Syncronize(); err != nil { + fmt.Println("ERROR: Ldap Syncronization Failed") + l4g.Error("%v", err.Error()) + flushLogAndExit(1) + } else { + fmt.Println("SUCCESS: Ldap Syncronization Complete") + flushLogAndExit(0) + } + } + } +} + func cmdUploadLicense() { if flagCmdUploadLicense { if model.BuildEnterpriseReady != "true" { diff --git a/model/config.go b/model/config.go index 08b00b90f6..7e810be02f 100644 --- a/model/config.go +++ b/model/config.go @@ -190,6 +190,9 @@ type LdapSettings struct { NicknameAttribute *string IdAttribute *string + // Syncronization + SyncIntervalMinutes *int + // Advanced SkipCertificateVerification *bool QueryTimeout *int @@ -441,6 +444,11 @@ func (o *Config) SetDefaults() { *o.LdapSettings.LoginFieldName = "" } + if o.LdapSettings.SyncIntervalMinutes == nil { + o.LdapSettings.SyncIntervalMinutes = new(int) + *o.LdapSettings.SyncIntervalMinutes = 60 + } + if o.ServiceSettings.SessionLengthWebInDays == nil { o.ServiceSettings.SessionLengthWebInDays = new(int) *o.ServiceSettings.SessionLengthWebInDays = 30 @@ -635,6 +643,10 @@ func (o *Config) IsValid() *AppError { return NewLocAppError("Config.IsValid", "model.config.is_valid.ldap_security.app_error", nil, "") } + if *o.LdapSettings.SyncIntervalMinutes <= 0 { + return NewLocAppError("Config.IsValid", "model.config.is_valid.ldap_sync_interval.app_error", nil, "") + } + return nil } diff --git a/model/job.go b/model/job.go new file mode 100644 index 0000000000..bcae7a830b --- /dev/null +++ b/model/job.go @@ -0,0 +1,91 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "fmt" + "time" +) + +type TaskFunc func() + +type ScheduledTask struct { + Name string `json:"name"` + Interval time.Duration `json:"interval"` + Recurring bool `json:"recurring"` + function TaskFunc `json:",omitempty"` + timer *time.Timer `json:",omitempty"` +} + +var tasks = make(map[string]*ScheduledTask) + +func addTask(task *ScheduledTask) { + tasks[task.Name] = task +} + +func removeTaskByName(name string) { + delete(tasks, name) +} + +func getTaskByName(name string) *ScheduledTask { + return tasks[name] +} + +func GetAllTasks() *map[string]*ScheduledTask { + return &tasks +} + +func CreateTask(name string, function TaskFunc, timeToExecution time.Duration) *ScheduledTask { + task := &ScheduledTask{ + Name: name, + Interval: timeToExecution, + Recurring: false, + function: function, + } + + taskRunner := func() { + go task.function() + removeTaskByName(task.Name) + } + + task.timer = time.AfterFunc(timeToExecution, taskRunner) + + addTask(task) + + return task +} + +func CreateRecurringTask(name string, function TaskFunc, interval time.Duration) *ScheduledTask { + task := &ScheduledTask{ + Name: name, + Interval: interval, + Recurring: true, + function: function, + } + + taskRecurer := func() { + go task.function() + task.timer.Reset(task.Interval) + } + + task.timer = time.AfterFunc(interval, taskRecurer) + + addTask(task) + + return task +} + +func (task *ScheduledTask) Cancel() { + task.timer.Stop() + removeTaskByName(task.Name) +} + +func (task *ScheduledTask) String() string { + return fmt.Sprintf( + "%s\nInterval: %s\nRecurring: %t\n", + task.Name, + task.Interval.String(), + task.Recurring, + ) +} diff --git a/model/job_test.go b/model/job_test.go new file mode 100644 index 0000000000..2a307de1ef --- /dev/null +++ b/model/job_test.go @@ -0,0 +1,128 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "testing" + "time" +) + +func TestCreateTask(t *testing.T) { + TASK_NAME := "Test Task" + TASK_TIME := time.Second * 3 + + testValue := 0 + testFunc := func() { + testValue = 1 + } + + task := CreateTask(TASK_NAME, testFunc, TASK_TIME) + if testValue != 0 { + t.Fatal("Unexpected execuition of task") + } + + time.Sleep(TASK_TIME + time.Second) + + if testValue != 1 { + t.Fatal("Task did not execute") + } + + if task.Name != TASK_NAME { + t.Fatal("Bad name") + } + + if task.Interval != TASK_TIME { + t.Fatal("Bad interval") + } + + if task.Recurring != false { + t.Fatal("should not reccur") + } +} + +func TestCreateRecurringTask(t *testing.T) { + TASK_NAME := "Test Recurring Task" + TASK_TIME := time.Second * 3 + + testValue := 0 + testFunc := func() { + testValue += 1 + } + + task := CreateRecurringTask(TASK_NAME, testFunc, TASK_TIME) + if testValue != 0 { + t.Fatal("Unexpected execuition of task") + } + + time.Sleep(TASK_TIME + time.Second) + + if testValue != 1 { + t.Fatal("Task did not execute") + } + + time.Sleep(TASK_TIME) + + if testValue != 2 { + t.Fatal("Task did not re-execute") + } + + if task.Name != TASK_NAME { + t.Fatal("Bad name") + } + + if task.Interval != TASK_TIME { + t.Fatal("Bad interval") + } + + if task.Recurring != true { + t.Fatal("should reccur") + } + + task.Cancel() +} + +func TestCancelTask(t *testing.T) { + TASK_NAME := "Test Task" + TASK_TIME := time.Second * 3 + + testValue := 0 + testFunc := func() { + testValue = 1 + } + + task := CreateTask(TASK_NAME, testFunc, TASK_TIME) + if testValue != 0 { + t.Fatal("Unexpected execuition of task") + } + task.Cancel() + + time.Sleep(TASK_TIME + time.Second) + + if testValue != 0 { + t.Fatal("Unexpected execuition of task") + } +} + +func TestGetAllTasks(t *testing.T) { + doNothing := func() {} + + CreateTask("Task1", doNothing, time.Hour) + CreateTask("Task2", doNothing, time.Second) + CreateRecurringTask("Task3", doNothing, time.Second) + task4 := CreateRecurringTask("Task4", doNothing, time.Second) + + task4.Cancel() + + time.Sleep(time.Second * 3) + + tasks := *GetAllTasks() + if len(tasks) != 2 { + t.Fatal("Wrong number of tasks got: ", len(tasks)) + } + for _, task := range tasks { + if task.Name != "Task1" && task.Name != "Task3" { + t.Fatal("Wrong tasks") + } + } +} diff --git a/store/sql_user_store.go b/store/sql_user_store.go index 11a9150559..07a801dc6d 100644 --- a/store/sql_user_store.go +++ b/store/sql_user_store.go @@ -793,6 +793,27 @@ func (us SqlUserStore) GetByAuth(authData *string, authService string) StoreChan return storeChannel } +func (us SqlUserStore) GetAllUsingAuthService(authService string) StoreChannel { + + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + var data []*model.User + + if _, err := us.GetReplica().Select(&data, "SELECT * FROM Users WHERE AuthService = :AuthService", map[string]interface{}{"AuthService": authService}); err != nil { + result.Err = model.NewLocAppError("SqlUserStore.GetByAuth", "store.sql_user.get_by_auth.other.app_error", nil, "authService="+authService+", "+err.Error()) + } + + result.Data = data + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + func (us SqlUserStore) GetByUsername(username string) StoreChannel { storeChannel := make(StoreChannel) diff --git a/store/store.go b/store/store.go index ebbd2e454b..7f4db396c9 100644 --- a/store/store.go +++ b/store/store.go @@ -139,6 +139,7 @@ type UserStore interface { GetProfileByIds(userId []string) StoreChannel GetByEmail(email string) StoreChannel GetByAuth(authData *string, authService string) StoreChannel + GetAllUsingAuthService(authService string) StoreChannel GetByUsername(username string) StoreChannel GetForLogin(loginId string, allowSignInWithUsername, allowSignInWithEmail, ldapEnabled bool) StoreChannel VerifyEmail(userId string) StoreChannel diff --git a/webapp/components/admin_console/ldap_settings.jsx b/webapp/components/admin_console/ldap_settings.jsx index e4fd7f6cc8..fb121f656c 100644 --- a/webapp/components/admin_console/ldap_settings.jsx +++ b/webapp/components/admin_console/ldap_settings.jsx @@ -36,6 +36,7 @@ export default class LdapSettings extends AdminSettings { emailAttribute: props.config.LdapSettings.EmailAttribute, usernameAttribute: props.config.LdapSettings.UsernameAttribute, idAttribute: props.config.LdapSettings.IdAttribute, + syncIntervalMinutes: props.config.LdapSettings.SyncIntervalMinutes, skipCertificateVerification: props.config.LdapSettings.SkipCertificateVerification, queryTimeout: props.config.LdapSettings.QueryTimeout, loginFieldName: props.config.LdapSettings.LoginFieldName @@ -57,6 +58,7 @@ export default class LdapSettings extends AdminSettings { config.LdapSettings.EmailAttribute = this.state.emailAttribute; config.LdapSettings.UsernameAttribute = this.state.usernameAttribute; config.LdapSettings.IdAttribute = this.state.idAttribute; + config.LdapSettings.SyncIntervalMinutes = this.parseIntNonZero(this.state.syncIntervalMinutes); config.LdapSettings.SkipCertificateVerification = this.state.skipCertificateVerification; config.LdapSettings.QueryTimeout = this.parseIntNonZero(this.state.queryTimeout); config.LdapSettings.LoginFieldName = this.state.loginFieldName; @@ -339,6 +341,24 @@ export default class LdapSettings extends AdminSettings { onChange={this.handleChange} disabled={!this.state.enable} /> + + } + helpText={ + + } + value={this.state.syncIntervalMinutes} + onChange={this.handleChange} + disabled={!this.state.enable} + />