From 98186e5018bbc604796d4f9762c93f4f75e2913f Mon Sep 17 00:00:00 2001 From: JoramWilander Date: Mon, 21 Sep 2015 14:22:23 -0400 Subject: Implement incoming webhooks. --- api/api.go | 1 + api/channel.go | 6 +- api/web_socket_test.go | 9 - api/webhook.go | 119 ++++ api/webhook_test.go | 162 +++++ config/config.json | 3 +- docker/dev/config_docker.json | 3 +- docker/local/config_docker.json | 3 +- model/channel.go | 8 + model/client.go | 38 +- model/config.go | 1 + model/webhook.go | 101 +++ model/webhook_test.go | 82 +++ store/sql_store.go | 8 + store/sql_webhook_store.go | 128 ++++ store/sql_webhook_store_test.go | 77 +++ store/store.go | 8 + utils/config.go | 1 + web/react/components/user_settings.jsx | 100 --- .../user_settings/manage_incoming_hooks.jsx | 177 ++++++ .../components/user_settings/user_settings.jsx | 112 ++++ .../user_settings/user_settings_appearance.jsx | 181 ++++++ .../user_settings/user_settings_developer.jsx | 93 +++ .../user_settings/user_settings_general.jsx | 562 +++++++++++++++++ .../user_settings/user_settings_integrations.jsx | 95 +++ .../user_settings/user_settings_modal.jsx | 96 +++ .../user_settings/user_settings_notifications.jsx | 697 +++++++++++++++++++++ .../user_settings/user_settings_security.jsx | 300 +++++++++ web/react/components/user_settings_appearance.jsx | 181 ------ web/react/components/user_settings_developer.jsx | 93 --- web/react/components/user_settings_general.jsx | 562 ----------------- web/react/components/user_settings_modal.jsx | 93 --- .../components/user_settings_notifications.jsx | 697 --------------------- web/react/components/user_settings_security.jsx | 300 --------- web/react/pages/channel.jsx | 2 +- web/react/utils/client.jsx | 153 +++-- web/web.go | 85 +++ web/web_test.go | 45 ++ 38 files changed, 3282 insertions(+), 2100 deletions(-) create mode 100644 api/webhook.go create mode 100644 api/webhook_test.go create mode 100644 model/webhook.go create mode 100644 model/webhook_test.go create mode 100644 store/sql_webhook_store.go create mode 100644 store/sql_webhook_store_test.go delete mode 100644 web/react/components/user_settings.jsx create mode 100644 web/react/components/user_settings/manage_incoming_hooks.jsx create mode 100644 web/react/components/user_settings/user_settings.jsx create mode 100644 web/react/components/user_settings/user_settings_appearance.jsx create mode 100644 web/react/components/user_settings/user_settings_developer.jsx create mode 100644 web/react/components/user_settings/user_settings_general.jsx create mode 100644 web/react/components/user_settings/user_settings_integrations.jsx create mode 100644 web/react/components/user_settings/user_settings_modal.jsx create mode 100644 web/react/components/user_settings/user_settings_notifications.jsx create mode 100644 web/react/components/user_settings/user_settings_security.jsx delete mode 100644 web/react/components/user_settings_appearance.jsx delete mode 100644 web/react/components/user_settings_developer.jsx delete mode 100644 web/react/components/user_settings_general.jsx delete mode 100644 web/react/components/user_settings_modal.jsx delete mode 100644 web/react/components/user_settings_notifications.jsx delete mode 100644 web/react/components/user_settings_security.jsx diff --git a/api/api.go b/api/api.go index c8f97c5af..a50cce946 100644 --- a/api/api.go +++ b/api/api.go @@ -44,6 +44,7 @@ func InitApi() { InitCommand(r) InitAdmin(r) InitOAuth(r) + InitWebhook(r) templatesDir := utils.FindDir("api/templates") l4g.Debug("Parsing server templates at %v", templatesDir) diff --git a/api/channel.go b/api/channel.go index 63acaa8d1..896e22793 100644 --- a/api/channel.go +++ b/api/channel.go @@ -121,11 +121,7 @@ func CreateDirectChannel(c *Context, otherUserId string) (*model.Channel, *model channel := new(model.Channel) channel.DisplayName = "" - if otherUserId > c.Session.UserId { - channel.Name = c.Session.UserId + "__" + otherUserId - } else { - channel.Name = otherUserId + "__" + c.Session.UserId - } + channel.Name = model.GetDMNameFromIds(otherUserId, c.Session.UserId) channel.TeamId = c.Session.TeamId channel.Description = "" diff --git a/api/web_socket_test.go b/api/web_socket_test.go index 161274ff7..7f9ce024b 100644 --- a/api/web_socket_test.go +++ b/api/web_socket_test.go @@ -116,12 +116,3 @@ func TestSocket(t *testing.T) { time.Sleep(2 * time.Second) } - -func TestZZWebSocketTearDown(t *testing.T) { - // *IMPORTANT* - Kind of hacky - // This should be the last function in any test file - // that calls Setup() - // Should be in the last file too sorted by name - time.Sleep(2 * time.Second) - TearDown() -} diff --git a/api/webhook.go b/api/webhook.go new file mode 100644 index 000000000..9ca725d45 --- /dev/null +++ b/api/webhook.go @@ -0,0 +1,119 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +package api + +import ( + l4g "code.google.com/p/log4go" + "github.com/gorilla/mux" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" + "net/http" +) + +func InitWebhook(r *mux.Router) { + l4g.Debug("Initializing webhook api routes") + + sr := r.PathPrefix("/hooks").Subrouter() + sr.Handle("/incoming/create", ApiUserRequired(createIncomingHook)).Methods("POST") + sr.Handle("/incoming/delete", ApiUserRequired(deleteIncomingHook)).Methods("POST") + sr.Handle("/incoming/list", ApiUserRequired(getIncomingHooks)).Methods("GET") +} + +func createIncomingHook(c *Context, w http.ResponseWriter, r *http.Request) { + if !utils.Cfg.ServiceSettings.AllowIncomingWebhooks { + c.Err = model.NewAppError("createIncomingHook", "Incoming webhooks have been disabled by the system admin.", "") + c.Err.StatusCode = http.StatusNotImplemented + return + } + + c.LogAudit("attempt") + + hook := model.IncomingWebhookFromJson(r.Body) + + if hook == nil { + c.SetInvalidParam("createIncomingHook", "webhook") + return + } + + cchan := Srv.Store.Channel().Get(hook.ChannelId) + pchan := Srv.Store.Channel().CheckPermissionsTo(c.Session.TeamId, hook.ChannelId, c.Session.UserId) + + hook.UserId = c.Session.UserId + hook.TeamId = c.Session.TeamId + + var channel *model.Channel + if result := <-cchan; result.Err != nil { + c.Err = result.Err + return + } else { + channel = result.Data.(*model.Channel) + } + + if !c.HasPermissionsToChannel(pchan, "createIncomingHook") && channel.Type != model.CHANNEL_OPEN { + c.LogAudit("fail - bad channel permissions") + return + } + + if result := <-Srv.Store.Webhook().SaveIncoming(hook); result.Err != nil { + c.Err = result.Err + return + } else { + c.LogAudit("success") + rhook := result.Data.(*model.IncomingWebhook) + w.Write([]byte(rhook.ToJson())) + } +} + +func deleteIncomingHook(c *Context, w http.ResponseWriter, r *http.Request) { + if !utils.Cfg.ServiceSettings.AllowIncomingWebhooks { + c.Err = model.NewAppError("createIncomingHook", "Incoming webhooks have been disabled by the system admin.", "") + c.Err.StatusCode = http.StatusNotImplemented + return + } + + c.LogAudit("attempt") + + props := model.MapFromJson(r.Body) + + id := props["id"] + if len(id) == 0 { + c.SetInvalidParam("deleteIncomingHook", "id") + return + } + + if result := <-Srv.Store.Webhook().GetIncoming(id); result.Err != nil { + c.Err = result.Err + return + } else { + if c.Session.UserId != result.Data.(*model.IncomingWebhook).UserId && !model.IsInRole(c.Session.Roles, model.ROLE_TEAM_ADMIN) { + c.LogAudit("fail - inappropriate conditions") + c.Err = model.NewAppError("deleteIncomingHook", "Inappropriate permissions to delete incoming webhook", "user_id="+c.Session.UserId) + return + } + } + + if err := (<-Srv.Store.Webhook().DeleteIncoming(id, model.GetMillis())).Err; err != nil { + c.Err = err + return + } + + c.LogAudit("success") + w.Write([]byte(model.MapToJson(props))) +} + +func getIncomingHooks(c *Context, w http.ResponseWriter, r *http.Request) { + if !utils.Cfg.ServiceSettings.AllowIncomingWebhooks { + c.Err = model.NewAppError("createIncomingHook", "Incoming webhooks have been disabled by the system admin.", "") + c.Err.StatusCode = http.StatusNotImplemented + return + } + + if result := <-Srv.Store.Webhook().GetIncomingByUser(c.Session.UserId); result.Err != nil { + c.Err = result.Err + return + } else { + hooks := result.Data.([]*model.IncomingWebhook) + w.Write([]byte(model.IncomingWebhookListToJson(hooks))) + } +} diff --git a/api/webhook_test.go b/api/webhook_test.go new file mode 100644 index 000000000..fd4c723b7 --- /dev/null +++ b/api/webhook_test.go @@ -0,0 +1,162 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +package api + +import ( + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/store" + "github.com/mattermost/platform/utils" + "testing" + "time" +) + +func TestCreateIncomingHook(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() + "corey@test.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: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id} + channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel) + + channel2 := &model.Channel{DisplayName: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id} + channel2 = Client.Must(Client.CreateChannel(channel2)).Data.(*model.Channel) + + hook := &model.IncomingWebhook{ChannelId: channel1.Id} + + if utils.Cfg.ServiceSettings.AllowIncomingWebhooks { + var rhook *model.IncomingWebhook + if result, err := Client.CreateIncomingWebhook(hook); err != nil { + t.Fatal(err) + } else { + rhook = result.Data.(*model.IncomingWebhook) + } + + if hook.ChannelId != rhook.ChannelId { + t.Fatal("channel ids didn't match") + } + + if rhook.UserId != user.Id { + t.Fatal("user ids didn't match") + } + + if rhook.TeamId != team.Id { + t.Fatal("team ids didn't match") + } + + hook = &model.IncomingWebhook{ChannelId: "junk"} + if _, err := Client.CreateIncomingWebhook(hook); err == nil { + t.Fatal("should have failed - bad channel id") + } + + hook = &model.IncomingWebhook{ChannelId: channel2.Id, UserId: "123", TeamId: "456"} + if result, err := Client.CreateIncomingWebhook(hook); err != nil { + t.Fatal(err) + } else { + if result.Data.(*model.IncomingWebhook).UserId != user.Id { + t.Fatal("bad user id wasn't overwritten") + } + if result.Data.(*model.IncomingWebhook).TeamId != team.Id { + t.Fatal("bad team id wasn't overwritten") + } + } + } else { + if _, err := Client.CreateIncomingWebhook(hook); err == nil { + t.Fatal("should have errored - webhooks turned off") + } + } +} + +func TestListIncomingHooks(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() + "corey@test.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: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id} + channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel) + + if utils.Cfg.ServiceSettings.AllowIncomingWebhooks { + hook1 := &model.IncomingWebhook{ChannelId: channel1.Id} + hook1 = Client.Must(Client.CreateIncomingWebhook(hook1)).Data.(*model.IncomingWebhook) + + hook2 := &model.IncomingWebhook{ChannelId: channel1.Id} + hook2 = Client.Must(Client.CreateIncomingWebhook(hook2)).Data.(*model.IncomingWebhook) + + if result, err := Client.ListIncomingWebhooks(); err != nil { + t.Fatal(err) + } else { + hooks := result.Data.([]*model.IncomingWebhook) + + if len(hooks) != 2 { + t.Fatal("incorrect number of hooks") + } + } + } else { + if _, err := Client.ListIncomingWebhooks(); err == nil { + t.Fatal("should have errored - webhooks turned off") + } + } +} + +func TestDeleteIncomingHook(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() + "corey@test.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: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id} + channel1 = Client.Must(Client.CreateChannel(channel1)).Data.(*model.Channel) + + if utils.Cfg.ServiceSettings.AllowIncomingWebhooks { + hook := &model.IncomingWebhook{ChannelId: channel1.Id} + hook = Client.Must(Client.CreateIncomingWebhook(hook)).Data.(*model.IncomingWebhook) + + data := make(map[string]string) + data["id"] = hook.Id + + if _, err := Client.DeleteIncomingWebhook(data); err != nil { + t.Fatal(err) + } + + hooks := Client.Must(Client.ListIncomingWebhooks()).Data.([]*model.IncomingWebhook) + if len(hooks) != 0 { + t.Fatal("delete didn't work properly") + } + } else { + data := make(map[string]string) + data["id"] = "123" + + if _, err := Client.DeleteIncomingWebhook(data); err == nil { + t.Fatal("should have errored - webhooks turned off") + } + } +} + +func TestZZWebSocketTearDown(t *testing.T) { + // *IMPORTANT* - Kind of hacky + // This should be the last function in any test file + // that calls Setup() + // Should be in the last file too sorted by name + time.Sleep(2 * time.Second) + TearDown() +} diff --git a/config/config.json b/config/config.json index 38948641c..c573a299f 100644 --- a/config/config.json +++ b/config/config.json @@ -22,7 +22,8 @@ "StorageDirectory": "./data/", "AllowedLoginAttempts": 10, "DisableEmailSignUp": false, - "EnableOAuthServiceProvider": false + "EnableOAuthServiceProvider": false, + "AllowIncomingWebhooks": false }, "SqlSettings": { "DriverName": "mysql", diff --git a/docker/dev/config_docker.json b/docker/dev/config_docker.json index aceeb95b4..d50439f2c 100644 --- a/docker/dev/config_docker.json +++ b/docker/dev/config_docker.json @@ -24,7 +24,8 @@ "StorageDirectory": "/mattermost/data/", "AllowedLoginAttempts": 10, "DisableEmailSignUp": false, - "EnableOAuthServiceProvider": false + "EnableOAuthServiceProvider": false, + "AllowIncomingWebhooks": false }, "SSOSettings": { "gitlab": { diff --git a/docker/local/config_docker.json b/docker/local/config_docker.json index bc42951b8..b6e02635c 100644 --- a/docker/local/config_docker.json +++ b/docker/local/config_docker.json @@ -24,7 +24,8 @@ "StorageDirectory": "/mattermost/data/", "AllowedLoginAttempts": 10, "DisableEmailSignUp": false, - "EnableOAuthServiceProvider": false + "EnableOAuthServiceProvider": false, + "AllowIncomingWebhooks": false }, "SSOSettings": { "gitlab": { diff --git a/model/channel.go b/model/channel.go index 7d8edeee7..a7f007960 100644 --- a/model/channel.go +++ b/model/channel.go @@ -120,3 +120,11 @@ func (o *Channel) ExtraUpdated() { func (o *Channel) PreExport() { } + +func GetDMNameFromIds(userId1, userId2 string) string { + if userId1 > userId2 { + return userId2 + "__" + userId1 + } else { + return userId1 + "__" + userId2 + } +} diff --git a/model/client.go b/model/client.go index f9127719f..6817a80f6 100644 --- a/model/client.go +++ b/model/client.go @@ -48,7 +48,7 @@ func NewClient(url string) *Client { return &Client{url, url + API_URL_SUFFIX, &http.Client{}, "", ""} } -func (c *Client) DoPost(url string, data, contentType string) (*http.Response, *AppError) { +func (c *Client) DoPost(url, data, contentType string) (*http.Response, *AppError) { rq, _ := http.NewRequest("POST", c.Url+url, strings.NewReader(data)) rq.Header.Set("Content-Type", contentType) @@ -806,6 +806,42 @@ func (c *Client) GetAccessToken(data url.Values) (*Result, *AppError) { } } +func (c *Client) CreateIncomingWebhook(hook *IncomingWebhook) (*Result, *AppError) { + if r, err := c.DoApiPost("/hooks/incoming/create", hook.ToJson()); err != nil { + return nil, err + } else { + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), IncomingWebhookFromJson(r.Body)}, nil + } +} + +func (c *Client) PostToWebhook(id, payload string) (*Result, *AppError) { + if r, err := c.DoPost("/hooks/"+id, payload, "application/x-www-form-urlencoded"); err != nil { + return nil, err + } else { + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), nil}, nil + } +} + +func (c *Client) DeleteIncomingWebhook(data map[string]string) (*Result, *AppError) { + if r, err := c.DoApiPost("/hooks/incoming/delete", MapToJson(data)); err != nil { + return nil, err + } else { + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), MapFromJson(r.Body)}, nil + } +} + +func (c *Client) ListIncomingWebhooks() (*Result, *AppError) { + if r, err := c.DoApiGet("/hooks/incoming/list", "", ""); err != nil { + return nil, err + } else { + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), IncomingWebhookListFromJson(r.Body)}, nil + } +} + func (c *Client) MockSession(sessionToken string) { c.AuthToken = sessionToken c.AuthType = HEADER_BEARER diff --git a/model/config.go b/model/config.go index 3b333dbe1..436f063c8 100644 --- a/model/config.go +++ b/model/config.go @@ -24,6 +24,7 @@ type ServiceSettings struct { AllowedLoginAttempts int DisableEmailSignUp bool EnableOAuthServiceProvider bool + AllowIncomingWebhooks bool } type SSOSetting struct { diff --git a/model/webhook.go b/model/webhook.go new file mode 100644 index 000000000..9b4db3246 --- /dev/null +++ b/model/webhook.go @@ -0,0 +1,101 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "encoding/json" + "io" +) + +type IncomingWebhook struct { + Id string `json:"id"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + DeleteAt int64 `json:"delete_at"` + UserId string `json:"user_id"` + ChannelId string `json:"channel_id"` + TeamId string `json:"team_id"` +} + +func (o *IncomingWebhook) ToJson() string { + b, err := json.Marshal(o) + if err != nil { + return "" + } else { + return string(b) + } +} + +func IncomingWebhookFromJson(data io.Reader) *IncomingWebhook { + decoder := json.NewDecoder(data) + var o IncomingWebhook + err := decoder.Decode(&o) + if err == nil { + return &o + } else { + return nil + } +} + +func IncomingWebhookListToJson(l []*IncomingWebhook) string { + b, err := json.Marshal(l) + if err != nil { + return "" + } else { + return string(b) + } +} + +func IncomingWebhookListFromJson(data io.Reader) []*IncomingWebhook { + decoder := json.NewDecoder(data) + var o []*IncomingWebhook + err := decoder.Decode(&o) + if err == nil { + return o + } else { + return nil + } +} + +func (o *IncomingWebhook) IsValid() *AppError { + + if len(o.Id) != 26 { + return NewAppError("IncomingWebhook.IsValid", "Invalid Id", "") + } + + if o.CreateAt == 0 { + return NewAppError("IncomingWebhook.IsValid", "Create at must be a valid time", "id="+o.Id) + } + + if o.UpdateAt == 0 { + return NewAppError("IncomingWebhook.IsValid", "Update at must be a valid time", "id="+o.Id) + } + + if len(o.UserId) != 26 { + return NewAppError("IncomingWebhook.IsValid", "Invalid user id", "") + } + + if len(o.ChannelId) != 26 { + return NewAppError("IncomingWebhook.IsValid", "Invalid channel id", "") + } + + if len(o.TeamId) != 26 { + return NewAppError("IncomingWebhook.IsValid", "Invalid channel id", "") + } + + return nil +} + +func (o *IncomingWebhook) PreSave() { + if o.Id == "" { + o.Id = NewId() + } + + o.CreateAt = GetMillis() + o.UpdateAt = o.CreateAt +} + +func (o *IncomingWebhook) PreUpdate() { + o.UpdateAt = GetMillis() +} diff --git a/model/webhook_test.go b/model/webhook_test.go new file mode 100644 index 000000000..ddbe18cd3 --- /dev/null +++ b/model/webhook_test.go @@ -0,0 +1,82 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "strings" + "testing" +) + +func TestIncomingWebhookJson(t *testing.T) { + o := IncomingWebhook{Id: NewId()} + json := o.ToJson() + ro := IncomingWebhookFromJson(strings.NewReader(json)) + + if o.Id != ro.Id { + t.Fatal("Ids do not match") + } +} + +func TestIncomingWebhookIsValid(t *testing.T) { + o := IncomingWebhook{} + + if err := o.IsValid(); err == nil { + t.Fatal("should be invalid") + } + + o.Id = NewId() + if err := o.IsValid(); err == nil { + t.Fatal("should be invalid") + } + + o.CreateAt = GetMillis() + if err := o.IsValid(); err == nil { + t.Fatal("should be invalid") + } + + o.UpdateAt = GetMillis() + if err := o.IsValid(); err == nil { + t.Fatal("should be invalid") + } + + o.UserId = "123" + if err := o.IsValid(); err == nil { + t.Fatal("should be invalid") + } + + o.UserId = NewId() + if err := o.IsValid(); err == nil { + t.Fatal("should be invalid") + } + + o.ChannelId = "123" + if err := o.IsValid(); err == nil { + t.Fatal("should be invalid") + } + + o.ChannelId = NewId() + if err := o.IsValid(); err == nil { + t.Fatal("should be invalid") + } + + o.TeamId = "123" + if err := o.IsValid(); err == nil { + t.Fatal("should be invalid") + } + + o.TeamId = NewId() + if err := o.IsValid(); err != nil { + t.Fatal(err) + } +} + +func TestIncomingWebhookPreSave(t *testing.T) { + o := IncomingWebhook{} + o.PreSave() +} + +func TestIncomingWebhookPreUpdate(t *testing.T) { + o := IncomingWebhook{} + o.PreUpdate() +} diff --git a/store/sql_store.go b/store/sql_store.go index adac47b4d..98703841a 100644 --- a/store/sql_store.go +++ b/store/sql_store.go @@ -40,6 +40,7 @@ type SqlStore struct { session SessionStore oauth OAuthStore system SystemStore + webhook WebhookStore } func NewSqlStore() Store { @@ -91,6 +92,7 @@ func NewSqlStore() Store { sqlStore.session = NewSqlSessionStore(sqlStore) sqlStore.oauth = NewSqlOAuthStore(sqlStore) sqlStore.system = NewSqlSystemStore(sqlStore) + sqlStore.webhook = NewSqlWebhookStore(sqlStore) sqlStore.master.CreateTablesIfNotExists() @@ -102,6 +104,7 @@ func NewSqlStore() Store { sqlStore.session.(*SqlSessionStore).UpgradeSchemaIfNeeded() sqlStore.oauth.(*SqlOAuthStore).UpgradeSchemaIfNeeded() sqlStore.system.(*SqlSystemStore).UpgradeSchemaIfNeeded() + sqlStore.webhook.(*SqlWebhookStore).UpgradeSchemaIfNeeded() sqlStore.team.(*SqlTeamStore).CreateIndexesIfNotExists() sqlStore.channel.(*SqlChannelStore).CreateIndexesIfNotExists() @@ -111,6 +114,7 @@ func NewSqlStore() Store { sqlStore.session.(*SqlSessionStore).CreateIndexesIfNotExists() sqlStore.oauth.(*SqlOAuthStore).CreateIndexesIfNotExists() sqlStore.system.(*SqlSystemStore).CreateIndexesIfNotExists() + sqlStore.webhook.(*SqlWebhookStore).CreateIndexesIfNotExists() if model.IsPreviousVersion(schemaVersion) { sqlStore.system.Update(&model.System{Name: "Version", Value: model.CurrentVersion}) @@ -469,6 +473,10 @@ func (ss SqlStore) System() SystemStore { return ss.system } +func (ss SqlStore) Webhook() WebhookStore { + return ss.webhook +} + type mattermConverter struct{} func (me mattermConverter) ToDb(val interface{}) (interface{}, error) { diff --git a/store/sql_webhook_store.go b/store/sql_webhook_store.go new file mode 100644 index 000000000..e309f79e4 --- /dev/null +++ b/store/sql_webhook_store.go @@ -0,0 +1,128 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +package store + +import ( + "github.com/mattermost/platform/model" +) + +type SqlWebhookStore struct { + *SqlStore +} + +func NewSqlWebhookStore(sqlStore *SqlStore) WebhookStore { + s := &SqlWebhookStore{sqlStore} + + for _, db := range sqlStore.GetAllConns() { + table := db.AddTableWithName(model.IncomingWebhook{}, "IncomingWebhooks").SetKeys(false, "Id") + table.ColMap("Id").SetMaxSize(26) + table.ColMap("UserId").SetMaxSize(26) + table.ColMap("ChannelId").SetMaxSize(26) + table.ColMap("TeamId").SetMaxSize(26) + } + + return s +} + +func (s SqlWebhookStore) UpgradeSchemaIfNeeded() { +} + +func (s SqlWebhookStore) CreateIndexesIfNotExists() { + s.CreateIndexIfNotExists("idx_webhook_user_id", "IncomingWebhooks", "UserId") + s.CreateIndexIfNotExists("idx_webhook_team_id", "IncomingWebhooks", "TeamId") +} + +func (s SqlWebhookStore) SaveIncoming(webhook *model.IncomingWebhook) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + if len(webhook.Id) > 0 { + result.Err = model.NewAppError("SqlWebhookStore.SaveIncoming", + "You cannot overwrite an existing IncomingWebhook", "id="+webhook.Id) + storeChannel <- result + close(storeChannel) + return + } + + webhook.PreSave() + if result.Err = webhook.IsValid(); result.Err != nil { + storeChannel <- result + close(storeChannel) + return + } + + if err := s.GetMaster().Insert(webhook); err != nil { + result.Err = model.NewAppError("SqlWebhookStore.SaveIncoming", "We couldn't save the IncomingWebhook", "id="+webhook.Id+", "+err.Error()) + } else { + result.Data = webhook + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (s SqlWebhookStore) GetIncoming(id string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + var webhook model.IncomingWebhook + + if err := s.GetReplica().SelectOne(&webhook, "SELECT * FROM IncomingWebhooks WHERE Id = :Id AND DeleteAt = 0", map[string]interface{}{"Id": id}); err != nil { + result.Err = model.NewAppError("SqlWebhookStore.GetIncoming", "We couldn't get the webhook", "id="+id+", err="+err.Error()) + } + + result.Data = &webhook + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (s SqlWebhookStore) DeleteIncoming(webhookId string, time int64) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + _, err := s.GetMaster().Exec("Update IncomingWebhooks SET DeleteAt = :DeleteAt, UpdateAt = :UpdateAt WHERE Id = :Id", map[string]interface{}{"DeleteAt": time, "UpdateAt": time, "Id": webhookId}) + if err != nil { + result.Err = model.NewAppError("SqlWebhookStore.DeleteIncoming", "We couldn't delete the webhook", "id="+webhookId+", err="+err.Error()) + } + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + +func (s SqlWebhookStore) GetIncomingByUser(userId string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + var webhooks []*model.IncomingWebhook + + if _, err := s.GetReplica().Select(&webhooks, "SELECT * FROM IncomingWebhooks WHERE UserId = :UserId AND DeleteAt = 0", map[string]interface{}{"UserId": userId}); err != nil { + result.Err = model.NewAppError("SqlWebhookStore.GetIncomingByUser", "We couldn't get the webhook", "userId="+userId+", err="+err.Error()) + } + + result.Data = webhooks + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} diff --git a/store/sql_webhook_store_test.go b/store/sql_webhook_store_test.go new file mode 100644 index 000000000..0a015eaf9 --- /dev/null +++ b/store/sql_webhook_store_test.go @@ -0,0 +1,77 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +package store + +import ( + "github.com/mattermost/platform/model" + "testing" +) + +func TestIncomingWebhookStoreSaveIncoming(t *testing.T) { + Setup() + + o1 := model.IncomingWebhook{} + o1.ChannelId = model.NewId() + o1.UserId = model.NewId() + o1.TeamId = model.NewId() + + if err := (<-store.Webhook().SaveIncoming(&o1)).Err; err != nil { + t.Fatal("couldn't save item", err) + } + + if err := (<-store.Webhook().SaveIncoming(&o1)).Err; err == nil { + t.Fatal("shouldn't be able to update from save") + } +} + +func TestIncomingWebhookStoreGetIncoming(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 r1 := <-store.Webhook().GetIncoming(o1.Id); r1.Err != nil { + t.Fatal(r1.Err) + } else { + if r1.Data.(*model.IncomingWebhook).CreateAt != o1.CreateAt { + t.Fatal("invalid returned webhook") + } + } + + if err := (<-store.Webhook().GetIncoming("123")).Err; err == nil { + t.Fatal("Missing id should have failed") + } +} + +func TestIncomingWebhookStoreDelete(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 r1 := <-store.Webhook().GetIncoming(o1.Id); r1.Err != nil { + t.Fatal(r1.Err) + } else { + if r1.Data.(*model.IncomingWebhook).CreateAt != o1.CreateAt { + t.Fatal("invalid returned webhook") + } + } + + if r2 := <-store.Webhook().DeleteIncoming(o1.Id, model.GetMillis()); r2.Err != nil { + t.Fatal(r2.Err) + } + + if r3 := (<-store.Webhook().GetIncoming(o1.Id)); r3.Err == nil { + t.Log(r3.Data) + t.Fatal("Missing id should have failed") + } +} diff --git a/store/store.go b/store/store.go index 1344c4ebe..c9d40cfa5 100644 --- a/store/store.go +++ b/store/store.go @@ -36,6 +36,7 @@ type Store interface { Session() SessionStore OAuth() OAuthStore System() SystemStore + Webhook() WebhookStore Close() } @@ -137,3 +138,10 @@ type SystemStore interface { Update(system *model.System) StoreChannel Get() StoreChannel } + +type WebhookStore interface { + SaveIncoming(webhook *model.IncomingWebhook) StoreChannel + GetIncoming(id string) StoreChannel + GetIncomingByUser(userId string) StoreChannel + DeleteIncoming(webhookId string, time int64) StoreChannel +} diff --git a/utils/config.go b/utils/config.go index dd2c17977..35631358b 100644 --- a/utils/config.go +++ b/utils/config.go @@ -188,6 +188,7 @@ func getClientProperties(c *model.Config) map[string]string { props["ProfileWidth"] = fmt.Sprintf("%v", c.ImageSettings.ProfileWidth) props["ProfileWidth"] = fmt.Sprintf("%v", c.ImageSettings.ProfileWidth) props["EnableOAuthServiceProvider"] = strconv.FormatBool(c.ServiceSettings.EnableOAuthServiceProvider) + props["AllowIncomingWebhooks"] = strconv.FormatBool(c.ServiceSettings.AllowIncomingWebhooks) return props } diff --git a/web/react/components/user_settings.jsx b/web/react/components/user_settings.jsx deleted file mode 100644 index 48b499068..000000000 --- a/web/react/components/user_settings.jsx +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. -// See License.txt for license information. - -var UserStore = require('../stores/user_store.jsx'); -var utils = require('../utils/utils.jsx'); -var NotificationsTab = require('./user_settings_notifications.jsx'); -var SecurityTab = require('./user_settings_security.jsx'); -var GeneralTab = require('./user_settings_general.jsx'); -var AppearanceTab = require('./user_settings_appearance.jsx'); -var DeveloperTab = require('./user_settings_developer.jsx'); - -export default class UserSettings extends React.Component { - constructor(props) { - super(props); - - this.onListenerChange = this.onListenerChange.bind(this); - - this.state = {user: UserStore.getCurrentUser()}; - } - - componentDidMount() { - UserStore.addChangeListener(this.onListenerChange); - } - - componentWillUnmount() { - UserStore.removeChangeListener(this.onListenerChange); - } - - onListenerChange() { - var user = UserStore.getCurrentUser(); - if (!utils.areStatesEqual(this.state.user, user)) { - this.setState({user: user}); - } - } - - render() { - if (this.props.activeTab === 'general') { - return ( -
- -
- ); - } else if (this.props.activeTab === 'security') { - return ( -
- -
- ); - } else if (this.props.activeTab === 'notifications') { - return ( -
- -
- ); - } else if (this.props.activeTab === 'appearance') { - return ( -
- -
- ); - } else if (this.props.activeTab === 'developer') { - return ( -
- -
- ); - } - - return
; - } -} - -UserSettings.propTypes = { - activeTab: React.PropTypes.string, - activeSection: React.PropTypes.string, - updateSection: React.PropTypes.func, - updateTab: React.PropTypes.func -}; diff --git a/web/react/components/user_settings/manage_incoming_hooks.jsx b/web/react/components/user_settings/manage_incoming_hooks.jsx new file mode 100644 index 000000000..df089a403 --- /dev/null +++ b/web/react/components/user_settings/manage_incoming_hooks.jsx @@ -0,0 +1,177 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var Client = require('../../utils/client.jsx'); +var Utils = require('../../utils/utils.jsx'); +var Constants = require('../../utils/constants.jsx'); +var ChannelStore = require('../../stores/channel_store.jsx'); +var LoadingScreen = require('../loading_screen.jsx'); + +export default class ManageIncomingHooks extends React.Component { + constructor() { + super(); + + this.getHooks = this.getHooks.bind(this); + this.addNewHook = this.addNewHook.bind(this); + this.updateChannelId = this.updateChannelId.bind(this); + + this.state = {hooks: [], channelId: ChannelStore.getByName(Constants.DEFAULT_CHANNEL).id, getHooksComplete: false}; + } + componentDidMount() { + this.getHooks(); + } + addNewHook() { + let hook = {}; //eslint-disable-line prefer-const + hook.channel_id = this.state.channelId; + + Client.addIncomingHook( + hook, + (data) => { + let hooks = this.state.hooks; + if (!hooks) { + hooks = []; + } + hooks.push(data); + this.setState({hooks}); + }, + (err) => { + this.setState({serverError: err}); + } + ); + } + removeHook(id) { + let data = {}; //eslint-disable-line prefer-const + data.id = id; + + Client.deleteIncomingHook( + data, + () => { + let hooks = this.state.hooks; //eslint-disable-line prefer-const + let index = -1; + for (let i = 0; i < hooks.length; i++) { + if (hooks[i].id === id) { + index = i; + break; + } + } + + if (index !== -1) { + hooks.splice(index, 1); + } + + this.setState({hooks}); + }, + (err) => { + this.setState({serverError: err}); + } + ); + } + getHooks() { + Client.listIncomingHooks( + (data) => { + let state = this.state; //eslint-disable-line prefer-const + + if (data) { + state.hooks = data; + } + + state.getHooksComplete = true; + this.setState(state); + }, + (err) => { + this.setState({serverError: err}); + } + ); + } + updateChannelId(e) { + this.setState({channelId: e.target.value}); + } + render() { + let serverError; + if (this.state.serverError) { + serverError = ; + } + + const channels = ChannelStore.getAll(); + let options = []; //eslint-disable-line prefer-const + channels.forEach((channel) => { + options.push(); + }); + + let disableButton = ''; + if (this.state.channelId === '') { + disableButton = ' disable'; + } + + let hooks = []; //eslint-disable-line prefer-const + this.state.hooks.forEach((hook) => { + const c = ChannelStore.get(hook.channel_id); + hooks.push( +
+
+ + {'URL: '}{Utils.getWindowLocationOrigin() + '/hooks/' + hook.id} + +
+ + {'Channel: '}{c.name} + +
+ + {'Remove'} + +
+ ); + }); + + let displayHooks; + if (!this.state.getHooksComplete) { + displayHooks = ; + } else if (hooks.length > 0) { + displayHooks = hooks; + } else { + displayHooks = ; + } + + const existingHooks = ( +
+ +
+ {displayHooks} +
+ ); + + return ( +
+ +
+
+ +
+ {serverError} + + {'Add'} + +
+ {existingHooks} +
+ ); + } +} diff --git a/web/react/components/user_settings/user_settings.jsx b/web/react/components/user_settings/user_settings.jsx new file mode 100644 index 000000000..0eab333c4 --- /dev/null +++ b/web/react/components/user_settings/user_settings.jsx @@ -0,0 +1,112 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var UserStore = require('../../stores/user_store.jsx'); +var utils = require('../../utils/utils.jsx'); +var NotificationsTab = require('./user_settings_notifications.jsx'); +var SecurityTab = require('./user_settings_security.jsx'); +var GeneralTab = require('./user_settings_general.jsx'); +var AppearanceTab = require('./user_settings_appearance.jsx'); +var DeveloperTab = require('./user_settings_developer.jsx'); +var IntegrationsTab = require('./user_settings_integrations.jsx'); + +export default class UserSettings extends React.Component { + constructor(props) { + super(props); + + this.onListenerChange = this.onListenerChange.bind(this); + + this.state = {user: UserStore.getCurrentUser()}; + } + + componentDidMount() { + UserStore.addChangeListener(this.onListenerChange); + } + + componentWillUnmount() { + UserStore.removeChangeListener(this.onListenerChange); + } + + onListenerChange() { + var user = UserStore.getCurrentUser(); + if (!utils.areStatesEqual(this.state.user, user)) { + this.setState({user: user}); + } + } + + render() { + if (this.props.activeTab === 'general') { + return ( +
+ +
+ ); + } else if (this.props.activeTab === 'security') { + return ( +
+ +
+ ); + } else if (this.props.activeTab === 'notifications') { + return ( +
+ +
+ ); + } else if (this.props.activeTab === 'appearance') { + return ( +
+ +
+ ); + } else if (this.props.activeTab === 'developer') { + return ( +
+ +
+ ); + } else if (this.props.activeTab === 'integrations') { + return ( +
+ +
+ ); + } + + return
; + } +} + +UserSettings.propTypes = { + activeTab: React.PropTypes.string, + activeSection: React.PropTypes.string, + updateSection: React.PropTypes.func, + updateTab: React.PropTypes.func +}; diff --git a/web/react/components/user_settings/user_settings_appearance.jsx b/web/react/components/user_settings/user_settings_appearance.jsx new file mode 100644 index 000000000..aec3b319d --- /dev/null +++ b/web/react/components/user_settings/user_settings_appearance.jsx @@ -0,0 +1,181 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var UserStore = require('../../stores/user_store.jsx'); +var SettingItemMin = require('../setting_item_min.jsx'); +var SettingItemMax = require('../setting_item_max.jsx'); +var Client = require('../../utils/client.jsx'); +var Utils = require('../../utils/utils.jsx'); + +var ThemeColors = ['#2389d7', '#008a17', '#dc4fad', '#ac193d', '#0072c6', '#d24726', '#ff8f32', '#82ba00', '#03b3b2', '#008299', '#4617b4', '#8c0095', '#004b8b', '#004b8b', '#570000', '#380000', '#585858', '#000000']; + +export default class UserSettingsAppearance extends React.Component { + constructor(props) { + super(props); + + this.submitTheme = this.submitTheme.bind(this); + this.updateTheme = this.updateTheme.bind(this); + this.handleClose = this.handleClose.bind(this); + + this.state = this.getStateFromStores(); + } + getStateFromStores() { + var user = UserStore.getCurrentUser(); + var theme = '#2389d7'; + if (ThemeColors != null) { + theme = ThemeColors[0]; + } + if (user.props && user.props.theme) { + theme = user.props.theme; + } + + return {theme: theme.toLowerCase()}; + } + submitTheme(e) { + e.preventDefault(); + var user = UserStore.getCurrentUser(); + if (!user.props) { + user.props = {}; + } + user.props.theme = this.state.theme; + + Client.updateUser(user, + function success() { + this.props.updateSection(''); + window.location.reload(); + }.bind(this), + function fail(err) { + var state = this.getStateFromStores(); + state.serverError = err; + this.setState(state); + }.bind(this) + ); + } + updateTheme(e) { + var hex = Utils.rgb2hex(e.target.style.backgroundColor); + this.setState({theme: hex.toLowerCase()}); + } + handleClose() { + this.setState({serverError: null}); + this.props.updateTab('general'); + } + componentDidMount() { + if (this.props.activeSection === 'theme') { + $(React.findDOMNode(this.refs[this.state.theme])).addClass('active-border'); + } + $('#user_settings').on('hidden.bs.modal', this.handleClose); + } + componentDidUpdate() { + if (this.props.activeSection === 'theme') { + $('.color-btn').removeClass('active-border'); + $(React.findDOMNode(this.refs[this.state.theme])).addClass('active-border'); + } + } + componentWillUnmount() { + $('#user_settings').off('hidden.bs.modal', this.handleClose); + this.props.updateSection(''); + } + render() { + var serverError; + if (this.state.serverError) { + serverError = this.state.serverError; + } + + var themeSection; + var self = this; + + if (ThemeColors != null) { + if (this.props.activeSection === 'theme') { + var themeButtons = []; + + for (var i = 0; i < ThemeColors.length; i++) { + themeButtons.push( + +

+ Appearance Settings +

+
+
+

Appearance Settings

+
+ {themeSection} +
+
+
+ ); + } +} + +UserSettingsAppearance.defaultProps = { + activeSection: '' +}; +UserSettingsAppearance.propTypes = { + activeSection: React.PropTypes.string, + updateSection: React.PropTypes.func, + updateTab: React.PropTypes.func +}; diff --git a/web/react/components/user_settings/user_settings_developer.jsx b/web/react/components/user_settings/user_settings_developer.jsx new file mode 100644 index 000000000..1694aaa79 --- /dev/null +++ b/web/react/components/user_settings/user_settings_developer.jsx @@ -0,0 +1,93 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var SettingItemMin = require('../setting_item_min.jsx'); +var SettingItemMax = require('../setting_item_max.jsx'); + +export default class DeveloperTab extends React.Component { + constructor(props) { + super(props); + + this.state = {}; + } + register() { + $('#user_settings1').modal('hide'); + $('#register_app').modal('show'); + } + render() { + var appSection; + var self = this; + if (this.props.activeSection === 'app') { + var inputs = []; + + inputs.push( +
+ +
+ ); + + appSection = ( + + ); + } else { + appSection = ( + + ); + } + + return ( +
+
+ +

+ {'Developer Settings'} +

+
+
+

{'Developer Settings'}

+
+ {appSection} +
+
+
+ ); + } +} + +DeveloperTab.defaultProps = { + activeSection: '' +}; +DeveloperTab.propTypes = { + activeSection: React.PropTypes.string, + updateSection: React.PropTypes.func +}; diff --git a/web/react/components/user_settings/user_settings_general.jsx b/web/react/components/user_settings/user_settings_general.jsx new file mode 100644 index 000000000..5d9d9bfde --- /dev/null +++ b/web/react/components/user_settings/user_settings_general.jsx @@ -0,0 +1,562 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var UserStore = require('../../stores/user_store.jsx'); +var SettingItemMin = require('../setting_item_min.jsx'); +var SettingItemMax = require('../setting_item_max.jsx'); +var SettingPicture = require('../setting_picture.jsx'); +var client = require('../../utils/client.jsx'); +var AsyncClient = require('../../utils/async_client.jsx'); +var utils = require('../../utils/utils.jsx'); +var assign = require('object-assign'); + +export default class UserSettingsGeneralTab extends React.Component { + constructor(props) { + super(props); + this.submitActive = false; + + this.submitUsername = this.submitUsername.bind(this); + this.submitNickname = this.submitNickname.bind(this); + this.submitName = this.submitName.bind(this); + this.submitEmail = this.submitEmail.bind(this); + this.submitUser = this.submitUser.bind(this); + this.submitPicture = this.submitPicture.bind(this); + + this.updateUsername = this.updateUsername.bind(this); + this.updateFirstName = this.updateFirstName.bind(this); + this.updateLastName = this.updateLastName.bind(this); + this.updateNickname = this.updateNickname.bind(this); + this.updateEmail = this.updateEmail.bind(this); + this.updatePicture = this.updatePicture.bind(this); + this.updateSection = this.updateSection.bind(this); + + this.handleClose = this.handleClose.bind(this); + this.setupInitialState = this.setupInitialState.bind(this); + + this.state = this.setupInitialState(props); + } + submitUsername(e) { + e.preventDefault(); + + var user = this.props.user; + var username = this.state.username.trim().toLowerCase(); + + var usernameError = utils.isValidUsername(username); + if (usernameError === 'Cannot use a reserved word as a username.') { + this.setState({clientError: 'This username is reserved, please choose a new one.'}); + return; + } else if (usernameError) { + this.setState({clientError: "Username must begin with a letter, and contain between 3 to 15 lowercase characters made up of numbers, letters, and the symbols '.', '-' and '_'."}); + return; + } + + if (user.username === username) { + this.setState({clientError: 'You must submit a new username'}); + return; + } + + user.username = username; + + this.submitUser(user); + } + submitNickname(e) { + e.preventDefault(); + + var user = UserStore.getCurrentUser(); + var nickname = this.state.nickname.trim(); + + if (user.nickname === nickname) { + this.setState({clientError: 'You must submit a new nickname'}); + return; + } + + user.nickname = nickname; + + this.submitUser(user); + } + submitName(e) { + e.preventDefault(); + + var user = UserStore.getCurrentUser(); + var firstName = this.state.firstName.trim(); + var lastName = this.state.lastName.trim(); + + if (user.first_name === firstName && user.last_name === lastName) { + this.setState({clientError: 'You must submit a new first or last name'}); + return; + } + + user.first_name = firstName; + user.last_name = lastName; + + this.submitUser(user); + } + submitEmail(e) { + e.preventDefault(); + + var user = UserStore.getCurrentUser(); + var email = this.state.email.trim().toLowerCase(); + + if (user.email === email) { + return; + } + + if (email === '' || !utils.isEmail(email)) { + this.setState({emailError: 'Please enter a valid email address'}); + return; + } + + user.email = email; + + this.submitUser(user); + } + submitUser(user) { + client.updateUser(user, + function updateSuccess() { + this.updateSection(''); + AsyncClient.getMe(); + }.bind(this), + function updateFailure(err) { + var state = this.setupInitialState(this.props); + if (err.message) { + state.serverError = err.message; + } else { + state.serverError = err; + } + this.setState(state); + }.bind(this) + ); + } + submitPicture(e) { + e.preventDefault(); + + if (!this.state.picture) { + return; + } + + if (!this.submitActive) { + return; + } + + var picture = this.state.picture; + + if (picture.type !== 'image/jpeg' && picture.type !== 'image/png') { + this.setState({clientError: 'Only JPG or PNG images may be used for profile pictures'}); + return; + } + + var formData = new FormData(); + formData.append('image', picture, picture.name); + this.setState({loadingPicture: true}); + + client.uploadProfileImage(formData, + function imageUploadSuccess() { + this.submitActive = false; + AsyncClient.getMe(); + window.location.reload(); + }.bind(this), + function imageUploadFailure(err) { + var state = this.setupInitialState(this.props); + state.serverError = err; + this.setState(state); + }.bind(this) + ); + } + updateUsername(e) { + this.setState({username: e.target.value}); + } + updateFirstName(e) { + this.setState({firstName: e.target.value}); + } + updateLastName(e) { + this.setState({lastName: e.target.value}); + } + updateNickname(e) { + this.setState({nickname: e.target.value}); + } + updateEmail(e) { + this.setState({email: e.target.value}); + } + updatePicture(e) { + if (e.target.files && e.target.files[0]) { + this.setState({picture: e.target.files[0]}); + + this.submitActive = true; + this.setState({clientError: null}); + } else { + this.setState({picture: null}); + } + } + updateSection(section) { + this.setState(assign({}, this.setupInitialState(this.props), {clientError: '', serverError: '', emailError: ''})); + this.submitActive = false; + this.props.updateSection(section); + } + handleClose() { + $(React.findDOMNode(this)).find('.form-control').each(function clearForms() { + this.value = ''; + }); + + this.setState(assign({}, this.setupInitialState(this.props), {clientError: null, serverError: null, emailError: null})); + this.props.updateSection(''); + } + componentDidMount() { + $('#user_settings').on('hidden.bs.modal', this.handleClose); + } + componentWillUnmount() { + $('#user_settings').off('hidden.bs.modal', this.handleClose); + } + setupInitialState(props) { + var user = props.user; + var emailEnabled = !global.window.config.ByPassEmail; + return {username: user.username, firstName: user.first_name, lastName: user.last_name, nickname: user.nickname, + email: user.email, picture: null, loadingPicture: false, emailEnabled: emailEnabled}; + } + render() { + var user = this.props.user; + + var clientError = null; + if (this.state.clientError) { + clientError = this.state.clientError; + } + var serverError = null; + if (this.state.serverError) { + serverError = this.state.serverError; + } + var emailError = null; + if (this.state.emailError) { + emailError = this.state.emailError; + } + + var nameSection; + var inputs = []; + + if (this.props.activeSection === 'name') { + inputs.push( +
+ +
+ +
+
+ ); + + inputs.push( +
+ +
+ +
+
+ ); + + function notifClick(e) { + e.preventDefault(); + this.updateSection(''); + this.props.updateTab('notifications'); + } + + const notifLink = ( + + {'Notifications'} + + ); + + const extraInfo = ( + + {'By default, you will receive mention notifications when someone types your first name. '} + {'Go to '} {notifLink} {'settings to change this default.'} + + ); + + nameSection = ( + + ); + } else { + var fullName = ''; + + if (user.first_name && user.last_name) { + fullName = user.first_name + ' ' + user.last_name; + } else if (user.first_name) { + fullName = user.first_name; + } else if (user.last_name) { + fullName = user.last_name; + } + + nameSection = ( + + ); + } + + var nicknameSection; + if (this.props.activeSection === 'nickname') { + let nicknameLabel = 'Nickname'; + if (utils.isMobile()) { + nicknameLabel = ''; + } + + inputs.push( +
+ +
+ +
+
+ ); + + const extraInfo = ( + + {'Use Nickname for a name you might be called that is different from your first name and user name.'} + {'This is most often used when two or more people have similar sounding names and usernames.'} + + ); + + nicknameSection = ( + + ); + } else { + nicknameSection = ( + + ); + } + + var usernameSection; + if (this.props.activeSection === 'username') { + let usernameLabel = 'Username'; + if (utils.isMobile()) { + usernameLabel = ''; + } + + inputs.push( +
+ +
+ +
+
+ ); + + const extraInfo = ({'Pick something easy for teammates to recognize and recall.'}); + + usernameSection = ( + + ); + } else { + usernameSection = ( + + ); + } + var emailSection; + if (this.props.activeSection === 'email') { + let helpText =
Email is used for notifications, and requires verification if changed.
; + + if (!this.state.emailEnabled) { + helpText =
{'Email has been disabled by your system administrator. No notification emails will be sent until it is enabled.'}
; + } + + inputs.push( +
+
+ +
+ +
+
+ {helpText} +
+ ); + + emailSection = ( + + ); + } else { + emailSection = ( + + ); + } + + var pictureSection; + if (this.props.activeSection === 'picture') { + pictureSection = ( + + ); + } else { + var minMessage = 'Click \'Edit\' to upload an image.'; + if (user.last_picture_update) { + minMessage = 'Image last updated ' + utils.displayDate(user.last_picture_update); + } + pictureSection = ( + + ); + } + return ( +
+
+ +

+ + {'General Settings'} +

+
+
+

{'General Settings'}

+
+ {nameSection} +
+ {usernameSection} +
+ {nicknameSection} +
+ {emailSection} +
+ {pictureSection} +
+
+
+ ); + } +} + +UserSettingsGeneralTab.propTypes = { + user: React.PropTypes.object, + updateSection: React.PropTypes.func, + updateTab: React.PropTypes.func, + activeSection: React.PropTypes.string +}; diff --git a/web/react/components/user_settings/user_settings_integrations.jsx b/web/react/components/user_settings/user_settings_integrations.jsx new file mode 100644 index 000000000..cb45c5178 --- /dev/null +++ b/web/react/components/user_settings/user_settings_integrations.jsx @@ -0,0 +1,95 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var SettingItemMin = require('../setting_item_min.jsx'); +var SettingItemMax = require('../setting_item_max.jsx'); +var ManageIncomingHooks = require('./manage_incoming_hooks.jsx'); + +export default class UserSettingsIntegrationsTab extends React.Component { + constructor(props) { + super(props); + + this.updateSection = this.updateSection.bind(this); + this.handleClose = this.handleClose.bind(this); + + this.state = {}; + } + updateSection(section) { + this.props.updateSection(section); + } + handleClose() { + this.updateSection(''); + } + componentDidMount() { + $('#user_settings').on('hidden.bs.modal', this.handleClose); + } + componentWillUnmount() { + $('#user_settings').off('hidden.bs.modal', this.handleClose); + } + render() { + let incomingHooksSection; + var inputs = []; + + if (this.props.activeSection === 'incoming-hooks') { + inputs.push( + + ); + + incomingHooksSection = ( + + ); + } else { + incomingHooksSection = ( + + ); + } + + return ( +
+
+ +

+ + {'Integration Settings'} +

+
+
+

{'Integration Settings'}

+
+ {incomingHooksSection} +
+
+
+ ); + } +} + +UserSettingsIntegrationsTab.propTypes = { + user: React.PropTypes.object, + updateSection: React.PropTypes.func, + updateTab: React.PropTypes.func, + activeSection: React.PropTypes.string +}; diff --git a/web/react/components/user_settings/user_settings_modal.jsx b/web/react/components/user_settings/user_settings_modal.jsx new file mode 100644 index 000000000..1b22e6045 --- /dev/null +++ b/web/react/components/user_settings/user_settings_modal.jsx @@ -0,0 +1,96 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var SettingsSidebar = require('../settings_sidebar.jsx'); +var UserSettings = require('./user_settings.jsx'); + +export default class UserSettingsModal extends React.Component { + constructor(props) { + super(props); + + this.updateTab = this.updateTab.bind(this); + this.updateSection = this.updateSection.bind(this); + + this.state = {active_tab: 'general', active_section: ''}; + } + componentDidMount() { + $('body').on('click', '.modal-back', function changeDisplay() { + $(this).closest('.modal-dialog').removeClass('display--content'); + }); + $('body').on('click', '.modal-header .close', () => { + setTimeout(() => { + $('.modal-dialog.display--content').removeClass('display--content'); + }, 500); + }); + } + updateTab(tab) { + this.setState({active_tab: tab}); + } + updateSection(section) { + this.setState({active_section: section}); + } + render() { + var tabs = []; + tabs.push({name: 'general', uiName: 'General', icon: 'glyphicon glyphicon-cog'}); + tabs.push({name: 'security', uiName: 'Security', icon: 'glyphicon glyphicon-lock'}); + tabs.push({name: 'notifications', uiName: 'Notifications', icon: 'glyphicon glyphicon-exclamation-sign'}); + tabs.push({name: 'appearance', uiName: 'Appearance', icon: 'glyphicon glyphicon-wrench'}); + if (global.window.config.EnableOAuthServiceProvider === 'true') { + tabs.push({name: 'developer', uiName: 'Developer', icon: 'glyphicon glyphicon-th'}); + } + if (global.window.config.AllowIncomingWebhooks === 'true') { + tabs.push({name: 'integrations', uiName: 'Integrations', icon: 'glyphicon glyphicon-transfer'}); + } + + return ( + + ); + } +} diff --git a/web/react/components/user_settings/user_settings_notifications.jsx b/web/react/components/user_settings/user_settings_notifications.jsx new file mode 100644 index 000000000..fde4970ce --- /dev/null +++ b/web/react/components/user_settings/user_settings_notifications.jsx @@ -0,0 +1,697 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var UserStore = require('../../stores/user_store.jsx'); +var SettingItemMin = require('../setting_item_min.jsx'); +var SettingItemMax = require('../setting_item_max.jsx'); +var client = require('../../utils/client.jsx'); +var AsyncClient = require('../../utils/async_client.jsx'); +var utils = require('../../utils/utils.jsx'); +var assign = require('object-assign'); + +function getNotificationsStateFromStores() { + var user = UserStore.getCurrentUser(); + var soundNeeded = !utils.isBrowserFirefox(); + + var sound = 'true'; + if (user.notify_props && user.notify_props.desktop_sound) { + sound = user.notify_props.desktop_sound; + } + var desktop = 'all'; + if (user.notify_props && user.notify_props.desktop) { + desktop = user.notify_props.desktop; + } + var email = 'true'; + if (user.notify_props && user.notify_props.email) { + email = user.notify_props.email; + } + + var usernameKey = false; + var mentionKey = false; + var customKeys = ''; + var firstNameKey = false; + var allKey = false; + var channelKey = false; + + if (user.notify_props) { + if (user.notify_props.mention_keys) { + var keys = user.notify_props.mention_keys.split(','); + + if (keys.indexOf(user.username) !== -1) { + usernameKey = true; + keys.splice(keys.indexOf(user.username), 1); + } else { + usernameKey = false; + } + + if (keys.indexOf('@' + user.username) !== -1) { + mentionKey = true; + keys.splice(keys.indexOf('@' + user.username), 1); + } else { + mentionKey = false; + } + + customKeys = keys.join(','); + } + + if (user.notify_props.first_name) { + firstNameKey = user.notify_props.first_name === 'true'; + } + + if (user.notify_props.all) { + allKey = user.notify_props.all === 'true'; + } + + if (user.notify_props.channel) { + channelKey = user.notify_props.channel === 'true'; + } + } + + return {notifyLevel: desktop, enableEmail: email, soundNeeded: soundNeeded, enableSound: sound, + usernameKey: usernameKey, mentionKey: mentionKey, customKeys: customKeys, customKeysChecked: customKeys.length > 0, + firstNameKey: firstNameKey, allKey: allKey, channelKey: channelKey}; +} + +export default class NotificationsTab extends React.Component { + constructor(props) { + super(props); + + this.handleSubmit = this.handleSubmit.bind(this); + this.handleClose = this.handleClose.bind(this); + this.updateSection = this.updateSection.bind(this); + this.onListenerChange = this.onListenerChange.bind(this); + this.handleNotifyRadio = this.handleNotifyRadio.bind(this); + this.handleEmailRadio = this.handleEmailRadio.bind(this); + this.handleSoundRadio = this.handleSoundRadio.bind(this); + this.updateUsernameKey = this.updateUsernameKey.bind(this); + this.updateMentionKey = this.updateMentionKey.bind(this); + this.updateFirstNameKey = this.updateFirstNameKey.bind(this); + this.updateAllKey = this.updateAllKey.bind(this); + this.updateChannelKey = this.updateChannelKey.bind(this); + this.updateCustomMentionKeys = this.updateCustomMentionKeys.bind(this); + this.onCustomChange = this.onCustomChange.bind(this); + + this.state = getNotificationsStateFromStores(); + } + handleSubmit() { + var data = {}; + data.user_id = this.props.user.id; + data.email = this.state.enableEmail; + data.desktop_sound = this.state.enableSound; + data.desktop = this.state.notifyLevel; + + var mentionKeys = []; + if (this.state.usernameKey) { + mentionKeys.push(this.props.user.username); + } + if (this.state.mentionKey) { + mentionKeys.push('@' + this.props.user.username); + } + + var stringKeys = mentionKeys.join(','); + if (this.state.customKeys.length > 0 && this.state.customKeysChecked) { + stringKeys += ',' + this.state.customKeys; + } + + data.mention_keys = stringKeys; + data.first_name = this.state.firstNameKey.toString(); + data.all = this.state.allKey.toString(); + data.channel = this.state.channelKey.toString(); + + client.updateUserNotifyProps(data, + function success() { + this.props.updateSection(''); + AsyncClient.getMe(); + }.bind(this), + function failure(err) { + this.setState({serverError: err.message}); + }.bind(this) + ); + } + handleClose() { + $(React.findDOMNode(this)).find('.form-control').each(function clearField() { + this.value = ''; + }); + + this.setState(assign({}, getNotificationsStateFromStores(), {serverError: null})); + + this.props.updateTab('general'); + } + updateSection(section) { + this.setState(getNotificationsStateFromStores()); + this.props.updateSection(section); + } + componentDidMount() { + UserStore.addChangeListener(this.onListenerChange); + $('#user_settings').on('hidden.bs.modal', this.handleClose); + } + componentWillUnmount() { + UserStore.removeChangeListener(this.onListenerChange); + $('#user_settings').off('hidden.bs.modal', this.handleClose); + this.props.updateSection(''); + } + onListenerChange() { + var newState = getNotificationsStateFromStores(); + if (!utils.areStatesEqual(newState, this.state)) { + this.setState(newState); + } + } + handleNotifyRadio(notifyLevel) { + this.setState({notifyLevel: notifyLevel}); + React.findDOMNode(this.refs.wrapper).focus(); + } + handleEmailRadio(enableEmail) { + this.setState({enableEmail: enableEmail}); + React.findDOMNode(this.refs.wrapper).focus(); + } + handleSoundRadio(enableSound) { + this.setState({enableSound: enableSound}); + React.findDOMNode(this.refs.wrapper).focus(); + } + updateUsernameKey(val) { + this.setState({usernameKey: val}); + } + updateMentionKey(val) { + this.setState({mentionKey: val}); + } + updateFirstNameKey(val) { + this.setState({firstNameKey: val}); + } + updateAllKey(val) { + this.setState({allKey: val}); + } + updateChannelKey(val) { + this.setState({channelKey: val}); + } + updateCustomMentionKeys() { + var checked = React.findDOMNode(this.refs.customcheck).checked; + + if (checked) { + var text = React.findDOMNode(this.refs.custommentions).value; + + // remove all spaces and split string into individual keys + this.setState({customKeys: text.replace(/ /g, ''), customKeysChecked: true}); + } else { + this.setState({customKeys: '', customKeysChecked: false}); + } + } + onCustomChange() { + React.findDOMNode(this.refs.customcheck).checked = true; + this.updateCustomMentionKeys(); + } + render() { + var serverError = null; + if (this.state.serverError) { + serverError = this.state.serverError; + } + + var user = this.props.user; + + var desktopSection; + var handleUpdateDesktopSection; + if (this.props.activeSection === 'desktop') { + var notifyActive = [false, false, false]; + if (this.state.notifyLevel === 'mention') { + notifyActive[1] = true; + } else if (this.state.notifyLevel === 'none') { + notifyActive[2] = true; + } else { + notifyActive[0] = true; + } + + let inputs = []; + + inputs.push( +
+
+ +
+
+
+ +
+
+
+ +
+
+ ); + + handleUpdateDesktopSection = function updateDesktopSection(e) { + this.props.updateSection(''); + e.preventDefault(); + }.bind(this); + + desktopSection = ( + + ); + } else { + let describe = ''; + if (this.state.notifyLevel === 'mention') { + describe = 'Only for mentions and private messages'; + } else if (this.state.notifyLevel === 'none') { + describe = 'Never'; + } else { + describe = 'For all activity'; + } + + handleUpdateDesktopSection = function updateDesktopSection() { + this.props.updateSection('desktop'); + }.bind(this); + + desktopSection = ( + + ); + } + + var soundSection; + var handleUpdateSoundSection; + if (this.props.activeSection === 'sound' && this.state.soundNeeded) { + var soundActive = [false, false]; + if (this.state.enableSound === 'false') { + soundActive[1] = true; + } else { + soundActive[0] = true; + } + + let inputs = []; + + inputs.push( +
+
+ +
+
+
+ +
+
+
+ ); + + handleUpdateSoundSection = function updateSoundSection(e) { + this.props.updateSection(''); + e.preventDefault(); + }.bind(this); + + soundSection = ( + + ); + } else { + let describe = ''; + if (!this.state.soundNeeded) { + describe = 'Please configure notification sounds in your browser settings'; + } else if (this.state.enableSound === 'false') { + describe = 'Off'; + } else { + describe = 'On'; + } + + handleUpdateSoundSection = function updateSoundSection() { + this.props.updateSection('sound'); + }.bind(this); + + soundSection = ( + + ); + } + + var emailSection; + var handleUpdateEmailSection; + if (this.props.activeSection === 'email') { + var emailActive = [false, false]; + if (this.state.enableEmail === 'false') { + emailActive[1] = true; + } else { + emailActive[0] = true; + } + + let inputs = []; + + inputs.push( +
+
+ +
+
+
+ +
+
+

{'Email notifications are sent for mentions and private messages after you have been away from ' + global.window.config.SiteName + ' for 5 minutes.'}
+
+ ); + + handleUpdateEmailSection = function updateEmailSection(e) { + this.props.updateSection(''); + e.preventDefault(); + }.bind(this); + + emailSection = ( + + ); + } else { + let describe = ''; + if (this.state.enableEmail === 'false') { + describe = 'Off'; + } else { + describe = 'On'; + } + + handleUpdateEmailSection = function updateEmailSection() { + this.props.updateSection('email'); + }.bind(this); + + emailSection = ( + + ); + } + + var keysSection; + var handleUpdateKeysSection; + if (this.props.activeSection === 'keys') { + let inputs = []; + + let handleUpdateFirstNameKey; + let handleUpdateUsernameKey; + let handleUpdateMentionKey; + let handleUpdateAllKey; + let handleUpdateChannelKey; + + if (user.first_name) { + handleUpdateFirstNameKey = function handleFirstNameKeyChange(e) { + this.updateFirstNameKey(e.target.checked); + }.bind(this); + inputs.push( +
+
+ +
+
+ ); + } + + handleUpdateUsernameKey = function handleUsernameKeyChange(e) { + this.updateUsernameKey(e.target.checked); + }.bind(this); + inputs.push( +
+
+ +
+
+ ); + + handleUpdateMentionKey = function handleMentionKeyChange(e) { + this.updateMentionKey(e.target.checked); + }.bind(this); + inputs.push( +
+
+ +
+
+ ); + + handleUpdateAllKey = function handleAllKeyChange(e) { + this.updateAllKey(e.target.checked); + }.bind(this); + inputs.push( +
+
+ +
+
+ ); + + handleUpdateChannelKey = function handleChannelKeyChange(e) { + this.updateChannelKey(e.target.checked); + }.bind(this); + inputs.push( +
+
+ +
+
+ ); + + inputs.push( +
+
+ +
+ +
+ ); + + handleUpdateKeysSection = function updateKeysSection(e) { + this.props.updateSection(''); + e.preventDefault(); + }.bind(this); + keysSection = ( + + ); + } else { + let keys = []; + if (this.state.firstNameKey) { + keys.push(user.first_name); + } + if (this.state.usernameKey) { + keys.push(user.username); + } + if (this.state.mentionKey) { + keys.push('@' + user.username); + } + if (this.state.allKey) { + keys.push('@all'); + } + if (this.state.channelKey) { + keys.push('@channel'); + } + if (this.state.customKeys.length > 0) { + keys = keys.concat(this.state.customKeys.split(',')); + } + + let describe = ''; + for (var i = 0; i < keys.length; i++) { + describe += '"' + keys[i] + '", '; + } + + if (describe.length > 0) { + describe = describe.substring(0, describe.length - 2); + } else { + describe = 'No words configured'; + } + + handleUpdateKeysSection = function updateKeysSection() { + this.props.updateSection('keys'); + }.bind(this); + + keysSection = ( + + ); + } + + return ( +
+
+ +

+ + Notifications +

+
+
+

Notifications

+
+ {desktopSection} +
+ {soundSection} +
+ {emailSection} +
+ {keysSection} +
+
+
+ + ); + } +} + +NotificationsTab.defaultProps = { + user: null, + activeSection: '', + activeTab: '' +}; +NotificationsTab.propTypes = { + user: React.PropTypes.object, + updateSection: React.PropTypes.func, + updateTab: React.PropTypes.func, + activeSection: React.PropTypes.string, + activeTab: React.PropTypes.string +}; diff --git a/web/react/components/user_settings/user_settings_security.jsx b/web/react/components/user_settings/user_settings_security.jsx new file mode 100644 index 000000000..b59c08af0 --- /dev/null +++ b/web/react/components/user_settings/user_settings_security.jsx @@ -0,0 +1,300 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +var SettingItemMin = require('../setting_item_min.jsx'); +var SettingItemMax = require('../setting_item_max.jsx'); +var Client = require('../../utils/client.jsx'); +var AsyncClient = require('../../utils/async_client.jsx'); +var Constants = require('../../utils/constants.jsx'); + +export default class SecurityTab extends React.Component { + constructor(props) { + super(props); + + this.submitPassword = this.submitPassword.bind(this); + this.updateCurrentPassword = this.updateCurrentPassword.bind(this); + this.updateNewPassword = this.updateNewPassword.bind(this); + this.updateConfirmPassword = this.updateConfirmPassword.bind(this); + this.handleClose = this.handleClose.bind(this); + this.setupInitialState = this.setupInitialState.bind(this); + + this.state = this.setupInitialState(); + } + submitPassword(e) { + e.preventDefault(); + + var user = this.props.user; + var currentPassword = this.state.currentPassword; + var newPassword = this.state.newPassword; + var confirmPassword = this.state.confirmPassword; + + if (currentPassword === '') { + this.setState({passwordError: 'Please enter your current password', serverError: ''}); + return; + } + + if (newPassword.length < 5) { + this.setState({passwordError: 'New passwords must be at least 5 characters', serverError: ''}); + return; + } + + if (newPassword !== confirmPassword) { + this.setState({passwordError: 'The new passwords you entered do not match', serverError: ''}); + return; + } + + var data = {}; + data.user_id = user.id; + data.current_password = currentPassword; + data.new_password = newPassword; + + Client.updatePassword(data, + function success() { + this.props.updateSection(''); + AsyncClient.getMe(); + this.setState(this.setupInitialState()); + }.bind(this), + function fail(err) { + var state = this.setupInitialState(); + if (err.message) { + state.serverError = err.message; + } else { + state.serverError = err; + } + state.passwordError = ''; + this.setState(state); + }.bind(this) + ); + } + updateCurrentPassword(e) { + this.setState({currentPassword: e.target.value}); + } + updateNewPassword(e) { + this.setState({newPassword: e.target.value}); + } + updateConfirmPassword(e) { + this.setState({confirmPassword: e.target.value}); + } + handleHistoryOpen() { + $('#user_settings').modal('hide'); + } + handleDevicesOpen() { + $('#user_settings').modal('hide'); + } + handleClose() { + $(React.findDOMNode(this)).find('.form-control').each(function resetValue() { + this.value = ''; + }); + this.setState({currentPassword: '', newPassword: '', confirmPassword: '', serverError: null, passwordError: null}); + + this.props.updateTab('general'); + } + setupInitialState() { + return {currentPassword: '', newPassword: '', confirmPassword: ''}; + } + componentDidMount() { + $('#user_settings').on('hidden.bs.modal', this.handleClose); + } + componentWillUnmount() { + $('#user_settings').off('hidden.bs.modal', this.handleClose); + this.props.updateSection(''); + } + render() { + var serverError; + if (this.state.serverError) { + serverError = this.state.serverError; + } + var passwordError; + if (this.state.passwordError) { + passwordError = this.state.passwordError; + } + + var updateSectionStatus; + var passwordSection; + if (this.props.activeSection === 'password') { + var inputs = []; + var submit = null; + + if (this.props.user.auth_service === '') { + inputs.push( +
+ +
+ +
+
+ ); + inputs.push( +
+ +
+ +
+
+ ); + inputs.push( +
+ +
+ +
+
+ ); + + submit = this.submitPassword; + } else { + inputs.push( +
+ +
+ ); + } + + updateSectionStatus = function resetSection(e) { + this.props.updateSection(''); + this.setState({currentPassword: '', newPassword: '', confirmPassword: '', serverError: null, passwordError: null}); + e.preventDefault(); + }.bind(this); + + passwordSection = ( + + ); + } else { + var describe; + if (this.props.user.auth_service === '') { + var d = new Date(this.props.user.last_password_update); + var hour = '12'; + if (d.getHours() % 12) { + hour = String(d.getHours() % 12); + } + var min = String(d.getMinutes()); + if (d.getMinutes() < 10) { + min = '0' + d.getMinutes(); + } + var timeOfDay = ' am'; + if (d.getHours() >= 12) { + timeOfDay = ' pm'; + } + + describe = 'Last updated ' + Constants.MONTHS[d.getMonth()] + ' ' + d.getDate() + ', ' + d.getFullYear() + ' at ' + hour + ':' + min + timeOfDay; + } else { + describe = 'Log in done through GitLab'; + } + + updateSectionStatus = function updateSection() { + this.props.updateSection('password'); + }.bind(this); + + passwordSection = ( + + ); + } + + return ( +
+
+ +

+ Security Settings +

+
+
+

Security Settings

+
+ {passwordSection} +
+
    +
  • {'Version ' + global.window.config.Version}
  • +
  • +
    {'Build Number: ' + global.window.config.BuildNumber}
    +
    {'Build Date: ' + global.window.config.BuildDate}
    +
    {'Build Hash: ' + global.window.config.BuildHash}
    +
  • +
+ +
+ ); + } +} + +SecurityTab.defaultProps = { + user: {}, + activeSection: '' +}; +SecurityTab.propTypes = { + user: React.PropTypes.object, + activeSection: React.PropTypes.string, + updateSection: React.PropTypes.func, + updateTab: React.PropTypes.func +}; diff --git a/web/react/components/user_settings_appearance.jsx b/web/react/components/user_settings_appearance.jsx deleted file mode 100644 index 3df013d03..000000000 --- a/web/react/components/user_settings_appearance.jsx +++ /dev/null @@ -1,181 +0,0 @@ -// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. -// See License.txt for license information. - -var UserStore = require('../stores/user_store.jsx'); -var SettingItemMin = require('./setting_item_min.jsx'); -var SettingItemMax = require('./setting_item_max.jsx'); -var Client = require('../utils/client.jsx'); -var Utils = require('../utils/utils.jsx'); - -var ThemeColors = ['#2389d7', '#008a17', '#dc4fad', '#ac193d', '#0072c6', '#d24726', '#ff8f32', '#82ba00', '#03b3b2', '#008299', '#4617b4', '#8c0095', '#004b8b', '#004b8b', '#570000', '#380000', '#585858', '#000000']; - -export default class UserSettingsAppearance extends React.Component { - constructor(props) { - super(props); - - this.submitTheme = this.submitTheme.bind(this); - this.updateTheme = this.updateTheme.bind(this); - this.handleClose = this.handleClose.bind(this); - - this.state = this.getStateFromStores(); - } - getStateFromStores() { - var user = UserStore.getCurrentUser(); - var theme = '#2389d7'; - if (ThemeColors != null) { - theme = ThemeColors[0]; - } - if (user.props && user.props.theme) { - theme = user.props.theme; - } - - return {theme: theme.toLowerCase()}; - } - submitTheme(e) { - e.preventDefault(); - var user = UserStore.getCurrentUser(); - if (!user.props) { - user.props = {}; - } - user.props.theme = this.state.theme; - - Client.updateUser(user, - function success() { - this.props.updateSection(''); - window.location.reload(); - }.bind(this), - function fail(err) { - var state = this.getStateFromStores(); - state.serverError = err; - this.setState(state); - }.bind(this) - ); - } - updateTheme(e) { - var hex = Utils.rgb2hex(e.target.style.backgroundColor); - this.setState({theme: hex.toLowerCase()}); - } - handleClose() { - this.setState({serverError: null}); - this.props.updateTab('general'); - } - componentDidMount() { - if (this.props.activeSection === 'theme') { - $(React.findDOMNode(this.refs[this.state.theme])).addClass('active-border'); - } - $('#user_settings').on('hidden.bs.modal', this.handleClose); - } - componentDidUpdate() { - if (this.props.activeSection === 'theme') { - $('.color-btn').removeClass('active-border'); - $(React.findDOMNode(this.refs[this.state.theme])).addClass('active-border'); - } - } - componentWillUnmount() { - $('#user_settings').off('hidden.bs.modal', this.handleClose); - this.props.updateSection(''); - } - render() { - var serverError; - if (this.state.serverError) { - serverError = this.state.serverError; - } - - var themeSection; - var self = this; - - if (ThemeColors != null) { - if (this.props.activeSection === 'theme') { - var themeButtons = []; - - for (var i = 0; i < ThemeColors.length; i++) { - themeButtons.push( - -

- Appearance Settings -

-
-
-

Appearance Settings

-
- {themeSection} -
-
-
- ); - } -} - -UserSettingsAppearance.defaultProps = { - activeSection: '' -}; -UserSettingsAppearance.propTypes = { - activeSection: React.PropTypes.string, - updateSection: React.PropTypes.func, - updateTab: React.PropTypes.func -}; diff --git a/web/react/components/user_settings_developer.jsx b/web/react/components/user_settings_developer.jsx deleted file mode 100644 index 1b04149dc..000000000 --- a/web/react/components/user_settings_developer.jsx +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. -// See License.txt for license information. - -var SettingItemMin = require('./setting_item_min.jsx'); -var SettingItemMax = require('./setting_item_max.jsx'); - -export default class DeveloperTab extends React.Component { - constructor(props) { - super(props); - - this.state = {}; - } - register() { - $('#user_settings1').modal('hide'); - $('#register_app').modal('show'); - } - render() { - var appSection; - var self = this; - if (this.props.activeSection === 'app') { - var inputs = []; - - inputs.push( - - ); - - appSection = ( - - ); - } else { - appSection = ( - - ); - } - - return ( -
-
- -

- {'Developer Settings'} -

-
-
-

{'Developer Settings'}

-
- {appSection} -
-
-
- ); - } -} - -DeveloperTab.defaultProps = { - activeSection: '' -}; -DeveloperTab.propTypes = { - activeSection: React.PropTypes.string, - updateSection: React.PropTypes.func -}; diff --git a/web/react/components/user_settings_general.jsx b/web/react/components/user_settings_general.jsx deleted file mode 100644 index 66cde6ca2..000000000 --- a/web/react/components/user_settings_general.jsx +++ /dev/null @@ -1,562 +0,0 @@ -// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. -// See License.txt for license information. - -var UserStore = require('../stores/user_store.jsx'); -var SettingItemMin = require('./setting_item_min.jsx'); -var SettingItemMax = require('./setting_item_max.jsx'); -var SettingPicture = require('./setting_picture.jsx'); -var client = require('../utils/client.jsx'); -var AsyncClient = require('../utils/async_client.jsx'); -var utils = require('../utils/utils.jsx'); -var assign = require('object-assign'); - -export default class UserSettingsGeneralTab extends React.Component { - constructor(props) { - super(props); - this.submitActive = false; - - this.submitUsername = this.submitUsername.bind(this); - this.submitNickname = this.submitNickname.bind(this); - this.submitName = this.submitName.bind(this); - this.submitEmail = this.submitEmail.bind(this); - this.submitUser = this.submitUser.bind(this); - this.submitPicture = this.submitPicture.bind(this); - - this.updateUsername = this.updateUsername.bind(this); - this.updateFirstName = this.updateFirstName.bind(this); - this.updateLastName = this.updateLastName.bind(this); - this.updateNickname = this.updateNickname.bind(this); - this.updateEmail = this.updateEmail.bind(this); - this.updatePicture = this.updatePicture.bind(this); - this.updateSection = this.updateSection.bind(this); - - this.handleClose = this.handleClose.bind(this); - this.setupInitialState = this.setupInitialState.bind(this); - - this.state = this.setupInitialState(props); - } - submitUsername(e) { - e.preventDefault(); - - var user = this.props.user; - var username = this.state.username.trim().toLowerCase(); - - var usernameError = utils.isValidUsername(username); - if (usernameError === 'Cannot use a reserved word as a username.') { - this.setState({clientError: 'This username is reserved, please choose a new one.'}); - return; - } else if (usernameError) { - this.setState({clientError: "Username must begin with a letter, and contain between 3 to 15 lowercase characters made up of numbers, letters, and the symbols '.', '-' and '_'."}); - return; - } - - if (user.username === username) { - this.setState({clientError: 'You must submit a new username'}); - return; - } - - user.username = username; - - this.submitUser(user); - } - submitNickname(e) { - e.preventDefault(); - - var user = UserStore.getCurrentUser(); - var nickname = this.state.nickname.trim(); - - if (user.nickname === nickname) { - this.setState({clientError: 'You must submit a new nickname'}); - return; - } - - user.nickname = nickname; - - this.submitUser(user); - } - submitName(e) { - e.preventDefault(); - - var user = UserStore.getCurrentUser(); - var firstName = this.state.firstName.trim(); - var lastName = this.state.lastName.trim(); - - if (user.first_name === firstName && user.last_name === lastName) { - this.setState({clientError: 'You must submit a new first or last name'}); - return; - } - - user.first_name = firstName; - user.last_name = lastName; - - this.submitUser(user); - } - submitEmail(e) { - e.preventDefault(); - - var user = UserStore.getCurrentUser(); - var email = this.state.email.trim().toLowerCase(); - - if (user.email === email) { - return; - } - - if (email === '' || !utils.isEmail(email)) { - this.setState({emailError: 'Please enter a valid email address'}); - return; - } - - user.email = email; - - this.submitUser(user); - } - submitUser(user) { - client.updateUser(user, - function updateSuccess() { - this.updateSection(''); - AsyncClient.getMe(); - }.bind(this), - function updateFailure(err) { - var state = this.setupInitialState(this.props); - if (err.message) { - state.serverError = err.message; - } else { - state.serverError = err; - } - this.setState(state); - }.bind(this) - ); - } - submitPicture(e) { - e.preventDefault(); - - if (!this.state.picture) { - return; - } - - if (!this.submitActive) { - return; - } - - var picture = this.state.picture; - - if (picture.type !== 'image/jpeg' && picture.type !== 'image/png') { - this.setState({clientError: 'Only JPG or PNG images may be used for profile pictures'}); - return; - } - - var formData = new FormData(); - formData.append('image', picture, picture.name); - this.setState({loadingPicture: true}); - - client.uploadProfileImage(formData, - function imageUploadSuccess() { - this.submitActive = false; - AsyncClient.getMe(); - window.location.reload(); - }.bind(this), - function imageUploadFailure(err) { - var state = this.setupInitialState(this.props); - state.serverError = err; - this.setState(state); - }.bind(this) - ); - } - updateUsername(e) { - this.setState({username: e.target.value}); - } - updateFirstName(e) { - this.setState({firstName: e.target.value}); - } - updateLastName(e) { - this.setState({lastName: e.target.value}); - } - updateNickname(e) { - this.setState({nickname: e.target.value}); - } - updateEmail(e) { - this.setState({email: e.target.value}); - } - updatePicture(e) { - if (e.target.files && e.target.files[0]) { - this.setState({picture: e.target.files[0]}); - - this.submitActive = true; - this.setState({clientError: null}); - } else { - this.setState({picture: null}); - } - } - updateSection(section) { - this.setState(assign({}, this.setupInitialState(this.props), {clientError: '', serverError: '', emailError: ''})); - this.submitActive = false; - this.props.updateSection(section); - } - handleClose() { - $(React.findDOMNode(this)).find('.form-control').each(function clearForms() { - this.value = ''; - }); - - this.setState(assign({}, this.setupInitialState(this.props), {clientError: null, serverError: null, emailError: null})); - this.props.updateSection(''); - } - componentDidMount() { - $('#user_settings').on('hidden.bs.modal', this.handleClose); - } - componentWillUnmount() { - $('#user_settings').off('hidden.bs.modal', this.handleClose); - } - setupInitialState(props) { - var user = props.user; - var emailEnabled = !global.window.config.ByPassEmail; - return {username: user.username, firstName: user.first_name, lastName: user.last_name, nickname: user.nickname, - email: user.email, picture: null, loadingPicture: false, emailEnabled: emailEnabled}; - } - render() { - var user = this.props.user; - - var clientError = null; - if (this.state.clientError) { - clientError = this.state.clientError; - } - var serverError = null; - if (this.state.serverError) { - serverError = this.state.serverError; - } - var emailError = null; - if (this.state.emailError) { - emailError = this.state.emailError; - } - - var nameSection; - var inputs = []; - - if (this.props.activeSection === 'name') { - inputs.push( -
- -
- -
-
- ); - - inputs.push( -
- -
- -
-
- ); - - function notifClick(e) { - e.preventDefault(); - this.updateSection(''); - this.props.updateTab('notifications'); - } - - const notifLink = ( - - {'Notifications'} - - ); - - const extraInfo = ( - - {'By default, you will receive mention notifications when someone types your first name. '} - {'Go to '} {notifLink} {'settings to change this default.'} - - ); - - nameSection = ( - - ); - } else { - var fullName = ''; - - if (user.first_name && user.last_name) { - fullName = user.first_name + ' ' + user.last_name; - } else if (user.first_name) { - fullName = user.first_name; - } else if (user.last_name) { - fullName = user.last_name; - } - - nameSection = ( - - ); - } - - var nicknameSection; - if (this.props.activeSection === 'nickname') { - let nicknameLabel = 'Nickname'; - if (utils.isMobile()) { - nicknameLabel = ''; - } - - inputs.push( -
- -
- -
-
- ); - - const extraInfo = ( - - {'Use Nickname for a name you might be called that is different from your first name and user name.'} - {'This is most often used when two or more people have similar sounding names and usernames.'} - - ); - - nicknameSection = ( - - ); - } else { - nicknameSection = ( - - ); - } - - var usernameSection; - if (this.props.activeSection === 'username') { - let usernameLabel = 'Username'; - if (utils.isMobile()) { - usernameLabel = ''; - } - - inputs.push( -
- -
- -
-
- ); - - const extraInfo = ({'Pick something easy for teammates to recognize and recall.'}); - - usernameSection = ( - - ); - } else { - usernameSection = ( - - ); - } - var emailSection; - if (this.props.activeSection === 'email') { - let helpText =
Email is used for notifications, and requires verification if changed.
; - - if (!this.state.emailEnabled) { - helpText =
{'Email has been disabled by your system administrator. No notification emails will be sent until it is enabled.'}
; - } - - inputs.push( -
-
- -
- -
-
- {helpText} -
- ); - - emailSection = ( - - ); - } else { - emailSection = ( - - ); - } - - var pictureSection; - if (this.props.activeSection === 'picture') { - pictureSection = ( - - ); - } else { - var minMessage = 'Click \'Edit\' to upload an image.'; - if (user.last_picture_update) { - minMessage = 'Image last updated ' + utils.displayDate(user.last_picture_update); - } - pictureSection = ( - - ); - } - return ( -
-
- -

- - {'General Settings'} -

-
-
-

{'General Settings'}

-
- {nameSection} -
- {usernameSection} -
- {nicknameSection} -
- {emailSection} -
- {pictureSection} -
-
-
- ); - } -} - -UserSettingsGeneralTab.propTypes = { - user: React.PropTypes.object, - updateSection: React.PropTypes.func, - updateTab: React.PropTypes.func, - activeSection: React.PropTypes.string -}; diff --git a/web/react/components/user_settings_modal.jsx b/web/react/components/user_settings_modal.jsx deleted file mode 100644 index 67a4d0041..000000000 --- a/web/react/components/user_settings_modal.jsx +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. -// See License.txt for license information. - -var SettingsSidebar = require('./settings_sidebar.jsx'); -var UserSettings = require('./user_settings.jsx'); - -export default class UserSettingsModal extends React.Component { - constructor(props) { - super(props); - - this.updateTab = this.updateTab.bind(this); - this.updateSection = this.updateSection.bind(this); - - this.state = {active_tab: 'general', active_section: ''}; - } - componentDidMount() { - $('body').on('click', '.modal-back', function changeDisplay() { - $(this).closest('.modal-dialog').removeClass('display--content'); - }); - $('body').on('click', '.modal-header .close', () => { - setTimeout(() => { - $('.modal-dialog.display--content').removeClass('display--content'); - }, 500); - }); - } - updateTab(tab) { - this.setState({active_tab: tab}); - } - updateSection(section) { - this.setState({active_section: section}); - } - render() { - var tabs = []; - tabs.push({name: 'general', uiName: 'General', icon: 'glyphicon glyphicon-cog'}); - tabs.push({name: 'security', uiName: 'Security', icon: 'glyphicon glyphicon-lock'}); - tabs.push({name: 'notifications', uiName: 'Notifications', icon: 'glyphicon glyphicon-exclamation-sign'}); - tabs.push({name: 'appearance', uiName: 'Appearance', icon: 'glyphicon glyphicon-wrench'}); - if (global.window.config.EnableOAuthServiceProvider === 'true') { - tabs.push({name: 'developer', uiName: 'Developer', icon: 'glyphicon glyphicon-th'}); - } - - return ( - - ); - } -} diff --git a/web/react/components/user_settings_notifications.jsx b/web/react/components/user_settings_notifications.jsx deleted file mode 100644 index dadbb669b..000000000 --- a/web/react/components/user_settings_notifications.jsx +++ /dev/null @@ -1,697 +0,0 @@ -// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. -// See License.txt for license information. - -var UserStore = require('../stores/user_store.jsx'); -var SettingItemMin = require('./setting_item_min.jsx'); -var SettingItemMax = require('./setting_item_max.jsx'); -var client = require('../utils/client.jsx'); -var AsyncClient = require('../utils/async_client.jsx'); -var utils = require('../utils/utils.jsx'); -var assign = require('object-assign'); - -function getNotificationsStateFromStores() { - var user = UserStore.getCurrentUser(); - var soundNeeded = !utils.isBrowserFirefox(); - - var sound = 'true'; - if (user.notify_props && user.notify_props.desktop_sound) { - sound = user.notify_props.desktop_sound; - } - var desktop = 'all'; - if (user.notify_props && user.notify_props.desktop) { - desktop = user.notify_props.desktop; - } - var email = 'true'; - if (user.notify_props && user.notify_props.email) { - email = user.notify_props.email; - } - - var usernameKey = false; - var mentionKey = false; - var customKeys = ''; - var firstNameKey = false; - var allKey = false; - var channelKey = false; - - if (user.notify_props) { - if (user.notify_props.mention_keys) { - var keys = user.notify_props.mention_keys.split(','); - - if (keys.indexOf(user.username) !== -1) { - usernameKey = true; - keys.splice(keys.indexOf(user.username), 1); - } else { - usernameKey = false; - } - - if (keys.indexOf('@' + user.username) !== -1) { - mentionKey = true; - keys.splice(keys.indexOf('@' + user.username), 1); - } else { - mentionKey = false; - } - - customKeys = keys.join(','); - } - - if (user.notify_props.first_name) { - firstNameKey = user.notify_props.first_name === 'true'; - } - - if (user.notify_props.all) { - allKey = user.notify_props.all === 'true'; - } - - if (user.notify_props.channel) { - channelKey = user.notify_props.channel === 'true'; - } - } - - return {notifyLevel: desktop, enableEmail: email, soundNeeded: soundNeeded, enableSound: sound, - usernameKey: usernameKey, mentionKey: mentionKey, customKeys: customKeys, customKeysChecked: customKeys.length > 0, - firstNameKey: firstNameKey, allKey: allKey, channelKey: channelKey}; -} - -export default class NotificationsTab extends React.Component { - constructor(props) { - super(props); - - this.handleSubmit = this.handleSubmit.bind(this); - this.handleClose = this.handleClose.bind(this); - this.updateSection = this.updateSection.bind(this); - this.onListenerChange = this.onListenerChange.bind(this); - this.handleNotifyRadio = this.handleNotifyRadio.bind(this); - this.handleEmailRadio = this.handleEmailRadio.bind(this); - this.handleSoundRadio = this.handleSoundRadio.bind(this); - this.updateUsernameKey = this.updateUsernameKey.bind(this); - this.updateMentionKey = this.updateMentionKey.bind(this); - this.updateFirstNameKey = this.updateFirstNameKey.bind(this); - this.updateAllKey = this.updateAllKey.bind(this); - this.updateChannelKey = this.updateChannelKey.bind(this); - this.updateCustomMentionKeys = this.updateCustomMentionKeys.bind(this); - this.onCustomChange = this.onCustomChange.bind(this); - - this.state = getNotificationsStateFromStores(); - } - handleSubmit() { - var data = {}; - data.user_id = this.props.user.id; - data.email = this.state.enableEmail; - data.desktop_sound = this.state.enableSound; - data.desktop = this.state.notifyLevel; - - var mentionKeys = []; - if (this.state.usernameKey) { - mentionKeys.push(this.props.user.username); - } - if (this.state.mentionKey) { - mentionKeys.push('@' + this.props.user.username); - } - - var stringKeys = mentionKeys.join(','); - if (this.state.customKeys.length > 0 && this.state.customKeysChecked) { - stringKeys += ',' + this.state.customKeys; - } - - data.mention_keys = stringKeys; - data.first_name = this.state.firstNameKey.toString(); - data.all = this.state.allKey.toString(); - data.channel = this.state.channelKey.toString(); - - client.updateUserNotifyProps(data, - function success() { - this.props.updateSection(''); - AsyncClient.getMe(); - }.bind(this), - function failure(err) { - this.setState({serverError: err.message}); - }.bind(this) - ); - } - handleClose() { - $(React.findDOMNode(this)).find('.form-control').each(function clearField() { - this.value = ''; - }); - - this.setState(assign({}, getNotificationsStateFromStores(), {serverError: null})); - - this.props.updateTab('general'); - } - updateSection(section) { - this.setState(getNotificationsStateFromStores()); - this.props.updateSection(section); - } - componentDidMount() { - UserStore.addChangeListener(this.onListenerChange); - $('#user_settings').on('hidden.bs.modal', this.handleClose); - } - componentWillUnmount() { - UserStore.removeChangeListener(this.onListenerChange); - $('#user_settings').off('hidden.bs.modal', this.handleClose); - this.props.updateSection(''); - } - onListenerChange() { - var newState = getNotificationsStateFromStores(); - if (!utils.areStatesEqual(newState, this.state)) { - this.setState(newState); - } - } - handleNotifyRadio(notifyLevel) { - this.setState({notifyLevel: notifyLevel}); - React.findDOMNode(this.refs.wrapper).focus(); - } - handleEmailRadio(enableEmail) { - this.setState({enableEmail: enableEmail}); - React.findDOMNode(this.refs.wrapper).focus(); - } - handleSoundRadio(enableSound) { - this.setState({enableSound: enableSound}); - React.findDOMNode(this.refs.wrapper).focus(); - } - updateUsernameKey(val) { - this.setState({usernameKey: val}); - } - updateMentionKey(val) { - this.setState({mentionKey: val}); - } - updateFirstNameKey(val) { - this.setState({firstNameKey: val}); - } - updateAllKey(val) { - this.setState({allKey: val}); - } - updateChannelKey(val) { - this.setState({channelKey: val}); - } - updateCustomMentionKeys() { - var checked = React.findDOMNode(this.refs.customcheck).checked; - - if (checked) { - var text = React.findDOMNode(this.refs.custommentions).value; - - // remove all spaces and split string into individual keys - this.setState({customKeys: text.replace(/ /g, ''), customKeysChecked: true}); - } else { - this.setState({customKeys: '', customKeysChecked: false}); - } - } - onCustomChange() { - React.findDOMNode(this.refs.customcheck).checked = true; - this.updateCustomMentionKeys(); - } - render() { - var serverError = null; - if (this.state.serverError) { - serverError = this.state.serverError; - } - - var user = this.props.user; - - var desktopSection; - var handleUpdateDesktopSection; - if (this.props.activeSection === 'desktop') { - var notifyActive = [false, false, false]; - if (this.state.notifyLevel === 'mention') { - notifyActive[1] = true; - } else if (this.state.notifyLevel === 'none') { - notifyActive[2] = true; - } else { - notifyActive[0] = true; - } - - let inputs = []; - - inputs.push( -
-
- -
-
-
- -
-
-
- -
-
- ); - - handleUpdateDesktopSection = function updateDesktopSection(e) { - this.props.updateSection(''); - e.preventDefault(); - }.bind(this); - - desktopSection = ( - - ); - } else { - let describe = ''; - if (this.state.notifyLevel === 'mention') { - describe = 'Only for mentions and private messages'; - } else if (this.state.notifyLevel === 'none') { - describe = 'Never'; - } else { - describe = 'For all activity'; - } - - handleUpdateDesktopSection = function updateDesktopSection() { - this.props.updateSection('desktop'); - }.bind(this); - - desktopSection = ( - - ); - } - - var soundSection; - var handleUpdateSoundSection; - if (this.props.activeSection === 'sound' && this.state.soundNeeded) { - var soundActive = [false, false]; - if (this.state.enableSound === 'false') { - soundActive[1] = true; - } else { - soundActive[0] = true; - } - - let inputs = []; - - inputs.push( -
-
- -
-
-
- -
-
-
- ); - - handleUpdateSoundSection = function updateSoundSection(e) { - this.props.updateSection(''); - e.preventDefault(); - }.bind(this); - - soundSection = ( - - ); - } else { - let describe = ''; - if (!this.state.soundNeeded) { - describe = 'Please configure notification sounds in your browser settings'; - } else if (this.state.enableSound === 'false') { - describe = 'Off'; - } else { - describe = 'On'; - } - - handleUpdateSoundSection = function updateSoundSection() { - this.props.updateSection('sound'); - }.bind(this); - - soundSection = ( - - ); - } - - var emailSection; - var handleUpdateEmailSection; - if (this.props.activeSection === 'email') { - var emailActive = [false, false]; - if (this.state.enableEmail === 'false') { - emailActive[1] = true; - } else { - emailActive[0] = true; - } - - let inputs = []; - - inputs.push( -
-
- -
-
-
- -
-
-

{'Email notifications are sent for mentions and private messages after you have been away from ' + global.window.config.SiteName + ' for 5 minutes.'}
-
- ); - - handleUpdateEmailSection = function updateEmailSection(e) { - this.props.updateSection(''); - e.preventDefault(); - }.bind(this); - - emailSection = ( - - ); - } else { - let describe = ''; - if (this.state.enableEmail === 'false') { - describe = 'Off'; - } else { - describe = 'On'; - } - - handleUpdateEmailSection = function updateEmailSection() { - this.props.updateSection('email'); - }.bind(this); - - emailSection = ( - - ); - } - - var keysSection; - var handleUpdateKeysSection; - if (this.props.activeSection === 'keys') { - let inputs = []; - - let handleUpdateFirstNameKey; - let handleUpdateUsernameKey; - let handleUpdateMentionKey; - let handleUpdateAllKey; - let handleUpdateChannelKey; - - if (user.first_name) { - handleUpdateFirstNameKey = function handleFirstNameKeyChange(e) { - this.updateFirstNameKey(e.target.checked); - }.bind(this); - inputs.push( -
-
- -
-
- ); - } - - handleUpdateUsernameKey = function handleUsernameKeyChange(e) { - this.updateUsernameKey(e.target.checked); - }.bind(this); - inputs.push( -
-
- -
-
- ); - - handleUpdateMentionKey = function handleMentionKeyChange(e) { - this.updateMentionKey(e.target.checked); - }.bind(this); - inputs.push( -
-
- -
-
- ); - - handleUpdateAllKey = function handleAllKeyChange(e) { - this.updateAllKey(e.target.checked); - }.bind(this); - inputs.push( -
-
- -
-
- ); - - handleUpdateChannelKey = function handleChannelKeyChange(e) { - this.updateChannelKey(e.target.checked); - }.bind(this); - inputs.push( -
-
- -
-
- ); - - inputs.push( -
-
- -
- -
- ); - - handleUpdateKeysSection = function updateKeysSection(e) { - this.props.updateSection(''); - e.preventDefault(); - }.bind(this); - keysSection = ( - - ); - } else { - let keys = []; - if (this.state.firstNameKey) { - keys.push(user.first_name); - } - if (this.state.usernameKey) { - keys.push(user.username); - } - if (this.state.mentionKey) { - keys.push('@' + user.username); - } - if (this.state.allKey) { - keys.push('@all'); - } - if (this.state.channelKey) { - keys.push('@channel'); - } - if (this.state.customKeys.length > 0) { - keys = keys.concat(this.state.customKeys.split(',')); - } - - let describe = ''; - for (var i = 0; i < keys.length; i++) { - describe += '"' + keys[i] + '", '; - } - - if (describe.length > 0) { - describe = describe.substring(0, describe.length - 2); - } else { - describe = 'No words configured'; - } - - handleUpdateKeysSection = function updateKeysSection() { - this.props.updateSection('keys'); - }.bind(this); - - keysSection = ( - - ); - } - - return ( -
-
- -

- - Notifications -

-
-
-

Notifications

-
- {desktopSection} -
- {soundSection} -
- {emailSection} -
- {keysSection} -
-
-
- - ); - } -} - -NotificationsTab.defaultProps = { - user: null, - activeSection: '', - activeTab: '' -}; -NotificationsTab.propTypes = { - user: React.PropTypes.object, - updateSection: React.PropTypes.func, - updateTab: React.PropTypes.func, - activeSection: React.PropTypes.string, - activeTab: React.PropTypes.string -}; diff --git a/web/react/components/user_settings_security.jsx b/web/react/components/user_settings_security.jsx deleted file mode 100644 index c10d790ae..000000000 --- a/web/react/components/user_settings_security.jsx +++ /dev/null @@ -1,300 +0,0 @@ -// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. -// See License.txt for license information. - -var SettingItemMin = require('./setting_item_min.jsx'); -var SettingItemMax = require('./setting_item_max.jsx'); -var Client = require('../utils/client.jsx'); -var AsyncClient = require('../utils/async_client.jsx'); -var Constants = require('../utils/constants.jsx'); - -export default class SecurityTab extends React.Component { - constructor(props) { - super(props); - - this.submitPassword = this.submitPassword.bind(this); - this.updateCurrentPassword = this.updateCurrentPassword.bind(this); - this.updateNewPassword = this.updateNewPassword.bind(this); - this.updateConfirmPassword = this.updateConfirmPassword.bind(this); - this.handleClose = this.handleClose.bind(this); - this.setupInitialState = this.setupInitialState.bind(this); - - this.state = this.setupInitialState(); - } - submitPassword(e) { - e.preventDefault(); - - var user = this.props.user; - var currentPassword = this.state.currentPassword; - var newPassword = this.state.newPassword; - var confirmPassword = this.state.confirmPassword; - - if (currentPassword === '') { - this.setState({passwordError: 'Please enter your current password', serverError: ''}); - return; - } - - if (newPassword.length < 5) { - this.setState({passwordError: 'New passwords must be at least 5 characters', serverError: ''}); - return; - } - - if (newPassword !== confirmPassword) { - this.setState({passwordError: 'The new passwords you entered do not match', serverError: ''}); - return; - } - - var data = {}; - data.user_id = user.id; - data.current_password = currentPassword; - data.new_password = newPassword; - - Client.updatePassword(data, - function success() { - this.props.updateSection(''); - AsyncClient.getMe(); - this.setState(this.setupInitialState()); - }.bind(this), - function fail(err) { - var state = this.setupInitialState(); - if (err.message) { - state.serverError = err.message; - } else { - state.serverError = err; - } - state.passwordError = ''; - this.setState(state); - }.bind(this) - ); - } - updateCurrentPassword(e) { - this.setState({currentPassword: e.target.value}); - } - updateNewPassword(e) { - this.setState({newPassword: e.target.value}); - } - updateConfirmPassword(e) { - this.setState({confirmPassword: e.target.value}); - } - handleHistoryOpen() { - $('#user_settings').modal('hide'); - } - handleDevicesOpen() { - $('#user_settings').modal('hide'); - } - handleClose() { - $(React.findDOMNode(this)).find('.form-control').each(function resetValue() { - this.value = ''; - }); - this.setState({currentPassword: '', newPassword: '', confirmPassword: '', serverError: null, passwordError: null}); - - this.props.updateTab('general'); - } - setupInitialState() { - return {currentPassword: '', newPassword: '', confirmPassword: ''}; - } - componentDidMount() { - $('#user_settings').on('hidden.bs.modal', this.handleClose); - } - componentWillUnmount() { - $('#user_settings').off('hidden.bs.modal', this.handleClose); - this.props.updateSection(''); - } - render() { - var serverError; - if (this.state.serverError) { - serverError = this.state.serverError; - } - var passwordError; - if (this.state.passwordError) { - passwordError = this.state.passwordError; - } - - var updateSectionStatus; - var passwordSection; - if (this.props.activeSection === 'password') { - var inputs = []; - var submit = null; - - if (this.props.user.auth_service === '') { - inputs.push( -
- -
- -
-
- ); - inputs.push( -
- -
- -
-
- ); - inputs.push( -
- -
- -
-
- ); - - submit = this.submitPassword; - } else { - inputs.push( -
- -
- ); - } - - updateSectionStatus = function resetSection(e) { - this.props.updateSection(''); - this.setState({currentPassword: '', newPassword: '', confirmPassword: '', serverError: null, passwordError: null}); - e.preventDefault(); - }.bind(this); - - passwordSection = ( - - ); - } else { - var describe; - if (this.props.user.auth_service === '') { - var d = new Date(this.props.user.last_password_update); - var hour = '12'; - if (d.getHours() % 12) { - hour = String(d.getHours() % 12); - } - var min = String(d.getMinutes()); - if (d.getMinutes() < 10) { - min = '0' + d.getMinutes(); - } - var timeOfDay = ' am'; - if (d.getHours() >= 12) { - timeOfDay = ' pm'; - } - - describe = 'Last updated ' + Constants.MONTHS[d.getMonth()] + ' ' + d.getDate() + ', ' + d.getFullYear() + ' at ' + hour + ':' + min + timeOfDay; - } else { - describe = 'Log in done through GitLab'; - } - - updateSectionStatus = function updateSection() { - this.props.updateSection('password'); - }.bind(this); - - passwordSection = ( - - ); - } - - return ( -
-
- -

- Security Settings -

-
-
-

Security Settings

-
- {passwordSection} -
-
    -
  • {'Version ' + global.window.config.Version}
  • -
  • -
    {'Build Number: ' + global.window.config.BuildNumber}
    -
    {'Build Date: ' + global.window.config.BuildDate}
    -
    {'Build Hash: ' + global.window.config.BuildHash}
    -
  • -
- -
- ); - } -} - -SecurityTab.defaultProps = { - user: {}, - activeSection: '' -}; -SecurityTab.propTypes = { - user: React.PropTypes.object, - activeSection: React.PropTypes.string, - updateSection: React.PropTypes.func, - updateTab: React.PropTypes.func -}; diff --git a/web/react/pages/channel.jsx b/web/react/pages/channel.jsx index 43493de45..d24fe0b98 100644 --- a/web/react/pages/channel.jsx +++ b/web/react/pages/channel.jsx @@ -19,7 +19,7 @@ var DeletePostModal = require('../components/delete_post_modal.jsx'); var MoreChannelsModal = require('../components/more_channels.jsx'); var PostDeletedModal = require('../components/post_deleted_modal.jsx'); var ChannelNotificationsModal = require('../components/channel_notifications.jsx'); -var UserSettingsModal = require('../components/user_settings_modal.jsx'); +var UserSettingsModal = require('../components/user_settings/user_settings_modal.jsx'); var TeamSettingsModal = require('../components/team_settings_modal.jsx'); var ChannelMembersModal = require('../components/channel_members.jsx'); var ChannelInviteModal = require('../components/channel_invite_modal.jsx'); diff --git a/web/react/utils/client.jsx b/web/react/utils/client.jsx index c9eb09c00..531e4fdae 100644 --- a/web/react/utils/client.jsx +++ b/web/react/utils/client.jsx @@ -59,7 +59,7 @@ export function createTeamFromSignup(teamSignup, success, error) { contentType: 'application/json', type: 'POST', data: JSON.stringify(teamSignup), - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('createTeamFromSignup', xhr, status, err); error(e); @@ -74,7 +74,7 @@ export function createTeamWithSSO(team, service, success, error) { contentType: 'application/json', type: 'POST', data: JSON.stringify(team), - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('createTeamWithSSO', xhr, status, err); error(e); @@ -89,7 +89,7 @@ export function createUser(user, data, emailHash, success, error) { contentType: 'application/json', type: 'POST', data: JSON.stringify(user), - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('createUser', xhr, status, err); error(e); @@ -106,7 +106,7 @@ export function updateUser(user, success, error) { contentType: 'application/json', type: 'POST', data: JSON.stringify(user), - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('updateUser', xhr, status, err); error(e); @@ -123,7 +123,7 @@ export function updatePassword(data, success, error) { contentType: 'application/json', type: 'POST', data: JSON.stringify(data), - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('newPassword', xhr, status, err); error(e); @@ -140,7 +140,7 @@ export function updateUserNotifyProps(data, success, error) { contentType: 'application/json', type: 'POST', data: JSON.stringify(data), - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('updateUserNotifyProps', xhr, status, err); error(e); @@ -155,7 +155,7 @@ export function updateRoles(data, success, error) { contentType: 'application/json', type: 'POST', data: JSON.stringify(data), - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('updateRoles', xhr, status, err); error(e); @@ -176,7 +176,7 @@ export function updateActive(userId, active, success, error) { contentType: 'application/json', type: 'POST', data: JSON.stringify(data), - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('updateActive', xhr, status, err); error(e); @@ -193,7 +193,7 @@ export function sendPasswordReset(data, success, error) { contentType: 'application/json', type: 'POST', data: JSON.stringify(data), - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('sendPasswordReset', xhr, status, err); error(e); @@ -210,7 +210,7 @@ export function resetPassword(data, success, error) { contentType: 'application/json', type: 'POST', data: JSON.stringify(data), - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('resetPassword', xhr, status, err); error(e); @@ -254,7 +254,7 @@ export function revokeSession(altId, success, error) { contentType: 'application/json', type: 'POST', data: JSON.stringify({id: altId}), - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('revokeSession', xhr, status, err); error(e); @@ -269,7 +269,7 @@ export function getSessions(userId, success, error) { dataType: 'json', contentType: 'application/json', type: 'GET', - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('getSessions', xhr, status, err); error(e); @@ -283,7 +283,7 @@ export function getAudits(userId, success, error) { dataType: 'json', contentType: 'application/json', type: 'GET', - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('getAudits', xhr, status, err); error(e); @@ -367,7 +367,7 @@ export function inviteMembers(data, success, error) { contentType: 'application/json', type: 'POST', data: JSON.stringify(data), - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('inviteMembers', xhr, status, err); error(e); @@ -384,7 +384,7 @@ export function updateTeamDisplayName(data, success, error) { contentType: 'application/json', type: 'POST', data: JSON.stringify(data), - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('updateTeamDisplayName', xhr, status, err); error(e); @@ -401,7 +401,7 @@ export function signupTeam(email, success, error) { contentType: 'application/json', type: 'POST', data: JSON.stringify({email: email}), - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('singupTeam', xhr, status, err); error(e); @@ -418,7 +418,7 @@ export function createTeam(team, success, error) { contentType: 'application/json', type: 'POST', data: JSON.stringify(team), - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('createTeam', xhr, status, err); error(e); @@ -433,7 +433,7 @@ export function findTeamByName(teamName, success, error) { contentType: 'application/json', type: 'POST', data: JSON.stringify({name: teamName}), - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('findTeamByName', xhr, status, err); error(e); @@ -448,7 +448,7 @@ export function findTeamsSendEmail(email, success, error) { contentType: 'application/json', type: 'POST', data: JSON.stringify({email: email}), - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('findTeamsSendEmail', xhr, status, err); error(e); @@ -465,7 +465,7 @@ export function findTeams(email, success, error) { contentType: 'application/json', type: 'POST', data: JSON.stringify({email: email}), - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('findTeams', xhr, status, err); error(e); @@ -480,7 +480,7 @@ export function createChannel(channel, success, error) { contentType: 'application/json', type: 'POST', data: JSON.stringify(channel), - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('createChannel', xhr, status, err); error(e); @@ -497,7 +497,7 @@ export function createDirectChannel(channel, userId, success, error) { contentType: 'application/json', type: 'POST', data: JSON.stringify({user_id: userId}), - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('createDirectChannel', xhr, status, err); error(e); @@ -514,7 +514,7 @@ export function updateChannel(channel, success, error) { contentType: 'application/json', type: 'POST', data: JSON.stringify(channel), - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('updateChannel', xhr, status, err); error(e); @@ -531,7 +531,7 @@ export function updateChannelDesc(data, success, error) { contentType: 'application/json', type: 'POST', data: JSON.stringify(data), - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('updateChannelDesc', xhr, status, err); error(e); @@ -548,7 +548,7 @@ export function updateNotifyLevel(data, success, error) { contentType: 'application/json', type: 'POST', data: JSON.stringify(data), - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('updateNotifyLevel', xhr, status, err); error(e); @@ -562,7 +562,7 @@ export function joinChannel(id, success, error) { dataType: 'json', contentType: 'application/json', type: 'POST', - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('joinChannel', xhr, status, err); error(e); @@ -578,7 +578,7 @@ export function leaveChannel(id, success, error) { dataType: 'json', contentType: 'application/json', type: 'POST', - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('leaveChannel', xhr, status, err); error(e); @@ -594,7 +594,7 @@ export function deleteChannel(id, success, error) { dataType: 'json', contentType: 'application/json', type: 'POST', - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('deleteChannel', xhr, status, err); error(e); @@ -610,7 +610,7 @@ export function updateLastViewedAt(channelId, success, error) { dataType: 'json', contentType: 'application/json', type: 'POST', - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('updateLastViewedAt', xhr, status, err); error(e); @@ -624,7 +624,7 @@ export function getChannels(success, error) { url: '/api/v1/channels/', dataType: 'json', type: 'GET', - success: success, + success, ifModified: true, error: function onError(xhr, status, err) { var e = handleError('getChannels', xhr, status, err); @@ -639,7 +639,7 @@ export function getChannel(id, success, error) { url: '/api/v1/channels/' + id + '/', dataType: 'json', type: 'GET', - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('getChannel', xhr, status, err); error(e); @@ -654,7 +654,7 @@ export function getMoreChannels(success, error) { url: '/api/v1/channels/more', dataType: 'json', type: 'GET', - success: success, + success, ifModified: true, error: function onError(xhr, status, err) { var e = handleError('getMoreChannels', xhr, status, err); @@ -669,7 +669,7 @@ export function getChannelCounts(success, error) { url: '/api/v1/channels/counts', dataType: 'json', type: 'GET', - success: success, + success, ifModified: true, error: function onError(xhr, status, err) { var e = handleError('getChannelCounts', xhr, status, err); @@ -683,7 +683,7 @@ export function getChannelExtraInfo(id, success, error) { url: '/api/v1/channels/' + id + '/extra_info', dataType: 'json', type: 'GET', - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('getChannelExtraInfo', xhr, status, err); error(e); @@ -698,7 +698,7 @@ export function executeCommand(channelId, command, suggest, success, error) { contentType: 'application/json', type: 'POST', data: JSON.stringify({channelId: channelId, command: command, suggest: '' + suggest}), - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('executeCommand', xhr, status, err); error(e); @@ -713,7 +713,7 @@ export function getPostsPage(channelId, offset, limit, success, error, complete) dataType: 'json', type: 'GET', ifModified: true, - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('getPosts', xhr, status, err); error(e); @@ -728,7 +728,7 @@ export function getPosts(channelId, since, success, error, complete) { dataType: 'json', type: 'GET', ifModified: true, - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('getPosts', xhr, status, err); error(e); @@ -744,7 +744,7 @@ export function getPost(channelId, postId, success, error) { dataType: 'json', type: 'GET', ifModified: false, - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('getPost', xhr, status, err); error(e); @@ -758,7 +758,7 @@ export function search(terms, success, error) { dataType: 'json', type: 'GET', data: {terms: terms}, - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('search', xhr, status, err); error(e); @@ -774,7 +774,7 @@ export function deletePost(channelId, id, success, error) { dataType: 'json', contentType: 'application/json', type: 'POST', - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('deletePost', xhr, status, err); error(e); @@ -791,7 +791,7 @@ export function createPost(post, channel, success, error) { contentType: 'application/json', type: 'POST', data: JSON.stringify(post), - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('createPost', xhr, status, err); error(e); @@ -817,7 +817,7 @@ export function updatePost(post, success, error) { contentType: 'application/json', type: 'POST', data: JSON.stringify(post), - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('updatePost', xhr, status, err); error(e); @@ -834,7 +834,7 @@ export function addChannelMember(id, data, success, error) { contentType: 'application/json', type: 'POST', data: JSON.stringify(data), - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('addChannelMember', xhr, status, err); error(e); @@ -851,7 +851,7 @@ export function removeChannelMember(id, data, success, error) { contentType: 'application/json', type: 'POST', data: JSON.stringify(data), - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('removeChannelMember', xhr, status, err); error(e); @@ -868,7 +868,7 @@ export function getProfiles(success, error) { dataType: 'json', contentType: 'application/json', type: 'GET', - success: success, + success, ifModified: true, error: function onError(xhr, status, err) { var e = handleError('getProfiles', xhr, status, err); @@ -885,7 +885,7 @@ export function uploadFile(formData, success, error) { cache: false, contentType: false, processData: false, - success: success, + success, error: function onError(xhr, status, err) { if (err !== 'abort') { var e = handleError('uploadFile', xhr, status, err); @@ -905,7 +905,7 @@ export function getFileInfo(filename, success, error) { dataType: 'json', contentType: 'application/json', type: 'GET', - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('getFileInfo', xhr, status, err); error(e); @@ -919,7 +919,7 @@ export function getPublicLink(data, success, error) { dataType: 'json', type: 'POST', data: JSON.stringify(data), - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('getPublicLink', xhr, status, err); error(e); @@ -935,7 +935,7 @@ export function uploadProfileImage(imageData, success, error) { cache: false, contentType: false, processData: false, - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('uploadProfileImage', xhr, status, err); error(e); @@ -951,7 +951,7 @@ export function importSlack(fileData, success, error) { cache: false, contentType: false, processData: false, - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('importTeam', xhr, status, err); error(e); @@ -964,7 +964,7 @@ export function exportTeam(success, error) { url: '/api/v1/teams/export_team', type: 'GET', dataType: 'json', - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('exportTeam', xhr, status, err); error(e); @@ -978,7 +978,7 @@ export function getStatuses(success, error) { dataType: 'json', contentType: 'application/json', type: 'GET', - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('getStatuses', xhr, status, err); error(e); @@ -991,7 +991,7 @@ export function getMyTeam(success, error) { url: '/api/v1/teams/me', dataType: 'json', type: 'GET', - success: success, + success, ifModified: true, error: function onError(xhr, status, err) { var e = handleError('getMyTeam', xhr, status, err); @@ -1007,7 +1007,7 @@ export function updateValetFeature(data, success, error) { contentType: 'application/json', type: 'POST', data: JSON.stringify(data), - success: success, + success, error: function onError(xhr, status, err) { var e = handleError('updateValetFeature', xhr, status, err); error(e); @@ -1040,7 +1040,7 @@ export function allowOAuth2(responseType, clientId, redirectUri, state, scope, s dataType: 'json', contentType: 'application/json', type: 'GET', - success: success, + success, error: (xhr, status, err) => { const e = handleError('allowOAuth2', xhr, status, err); error(e); @@ -1049,3 +1049,46 @@ export function allowOAuth2(responseType, clientId, redirectUri, state, scope, s module.exports.track('api', 'api_users_allow_oauth2'); } + +export function addIncomingHook(hook, success, error) { + $.ajax({ + url: '/api/v1/hooks/incoming/create', + dataType: 'json', + contentType: 'application/json', + type: 'POST', + data: JSON.stringify(hook), + success, + error: (xhr, status, err) => { + var e = handleError('addIncomingHook', xhr, status, err); + error(e); + } + }); +} + +export function deleteIncomingHook(data, success, error) { + $.ajax({ + url: '/api/v1/hooks/incoming/delete', + dataType: 'json', + contentType: 'application/json', + type: 'POST', + data: JSON.stringify(data), + success, + error: (xhr, status, err) => { + var e = handleError('deleteIncomingHook', xhr, status, err); + error(e); + } + }); +} + +export function listIncomingHooks(success, error) { + $.ajax({ + url: '/api/v1/hooks/incoming/list', + dataType: 'json', + type: 'GET', + success, + error: (xhr, status, err) => { + var e = handleError('listIncomingHooks', xhr, status, err); + error(e); + } + }); +} diff --git a/web/web.go b/web/web.go index 305e4f199..95a5a5881 100644 --- a/web/web.go +++ b/web/web.go @@ -9,11 +9,13 @@ import ( "github.com/gorilla/mux" "github.com/mattermost/platform/api" "github.com/mattermost/platform/model" + "github.com/mattermost/platform/store" "github.com/mattermost/platform/utils" "github.com/mssola/user_agent" "gopkg.in/fsnotify.v1" "html/template" "net/http" + "regexp" "strconv" "strings" ) @@ -63,6 +65,8 @@ func InitWeb() { mainrouter.Handle("/admin_console", api.UserRequired(adminConsole)).Methods("GET") + mainrouter.Handle("/hooks/{id:[A-Za-z0-9]+}", api.ApiAppHandler(incomingWebhook)).Methods("POST") + // ---------------------------------------------------------------------------------------------- // *ANYTHING* team specific should go below this line // ---------------------------------------------------------------------------------------------- @@ -838,3 +842,84 @@ func getAccessToken(c *api.Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(accessRsp.ToJson())) } + +func incomingWebhook(c *api.Context, w http.ResponseWriter, r *http.Request) { + params := mux.Vars(r) + id := params["id"] + + hchan := api.Srv.Store.Webhook().GetIncoming(id) + + r.ParseForm() + + props := model.MapFromJson(strings.NewReader(r.FormValue("payload"))) + + text := props["text"] + if len(text) == 0 { + c.Err = model.NewAppError("incomingWebhook", "No text specified", "") + return + } + + channelName := props["channel"] + + var hook *model.IncomingWebhook + if result := <-hchan; result.Err != nil { + c.Err = model.NewAppError("incomingWebhook", "Invalid webhook", "err="+result.Err.Message) + return + } else { + hook = result.Data.(*model.IncomingWebhook) + } + + var channel *model.Channel + var cchan store.StoreChannel + + if len(channelName) != 0 { + if channelName[0] == '@' { + if result := <-api.Srv.Store.User().GetByUsername(hook.TeamId, channelName[1:]); result.Err != nil { + c.Err = model.NewAppError("incomingWebhook", "Couldn't find the user", "err="+result.Err.Message) + return + } else { + channelName = model.GetDMNameFromIds(result.Data.(*model.User).Id, hook.UserId) + } + } else if channelName[0] == '#' { + channelName = channelName[1:] + } + + cchan = api.Srv.Store.Channel().GetByName(hook.TeamId, channelName) + } else { + cchan = api.Srv.Store.Channel().Get(hook.ChannelId) + } + + // parse links into Markdown format + linkWithTextRegex := regexp.MustCompile(`<([^<\|]+)\|([^>]+)>`) + text = linkWithTextRegex.ReplaceAllString(text, "[${2}](${1})") + + linkRegex := regexp.MustCompile(`<\s*(\S*)\s*>`) + text = linkRegex.ReplaceAllString(text, "${1}") + + if result := <-cchan; result.Err != nil { + c.Err = model.NewAppError("incomingWebhook", "Couldn't find the channel", "err="+result.Err.Message) + return + } else { + channel = result.Data.(*model.Channel) + } + + pchan := api.Srv.Store.Channel().CheckPermissionsTo(hook.TeamId, channel.Id, hook.UserId) + + post := &model.Post{UserId: hook.UserId, ChannelId: channel.Id, Message: text} + + if !c.HasPermissionsToChannel(pchan, "createIncomingHook") && channel.Type != model.CHANNEL_OPEN { + c.Err = model.NewAppError("incomingWebhook", "Inappropriate channel permissions", "") + return + } + + // create a mock session + c.Session = model.Session{UserId: hook.UserId, TeamId: hook.TeamId, IsOAuth: false} + + if _, err := api.CreatePost(c, post, false); err != nil { + c.Err = model.NewAppError("incomingWebhook", "Error creating post", "err="+err.Message) + return + } + + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte("ok")) +} diff --git a/web/web_test.go b/web/web_test.go index 3da7eb2dc..1cb1c0a34 100644 --- a/web/web_test.go +++ b/web/web_test.go @@ -180,6 +180,51 @@ func TestGetAccessToken(t *testing.T) { } } +func TestIncomingWebhook(t *testing.T) { + Setup() + + team := &model.Team{DisplayName: "Name", Name: "z-z-" + model.NewId() + "a", Email: "test@nowhere.com", Type: model.TEAM_OPEN} + team = ApiClient.Must(ApiClient.CreateTeam(team)).Data.(*model.Team) + + user := &model.User{TeamId: team.Id, Email: model.NewId() + "corey@test.com", Nickname: "Corey Hulen", Password: "pwd"} + user = ApiClient.Must(ApiClient.CreateUser(user, "")).Data.(*model.User) + store.Must(api.Srv.Store.User().VerifyEmail(user.Id)) + + ApiClient.LoginByEmail(team.Name, user.Email, "pwd") + + channel1 := &model.Channel{DisplayName: "Test API Name", Name: "a" + model.NewId() + "a", Type: model.CHANNEL_OPEN, TeamId: team.Id} + channel1 = ApiClient.Must(ApiClient.CreateChannel(channel1)).Data.(*model.Channel) + + if utils.Cfg.ServiceSettings.AllowIncomingWebhooks { + hook1 := &model.IncomingWebhook{ChannelId: channel1.Id} + hook1 = ApiClient.Must(ApiClient.CreateIncomingWebhook(hook1)).Data.(*model.IncomingWebhook) + + payload := "payload={\"text\": \"test text\"}" + if _, err := ApiClient.PostToWebhook(hook1.Id, payload); err != nil { + t.Fatal(err) + } + + payload = "payload={\"text\": \"\"}" + if _, err := ApiClient.PostToWebhook(hook1.Id, payload); err == nil { + t.Fatal("should have errored - no text to post") + } + + payload = "payload={\"text\": \"test text\", \"channel\": \"junk\"}" + if _, err := ApiClient.PostToWebhook(hook1.Id, payload); err == nil { + t.Fatal("should have errored - bad channel") + } + + payload = "payload={\"text\": \"test text\"}" + if _, err := ApiClient.PostToWebhook("abc123", payload); err == nil { + t.Fatal("should have errored - bad hook") + } + } else { + if _, err := ApiClient.PostToWebhook("123", "123"); err == nil { + t.Fatal("should have failed - webhooks turned off") + } + } +} + func TestZZWebTearDown(t *testing.T) { // *IMPORTANT* // This should be the last function in any test file -- cgit v1.2.3-1-g7c22