diff --git a/Makefile b/Makefile index 9c4e6ee1fa..eccdf39ba3 100644 --- a/Makefile +++ b/Makefile @@ -151,7 +151,7 @@ package: sed -i'.bak' 's|bundle.js|bundle-$(BUILD_NUMBER).min.js|g' $(DIST_PATH)/web/templates/head.html sed -i'.bak' 's|libs.min.js|libs-$(BUILD_NUMBER).min.js|g' $(DIST_PATH)/web/templates/head.html rm $(DIST_PATH)/web/templates/*.bak - + sudo mv -f $(DIST_PATH)/config/config.json.bak $(DIST_PATH)/config/config.json || echo 'nomv' tar -C dist -czf $(DIST_PATH).tar.gz mattermost @@ -283,7 +283,7 @@ run: start-docker .prepare-go .prepare-jsx $(GO) run $(GOFLAGS) mattermost.go -config=config.json & @echo Starting compass watch - cd web/sass-files && compass watch & + cd web/sass-files && compass compile && compass watch & stop: @for PID in $$(ps -ef | grep [c]ompass | awk '{ print $$2 }'); do \ @@ -296,7 +296,7 @@ stop: kill $$PID; \ done - @for PID in $$(ps -ef | grep [m]atterm | awk '{ print $$2 }'); do \ + @for PID in $$(ps -ef | grep [m]atterm | grep -v VirtualBox | awk '{ print $$2 }'); do \ echo stopping go web $$PID; \ kill $$PID; \ done diff --git a/api/admin.go b/api/admin.go index 0ea6341e28..e8cb8b3c7f 100644 --- a/api/admin.go +++ b/api/admin.go @@ -1,4 +1,4 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. package api @@ -21,6 +21,7 @@ func InitAdmin(r *mux.Router) { sr := r.PathPrefix("/admin").Subrouter() sr.Handle("/logs", ApiUserRequired(getLogs)).Methods("GET") + sr.Handle("/audits", ApiUserRequired(getAllAudits)).Methods("GET") sr.Handle("/config", ApiUserRequired(getConfig)).Methods("GET") sr.Handle("/save_config", ApiUserRequired(saveConfig)).Methods("POST") sr.Handle("/test_email", ApiUserRequired(testEmail)).Methods("POST") @@ -58,6 +59,32 @@ func getLogs(c *Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(model.ArrayToJson(lines))) } +func getAllAudits(c *Context, w http.ResponseWriter, r *http.Request) { + + if !c.HasSystemAdminPermissions("getAllAudits") { + return + } + + if result := <-Srv.Store.Audit().Get("", 200); result.Err != nil { + c.Err = result.Err + return + } else { + audits := result.Data.(model.Audits) + etag := audits.Etag() + + if HandleEtag(etag, w, r) { + return + } + + if len(etag) > 0 { + w.Header().Set(model.HEADER_ETAG_SERVER, etag) + } + + w.Write([]byte(audits.ToJson())) + return + } +} + func getClientConfig(c *Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(model.MapToJson(utils.ClientCfg))) } @@ -161,9 +188,10 @@ func getAnalytics(c *Context, w http.ResponseWriter, r *http.Request) { rows[1] = &model.AnalyticsRow{"channel_private_count", 0} rows[2] = &model.AnalyticsRow{"post_count", 0} rows[3] = &model.AnalyticsRow{"unique_user_count", 0} + openChan := Srv.Store.Channel().AnalyticsTypeCount(teamId, model.CHANNEL_OPEN) privateChan := Srv.Store.Channel().AnalyticsTypeCount(teamId, model.CHANNEL_PRIVATE) - postChan := Srv.Store.Post().AnalyticsPostCount(teamId) + postChan := Srv.Store.Post().AnalyticsPostCount(teamId, false, false) userChan := Srv.Store.User().AnalyticsUniqueUserCount(teamId) if r := <-openChan; r.Err != nil { @@ -209,6 +237,47 @@ func getAnalytics(c *Context, w http.ResponseWriter, r *http.Request) { } else { w.Write([]byte(r.Data.(model.AnalyticsRows).ToJson())) } + } else if name == "extra_counts" { + var rows model.AnalyticsRows = make([]*model.AnalyticsRow, 4) + rows[0] = &model.AnalyticsRow{"file_post_count", 0} + rows[1] = &model.AnalyticsRow{"hashtag_post_count", 0} + rows[2] = &model.AnalyticsRow{"incoming_webhook_count", 0} + rows[3] = &model.AnalyticsRow{"outgoing_webhook_count", 0} + + fileChan := Srv.Store.Post().AnalyticsPostCount(teamId, true, false) + hashtagChan := Srv.Store.Post().AnalyticsPostCount(teamId, false, true) + iHookChan := Srv.Store.Webhook().AnalyticsIncomingCount(teamId) + oHookChan := Srv.Store.Webhook().AnalyticsOutgoingCount(teamId) + + if r := <-fileChan; r.Err != nil { + c.Err = r.Err + return + } else { + rows[0].Value = float64(r.Data.(int64)) + } + + if r := <-hashtagChan; r.Err != nil { + c.Err = r.Err + return + } else { + rows[1].Value = float64(r.Data.(int64)) + } + + if r := <-iHookChan; r.Err != nil { + c.Err = r.Err + return + } else { + rows[2].Value = float64(r.Data.(int64)) + } + + if r := <-oHookChan; r.Err != nil { + c.Err = r.Err + return + } else { + rows[3].Value = float64(r.Data.(int64)) + } + + w.Write([]byte(rows.ToJson())) } else { c.SetInvalidParam("getAnalytics", "name") } diff --git a/api/admin_test.go b/api/admin_test.go index 2552e642c8..8a9c82b443 100644 --- a/api/admin_test.go +++ b/api/admin_test.go @@ -41,6 +41,36 @@ func TestGetLogs(t *testing.T) { } } +func TestGetAllAudits(t *testing.T) { + Setup() + + team := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN} + team = Client.Must(Client.CreateTeam(team)).Data.(*model.Team) + + user := &model.User{TeamId: team.Id, Email: model.NewId() + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "pwd"} + user = Client.Must(Client.CreateUser(user, "")).Data.(*model.User) + store.Must(Srv.Store.User().VerifyEmail(user.Id)) + + Client.LoginByEmail(team.Name, user.Email, "pwd") + + if _, err := Client.GetAllAudits(); err == nil { + t.Fatal("Shouldn't have permissions") + } + + c := &Context{} + c.RequestId = model.NewId() + c.IpAddress = "cmd_line" + UpdateRoles(c, user, model.ROLE_SYSTEM_ADMIN) + + Client.LoginByEmail(team.Name, user.Email, "pwd") + + if audits, err := Client.GetAllAudits(); err != nil { + t.Fatal(err) + } else if len(audits.Data.(model.Audits)) <= 0 { + t.Fatal() + } +} + func TestGetClientProperties(t *testing.T) { Setup() @@ -362,3 +392,113 @@ func TestUserCountsWithPostsByDay(t *testing.T) { } } } + +func TestGetTeamAnalyticsExtra(t *testing.T) { + Setup() + + team := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN} + team = Client.Must(Client.CreateTeam(team)).Data.(*model.Team) + + user := &model.User{TeamId: team.Id, Email: model.NewId() + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "pwd"} + user = Client.Must(Client.CreateUser(user, "")).Data.(*model.User) + store.Must(Srv.Store.User().VerifyEmail(user.Id)) + + Client.LoginByEmail(team.Name, user.Email, "pwd") + + channel1 := &model.Channel{DisplayName: "TestGetPosts", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_PRIVATE, TeamId: team.Id} + channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel) + + post1 := &model.Post{ChannelId: channel1.Id, Message: "a" + model.NewId() + "a"} + post1 = Client.Must(Client.CreatePost(post1)).Data.(*model.Post) + + post2 := &model.Post{ChannelId: channel1.Id, Message: "#test a" + model.NewId() + "a"} + post2 = Client.Must(Client.CreatePost(post2)).Data.(*model.Post) + + if _, err := Client.GetTeamAnalytics("", "extra_counts"); err == nil { + t.Fatal("Shouldn't have permissions") + } + + c := &Context{} + c.RequestId = model.NewId() + c.IpAddress = "cmd_line" + UpdateRoles(c, user, model.ROLE_SYSTEM_ADMIN) + + Client.LoginByEmail(team.Name, user.Email, "pwd") + + if result, err := Client.GetTeamAnalytics(team.Id, "extra_counts"); err != nil { + t.Fatal(err) + } else { + rows := result.Data.(model.AnalyticsRows) + + if rows[0].Name != "file_post_count" { + t.Log(rows.ToJson()) + t.Fatal() + } + + if rows[0].Value != 0 { + t.Log(rows.ToJson()) + t.Fatal() + } + + if rows[1].Name != "hashtag_post_count" { + t.Log(rows.ToJson()) + t.Fatal() + } + + if rows[1].Value != 1 { + t.Log(rows.ToJson()) + t.Fatal() + } + + if rows[2].Name != "incoming_webhook_count" { + t.Log(rows.ToJson()) + t.Fatal() + } + + if rows[2].Value != 0 { + t.Log(rows.ToJson()) + t.Fatal() + } + + if rows[3].Name != "outgoing_webhook_count" { + t.Log(rows.ToJson()) + t.Fatal() + } + + if rows[3].Value != 0 { + t.Log(rows.ToJson()) + t.Fatal() + } + } + + if result, err := Client.GetSystemAnalytics("extra_counts"); err != nil { + t.Fatal(err) + } else { + rows := result.Data.(model.AnalyticsRows) + + if rows[0].Name != "file_post_count" { + t.Log(rows.ToJson()) + t.Fatal() + } + + if rows[1].Name != "hashtag_post_count" { + t.Log(rows.ToJson()) + t.Fatal() + } + + if rows[1].Value < 1 { + t.Log(rows.ToJson()) + t.Fatal() + } + + if rows[2].Name != "incoming_webhook_count" { + t.Log(rows.ToJson()) + t.Fatal() + } + + if rows[3].Name != "outgoing_webhook_count" { + t.Log(rows.ToJson()) + t.Fatal() + } + } +} diff --git a/api/user.go b/api/user.go index 27afbd4692..a6817caa29 100644 --- a/api/user.go +++ b/api/user.go @@ -444,6 +444,38 @@ func LoginByEmail(c *Context, w http.ResponseWriter, r *http.Request, email, nam return nil } +func LoginByUsername(c *Context, w http.ResponseWriter, r *http.Request, username, name, password, deviceId string) *model.User { + var team *model.Team + + if result := <-Srv.Store.Team().GetByName(name); result.Err != nil { + c.Err = result.Err + return nil + } else { + team = result.Data.(*model.Team) + } + + if result := <-Srv.Store.User().GetByUsername(team.Id, username); result.Err != nil { + c.Err = result.Err + c.Err.StatusCode = http.StatusForbidden + return nil + } else { + user := result.Data.(*model.User) + + if len(user.AuthData) != 0 { + c.Err = model.NewLocAppError("LoginByUsername", "api.user.login_by_email.sign_in.app_error", + map[string]interface{}{"AuthService": user.AuthService}, "") + return nil + } + + if checkUserLoginAttempts(c, user) && checkUserPassword(c, user, password) { + Login(c, w, r, user, deviceId) + return user + } + } + + return nil +} + func LoginByOAuth(c *Context, w http.ResponseWriter, r *http.Request, service string, userData io.ReadCloser, team *model.Team) *model.User { authData := "" provider := einterfaces.GetOauthProvider(service) @@ -629,6 +661,8 @@ func login(c *Context, w http.ResponseWriter, r *http.Request) { user = LoginById(c, w, r, props["id"], props["password"], props["device_id"]) } else if len(props["email"]) != 0 && len(props["name"]) != 0 { user = LoginByEmail(c, w, r, props["email"], props["name"], props["password"], props["device_id"]) + } else if len(props["username"]) != 0 && len(props["name"]) != 0 { + user = LoginByUsername(c, w, r, props["username"], props["name"], props["password"], props["device_id"]) } else { c.Err = model.NewLocAppError("login", "api.user.login.not_provided.app_error", nil, "") c.Err.StatusCode = http.StatusForbidden diff --git a/api/user_test.go b/api/user_test.go index b2ae113f15..1a1cf9634a 100644 --- a/api/user_test.go +++ b/api/user_test.go @@ -99,7 +99,7 @@ func TestLogin(t *testing.T) { team := model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN} rteam, _ := Client.CreateTeam(&team) - user := model.User{TeamId: rteam.Data.(*model.Team).Id, Email: strings.ToLower(model.NewId()) + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Password: "pwd"} + user := model.User{TeamId: rteam.Data.(*model.Team).Id, Email: strings.ToLower(model.NewId()) + "success+test@simulator.amazonses.com", Nickname: "Corey Hulen", Username: "corey", Password: "pwd"} ruser, _ := Client.CreateUser(&user, "") store.Must(Srv.Store.User().VerifyEmail(ruser.Data.(*model.User).Id)) @@ -107,7 +107,7 @@ func TestLogin(t *testing.T) { t.Fatal(err) } else { if result.Data.(*model.User).Email != user.Email { - t.Fatal("email's didn't match") + t.Fatal("emails didn't match") } } @@ -119,14 +119,30 @@ func TestLogin(t *testing.T) { } } + if result, err := Client.LoginByUsername(team.Name, user.Username, user.Password); err != nil { + t.Fatal(err) + } else { + if result.Data.(*model.User).Email != user.Email { + t.Fatal("emails didn't match") + } + } + if _, err := Client.LoginByEmail(team.Name, user.Email, user.Password+"invalid"); err == nil { t.Fatal("Invalid Password") } + if _, err := Client.LoginByUsername(team.Name, user.Username, user.Password+"invalid"); err == nil { + t.Fatal("Invalid Password") + } + if _, err := Client.LoginByEmail(team.Name, "", user.Password); err == nil { t.Fatal("should have failed") } + if _, err := Client.LoginByUsername(team.Name, "", user.Password); err == nil { + t.Fatal("should have failed") + } + authToken := Client.AuthToken Client.AuthToken = "invalid" diff --git a/config/config.json b/config/config.json index 1415603668..5ed05fecde 100644 --- a/config/config.json +++ b/config/config.json @@ -68,6 +68,8 @@ }, "EmailSettings": { "EnableSignUpWithEmail": true, + "EnableSignInWithEmail": true, + "EnableSignInWithUsername": false, "SendEmailNotifications": false, "RequireEmailVerification": false, "FeedbackName": "", diff --git a/doc/developer/tests/test-attachments.md b/doc/developer/tests/test-attachments.md index e2fda0eb60..75a2285f8c 100644 --- a/doc/developer/tests/test-attachments.md +++ b/doc/developer/tests/test-attachments.md @@ -7,8 +7,9 @@ This test contains instructions for the core team to manually test common attach **Notes:** - All file types should upload and post. -- Read the expected for details on the behavior of the thumbnail and preview window. +- Read the expected for details on the behavior of the thumbnail and preview window. - The expected behavior of video and audio formats depends on the operating system, browser and plugins. View the permalinks to the Public Test Channel on Pre-Release Core to see the expected cases. +- If the browser can play the media file, media player controls should appear. If the browser cannot play the file, it should show appear as a regular attachment without the media controls. ### Images @@ -72,7 +73,7 @@ Expected: Generic Word thumbnail & preview window. **MP4** `Videos/MP4.mp4` -Expected: Generic video thumbnail & playable preview window. View Permalink. +Expected: Generic video thumbnail, view Permalink for preview window behavior. Expected depends on the operating system, browser and plugins. [Permalink](https://pre-release.mattermost.com/core/pl/5dx5qx9t9brqfnhohccxjynx7c) **AVI** @@ -114,7 +115,7 @@ Expected: Generic audio thumbnail & playable preview window **M4A** `Audio/M4a.m4a` -Expected: Generic audio thumbnail & playable preview window +Expected: Generic audio thumbnail, view Permalink for preview window behavior. Expected depends on the operating system, browser and plugins. [Permalink](https://pre-release.mattermost.com/core/pl/6c7qsw48ybd88bktgeykodsrrc) **AAC** diff --git a/docker/dev/config_docker.json b/docker/dev/config_docker.json index cbd771b719..e831bbb3a4 100644 --- a/docker/dev/config_docker.json +++ b/docker/dev/config_docker.json @@ -68,6 +68,8 @@ }, "EmailSettings": { "EnableSignUpWithEmail": true, + "EnableSignInWithEmail": true, + "EnableSignInWithUsername": false, "SendEmailNotifications": false, "RequireEmailVerification": false, "FeedbackName": "", diff --git a/docker/local/config_docker.json b/docker/local/config_docker.json index cbd771b719..e831bbb3a4 100644 --- a/docker/local/config_docker.json +++ b/docker/local/config_docker.json @@ -68,6 +68,8 @@ }, "EmailSettings": { "EnableSignUpWithEmail": true, + "EnableSignInWithEmail": true, + "EnableSignInWithUsername": false, "SendEmailNotifications": false, "RequireEmailVerification": false, "FeedbackName": "", diff --git a/i18n/en.json b/i18n/en.json index 8a3e993d25..d72d6dca57 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1667,6 +1667,38 @@ "id": "api.webhook.regen_outgoing_token.permissions.app_error", "translation": "Inappropriate permissions to regenerate outcoming webhook token" }, + { + "id": "ent.ldap.do_login.bind_admin_user.app_error", + "translation": "Unable to bind to LDAP server. Check BindUsername and BindPassword." + }, + { + "id": "ent.ldap.do_login.invalid_password.app_error", + "translation": "Invalid Password" + }, + { + "id": "ent.ldap.do_login.licence_disable.app_error", + "translation": "LDAP functionality disabled by current license. Please contact your system administrator about upgrading your enterprise license." + }, + { + "id": "ent.ldap.do_login.matched_to_many_users.app_error", + "translation": "Username given matches multiple users" + }, + { + "id": "ent.ldap.do_login.search_ldap_server.app_error", + "translation": "Failed to search LDAP server" + }, + { + "id": "ent.ldap.do_login.unable_to_connect.app_error", + "translation": "Unable to connect to LDAP server" + }, + { + "id": "ent.ldap.do_login.unable_to_create_user.app_error", + "translation": "Credentials valid but unable to create user." + }, + { + "id": "ent.ldap.do_login.user_not_registered.app_error", + "translation": "User not registered on LDAP server" + }, { "id": "manaultesting.get_channel_id.no_found.debug", "translation": "Could not find channel: %v, %v possibilites searched" @@ -3131,6 +3163,14 @@ "id": "store.sql_webhooks.update_outgoing.app_error", "translation": "We couldn't update the webhook" }, + { + "id": "store.sql_webhooks.analytics_incoming_count.app_error", + "translation": "We couldn't count the incoming webhooks" + }, + { + "id": "store.sql_webhooks.analytics_outgoing_count.app_error", + "translation": "We couldn't count the outgoing webhooks" + }, { "id": "utils.config.load_config.decoding.panic", "translation": "Error decoding config file={{.Filename}}, err={{.Error}}" @@ -3491,4 +3531,4 @@ "id": "web.watcher_fail.error", "translation": "Failed to add directory to watcher %v" } -] +] \ No newline at end of file diff --git a/i18n/es.json b/i18n/es.json index a79048652c..2c51581851 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -1531,6 +1531,38 @@ "id": "api.webhook.regen_outgoing_token.permissions.app_error", "translation": "Permisos inapropiados para regenerar un token para el Webhook saliente" }, + { + "id": "ent.ldap.do_login.bind_admin_user.app_error", + "translation": "No se pudo enlazar con el servidor LDAP. Revisa las opciones de BindUsername y BindPassword." + }, + { + "id": "ent.ldap.do_login.invalid_password.app_error", + "translation": "Contraseña inválida" + }, + { + "id": "ent.ldap.do_login.licence_disable.app_error", + "translation": "Las funcionalidades de LDAP están deshabilitadas con la licencia actual. Por favor contacta a un administrador del sistema acerca de mejorar la licencia enterprise." + }, + { + "id": "ent.ldap.do_login.matched_to_many_users.app_error", + "translation": "Nombre de usuario dado coincide con varios usuarios" + }, + { + "id": "ent.ldap.do_login.search_ldap_server.app_error", + "translation": "Falla al buscar en el servidor LDAP" + }, + { + "id": "ent.ldap.do_login.unable_to_connect.app_error", + "translation": "No se pudo conectar con el servidor LDAP" + }, + { + "id": "ent.ldap.do_login.unable_to_create_user.app_error", + "translation": "Credenciales válidas pero no se pudo crear el usuario." + }, + { + "id": "ent.ldap.do_login.user_not_registered.app_error", + "translation": "Usuario no registrado en el servidor LDAP" + }, { "id": "manaultesting.get_channel_id.no_found.debug", "translation": "No pudimos encontrar el canal: %v, búsqueda realizada con estas posibilidades %v" @@ -3287,4 +3319,4 @@ "id": "web.watcher_fail.error", "translation": "Falla al agregar el directorio a ser vigilado %v" } -] +] \ No newline at end of file diff --git a/mattermost.go b/mattermost.go index b6652d812f..43fa06601c 100644 --- a/mattermost.go +++ b/mattermost.go @@ -31,6 +31,8 @@ import ( _ "github.com/go-ldap/ldap" ) +//ENTERPRISE_IMPORTS + var flagCmdCreateTeam bool var flagCmdCreateUser bool var flagCmdAssignRole bool diff --git a/model/client.go b/model/client.go index a271e61623..560e47b76c 100644 --- a/model/client.go +++ b/model/client.go @@ -280,6 +280,14 @@ func (c *Client) LoginByEmail(name string, email string, password string) (*Resu return c.login(m) } +func (c *Client) LoginByUsername(name string, username string, password string) (*Result, *AppError) { + m := make(map[string]string) + m["name"] = name + m["username"] = username + m["password"] = password + return c.login(m) +} + func (c *Client) LoginByEmailWithDevice(name string, email string, password string, deviceId string) (*Result, *AppError) { m := make(map[string]string) m["name"] = name @@ -443,6 +451,15 @@ func (c *Client) GetLogs() (*Result, *AppError) { } } +func (c *Client) GetAllAudits() (*Result, *AppError) { + if r, err := c.DoApiGet("/admin/audits", "", ""); err != nil { + return nil, err + } else { + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), AuditsFromJson(r.Body)}, nil + } +} + func (c *Client) GetClientProperties() (*Result, *AppError) { if r, err := c.DoApiGet("/admin/client_props", "", ""); err != nil { return nil, err diff --git a/model/config.go b/model/config.go index f518e8f8d6..acb525abf7 100644 --- a/model/config.go +++ b/model/config.go @@ -99,6 +99,8 @@ type FileSettings struct { type EmailSettings struct { EnableSignUpWithEmail bool + EnableSignInWithEmail *bool + EnableSignInWithUsername *bool SendEmailNotifications bool RequireEmailVerification bool FeedbackName string @@ -260,6 +262,21 @@ func (o *Config) SetDefaults() { *o.TeamSettings.EnableTeamListing = false } + if o.EmailSettings.EnableSignInWithEmail == nil { + o.EmailSettings.EnableSignInWithEmail = new(bool) + + if o.EmailSettings.EnableSignUpWithEmail == true { + *o.EmailSettings.EnableSignInWithEmail = true + } else { + *o.EmailSettings.EnableSignInWithEmail = false + } + } + + if o.EmailSettings.EnableSignInWithUsername == nil { + o.EmailSettings.EnableSignInWithUsername = new(bool) + *o.EmailSettings.EnableSignInWithUsername = false + } + if o.EmailSettings.SendPushNotifications == nil { o.EmailSettings.SendPushNotifications = new(bool) *o.EmailSettings.SendPushNotifications = false diff --git a/store/sql_audit_store.go b/store/sql_audit_store.go index 97df5f7e71..dbcb9a616c 100644 --- a/store/sql_audit_store.go +++ b/store/sql_audit_store.go @@ -1,4 +1,4 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. package store @@ -72,9 +72,16 @@ func (s SqlAuditStore) Get(user_id string, limit int) StoreChannel { return } + query := "SELECT * FROM Audits" + + if len(user_id) != 0 { + query += " WHERE UserId = :user_id" + } + + query += " ORDER BY CreateAt DESC LIMIT :limit" + var audits model.Audits - if _, err := s.GetReplica().Select(&audits, "SELECT * FROM Audits WHERE UserId = :user_id ORDER BY CreateAt DESC LIMIT :limit", - map[string]interface{}{"user_id": user_id, "limit": limit}); err != nil { + if _, err := s.GetReplica().Select(&audits, query, map[string]interface{}{"user_id": user_id, "limit": limit}); err != nil { result.Err = model.NewLocAppError("SqlAuditStore.Get", "store.sql_audit.get.finding.app_error", nil, "user_id="+user_id) } else { result.Data = audits diff --git a/store/sql_audit_store_test.go b/store/sql_audit_store_test.go index b395631f1e..841b79627a 100644 --- a/store/sql_audit_store_test.go +++ b/store/sql_audit_store_test.go @@ -45,6 +45,14 @@ func TestSqlAuditStore(t *testing.T) { t.Fatal("Should have returned empty because user_id is missing") } + c = store.Audit().Get("", 100) + result = <-c + audits = result.Data.(model.Audits) + + if len(audits) <= 4 { + t.Fatal("Failed to save and retrieve 4 audit logs") + } + if r2 := <-store.Audit().PermanentDeleteByUser(audit.UserId); r2.Err != nil { t.Fatal(r2.Err) } diff --git a/store/sql_post_store.go b/store/sql_post_store.go index aeaa5922c6..dfb9563eb3 100644 --- a/store/sql_post_store.go +++ b/store/sql_post_store.go @@ -629,7 +629,7 @@ func (s SqlPostStore) Search(teamId string, userId string, params *model.SearchP if params.IsHashtag { searchType = "Hashtags" for _, term := range strings.Split(terms, " ") { - termMap[term] = true + termMap[strings.ToUpper(term)] = true } } @@ -748,7 +748,7 @@ func (s SqlPostStore) Search(teamId string, userId string, params *model.SearchP if searchType == "Hashtags" { exactMatch := false for _, tag := range strings.Split(p.Hashtags, " ") { - if termMap[tag] { + if termMap[strings.ToUpper(tag)] { exactMatch = true } } @@ -940,7 +940,7 @@ func (s SqlPostStore) AnalyticsPostCountsByDay(teamId string) StoreChannel { return storeChannel } -func (s SqlPostStore) AnalyticsPostCount(teamId string) StoreChannel { +func (s SqlPostStore) AnalyticsPostCount(teamId string, mustHaveFile bool, mustHaveHashtag bool) StoreChannel { storeChannel := make(StoreChannel) go func() { @@ -959,8 +959,15 @@ func (s SqlPostStore) AnalyticsPostCount(teamId string) StoreChannel { query += " AND Channels.TeamId = :TeamId" } - v, err := s.GetReplica().SelectInt(query, map[string]interface{}{"TeamId": teamId}) - if err != nil { + if mustHaveFile { + query += " AND Posts.Filenames != '[]'" + } + + if mustHaveHashtag { + query += " AND Posts.Hashtags != ''" + } + + if v, err := s.GetReplica().SelectInt(query, map[string]interface{}{"TeamId": teamId}); err != nil { result.Err = model.NewLocAppError("SqlPostStore.AnalyticsPostCount", "store.sql_post.analytics_posts_count.app_error", nil, err.Error()) } else { result.Data = v diff --git a/store/sql_post_store_test.go b/store/sql_post_store_test.go index 46b8d7678c..d69f7906c6 100644 --- a/store/sql_post_store_test.go +++ b/store/sql_post_store_test.go @@ -887,7 +887,7 @@ func TestPostCountsByDay(t *testing.T) { } } - if r1 := <-store.Post().AnalyticsPostCount(t1.Id); r1.Err != nil { + if r1 := <-store.Post().AnalyticsPostCount(t1.Id, false, false); r1.Err != nil { t.Fatal(r1.Err) } else { if r1.Data.(int64) != 4 { diff --git a/store/sql_webhook_store.go b/store/sql_webhook_store.go index 939574b9f5..740c9a33f0 100644 --- a/store/sql_webhook_store.go +++ b/store/sql_webhook_store.go @@ -329,3 +329,65 @@ func (s SqlWebhookStore) UpdateOutgoing(hook *model.OutgoingWebhook) StoreChanne return storeChannel } + +func (s SqlWebhookStore) AnalyticsIncomingCount(teamId string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + query := + `SELECT + COUNT(*) + FROM + IncomingWebhooks + WHERE + DeleteAt = 0` + + if len(teamId) > 0 { + query += " AND TeamId = :TeamId" + } + + if v, err := s.GetReplica().SelectInt(query, map[string]interface{}{"TeamId": teamId}); err != nil { + result.Err = model.NewLocAppError("SqlWebhookStore.AnalyticsIncomingCount", "store.sql_webhooks.analytics_incoming_count.app_error", nil, "team_id="+teamId+", err="+err.Error()) + } else { + result.Data = v + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (s SqlWebhookStore) AnalyticsOutgoingCount(teamId string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + query := + `SELECT + COUNT(*) + FROM + OutgoingWebhooks + WHERE + DeleteAt = 0` + + if len(teamId) > 0 { + query += " AND TeamId = :TeamId" + } + + if v, err := s.GetReplica().SelectInt(query, map[string]interface{}{"TeamId": teamId}); err != nil { + result.Err = model.NewLocAppError("SqlWebhookStore.AnalyticsOutgoingCount", "store.sql_webhooks.analytics_outgoing_count.app_error", nil, "team_id="+teamId+", err="+err.Error()) + } else { + result.Data = v + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} diff --git a/store/sql_webhook_store_test.go b/store/sql_webhook_store_test.go index 5b43d07307..251ecf5971 100644 --- a/store/sql_webhook_store_test.go +++ b/store/sql_webhook_store_test.go @@ -304,3 +304,42 @@ func TestWebhookStoreUpdateOutgoing(t *testing.T) { t.Fatal(r2.Err) } } + +func TestWebhookStoreCountIncoming(t *testing.T) { + Setup() + + o1 := &model.IncomingWebhook{} + o1.ChannelId = model.NewId() + o1.UserId = model.NewId() + o1.TeamId = model.NewId() + + o1 = (<-store.Webhook().SaveIncoming(o1)).Data.(*model.IncomingWebhook) + + if r := <-store.Webhook().AnalyticsIncomingCount(""); r.Err != nil { + t.Fatal(r.Err) + } else { + if r.Data.(int64) == 0 { + t.Fatal("should have at least 1 incoming hook") + } + } +} + +func TestWebhookStoreCountOutgoing(t *testing.T) { + Setup() + + o1 := &model.OutgoingWebhook{} + o1.ChannelId = model.NewId() + o1.CreatorId = model.NewId() + o1.TeamId = model.NewId() + o1.CallbackURLs = []string{"http://nowhere.com/"} + + o1 = (<-store.Webhook().SaveOutgoing(o1)).Data.(*model.OutgoingWebhook) + + if r := <-store.Webhook().AnalyticsOutgoingCount(""); r.Err != nil { + t.Fatal(r.Err) + } else { + if r.Data.(int64) == 0 { + t.Fatal("should have at least 1 outgoing hook") + } + } +} diff --git a/store/store.go b/store/store.go index a25d8b204a..95df368edb 100644 --- a/store/store.go +++ b/store/store.go @@ -101,7 +101,7 @@ type PostStore interface { GetForExport(channelId string) StoreChannel AnalyticsUserCountsWithPostsByDay(teamId string) StoreChannel AnalyticsPostCountsByDay(teamId string) StoreChannel - AnalyticsPostCount(teamId string) StoreChannel + AnalyticsPostCount(teamId string, mustHaveFile bool, mustHaveHashtag bool) StoreChannel } type UserStore interface { @@ -182,6 +182,8 @@ type WebhookStore interface { DeleteOutgoing(webhookId string, time int64) StoreChannel PermanentDeleteOutgoingByUser(userId string) StoreChannel UpdateOutgoing(hook *model.OutgoingWebhook) StoreChannel + AnalyticsIncomingCount(teamId string) StoreChannel + AnalyticsOutgoingCount(teamId string) StoreChannel } type CommandStore interface { diff --git a/utils/config.go b/utils/config.go index a2d341cd2a..3e4ba5c5bd 100644 --- a/utils/config.go +++ b/utils/config.go @@ -210,6 +210,8 @@ func getClientConfig(c *model.Config) map[string]string { props["SendEmailNotifications"] = strconv.FormatBool(c.EmailSettings.SendEmailNotifications) props["EnableSignUpWithEmail"] = strconv.FormatBool(c.EmailSettings.EnableSignUpWithEmail) + props["EnableSignInWithEmail"] = strconv.FormatBool(*c.EmailSettings.EnableSignInWithEmail) + props["EnableSignInWithUsername"] = strconv.FormatBool(*c.EmailSettings.EnableSignInWithUsername) props["RequireEmailVerification"] = strconv.FormatBool(c.EmailSettings.RequireEmailVerification) props["FeedbackEmail"] = c.EmailSettings.FeedbackEmail diff --git a/web/react/components/access_history_modal.jsx b/web/react/components/access_history_modal.jsx index 6319b56813..98b1d7cc12 100644 --- a/web/react/components/access_history_modal.jsx +++ b/web/react/components/access_history_modal.jsx @@ -1,204 +1,24 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. var Modal = ReactBootstrap.Modal; -import UserStore from '../stores/user_store.jsx'; -import ChannelStore from '../stores/channel_store.jsx'; -import * as AsyncClient from '../utils/async_client.jsx'; import LoadingScreen from './loading_screen.jsx'; +import AuditTable from './audit_table.jsx'; + +import UserStore from '../stores/user_store.jsx'; + +import * as AsyncClient from '../utils/async_client.jsx'; import * as Utils from '../utils/utils.jsx'; -import {intlShape, injectIntl, defineMessages, FormattedMessage} from 'mm-intl'; - -const holders = defineMessages({ - sessionRevoked: { - id: 'access_history.sessionRevoked', - defaultMessage: 'The session with id {sessionId} was revoked' - }, - channelCreated: { - id: 'access_history.channelCreated', - defaultMessage: 'Created the {channelName} channel/group' - }, - establishedDM: { - id: 'access_history.establishedDM', - defaultMessage: 'Established a direct message channel with {username}' - }, - nameUpdated: { - id: 'access_history.nameUpdated', - defaultMessage: 'Updated the {channelName} channel/group name' - }, - headerUpdated: { - id: 'access_history.headerUpdated', - defaultMessage: 'Updated the {channelName} channel/group header' - }, - channelDeleted: { - id: 'access_history.channelDeleted', - defaultMessage: 'Deleted the channel/group with the URL {url}' - }, - userAdded: { - id: 'access_history.userAdded', - defaultMessage: 'Added {username} to the {channelName} channel/group' - }, - userRemoved: { - id: 'access_history.userRemoved', - defaultMessage: 'Removed {username} to the {channelName} channel/group' - }, - attemptedRegisterApp: { - id: 'access_history.attemptedRegisterApp', - defaultMessage: 'Attempted to register a new OAuth Application with ID {id}' - }, - attemptedAllowOAuthAccess: { - id: 'access_history.attemptedAllowOAuthAccess', - defaultMessage: 'Attempted to allow a new OAuth service access' - }, - successfullOAuthAccess: { - id: 'access_history.successfullOAuthAccess', - defaultMessage: 'Successfully gave a new OAuth service access' - }, - failedOAuthAccess: { - id: 'access_history.failedOAuthAccess', - defaultMessage: 'Failed to allow a new OAuth service access - the redirect URI did not match the previously registered callback' - }, - attemptedOAuthToken: { - id: 'access_history.attemptedOAuthToken', - defaultMessage: 'Attempted to get an OAuth access token' - }, - successfullOAuthToken: { - id: 'access_history.successfullOAuthToken', - defaultMessage: 'Successfully added a new OAuth service' - }, - oauthTokenFailed: { - id: 'access_history.oauthTokenFailed', - defaultMessage: 'Failed to get an OAuth access token - {token}' - }, - attemptedLogin: { - id: 'access_history.attemptedLogin', - defaultMessage: 'Attempted to login' - }, - successfullLogin: { - id: 'access_history.successfullLogin', - defaultMessage: 'Successfully logged in' - }, - failedLogin: { - id: 'access_history.failedLogin', - defaultMessage: 'FAILED login attempt' - }, - updatePicture: { - id: 'access_history.updatePicture', - defaultMessage: 'Updated your profile picture' - }, - updateGeneral: { - id: 'access_history.updateGeneral', - defaultMessage: 'Updated the general settings of your account' - }, - attemptedPassword: { - id: 'access_history.attemptedPassword', - defaultMessage: 'Attempted to change password' - }, - successfullPassword: { - id: 'access_history.successfullPassword', - defaultMessage: 'Successfully changed password' - }, - failedPassword: { - id: 'access_history.failedPassword', - defaultMessage: 'Failed to change password - tried to update user password who was logged in through oauth' - }, - updatedRol: { - id: 'access_history.updatedRol', - defaultMessage: 'Updated user role(s) to ' - }, - member: { - id: 'access_history.member', - defaultMessage: 'member' - }, - accountActive: { - id: 'access_history.accountActive', - defaultMessage: 'Account made active' - }, - accountInactive: { - id: 'access_history.accountInactive', - defaultMessage: 'Account made inactive' - }, - by: { - id: 'access_history.by', - defaultMessage: ' by {username}' - }, - byAdmin: { - id: 'access_history.byAdmin', - defaultMessage: ' by an admin' - }, - sentEmail: { - id: 'access_history.sentEmail', - defaultMessage: 'Sent an email to {email} to reset your password' - }, - attemptedReset: { - id: 'access_history.attemptedReset', - defaultMessage: 'Attempted to reset password' - }, - successfullReset: { - id: 'access_history.successfullReset', - defaultMessage: 'Successfully reset password' - }, - updateGlobalNotifications: { - id: 'access_history.updateGlobalNotifications', - defaultMessage: 'Updated your global notification settings' - }, - attemptedWebhookCreate: { - id: 'access_history.attemptedWebhookCreate', - defaultMessage: 'Attempted to create a webhook' - }, - succcessfullWebhookCreate: { - id: 'access_history.successfullWebhookCreate', - defaultMessage: 'Successfully created a webhook' - }, - failedWebhookCreate: { - id: 'access_history.failedWebhookCreate', - defaultMessage: 'Failed to create a webhook - bad channel permissions' - }, - attemptedWebhookDelete: { - id: 'access_history.attemptedWebhookDelete', - defaultMessage: 'Attempted to delete a webhook' - }, - successfullWebhookDelete: { - id: 'access_history.successfullWebhookDelete', - defaultMessage: 'Successfully deleted a webhook' - }, - failedWebhookDelete: { - id: 'access_history.failedWebhookDelete', - defaultMessage: 'Failed to delete a webhook - inappropriate conditions' - }, - logout: { - id: 'access_history.logout', - defaultMessage: 'Logged out of your account' - }, - verified: { - id: 'access_history.verified', - defaultMessage: 'Sucessfully verified your email address' - }, - revokedAll: { - id: 'access_history.revokedAll', - defaultMessage: 'Revoked all current sessions for the team' - }, - loginAttempt: { - id: 'access_history.loginAttempt', - defaultMessage: ' (Login attempt)' - }, - loginFailure: { - id: 'access_history.loginFailure', - defaultMessage: ' (Login failure)' - } -}); +import {intlShape, injectIntl, FormattedMessage} from 'mm-intl'; class AccessHistoryModal extends React.Component { constructor(props) { super(props); this.onAuditChange = this.onAuditChange.bind(this); - this.handleMoreInfo = this.handleMoreInfo.bind(this); this.onShow = this.onShow.bind(this); this.onHide = this.onHide.bind(this); - this.formatAuditInfo = this.formatAuditInfo.bind(this); - this.handleRevokedSession = this.handleRevokedSession.bind(this); const state = this.getStateFromStoresForAudits(); state.moreInfo = []; @@ -245,359 +65,17 @@ class AccessHistoryModal extends React.Component { this.setState(newState); } } - handleMoreInfo(index) { - var newMoreInfo = this.state.moreInfo; - newMoreInfo[index] = true; - this.setState({moreInfo: newMoreInfo}); - } - handleRevokedSession(sessionId) { - return this.props.intl.formatMessage(holders.sessionRevoked, {sessionId: sessionId}); - } - formatAuditInfo(currentAudit) { - const currentActionURL = currentAudit.action.replace(/\/api\/v[1-9]/, ''); - - const {formatMessage} = this.props.intl; - let currentAuditDesc = ''; - - if (currentActionURL.indexOf('/channels') === 0) { - const channelInfo = currentAudit.extra_info.split(' '); - const channelNameField = channelInfo[0].split('='); - - let channelURL = ''; - let channelObj; - let channelName = ''; - if (channelNameField.indexOf('name') >= 0) { - channelURL = channelNameField[channelNameField.indexOf('name') + 1]; - channelObj = ChannelStore.getByName(channelURL); - if (channelObj) { - channelName = channelObj.display_name; - } else { - channelName = channelURL; - } - } - - switch (currentActionURL) { - case '/channels/create': - currentAuditDesc = formatMessage(holders.channelCreated, {channelName: channelName}); - break; - case '/channels/create_direct': - currentAuditDesc = formatMessage(holders.establishedDM, {username: Utils.getDirectTeammate(channelObj.id).username}); - break; - case '/channels/update': - currentAuditDesc = formatMessage(holders.nameUpdated, {channelName: channelName}); - break; - case '/channels/update_desc': // support the old path - case '/channels/update_header': - currentAuditDesc = formatMessage(holders.headerUpdated, {channelName: channelName}); - break; - default: { - let userIdField = []; - let userId = ''; - let username = ''; - - if (channelInfo[1]) { - userIdField = channelInfo[1].split('='); - - if (userIdField.indexOf('user_id') >= 0) { - userId = userIdField[userIdField.indexOf('user_id') + 1]; - username = UserStore.getProfile(userId).username; - } - } - - if (/\/channels\/[A-Za-z0-9]+\/delete/.test(currentActionURL)) { - currentAuditDesc = formatMessage(holders.channelDeleted, {url: channelURL}); - } else if (/\/channels\/[A-Za-z0-9]+\/add/.test(currentActionURL)) { - currentAuditDesc = formatMessage(holders.userAdded, {username: username, channelName: channelName}); - } else if (/\/channels\/[A-Za-z0-9]+\/remove/.test(currentActionURL)) { - currentAuditDesc = formatMessage(holders.userRemoved, {username: username, channelName: channelName}); - } - - break; - } - } - } else if (currentActionURL.indexOf('/oauth') === 0) { - const oauthInfo = currentAudit.extra_info.split(' '); - - switch (currentActionURL) { - case '/oauth/register': { - const clientIdField = oauthInfo[0].split('='); - - if (clientIdField[0] === 'client_id') { - currentAuditDesc = formatMessage(holders.attemptedRegisterApp, {id: clientIdField[1]}); - } - - break; - } - case '/oauth/allow': - if (oauthInfo[0] === 'attempt') { - currentAuditDesc = formatMessage(holders.attemptedAllowOAuthAccess); - } else if (oauthInfo[0] === 'success') { - currentAuditDesc = formatMessage(holders.successfullOAuthAccess); - } else if (oauthInfo[0] === 'fail - redirect_uri did not match registered callback') { - currentAuditDesc = formatMessage(holders.failedOAuthAccess); - } - - break; - case '/oauth/access_token': - if (oauthInfo[0] === 'attempt') { - currentAuditDesc = formatMessage(holders.attemptedOAuthToken); - } else if (oauthInfo[0] === 'success') { - currentAuditDesc = formatMessage(holders.successfullOAuthToken); - } else { - const oauthTokenFailure = oauthInfo[0].split('-'); - - if (oauthTokenFailure[0].trim() === 'fail' && oauthTokenFailure[1]) { - currentAuditDesc = formatMessage(oauthTokenFailure, {token: oauthTokenFailure[1].trim()}); - } - } - - break; - default: - break; - } - } else if (currentActionURL.indexOf('/users') === 0) { - const userInfo = currentAudit.extra_info.split(' '); - - switch (currentActionURL) { - case '/users/login': - if (userInfo[0] === 'attempt') { - currentAuditDesc = formatMessage(holders.attemptedLogin); - } else if (userInfo[0] === 'success') { - currentAuditDesc = formatMessage(holders.successfullLogin); - } else if (userInfo[0]) { - currentAuditDesc = formatMessage(holders.failedLogin); - } - - break; - case '/users/revoke_session': - currentAuditDesc = this.handleRevokedSession(userInfo[0].split('=')[1]); - break; - case '/users/newimage': - currentAuditDesc = formatMessage(holders.updatePicture); - break; - case '/users/update': - currentAuditDesc = formatMessage(holders.updateGeneral); - break; - case '/users/newpassword': - if (userInfo[0] === 'attempted') { - currentAuditDesc = formatMessage(holders.attemptedPassword); - } else if (userInfo[0] === 'completed') { - currentAuditDesc = formatMessage(holders.successfullPassword); - } else if (userInfo[0] === 'failed - tried to update user password who was logged in through oauth') { - currentAuditDesc = formatMessage(holders.failedPassword); - } - - break; - case '/users/update_roles': { - const userRoles = userInfo[0].split('=')[1]; - - currentAuditDesc = formatMessage(holders.updatedRol); - if (userRoles.trim()) { - currentAuditDesc += userRoles; - } else { - currentAuditDesc += formatMessage(holders.member); - } - - break; - } - case '/users/update_active': { - const updateType = userInfo[0].split('=')[0]; - const updateField = userInfo[0].split('=')[1]; - - /* Either describes account activation/deactivation or a revoked session as part of an account deactivation */ - if (updateType === 'active') { - if (updateField === 'true') { - currentAuditDesc = formatMessage(holders.accountActive); - } else if (updateField === 'false') { - currentAuditDesc = formatMessage(holders.accountInactive); - } - - const actingUserInfo = userInfo[1].split('='); - if (actingUserInfo[0] === 'session_user') { - const actingUser = UserStore.getProfile(actingUserInfo[1]); - const currentUser = UserStore.getCurrentUser(); - if (currentUser && actingUser && (Utils.isAdmin(currentUser.roles) || Utils.isSystemAdmin(currentUser.roles))) { - currentAuditDesc += formatMessage(holders.by, {username: actingUser.username}); - } else if (currentUser && actingUser) { - currentAuditDesc += formatMessage(holders.byAdmin); - } - } - } else if (updateType === 'session_id') { - currentAuditDesc = this.handleRevokedSession(updateField); - } - - break; - } - case '/users/send_password_reset': - currentAuditDesc = formatMessage(holders.sentEmail, {email: userInfo[0].split('=')[1]}); - break; - case '/users/reset_password': - if (userInfo[0] === 'attempt') { - currentAuditDesc = formatMessage(holders.attemptedReset); - } else if (userInfo[0] === 'success') { - currentAuditDesc = formatMessage(holders.successfullReset); - } - - break; - case '/users/update_notify': - currentAuditDesc = formatMessage(holders.updateGlobalNotifications); - break; - default: - break; - } - } else if (currentActionURL.indexOf('/hooks') === 0) { - const webhookInfo = currentAudit.extra_info.split(' '); - - switch (currentActionURL) { - case '/hooks/incoming/create': - if (webhookInfo[0] === 'attempt') { - currentAuditDesc = formatMessage(holders.attemptedWebhookCreate); - } else if (webhookInfo[0] === 'success') { - currentAuditDesc = formatMessage(holders.succcessfullWebhookCreate); - } else if (webhookInfo[0] === 'fail - bad channel permissions') { - currentAuditDesc = formatMessage(holders.failedWebhookCreate); - } - - break; - case '/hooks/incoming/delete': - if (webhookInfo[0] === 'attempt') { - currentAuditDesc = formatMessage(holders.attemptedWebhookDelete); - } else if (webhookInfo[0] === 'success') { - currentAuditDesc = formatMessage(holders.successfullWebhookDelete); - } else if (webhookInfo[0] === 'fail - inappropriate conditions') { - currentAuditDesc = formatMessage(holders.failedWebhookDelete); - } - - break; - default: - break; - } - } else { - switch (currentActionURL) { - case '/logout': - currentAuditDesc = formatMessage(holders.logout); - break; - case '/verify_email': - currentAuditDesc = formatMessage(holders.verified); - break; - default: - break; - } - } - - /* If all else fails... */ - if (!currentAuditDesc) { - /* Currently not called anywhere */ - if (currentAudit.extra_info.indexOf('revoked_all=') >= 0) { - currentAuditDesc = formatMessage(holders.revokedAll); - } else { - let currentActionDesc = ''; - if (currentActionURL && currentActionURL.lastIndexOf('/') !== -1) { - currentActionDesc = currentActionURL.substring(currentActionURL.lastIndexOf('/') + 1).replace('_', ' '); - currentActionDesc = Utils.toTitleCase(currentActionDesc); - } - - let currentExtraInfoDesc = ''; - if (currentAudit.extra_info) { - currentExtraInfoDesc = currentAudit.extra_info; - - if (currentExtraInfoDesc.indexOf('=') !== -1) { - currentExtraInfoDesc = currentExtraInfoDesc.substring(currentExtraInfoDesc.indexOf('=') + 1); - } - } - currentAuditDesc = currentActionDesc + ' ' + currentExtraInfoDesc; - } - } - - const currentDate = new Date(currentAudit.create_at); - const currentAuditInfo = currentDate.toLocaleDateString(global.window.mm_locale, {month: 'short', day: '2-digit', year: 'numeric'}) + ' - ' + - currentDate.toLocaleTimeString(global.window.mm_locale, {hour: '2-digit', minute: '2-digit'}) + ' | ' + currentAuditDesc; - return currentAuditInfo; - } render() { - var accessList = []; - - const {formatMessage} = this.props.intl; - for (var i = 0; i < this.state.audits.length; i++) { - const currentAudit = this.state.audits[i]; - const currentAuditInfo = this.formatAuditInfo(currentAudit); - - var moreInfo = ( - - - - ); - - if (this.state.moreInfo[i]) { - if (!currentAudit.session_id) { - currentAudit.session_id = 'N/A'; - - if (currentAudit.action.search('/users/login') >= 0) { - if (currentAudit.extra_info === 'attempt') { - currentAudit.session_id += formatMessage(holders.loginAttempt); - } else { - currentAudit.session_id += formatMessage(holders.loginFailure); - } - } - } - - moreInfo = ( -
-
- -
-
- -
-
- ); - } - - var divider = null; - if (i < this.state.audits.length - 1) { - divider = (
); - } - - accessList[i] = ( -
-
-
{currentAuditInfo}
-
- {moreInfo} -
- {divider} -
-
- ); - } - var content; if (this.state.audits.loading) { content = (); } else { - content = (
{accessList}
); + content = ( + + ); } return ( @@ -628,4 +106,4 @@ AccessHistoryModal.propTypes = { onHide: React.PropTypes.func.isRequired }; -export default injectIntl(AccessHistoryModal); \ No newline at end of file +export default injectIntl(AccessHistoryModal); diff --git a/web/react/components/admin_console/admin_controller.jsx b/web/react/components/admin_console/admin_controller.jsx index efd1630170..360ae3ef31 100644 --- a/web/react/components/admin_console/admin_controller.jsx +++ b/web/react/components/admin_console/admin_controller.jsx @@ -11,6 +11,7 @@ import * as Utils from '../../utils/utils.jsx'; import EmailSettingsTab from './email_settings.jsx'; import LogSettingsTab from './log_settings.jsx'; import LogsTab from './logs.jsx'; +import AuditsTab from './audits.jsx'; import FileSettingsTab from './image_settings.jsx'; import PrivacySettingsTab from './privacy_settings.jsx'; import RateSettingsTab from './rate_settings.jsx'; @@ -138,6 +139,8 @@ export default class AdminController extends React.Component { tab = ; } else if (this.state.selected === 'logs') { tab = ; + } else if (this.state.selected === 'audits') { + tab = ; } else if (this.state.selected === 'image_settings') { tab = ; } else if (this.state.selected === 'privacy_settings') { diff --git a/web/react/components/admin_console/admin_sidebar.jsx b/web/react/components/admin_console/admin_sidebar.jsx index d6bae1feb2..642bfe9d7e 100644 --- a/web/react/components/admin_console/admin_sidebar.jsx +++ b/web/react/components/admin_console/admin_sidebar.jsx @@ -214,6 +214,24 @@ export default class AdminSidebar extends React.Component { ); } + let audits; + if (global.window.mm_license.IsLicensed === 'true') { + audits = ( +
  • + + + +
  • + ); + } + return (
    @@ -448,6 +466,7 @@ export default class AdminSidebar extends React.Component { /> + {audits} diff --git a/web/react/components/admin_console/analytics.jsx b/web/react/components/admin_console/analytics.jsx index a22c26c346..0a159d2e38 100644 --- a/web/react/components/admin_console/analytics.jsx +++ b/web/react/components/admin_console/analytics.jsx @@ -4,11 +4,60 @@ import * as Utils from '../../utils/utils.jsx'; import Constants from '../../utils/constants.jsx'; import LineChart from './line_chart.jsx'; +import DoughnutChart from './doughnut_chart.jsx'; +import StatisticCount from './statistic_count.jsx'; var Tooltip = ReactBootstrap.Tooltip; var OverlayTrigger = ReactBootstrap.OverlayTrigger; -import {FormattedMessage} from 'mm-intl'; +import {injectIntl, intlShape, defineMessages, FormattedMessage} from 'mm-intl'; + +const holders = defineMessages({ + analyticsTotalUsers: { + id: 'admin.analytics.totalUsers', + defaultMessage: 'Total Users' + }, + analyticsPublicChannels: { + id: 'admin.analytics.publicChannels', + defaultMessage: 'Public Channels' + }, + analyticsPrivateGroups: { + id: 'admin.analytics.privateGroups', + defaultMessage: 'Private Groups' + }, + analyticsTotalPosts: { + id: 'admin.analytics.totalPosts', + defaultMessage: 'Total Posts' + }, + analyticsFilePosts: { + id: 'admin.analytics.totalFilePosts', + defaultMessage: 'Posts with Files' + }, + analyticsHashtagPosts: { + id: 'admin.analytics.totalHashtagPosts', + defaultMessage: 'Posts with Hashtags' + }, + analyticsIncomingHooks: { + id: 'admin.analytics.totalIncomingWebhooks', + defaultMessage: 'Incoming Webhooks' + }, + analyticsOutgoingHooks: { + id: 'admin.analytics.totalOutgoingWebhooks', + defaultMessage: 'Outgoing Webhooks' + }, + analyticsChannelTypes: { + id: 'admin.analytics.channelTypes', + defaultMessage: 'Channel Types' + }, + analyticsTextPosts: { + id: 'admin.analytics.textPosts', + defaultMessage: 'Posts with Text-only' + }, + analyticsPostTypes: { + id: 'admin.analytics.postTypes', + defaultMessage: 'Posts, Files and Hashtags' + } +}); export default class Analytics extends React.Component { constructor(props) { @@ -18,6 +67,8 @@ export default class Analytics extends React.Component { } render() { // in the future, break down these into smaller components + const {formatMessage} = this.props.intl; + var serverError = ''; if (this.props.serverError) { serverError =
    ; @@ -30,77 +81,129 @@ export default class Analytics extends React.Component { /> ); - var totalCount = ( -
    -
    -
    - -
    -
    {this.props.uniqueUserCount == null ? loading : this.props.uniqueUserCount}
    + let firstRow; + let extraGraphs; + if (this.props.showAdvanced) { + firstRow = ( +
    + + + +
    -
    - ); + ); - var openChannelCount = ( -
    -
    -
    - -
    -
    {this.props.channelOpenCount == null ? loading : this.props.channelOpenCount}
    + const channelTypeData = [ + { + value: this.props.channelOpenCount, + color: '#46BFBD', + highlight: '#5AD3D1', + label: formatMessage(holders.analyticsPublicChannels) + }, + { + value: this.props.channelPrivateCount, + color: '#FDB45C', + highlight: '#FFC870', + label: formatMessage(holders.analyticsPrivateGroups) + } + ]; + + const postTypeData = [ + { + value: this.props.filePostCount, + color: '#46BFBD', + highlight: '#5AD3D1', + label: formatMessage(holders.analyticsFilePosts) + }, + { + value: this.props.filePostCount, + color: '#F7464A', + highlight: '#FF5A5E', + label: formatMessage(holders.analyticsHashtagPosts) + }, + { + value: this.props.postCount - this.props.filePostCount - this.props.hashtagPostCount, + color: '#FDB45C', + highlight: '#FFC870', + label: formatMessage(holders.analyticsTextPosts) + } + ]; + + extraGraphs = ( +
    + +
    -
    - ); - - var openPrivateCount = ( -
    -
    -
    - -
    -
    {this.props.channelPrivateCount == null ? loading : this.props.channelPrivateCount}
    + ); + } else { + firstRow = ( +
    + + + +
    -
    - ); + ); + } - var postCount = ( -
    -
    -
    - -
    -
    {this.props.postCount == null ? loading : this.props.postCount}
    -
    -
    - ); - - var postCountsByDay = ( -
    -
    -
    - + let postCountsByDay; + if (this.props.postCountsDay == null) { + postCountsByDay = ( +
    +
    +
    + +
    +
    {loading}
    -
    {loading}
    -
    - ); - - if (this.props.postCountsDay != null) { + ); + } else { let content; if (this.props.postCountsDay.labels.length === 0) { content = ( @@ -137,21 +240,22 @@ export default class Analytics extends React.Component { ); } - var usersWithPostsByDay = ( -
    -
    -
    - + let usersWithPostsByDay; + if (this.props.userCountsWithPostsDay == null) { + usersWithPostsByDay = ( +
    +
    +
    + +
    +
    {loading}
    -
    {loading}
    -
    - ); - - if (this.props.userCountsWithPostsDay != null) { + ); + } else { let content; if (this.props.userCountsWithPostsDay.labels.length === 0) { content = ( @@ -312,12 +416,8 @@ export default class Analytics extends React.Component { /> {serverError} -
    - {totalCount} - {postCount} - {openChannelCount} - {openPrivateCount} -
    + {firstRow} + {extraGraphs}
    {postCountsByDay}
    @@ -347,10 +447,16 @@ Analytics.defaultProps = { }; Analytics.propTypes = { + intl: intlShape.isRequired, title: React.PropTypes.string, channelOpenCount: React.PropTypes.number, channelPrivateCount: React.PropTypes.number, postCount: React.PropTypes.number, + showAdvanced: React.PropTypes.bool, + filePostCount: React.PropTypes.number, + hashtagPostCount: React.PropTypes.number, + incomingWebhookCount: React.PropTypes.number, + outgoingWebhookCount: React.PropTypes.number, postCountsDay: React.PropTypes.object, userCountsWithPostsDay: React.PropTypes.object, recentActiveUsers: React.PropTypes.array, @@ -358,3 +464,5 @@ Analytics.propTypes = { uniqueUserCount: React.PropTypes.number, serverError: React.PropTypes.string }; + +export default injectIntl(Analytics); diff --git a/web/react/components/admin_console/audits.jsx b/web/react/components/admin_console/audits.jsx new file mode 100644 index 0000000000..866539b3db --- /dev/null +++ b/web/react/components/admin_console/audits.jsx @@ -0,0 +1,94 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import LoadingScreen from '../loading_screen.jsx'; +import AuditTable from '../audit_table.jsx'; + +import AdminStore from '../../stores/admin_store.jsx'; + +import * as AsyncClient from '../../utils/async_client.jsx'; + +import {FormattedMessage} from 'mm-intl'; + +export default class Audits extends React.Component { + constructor(props) { + super(props); + + this.onAuditListenerChange = this.onAuditListenerChange.bind(this); + this.reload = this.reload.bind(this); + + this.state = { + audits: AdminStore.getAudits() + }; + } + + componentDidMount() { + AdminStore.addAuditChangeListener(this.onAuditListenerChange); + AsyncClient.getServerAudits(); + } + + componentWillUnmount() { + AdminStore.removeAuditChangeListener(this.onAuditListenerChange); + } + + onAuditListenerChange() { + this.setState({ + audits: AdminStore.getAudits() + }); + } + + reload() { + AdminStore.saveAudits(null); + this.setState({ + audits: null + }); + + AsyncClient.getServerAudits(); + } + + render() { + var content = null; + + if (global.window.mm_license.IsLicensed !== 'true') { + return
    ; + } + + if (this.state.audits === null) { + content = ; + } else { + content = ( +
    + +
    + ); + } + + return ( +
    +

    + +

    + +
    + {content} +
    +
    + ); + } +} diff --git a/web/react/components/admin_console/doughnut_chart.jsx b/web/react/components/admin_console/doughnut_chart.jsx new file mode 100644 index 0000000000..e2dc01528d --- /dev/null +++ b/web/react/components/admin_console/doughnut_chart.jsx @@ -0,0 +1,77 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {FormattedMessage} from 'mm-intl'; + +export default class DoughnutChart extends React.Component { + constructor(props) { + super(props); + + this.initChart = this.initChart.bind(this); + this.chart = null; + } + + componentDidMount() { + this.initChart(this.props); + } + + componentWillReceiveProps(nextProps) { + if (this.chart) { + this.chart.destroy(); + this.initChart(nextProps); + } + } + + componentWillUnmount() { + if (this.chart) { + this.chart.destroy(); + } + } + + initChart(props) { + var el = ReactDOM.findDOMNode(this.refs.canvas); + var ctx = el.getContext('2d'); + this.chart = new Chart(ctx).Doughnut(props.data, props.options || {}); //eslint-disable-line new-cap + } + + render() { + let content; + if (this.props.data == null) { + content = ( + + ); + } else { + content = ( + + ); + } + + return ( +
    +
    +
    + {this.props.title} +
    +
    + {content} +
    +
    +
    + ); + } +} + +DoughnutChart.propTypes = { + title: React.PropTypes.string, + width: React.PropTypes.string, + height: React.PropTypes.string, + data: React.PropTypes.array, + options: React.PropTypes.object +}; diff --git a/web/react/components/admin_console/email_settings.jsx b/web/react/components/admin_console/email_settings.jsx index ce3c8cd122..17f25a04c1 100644 --- a/web/react/components/admin_console/email_settings.jsx +++ b/web/react/components/admin_console/email_settings.jsx @@ -112,6 +112,8 @@ class EmailSettings extends React.Component { buildConfig() { var config = this.props.config; config.EmailSettings.EnableSignUpWithEmail = ReactDOM.findDOMNode(this.refs.allowSignUpWithEmail).checked; + config.EmailSettings.EnableSignInWithEmail = ReactDOM.findDOMNode(this.refs.allowSignInWithEmail).checked; + config.EmailSettings.EnableSignInWithUsername = ReactDOM.findDOMNode(this.refs.allowSignInWithUsername).checked; config.EmailSettings.SendEmailNotifications = ReactDOM.findDOMNode(this.refs.sendEmailNotifications).checked; config.EmailSettings.SendPushNotifications = ReactDOM.findDOMNode(this.refs.sendPushNotifications).checked; config.EmailSettings.RequireEmailVerification = ReactDOM.findDOMNode(this.refs.requireEmailVerification).checked; @@ -317,6 +319,88 @@ class EmailSettings extends React.Component {
    +
    + +
    + + +

    + +

    +
    +
    + +
    + +
    + + +

    + +

    +
    +
    +

    @@ -138,7 +168,7 @@ export default class ChannelNotificationsModal extends React.Component { checked={notifyActive[1]} onChange={this.handleUpdateNotifyLevel.bind(this, 'all')} /> - {'For all activity'} +
    @@ -149,7 +179,7 @@ export default class ChannelNotificationsModal extends React.Component { checked={notifyActive[2]} onChange={this.handleUpdateNotifyLevel.bind(this, 'mention')} /> - {'Only for mentions'} +
    @@ -160,7 +190,7 @@ export default class ChannelNotificationsModal extends React.Component { checked={notifyActive[3]} onChange={this.handleUpdateNotifyLevel.bind(this, 'none')} /> - {'Never'} +
    @@ -174,13 +204,16 @@ export default class ChannelNotificationsModal extends React.Component { const extraInfo = ( - {'Selecting an option other than "Default" will override the global notification settings. Desktop notifications are available on Firefox, Safari, and Chrome.'} + ); return ( + ); } else if (this.state.notifyLevel === 'mention') { - describe = 'Only for mentions'; + describe = (); } else if (this.state.notifyLevel === 'all') { - describe = 'For all activity'; + describe = (); } else { - describe = 'Never'; + describe = (); } handleUpdateSection = function updateSection(e) { @@ -208,7 +248,7 @@ export default class ChannelNotificationsModal extends React.Component { return ( @@ -250,6 +290,12 @@ export default class ChannelNotificationsModal extends React.Component { createMarkUnreadLevelSection(serverError) { let content; + const markUnread = ( + + ); if (this.state.activeSection === 'markUnreadLevel') { const inputs = [(
    @@ -260,7 +306,10 @@ export default class ChannelNotificationsModal extends React.Component { checked={this.state.markUnreadLevel === 'all'} onChange={this.handleUpdateMarkUnreadLevel.bind(this, 'all')} /> - {'For all unread messages'} +
    @@ -271,7 +320,7 @@ export default class ChannelNotificationsModal extends React.Component { checked={this.state.markUnreadLevel === 'mention'} onChange={this.handleUpdateMarkUnreadLevel.bind(this, 'mention')} /> - {'Only for mentions'} +
    @@ -284,11 +333,18 @@ export default class ChannelNotificationsModal extends React.Component { e.preventDefault(); }.bind(this); - const extraInfo = {'The channel name is bolded in the sidebar when there are unread messages. Selecting "Only for mentions" will bold the channel only when you are mentioned.'}; + const extraInfo = ( + + + + ); content = ( + ); } else { - describe = 'Only for mentions'; + describe = (); } const handleUpdateSection = function handleUpdateSection(e) { @@ -312,7 +373,7 @@ export default class ChannelNotificationsModal extends React.Component { content = ( @@ -335,7 +396,13 @@ export default class ChannelNotificationsModal extends React.Component { onHide={this.props.onHide} > - {'Notification Preferences for '}{this.props.channel.display_name} + + + {this.props.channel.display_name} +
    diff --git a/web/react/components/delete_channel_modal.jsx b/web/react/components/delete_channel_modal.jsx index 1255067fdc..d9113bc9fb 100644 --- a/web/react/components/delete_channel_modal.jsx +++ b/web/react/components/delete_channel_modal.jsx @@ -5,7 +5,9 @@ import * as AsyncClient from '../utils/async_client.jsx'; import * as Client from '../utils/client.jsx'; const Modal = ReactBootstrap.Modal; import TeamStore from '../stores/team_store.jsx'; -import * as Utils from '../utils/utils.jsx'; +import Constants from '../utils/constants.jsx'; + +import {FormattedMessage} from 'mm-intl'; export default class DeleteChannelModal extends React.Component { constructor(props) { @@ -32,7 +34,20 @@ export default class DeleteChannelModal extends React.Component { } render() { - const channelTerm = Utils.getChannelTerm(this.props.channel.type).toLowerCase(); + let channelTerm = ( + + ); + if (this.props.channel.type === Constants.PRIVATE_CHANNEL) { + channelTerm = ( + + ); + } return ( -

    {'Confirm DELETE Channel'}

    +

    + +

    - {`Are you sure you wish to delete the ${this.props.channel.display_name} ${channelTerm}?`} +
    diff --git a/web/react/components/delete_post_modal.jsx b/web/react/components/delete_post_modal.jsx index 4cde5feed2..34fd724f57 100644 --- a/web/react/components/delete_post_modal.jsx +++ b/web/react/components/delete_post_modal.jsx @@ -9,6 +9,9 @@ import * as Utils from '../utils/utils.jsx'; import * as AsyncClient from '../utils/async_client.jsx'; import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; import Constants from '../utils/constants.jsx'; + +import {FormattedMessage} from 'mm-intl'; + var ActionTypes = Constants.ActionTypes; export default class DeletePostModal extends React.Component { @@ -128,10 +131,28 @@ export default class DeletePostModal extends React.Component { var commentWarning = ''; if (this.state.commentCount > 0) { - commentWarning = 'This post has ' + this.state.commentCount + ' comment(s) on it.'; + commentWarning = ( + + ); } - const postTerm = Utils.getPostTerm(this.state.post); + const postTerm = this.state.post.root_id ? ( + + ) : ( + + ); return ( - {`Confirm ${postTerm} Delete`} + + + - {`Are you sure you want to delete this ${postTerm.toLowerCase()}?`} +

    {commentWarning} @@ -154,7 +189,10 @@ export default class DeletePostModal extends React.Component { className='btn btn-default' onClick={this.handleHide} > - {'Cancel'} +
    diff --git a/web/react/components/edit_channel_header_modal.jsx b/web/react/components/edit_channel_header_modal.jsx index e4817f6e42..1066d123ea 100644 --- a/web/react/components/edit_channel_header_modal.jsx +++ b/web/react/components/edit_channel_header_modal.jsx @@ -6,9 +6,18 @@ import * as Client from '../utils/client.jsx'; import Constants from '../utils/constants.jsx'; import * as Utils from '../utils/utils.jsx'; +import {intlShape, injectIntl, defineMessages, FormattedMessage} from 'mm-intl'; + const Modal = ReactBootstrap.Modal; -export default class EditChannelHeaderModal extends React.Component { +const holders = defineMessages({ + error: { + id: 'edit_channel_header_modal.error', + defaultMessage: 'This channel header is too long, please enter a shorter one' + } +}); + +class EditChannelHeaderModal extends React.Component { constructor(props) { super(props); @@ -64,8 +73,8 @@ export default class EditChannelHeaderModal extends React.Component { }); }, (err) => { - if (err.message === 'Invalid channel_header parameter') { - this.setState({serverError: 'This channel header is too long, please enter a shorter one'}); + if (err.id === 'api.context.invalid_param.app_error') { + this.setState({serverError: this.props.intl.formatMessage(holders.error)}); } else { this.setState({serverError: err.message}); } @@ -99,10 +108,23 @@ export default class EditChannelHeaderModal extends React.Component { onHide={this.onHide} > - {'Edit Header for ' + this.props.channel.display_name} + + + -

    {'Edit the text appearing next to the channel name in the channel header.'}

    +

    + +