From ad64f315f338db0d1dda89bd58eca5b5799c8c1a Mon Sep 17 00:00:00 2001 From: Alan Mooiman Date: Fri, 29 Jan 2016 15:02:05 -0800 Subject: Adjustments to makefile Compass will compile on first run (instead of having to make a change to a sass file) Stop command won't kill virtualbox processes that are also named "mattermost". If we're going to wildcard kill processes, let's not go overboard. --- Makefile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 9c4e6ee1f..eccdf39ba 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 -- cgit v1.2.3-1-g7c22 From 482745323264a125ba7839175b80c1174b03a832 Mon Sep 17 00:00:00 2001 From: "Khoa, Le Ngoc" Date: Tue, 2 Feb 2016 16:04:25 +0700 Subject: Added help text indicating valid email required when user sign up from link. --- web/react/components/signup_user_complete.jsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/web/react/components/signup_user_complete.jsx b/web/react/components/signup_user_complete.jsx index 47ec58e98..bea10f0a4 100644 --- a/web/react/components/signup_user_complete.jsx +++ b/web/react/components/signup_user_complete.jsx @@ -150,9 +150,18 @@ class SignupUserComplete extends React.Component { // set up error labels var emailError = null; + var emailHelpText = ( + + + + ); var emailDivStyle = 'form-group'; if (this.state.emailError) { emailError = ; + emailHelpText = ''; emailDivStyle += ' has-error'; } @@ -232,6 +241,7 @@ class SignupUserComplete extends React.Component { spellCheck='false' /> {emailError} + {emailHelpText} ); -- cgit v1.2.3-1-g7c22 From 4d70e1246bd12ee167525d9f92d673b617d4039b Mon Sep 17 00:00:00 2001 From: "Khoa, Le Ngoc" Date: Tue, 2 Feb 2016 16:29:14 +0700 Subject: Fixed Eslint validation. --- web/react/components/signup_user_complete.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/react/components/signup_user_complete.jsx b/web/react/components/signup_user_complete.jsx index bea10f0a4..98a832542 100644 --- a/web/react/components/signup_user_complete.jsx +++ b/web/react/components/signup_user_complete.jsx @@ -154,8 +154,8 @@ class SignupUserComplete extends React.Component { + defaultMessage='Valid email required for sign-up' + /> ); var emailDivStyle = 'form-group'; -- cgit v1.2.3-1-g7c22 From 320fe1c39240644ce15fa2a436ac4a5591b95083 Mon Sep 17 00:00:00 2001 From: Christopher Speller Date: Tue, 2 Feb 2016 08:31:37 -0500 Subject: EE Fixes --- i18n/en.json | 32 ++++++++++++++++++++++++++++++++ mattermost.go | 2 ++ 2 files changed, 34 insertions(+) diff --git a/i18n/en.json b/i18n/en.json index c6d705819..7b7ee344a 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -3406,5 +3406,37 @@ { "id": "web.watcher_fail.error", "translation": "Failed to add directory to watcher %v" + }, + { + "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.unable_to_connect.app_error", + "translation": "Unable to connect to LDAP server" + }, + { + "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.search_ldap_server.app_error", + "translation": "Failed to search LDAP server" + }, + { + "id": "ent.ldap.do_login.user_not_registered.app_error", + "translation": "User not registered on LDAP server" + }, + { + "id": "ent.ldap.do_login.matched_to_many_users.app_error", + "translation": "Username given matches multiple users" + }, + { + "id": "ent.ldap.do_login.invalid_password.app_error", + "translation": "Invalid Password" + }, + { + "id": "ent.ldap.do_login.unable_to_create_user.app_error", + "translation": "Credentials valid but unable to create user." } ] diff --git a/mattermost.go b/mattermost.go index b6652d812..43fa06601 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 -- cgit v1.2.3-1-g7c22 From 3d03bdf2f1af5385c2150544977fbba89650b1ee Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Tue, 2 Feb 2016 08:41:02 -0500 Subject: Added extra system-wide statistics for EE --- api/admin.go | 44 +++- api/admin_test.go | 110 +++++++++ i18n/en.json | 8 + i18n/es.json | 8 + store/sql_post_store.go | 13 +- store/sql_post_store_test.go | 2 +- store/sql_webhook_store.go | 62 +++++ store/sql_webhook_store_test.go | 39 +++ store/store.go | 4 +- web/react/components/admin_console/analytics.jsx | 274 ++++++++++++++------- .../components/admin_console/doughnut_chart.jsx | 77 ++++++ .../components/admin_console/statistic_count.jsx | 37 +++ .../components/admin_console/system_analytics.jsx | 36 ++- .../components/admin_console/team_analytics.jsx | 3 +- web/sass-files/sass/partials/_statistics.scss | 5 +- web/static/i18n/en.json | 9 +- web/static/i18n/es.json | 9 +- 17 files changed, 645 insertions(+), 95 deletions(-) create mode 100644 web/react/components/admin_console/doughnut_chart.jsx create mode 100644 web/react/components/admin_console/statistic_count.jsx diff --git a/api/admin.go b/api/admin.go index 0ea6341e2..0ca05287e 100644 --- a/api/admin.go +++ b/api/admin.go @@ -161,9 +161,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 +210,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 2552e642c..053a2a730 100644 --- a/api/admin_test.go +++ b/api/admin_test.go @@ -362,3 +362,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/i18n/en.json b/i18n/en.json index c6d705819..b1848e163 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -3047,6 +3047,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}}" diff --git a/i18n/es.json b/i18n/es.json index f0f157474..0928aa0eb 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -3047,6 +3047,14 @@ "id": "store.sql_webhooks.update_outgoing.app_error", "translation": "No pudimos actualizar el 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 decifrando la configuración del archivo={{.Filename}}, err={{.Error}}" diff --git a/store/sql_post_store.go b/store/sql_post_store.go index aeaa5922c..c511dc370 100644 --- a/store/sql_post_store.go +++ b/store/sql_post_store.go @@ -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 46b8d7678..d69f7906c 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 cdfb949f5..5298b0b94 100644 --- a/store/sql_webhook_store.go +++ b/store/sql_webhook_store.go @@ -350,3 +350,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 1a9d5be3b..089e38244 100644 --- a/store/sql_webhook_store_test.go +++ b/store/sql_webhook_store_test.go @@ -332,3 +332,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 3988f0c6a..cfc679706 100644 --- a/store/store.go +++ b/store/store.go @@ -100,7 +100,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 PreferenceStore interface { diff --git a/web/react/components/admin_console/analytics.jsx b/web/react/components/admin_console/analytics.jsx index a22c26c34..0a159d2e3 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) + } + ]; - var openPrivateCount = ( -
-
-
- -
-
{this.props.channelPrivateCount == null ? loading : this.props.channelPrivateCount}
-
-
- ); + 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) + } + ]; - var postCount = ( -
-
-
- -
-
{this.props.postCount == null ? loading : this.props.postCount}
+ extraGraphs = ( +
+ +
-
- ); + ); + } else { + firstRow = ( +
+ + + + +
+ ); + } - 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/doughnut_chart.jsx b/web/react/components/admin_console/doughnut_chart.jsx new file mode 100644 index 000000000..e2dc01528 --- /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/statistic_count.jsx b/web/react/components/admin_console/statistic_count.jsx new file mode 100644 index 000000000..57af0ed1b --- /dev/null +++ b/web/react/components/admin_console/statistic_count.jsx @@ -0,0 +1,37 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {FormattedMessage} from 'mm-intl'; + +export default class StatisticCount extends React.Component { + constructor(props) { + super(props); + } + + render() { + let loading = ( + + ); + + return ( +
+
+
+ {this.props.title} + +
+
{this.props.count == null ? loading : this.props.count}
+
+
+ ); + } +} + +StatisticCount.propTypes = { + title: React.PropTypes.string.isRequired, + icon: React.PropTypes.string.isRequired, + count: React.PropTypes.number +}; diff --git a/web/react/components/admin_console/system_analytics.jsx b/web/react/components/admin_console/system_analytics.jsx index 2dd833fb2..f983db177 100644 --- a/web/react/components/admin_console/system_analytics.jsx +++ b/web/react/components/admin_console/system_analytics.jsx @@ -140,6 +140,34 @@ class SystemAnalytics extends React.Component { this.setState({serverError: err.message}); } ); + + if (global.window.mm_license.IsLicensed === 'true') { + Client.getSystemAnalytics( + 'extra_counts', + (data) => { + for (var index in data) { + if (data[index].name === 'file_post_count') { + this.setState({file_post_count: data[index].value}); + } + + if (data[index].name === 'hashtag_post_count') { + this.setState({hashtag_post_count: data[index].value}); + } + + if (data[index].name === 'incoming_webhook_count') { + this.setState({incoming_webhook_count: data[index].value}); + } + + if (data[index].name === 'outgoing_webhook_count') { + this.setState({outgoing_webhook_count: data[index].value}); + } + } + }, + (err) => { + this.setState({serverError: err.message}); + } + ); + } } componentWillReceiveProps() { @@ -160,10 +188,16 @@ class SystemAnalytics extends React.Component { return (
Date: Tue, 2 Feb 2016 08:49:50 -0500 Subject: Remove missing ES translations --- i18n/es.json | 8 -------- web/static/i18n/es.json | 7 ------- 2 files changed, 15 deletions(-) diff --git a/i18n/es.json b/i18n/es.json index 0928aa0eb..f0f157474 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -3047,14 +3047,6 @@ "id": "store.sql_webhooks.update_outgoing.app_error", "translation": "No pudimos actualizar el 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 decifrando la configuración del archivo={{.Filename}}, err={{.Error}}" diff --git a/web/static/i18n/es.json b/web/static/i18n/es.json index dc61b32d2..01d703416 100644 --- a/web/static/i18n/es.json +++ b/web/static/i18n/es.json @@ -78,13 +78,6 @@ "admin.analytics.title": "Estadísticas para {title}", "admin.analytics.totalPosts": "Total de Mensajes", "admin.analytics.totalUsers": "Total de Usuarios", - "admin.analytics.totalFilePosts": "Posts with Files", - "admin.analytics.totalHashtagPosts": "Posts with Hashtags", - "admin.analytics.totalIncomingWebhooks": "Incoming Webhooks", - "admin.analytics.totalOutgoingWebhooks": "Outgoing Webhooks", - "admin.analytics.channelTypes": "Channel Types", - "admin.analytics.textPosts": "Posts with Text-only", - "admin.analytics.postTypes": "Posts, Files and Hashtags", "admin.email.allowSignupDescription": "Cuando está en verdadero, Mattermost permite la creación de equipos y cuentas utilizando el correo electrónico y contraseña. Este valor debe estar en falso sólo cuando quieres limitar el inicio de sesión a través de servicios tipo OAuth o LDAP.", "admin.email.allowSignupTitle": "Permitir inicio de sesión con correo:", "admin.email.connectionSecurityNone": "Ninguno", -- cgit v1.2.3-1-g7c22 From 7587689735f37caaca63906aaf22fd6f345a53c9 Mon Sep 17 00:00:00 2001 From: hmhealey Date: Tue, 2 Feb 2016 12:51:40 -0500 Subject: Updated post help link --- web/react/components/textbox.jsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/web/react/components/textbox.jsx b/web/react/components/textbox.jsx index bb383aca1..00e5ace98 100644 --- a/web/react/components/textbox.jsx +++ b/web/react/components/textbox.jsx @@ -129,13 +129,6 @@ export default class Textbox extends React.Component { this.resize(); } - showHelp(e) { - e.preventDefault(); - e.target.blur(); - - global.window.open('/docs/Messaging'); - } - render() { let previewLink = null; if (Utils.isFeatureEnabled(PreReleaseFeatures.MARKDOWN_PREVIEW)) { @@ -194,7 +187,8 @@ export default class Textbox extends React.Component {
{previewLink} Date: Mon, 25 Jan 2016 14:13:15 -0800 Subject: Removed css clock from sub-posts --- web/sass-files/sass/partials/_post.scss | 4 ---- 1 file changed, 4 deletions(-) diff --git a/web/sass-files/sass/partials/_post.scss b/web/sass-files/sass/partials/_post.scss index be85ef07b..e62d37f38 100644 --- a/web/sass-files/sass/partials/_post.scss +++ b/web/sass-files/sass/partials/_post.scss @@ -457,10 +457,6 @@ body.ios { .post__time { - &:before { - @include opacity(0.5); - } - } } -- cgit v1.2.3-1-g7c22 From 92816619cc584c7c172c4e4fdde17624cf7f913f Mon Sep 17 00:00:00 2001 From: Reed Garmsen Date: Mon, 1 Feb 2016 14:30:16 -0800 Subject: Changed clock icon in repeat posts to simple timestamp --- web/react/components/post.jsx | 1 + web/react/components/post_header.jsx | 7 +++++-- web/react/components/post_info.jsx | 7 +++++-- web/react/components/time_since.jsx | 17 ++++++++++++++--- web/react/utils/constants.jsx | 3 ++- web/sass-files/sass/partials/_post.scss | 20 ++++---------------- 6 files changed, 31 insertions(+), 24 deletions(-) diff --git a/web/react/components/post.jsx b/web/react/components/post.jsx index 695d7daef..53fe7fb5d 100644 --- a/web/react/components/post.jsx +++ b/web/react/components/post.jsx @@ -214,6 +214,7 @@ export default class Post extends React.Component { commentCount={commentCount} handleCommentClick={this.handleCommentClick} isLastComment={this.props.isLastComment} + sameUser={this.props.sameUser} /> @@ -62,11 +63,13 @@ export default class PostHeader extends React.Component { PostHeader.defaultProps = { post: null, commentCount: 0, - isLastComment: false + isLastComment: false, + sameUser: false }; PostHeader.propTypes = { post: React.PropTypes.object, commentCount: React.PropTypes.number, isLastComment: React.PropTypes.bool, - handleCommentClick: React.PropTypes.func + handleCommentClick: React.PropTypes.func, + sameUser: React.PropTypes.bool }; diff --git a/web/react/components/post_info.jsx b/web/react/components/post_info.jsx index 2bff675a9..0fb9d7f4a 100644 --- a/web/react/components/post_info.jsx +++ b/web/react/components/post_info.jsx @@ -220,6 +220,7 @@ export default class PostInfo extends React.Component {
  • @@ -251,12 +252,14 @@ PostInfo.defaultProps = { post: null, commentCount: 0, isLastComment: false, - allowReply: false + allowReply: false, + sameUser: false }; PostInfo.propTypes = { post: React.PropTypes.object, commentCount: React.PropTypes.number, isLastComment: React.PropTypes.bool, allowReply: React.PropTypes.string, - handleCommentClick: React.PropTypes.func + handleCommentClick: React.PropTypes.func, + sameUser: React.PropTypes.bool }; diff --git a/web/react/components/time_since.jsx b/web/react/components/time_since.jsx index 32947bd60..0b549b1e6 100644 --- a/web/react/components/time_since.jsx +++ b/web/react/components/time_since.jsx @@ -14,7 +14,7 @@ export default class TimeSince extends React.Component { componentDidMount() { this.intervalId = setInterval(() => { this.forceUpdate(); - }, 30000); + }, Constants.TIME_SINCE_UPDATE_INTERVAL); } componentWillUnmount() { clearInterval(this.intervalId); @@ -23,6 +23,14 @@ export default class TimeSince extends React.Component { const displayDate = Utils.displayDate(this.props.eventTime); const displayTime = Utils.displayTime(this.props.eventTime); + if (this.props.sameUser) { + return ( + + ); + } + const tooltip = ( {displayDate + ' at ' + displayTime} @@ -42,10 +50,13 @@ export default class TimeSince extends React.Component { ); } } + TimeSince.defaultProps = { - eventTime: 0 + eventTime: 0, + sameUser: false }; TimeSince.propTypes = { - eventTime: React.PropTypes.number.isRequired + eventTime: React.PropTypes.number.isRequired, + sameUser: React.PropTypes.bool }; diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx index e1a4b8a8a..ad0e4b2fe 100644 --- a/web/react/utils/constants.jsx +++ b/web/react/utils/constants.jsx @@ -462,5 +462,6 @@ export default { MIN_USERNAME_LENGTH: 3, MAX_USERNAME_LENGTH: 15, MIN_PASSWORD_LENGTH: 5, - MAX_PASSWORD_LENGTH: 50 + MAX_PASSWORD_LENGTH: 50, + TIME_SINCE_UPDATE_INTERVAL: 30000 }; diff --git a/web/sass-files/sass/partials/_post.scss b/web/sass-files/sass/partials/_post.scss index e62d37f38..73c7bd9cb 100644 --- a/web/sass-files/sass/partials/_post.scss +++ b/web/sass-files/sass/partials/_post.scss @@ -456,7 +456,7 @@ body.ios { &:hover { .post__time { - + @include opacity(0.5); } } @@ -480,27 +480,15 @@ body.ios { } .post__time { - display: inline-block; - font: normal normal normal 14px/1 FontAwesome; - font-size: inherit; + font: normal normal normal FontAwesome; text-rendering: auto; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - font-size: 0; position: absolute; top: -3px; - left: 17px; - width: 30px; - height: 30px; + left: -1.0em; line-height: 37px; - - &:before { - @include opacity(0); - content: "\f017"; - content: "\f017"; - font-size: 19px; - } - + @include opacity(0); } } -- cgit v1.2.3-1-g7c22 From b013f02209c7c128a35d1c54f2d4a7d6a9701f72 Mon Sep 17 00:00:00 2001 From: Reed Garmsen Date: Wed, 13 Jan 2016 14:58:49 -0800 Subject: Added ability to sign in via username; separated email sign in and sign up config settings --- api/user.go | 34 ++++ api/user_test.go | 20 ++- config/config.json | 2 + docker/dev/config_docker.json | 2 + docker/local/config_docker.json | 2 + model/client.go | 8 + model/config.go | 17 ++ utils/config.go | 2 + .../components/admin_console/email_settings.jsx | 84 ++++++++++ web/react/components/login.jsx | 15 +- web/react/components/login_username.jsx | 181 +++++++++++++++++++++ web/react/stores/user_store.jsx | 10 ++ web/react/utils/client.jsx | 22 +++ web/static/i18n/en.json | 12 ++ 14 files changed, 407 insertions(+), 4 deletions(-) create mode 100644 web/react/components/login_username.jsx diff --git a/api/user.go b/api/user.go index 6ad0f67ac..91c8c022a 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 b2ae113f1..1a1cf9634 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 076f795cc..560073ad2 100644 --- a/config/config.json +++ b/config/config.json @@ -66,6 +66,8 @@ }, "EmailSettings": { "EnableSignUpWithEmail": true, + "EnableSignInWithEmail": true, + "EnableSignInWithUsername": false, "SendEmailNotifications": false, "RequireEmailVerification": false, "FeedbackName": "", diff --git a/docker/dev/config_docker.json b/docker/dev/config_docker.json index 1aa2ee843..80b99b66d 100644 --- a/docker/dev/config_docker.json +++ b/docker/dev/config_docker.json @@ -66,6 +66,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 1aa2ee843..80b99b66d 100644 --- a/docker/local/config_docker.json +++ b/docker/local/config_docker.json @@ -66,6 +66,8 @@ }, "EmailSettings": { "EnableSignUpWithEmail": true, + "EnableSignInWithEmail": true, + "EnableSignInWithUsername": false, "SendEmailNotifications": false, "RequireEmailVerification": false, "FeedbackName": "", diff --git a/model/client.go b/model/client.go index d31ac1592..3b72f65e4 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 diff --git a/model/config.go b/model/config.go index 5c8604ff1..a6d1c21dc 100644 --- a/model/config.go +++ b/model/config.go @@ -97,6 +97,8 @@ type FileSettings struct { type EmailSettings struct { EnableSignUpWithEmail bool + EnableSignInWithEmail *bool + EnableSignInWithUsername *bool SendEmailNotifications bool RequireEmailVerification bool FeedbackName string @@ -258,6 +260,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/utils/config.go b/utils/config.go index 9d2c2f588..e9b7e1878 100644 --- a/utils/config.go +++ b/utils/config.go @@ -208,6 +208,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/admin_console/email_settings.jsx b/web/react/components/admin_console/email_settings.jsx index ce3c8cd12..17f25a04c 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 {
  • +
    + +
    + + +

    + +

    +
    +
    + +
    + +
    + + +

    + +

    +
    +
    +
    - ); + ); } if (global.window.mm_config.EnableSignUpWithGoogle === 'true') { @@ -87,7 +88,7 @@ export default class Login extends React.Component { } let emailSignup; - if (global.window.mm_config.EnableSignUpWithEmail === 'true') { + if (global.window.mm_config.EnableSignInWithEmail === 'true') { emailSignup = ( + ); + } + return (
    @@ -210,6 +220,7 @@ export default class Login extends React.Component { {extraBox} {loginMessage} {emailSignup} + {usernameLogin} {ldapLogin} {userSignUp} {findTeams} diff --git a/web/react/components/login_username.jsx b/web/react/components/login_username.jsx new file mode 100644 index 000000000..f787490fa --- /dev/null +++ b/web/react/components/login_username.jsx @@ -0,0 +1,181 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import * as Utils from '../utils/utils.jsx'; +import * as Client from '../utils/client.jsx'; +import UserStore from '../stores/user_store.jsx'; + +import {injectIntl, intlShape, defineMessages, FormattedMessage} from 'mm-intl'; + +var holders = defineMessages({ + badTeam: { + id: 'login_username.badTeam', + defaultMessage: 'Bad team name' + }, + usernameReq: { + id: 'login_username.usernameReq', + defaultMessage: 'A username is required' + }, + pwdReq: { + id: 'login_username.pwdReq', + defaultMessage: 'A password is required' + }, + verifyEmailError: { + id: 'login_username.verifyEmailError', + defaultMessage: 'Please verify your email address. Check your inbox for an email.' + }, + userNotFoundError: { + id: 'login_username.userNotFoundError', + defaultMessage: "We couldn't find an existing account matching your username for this team." + }, + username: { + id: 'login_username.username', + defaultMessage: 'Username' + }, + pwd: { + id: 'login_username.pwd', + defaultMessage: 'Password' + } +}); + +export default class LoginUsername extends React.Component { + constructor(props) { + super(props); + + this.handleSubmit = this.handleSubmit.bind(this); + + this.state = { + serverError: '' + }; + } + handleSubmit(e) { + e.preventDefault(); + const {formatMessage} = this.props.intl; + var state = {}; + + const name = this.props.teamName; + if (!name) { + state.serverError = formatMessage(holders.badTeam); + this.setState(state); + return; + } + + const username = this.refs.username.value.trim(); + if (!username) { + state.serverError = formatMessage(holders.usernameReq); + this.setState(state); + return; + } + + const password = this.refs.password.value.trim(); + if (!password) { + state.serverError = formatMessage(holders.pwdReq); + this.setState(state); + return; + } + + state.serverError = ''; + this.setState(state); + + Client.loginByUsername(name, username, password, + () => { + UserStore.setLastUsername(username); + + const redirect = Utils.getUrlParameter('redirect'); + if (redirect) { + window.location.href = decodeURIComponent(redirect); + } else { + window.location.href = '/' + name + '/channels/town-square'; + } + }, + (err) => { + if (err.message === 'api.user.login.not_verified.app_error') { + state.serverError = formatMessage(holders.verifyEmailError); + } else if (err.message === 'store.sql_user.get_by_username.app_error') { + state.serverError = formatMessage(holders.userNotFoundError); + } else { + state.serverError = err.message; + } + + this.valid = false; + this.setState(state); + } + ); + } + render() { + let serverError; + let errorClass = ''; + if (this.state.serverError) { + serverError = ; + errorClass = ' has-error'; + } + + let priorUsername = UserStore.getLastUsername(); + let focusUsername = false; + let focusPassword = false; + if (priorUsername === '') { + focusUsername = true; + } else { + focusPassword = true; + } + + const emailParam = Utils.getUrlParameter('email'); + if (emailParam) { + priorUsername = decodeURIComponent(emailParam); + } + + const {formatMessage} = this.props.intl; + return ( +
    +
    +
    + {serverError} +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    + ); + } +} +LoginUsername.defaultProps = { +}; + +LoginUsername.propTypes = { + intl: intlShape.isRequired, + teamName: React.PropTypes.string.isRequired +}; + +export default injectIntl(LoginUsername); diff --git a/web/react/stores/user_store.jsx b/web/react/stores/user_store.jsx index 3e1871180..b97a0d87b 100644 --- a/web/react/stores/user_store.jsx +++ b/web/react/stores/user_store.jsx @@ -38,6 +38,8 @@ class UserStoreClass extends EventEmitter { this.setCurrentUser = this.setCurrentUser.bind(this); this.getLastEmail = this.getLastEmail.bind(this); this.setLastEmail = this.setLastEmail.bind(this); + this.getLastUsername = this.getLastUsername.bind(this); + this.setLastUsername = this.setLastUsername.bind(this); this.hasProfile = this.hasProfile.bind(this); this.getProfile = this.getProfile.bind(this); this.getProfileByUsername = this.getProfileByUsername.bind(this); @@ -159,6 +161,14 @@ class UserStoreClass extends EventEmitter { BrowserStore.setGlobalItem('last_email', email); } + getLastUsername() { + return BrowserStore.getGlobalItem('last_username', ''); + } + + setLastUsername(username) { + BrowserStore.setGlobalItem('last_username', username); + } + hasProfile(userId) { return this.getProfiles()[userId] != null; } diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx index 09cd4162a..c4b1bc061 100644 --- a/web/react/utils/client.jsx +++ b/web/react/utils/client.jsx @@ -305,6 +305,28 @@ export function loginByEmail(name, email, password, success, error) { }); } +export function loginByUsername(name, username, password, success, error) { + $.ajax({ + url: '/api/v1/users/login', + dataType: 'json', + contentType: 'application/json', + type: 'POST', + data: JSON.stringify({name, username, password}), + success: function onSuccess(data, textStatus, xhr) { + track('api', 'api_users_login_success', data.team_id, 'username', data.username); + sessionStorage.removeItem(data.id + '_last_error'); + BrowserStore.signalLogin(); + success(data, textStatus, xhr); + }, + error: function onError(xhr, status, err) { + track('api', 'api_users_login_fail', name, 'username', username); + + var e = handleError('loginByUsername', xhr, status, err); + error(e); + } + }); +} + export function loginByLdap(teamName, id, password, success, error) { $.ajax({ url: '/api/v1/users/login_ldap', diff --git a/web/static/i18n/en.json b/web/static/i18n/en.json index d6401ab6e..7c32d856a 100644 --- a/web/static/i18n/en.json +++ b/web/static/i18n/en.json @@ -127,6 +127,10 @@ "admin.email.true": "true", "admin.email.false": "false", "admin.email.allowSignupDescription": "When true, Mattermost allows team creation and account signup using email and password. This value should be false only when you want to limit signup to a single-sign-on service like OAuth or LDAP.", + "admin.email.allowEmailSignInTitle": "Allow Sign In With Email: ", + "admin.email.allowEmailSignInDescription": "When true, Mattermost allows users to sign in using their email and password.", + "admin.email.allowUsernameSignInTitle": "Allow Sign In With Username: ", + "admin.email.allowUsernameSignInDescription": "When true, Mattermost allows users to sign in using their username and password. This setting is typically only used when email verification is disabled.", "admin.email.notificationsTitle": "Send Email Notifications: ", "admin.email.notificationsDescription": "Typically set to true in production. When true, Mattermost attempts to send email notifications. Developers may set this field to false to skip email setup for faster development.
    Setting this to true removes the Preview Mode banner (requires logging out and logging back in after setting is changed).", "admin.email.requireVerificationTitle": "Require Email Verification: ", @@ -550,6 +554,14 @@ "login_email.email": "Email", "login_email.pwd": "Password", "login_email.signin": "Sign in", + "login_username.badTeam": "Bad team name", + "login_username.usernameReq": "A username is required", + "login_username.pwdReq": "A password is required", + "login_username.verifyEmailError": "Please verify your email address. Check your inbox for an email.", + "login_username.userNotFoundError": "We couldn't find an existing account matching your username for this team.", + "login_username.username": "Username", + "login_username.pwd": "Password", + "login_username.signin": "Sign in", "login_ldap.badTeam": "Bad team name", "login_ldap.idlReq": "An LDAP ID is required", "login_ldap.pwdReq": "An LDAP password is required", -- cgit v1.2.3-1-g7c22 From 70e0618f629122891e9bcfe358def061042bef49 Mon Sep 17 00:00:00 2001 From: Reed Garmsen Date: Mon, 1 Feb 2016 09:28:23 -0800 Subject: Fixed React invalid prop warning in Team Settings --- web/react/components/team_general_tab.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/react/components/team_general_tab.jsx b/web/react/components/team_general_tab.jsx index 0656d3b03..0a1b02853 100644 --- a/web/react/components/team_general_tab.jsx +++ b/web/react/components/team_general_tab.jsx @@ -575,6 +575,8 @@ class GeneralTab extends React.Component {
    ); + const nameExtraInfo = {formatMessage(holders.teamNameInfo)}; + nameSection = ( ); } else { -- cgit v1.2.3-1-g7c22 From eeb1c1b5b827cc20ae32449bfc8c227152ba1a9d Mon Sep 17 00:00:00 2001 From: Reed Garmsen Date: Fri, 29 Jan 2016 13:29:43 -0800 Subject: Properly disable the get team link functionality when user creation is disabled --- web/react/components/get_link_modal.jsx | 23 +++++++++++++++------- .../components/get_team_invite_link_modal.jsx | 12 ++++++++++- web/static/i18n/en.json | 1 + 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/web/react/components/get_link_modal.jsx b/web/react/components/get_link_modal.jsx index 3fc71ff96..de3387a35 100644 --- a/web/react/components/get_link_modal.jsx +++ b/web/react/components/get_link_modal.jsx @@ -41,6 +41,8 @@ export default class GetLinkModal extends React.Component { } render() { + const userCreationEnabled = global.window.mm_config.EnableUserCreation === 'true'; + let helpText = null; if (this.props.helpText) { helpText = ( @@ -53,7 +55,7 @@ export default class GetLinkModal extends React.Component { } let copyLink = null; - if (document.queryCommandSupported('copy')) { + if (userCreationEnabled && document.queryCommandSupported('copy')) { copyLink = (