diff options
-rw-r--r-- | Makefile | 2 | ||||
-rw-r--r-- | app/channel.go | 16 | ||||
-rw-r--r-- | app/diagnostics.go | 23 | ||||
-rw-r--r-- | app/diagnostics_test.go | 19 | ||||
-rw-r--r-- | app/plugin/api.go | 37 | ||||
-rw-r--r-- | app/plugin/base.go | 9 | ||||
-rw-r--r-- | app/plugin/hooks.go | 10 | ||||
-rw-r--r-- | app/plugin/jira/configuration.go | 10 | ||||
-rw-r--r-- | app/plugin/jira/plugin.go | 78 | ||||
-rw-r--r-- | app/plugin/jira/webhook.go | 168 | ||||
-rw-r--r-- | app/plugin/plugin.go | 9 | ||||
-rw-r--r-- | app/plugins.go | 86 | ||||
-rw-r--r-- | app/webhook.go | 37 | ||||
-rw-r--r-- | cmd/platform/server.go | 1 | ||||
-rw-r--r-- | config/config.json | 5 | ||||
-rw-r--r-- | model/config.go | 9 | ||||
-rw-r--r-- | webapp/components/admin_console/admin_sidebar.jsx | 9 | ||||
-rw-r--r-- | webapp/components/admin_console/generated_setting.jsx | 13 | ||||
-rw-r--r-- | webapp/components/suggestion/suggestion_box.jsx | 49 | ||||
-rwxr-xr-x | webapp/i18n/en.json | 14 | ||||
-rw-r--r-- | webapp/plugins/jira/components/settings.jsx | 251 | ||||
-rw-r--r-- | webapp/plugins/jira/components/style.scss | 60 | ||||
-rw-r--r-- | webapp/routes/route_admin_console.jsx | 5 | ||||
-rw-r--r-- | webapp/sass/routes/_admin-console.scss | 4 |
24 files changed, 891 insertions, 33 deletions
@@ -539,6 +539,8 @@ govet: $(GO) vet $(GOFLAGS) ./api || exit 1 $(GO) vet $(GOFLAGS) ./api4 || exit 1 $(GO) vet $(GOFLAGS) ./app || exit 1 + $(GO) vet $(GOFLAGS) ./app/plugin || exit 1 + $(GO) vet $(GOFLAGS) ./app/plugin/jira || exit 1 $(GO) vet $(GOFLAGS) ./cmd/platform || exit 1 $(GO) vet $(GOFLAGS) ./einterfaces || exit 1 $(GO) vet $(GOFLAGS) ./jobs || exit 1 diff --git a/app/channel.go b/app/channel.go index 03df0e800..4a0f94b42 100644 --- a/app/channel.go +++ b/app/channel.go @@ -1206,3 +1206,19 @@ func GetPinnedPosts(channelId string) (*model.PostList, *model.AppError) { return result.Data.(*model.PostList), nil } } + +func GetDirectChannel(userId1, userId2 string) (*model.Channel, *model.AppError) { + result := <-Srv.Store.Channel().GetByName("", model.GetDMNameFromIds(userId1, userId2), true) + if result.Err != nil && result.Err.Id == store.MISSING_CHANNEL_ERROR { + result := <-Srv.Store.Channel().CreateDirectChannel(userId1, userId2) + if result.Err != nil { + return nil, model.NewAppError("GetOrCreateDMChannel", "web.incoming_webhook.channel.app_error", nil, "err="+result.Err.Message, http.StatusBadRequest) + } + InvalidateCacheForUser(userId1) + InvalidateCacheForUser(userId2) + return result.Data.(*model.Channel), nil + } else if result.Err != nil { + return nil, model.NewAppError("GetOrCreateDMChannel", "web.incoming_webhook.channel.app_error", nil, "err="+result.Err.Message, result.Err.StatusCode) + } + return result.Data.(*model.Channel), nil +} diff --git a/app/diagnostics.go b/app/diagnostics.go index 603ceb8a5..a22e87587 100644 --- a/app/diagnostics.go +++ b/app/diagnostics.go @@ -4,6 +4,7 @@ package app import ( + "encoding/json" "log" "os" "runtime" @@ -38,6 +39,7 @@ const ( TRACK_CONFIG_ANALYTICS = "config_analytics" TRACK_CONFIG_ANNOUNCEMENT = "config_announcement" TRACK_CONFIG_ELASTICSEARCH = "config_elasticsearch" + TRACK_CONFIG_PLUGIN = "config_plugin" TRACK_ACTIVITY = "activity" TRACK_LICENSE = "license" @@ -87,6 +89,23 @@ func isDefault(setting interface{}, defaultValue interface{}) bool { return false } +func pluginSetting(plugin, key string, defaultValue interface{}) interface{} { + settings, ok := utils.Cfg.PluginSettings.Plugins[plugin] + if !ok { + return defaultValue + } + var m map[string]interface{} + if b, err := json.Marshal(settings); err != nil { + return defaultValue + } else { + json.Unmarshal(b, &m) + } + if value, ok := m[key]; ok { + return value + } + return defaultValue +} + func trackActivity() { var userCount int64 var activeUserCount int64 @@ -393,6 +412,10 @@ func trackConfig() { "enable_searching": *utils.Cfg.ElasticsearchSettings.EnableSearching, "sniff": *utils.Cfg.ElasticsearchSettings.Sniff, }) + + SendDiagnostic(TRACK_CONFIG_PLUGIN, map[string]interface{}{ + "enable_jira": pluginSetting("jira", "enabled", false), + }) } func trackLicense() { diff --git a/app/diagnostics_test.go b/app/diagnostics_test.go index 80c12fd2d..57e9eaf79 100644 --- a/app/diagnostics_test.go +++ b/app/diagnostics_test.go @@ -28,6 +28,24 @@ func newTestServer() (chan string, *httptest.Server) { return result, server } +func TestPluginSetting(t *testing.T) { + before := utils.Cfg.PluginSettings.Plugins + utils.Cfg.PluginSettings.Plugins = map[string]interface{}{ + "test": map[string]string{ + "foo": "bar", + }, + } + defer func() { + utils.Cfg.PluginSettings.Plugins = before + }() + if pluginSetting("test", "foo", "asd") != "bar" { + t.Fatal() + } + if pluginSetting("test", "qwe", "asd") != "asd" { + t.Fatal() + } +} + func TestDiagnostics(t *testing.T) { Setup().InitBasic() @@ -116,6 +134,7 @@ func TestDiagnostics(t *testing.T) { TRACK_CONFIG_SUPPORT, TRACK_CONFIG_NATIVEAPP, TRACK_CONFIG_ANALYTICS, + TRACK_CONFIG_PLUGIN, TRACK_ACTIVITY, TRACK_SERVER, } { diff --git a/app/plugin/api.go b/app/plugin/api.go new file mode 100644 index 000000000..ceea51969 --- /dev/null +++ b/app/plugin/api.go @@ -0,0 +1,37 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package plugin + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/mattermost/platform/model" +) + +type API interface { + // Loads the plugin's configuration + LoadPluginConfiguration(dest interface{}) error + + // The plugin's router + PluginRouter() *mux.Router + + // Gets a team by its name + GetTeamByName(name string) (*model.Team, *model.AppError) + + // Gets a user by its name + GetUserByName(name string) (*model.User, *model.AppError) + + // Gets a channel by its name + GetChannelByName(teamId, name string) (*model.Channel, *model.AppError) + + // Gets a direct message channel + GetDirectChannel(userId1, userId2 string) (*model.Channel, *model.AppError) + + // Creates a post + CreatePost(post *model.Post, teamId string) (*model.Post, *model.AppError) + + // Returns a localized string. If a request is given, its headers will be used to pick a locale. + I18n(id string, r *http.Request) string +} diff --git a/app/plugin/base.go b/app/plugin/base.go new file mode 100644 index 000000000..3ad696a77 --- /dev/null +++ b/app/plugin/base.go @@ -0,0 +1,9 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package plugin + +// Base provides default implementations for hooks. +type Base struct{} + +func (b *Base) OnConfigurationChange() {} diff --git a/app/plugin/hooks.go b/app/plugin/hooks.go new file mode 100644 index 000000000..31eac0710 --- /dev/null +++ b/app/plugin/hooks.go @@ -0,0 +1,10 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package plugin + +// All implementations should be safe for concurrent use. +type Hooks interface { + // Invoked when configuration changes may have been made + OnConfigurationChange() +} diff --git a/app/plugin/jira/configuration.go b/app/plugin/jira/configuration.go new file mode 100644 index 000000000..2afc20282 --- /dev/null +++ b/app/plugin/jira/configuration.go @@ -0,0 +1,10 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package jira + +type Configuration struct { + Enabled bool + Secret string + UserName string +} diff --git a/app/plugin/jira/plugin.go b/app/plugin/jira/plugin.go new file mode 100644 index 000000000..ad51e723c --- /dev/null +++ b/app/plugin/jira/plugin.go @@ -0,0 +1,78 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package jira + +import ( + "crypto/subtle" + "encoding/json" + "net/http" + "sync/atomic" + + l4g "github.com/alecthomas/log4go" + "github.com/mattermost/platform/app/plugin" + "github.com/mattermost/platform/model" +) + +type Plugin struct { + plugin.Base + api plugin.API + configuration atomic.Value +} + +func (p *Plugin) Initialize(api plugin.API) { + p.api = api + p.OnConfigurationChange() + api.PluginRouter().HandleFunc("/webhook", p.handleWebhook).Methods("POST") +} + +func (p *Plugin) config() *Configuration { + return p.configuration.Load().(*Configuration) +} + +func (p *Plugin) OnConfigurationChange() { + var configuration Configuration + if err := p.api.LoadPluginConfiguration(&configuration); err != nil { + l4g.Error(err.Error()) + } + p.configuration.Store(&configuration) +} + +func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) { + config := p.config() + if !config.Enabled || config.Secret == "" || config.UserName == "" { + http.Error(w, "This plugin is not configured.", http.StatusForbidden) + return + } else if subtle.ConstantTimeCompare([]byte(r.URL.Query().Get("secret")), []byte(config.Secret)) != 1 { + http.Error(w, "You must provide the configured secret.", http.StatusForbidden) + return + } + + var webhook Webhook + + if err := json.NewDecoder(r.Body).Decode(&webhook); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + } else if attachment, err := webhook.SlackAttachment(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } else if attachment == nil { + return + } else if r.URL.Query().Get("channel") == "" { + http.Error(w, "You must provide a channel.", http.StatusBadRequest) + } else if user, err := p.api.GetUserByName(config.UserName); err != nil { + http.Error(w, p.api.I18n(err.Message, r), err.StatusCode) + } else if team, err := p.api.GetTeamByName(r.URL.Query().Get("team")); err != nil { + http.Error(w, p.api.I18n(err.Message, r), err.StatusCode) + } else if channel, err := p.api.GetChannelByName(team.Id, r.URL.Query().Get("channel")); err != nil { + http.Error(w, p.api.I18n(err.Message, r), err.StatusCode) + } else if _, err := p.api.CreatePost(&model.Post{ + ChannelId: channel.Id, + Type: model.POST_SLACK_ATTACHMENT, + UserId: user.Id, + Props: map[string]interface{}{ + "from_webhook": "true", + "attachments": []*model.SlackAttachment{attachment}, + }, + }, channel.TeamId); err != nil { + http.Error(w, p.api.I18n(err.Message, r), err.StatusCode) + } +} diff --git a/app/plugin/jira/webhook.go b/app/plugin/jira/webhook.go new file mode 100644 index 000000000..c09e46703 --- /dev/null +++ b/app/plugin/jira/webhook.go @@ -0,0 +1,168 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package jira + +import ( + "bytes" + "net/url" + "strings" + "text/template" + + "github.com/mattermost/platform/model" +) + +type Webhook struct { + WebhookEvent string + Issue struct { + Self string + Key string + Fields struct { + Assignee *struct { + DisplayName string + Name string + } + Summary string + Description string + Priority *struct { + Id string + Name string + } + IssueType struct { + Name string + IconURL string + } + Resolution *struct { + Id string + } + Status struct { + Id string + } + } + } + User struct { + Name string + AvatarUrls map[string]string + DisplayName string + } + Comment struct { + Body string + } + ChangeLog struct { + Items []struct { + FromString string + ToString string + Field string + } + } +} + +// Returns the text to be placed in the resulting post or an empty string if nothing should be +// posted. +func (w *Webhook) SlackAttachment() (*model.SlackAttachment, error) { + switch w.WebhookEvent { + case "jira:issue_created": + case "jira:issue_updated": + isResolutionChange := false + for _, change := range w.ChangeLog.Items { + if change.Field == "resolution" { + isResolutionChange = (change.FromString == "") != (change.ToString == "") + break + } + } + if !isResolutionChange { + return nil, nil + } + case "jira:issue_deleted": + if w.Issue.Fields.Resolution != nil { + return nil, nil + } + default: + return nil, nil + } + + pretext, err := w.renderText("" + + "{{.User.DisplayName}} {{.Verb}} {{.Issue.Fields.IssueType.Name}} " + + "[{{.Issue.Key}}]({{.JIRAURL}}/browse/{{.Issue.Key}})" + + "") + if err != nil { + return nil, err + } + + text, err := w.renderText("" + + "[{{.Issue.Fields.Summary}}]({{.JIRAURL}}/browse/{{.Issue.Key}})" + + "{{if eq .WebhookEvent \"jira:issue_created\"}}{{if ne .Issue.Fields.Description \"\"}}" + + "\n\n{{.Issue.Fields.Description}}" + + "{{end}}{{end}}" + + "") + if err != nil { + return nil, err + } + + var fields []*model.SlackAttachmentField + if w.WebhookEvent == "jira:issue_created" { + if w.Issue.Fields.Assignee != nil { + fields = append(fields, &model.SlackAttachmentField{ + Title: "Assignee", + Value: w.Issue.Fields.Assignee.DisplayName, + Short: true, + }) + } + if w.Issue.Fields.Priority != nil { + fields = append(fields, &model.SlackAttachmentField{ + Title: "Priority", + Value: w.Issue.Fields.Priority.Name, + Short: true, + }) + } + } + + return &model.SlackAttachment{ + Fallback: pretext, + Color: "#95b7d0", + Pretext: pretext, + Text: text, + Fields: fields, + }, nil +} + +func (w *Webhook) renderText(tplBody string) (string, error) { + issueSelf, err := url.Parse(w.Issue.Self) + if err != nil { + return "", err + } + jiraURL := strings.TrimRight(issueSelf.ResolveReference(&url.URL{Path: "/"}).String(), "/") + verb := strings.TrimPrefix(w.WebhookEvent, "jira:issue_") + + if w.WebhookEvent == "jira:issue_updated" { + for _, change := range w.ChangeLog.Items { + if change.Field == "resolution" { + if change.ToString == "" && change.FromString != "" { + verb = "reopened" + } else if change.ToString != "" && change.FromString == "" { + verb = "resolved" + } + break + } + } + } + + tpl, err := template.New("post").Parse(tplBody) + if err != nil { + return "", err + } + + var buf bytes.Buffer + if err := tpl.Execute(&buf, struct { + *Webhook + JIRAURL string + Verb string + }{ + Webhook: w, + JIRAURL: jiraURL, + Verb: verb, + }); err != nil { + return "", err + } + return buf.String(), nil +} diff --git a/app/plugin/plugin.go b/app/plugin/plugin.go new file mode 100644 index 000000000..b4fec1be8 --- /dev/null +++ b/app/plugin/plugin.go @@ -0,0 +1,9 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package plugin + +type Plugin interface { + Initialize(API) + Hooks +} diff --git a/app/plugins.go b/app/plugins.go new file mode 100644 index 000000000..d569dcba2 --- /dev/null +++ b/app/plugins.go @@ -0,0 +1,86 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package app + +import ( + "encoding/json" + "net/http" + + l4g "github.com/alecthomas/log4go" + + "github.com/gorilla/mux" + "github.com/mattermost/platform/model" + "github.com/mattermost/platform/utils" + + "github.com/mattermost/platform/app/plugin" + "github.com/mattermost/platform/app/plugin/jira" +) + +type PluginAPI struct { + id string + router *mux.Router +} + +func (api *PluginAPI) LoadPluginConfiguration(dest interface{}) error { + if b, err := json.Marshal(utils.Cfg.PluginSettings.Plugins[api.id]); err != nil { + return err + } else { + return json.Unmarshal(b, dest) + } +} + +func (api *PluginAPI) PluginRouter() *mux.Router { + return api.router +} + +func (api *PluginAPI) GetTeamByName(name string) (*model.Team, *model.AppError) { + return GetTeamByName(name) +} + +func (api *PluginAPI) GetUserByName(name string) (*model.User, *model.AppError) { + return GetUserByUsername(name) +} + +func (api *PluginAPI) GetChannelByName(teamId, name string) (*model.Channel, *model.AppError) { + return GetChannelByName(name, teamId) +} + +func (api *PluginAPI) GetDirectChannel(userId1, userId2 string) (*model.Channel, *model.AppError) { + return GetDirectChannel(userId1, userId2) +} + +func (api *PluginAPI) CreatePost(post *model.Post, teamId string) (*model.Post, *model.AppError) { + return CreatePost(post, teamId, true) +} + +func (api *PluginAPI) I18n(id string, r *http.Request) string { + if r != nil { + f, _ := utils.GetTranslationsAndLocale(nil, r) + return f(id) + } + f, _ := utils.GetTranslationsBySystemLocale() + return f(id) +} + +func InitPlugins() { + plugins := map[string]plugin.Plugin{ + "jira": &jira.Plugin{}, + } + for id, p := range plugins { + l4g.Info("Initializing plugin: " + id) + api := &PluginAPI{ + id: id, + router: Srv.Router.PathPrefix("/plugins/" + id).Subrouter(), + } + p.Initialize(api) + } + utils.AddConfigListener(func(before, after *model.Config) { + for _, p := range plugins { + p.OnConfigurationChange() + } + }) + for _, p := range plugins { + p.OnConfigurationChange() + } +} diff --git a/app/webhook.go b/app/webhook.go index e92805608..29f642ea8 100644 --- a/app/webhook.go +++ b/app/webhook.go @@ -490,48 +490,43 @@ func HandleIncomingWebhook(hookId string, req *model.IncomingWebhookRequest) *mo var channel *model.Channel var cchan store.StoreChannel - var directUserId string if len(channelName) != 0 { if channelName[0] == '@' { if result := <-Srv.Store.User().GetByUsername(channelName[1:]); result.Err != nil { return model.NewAppError("HandleIncomingWebhook", "web.incoming_webhook.user.app_error", nil, "err="+result.Err.Message, http.StatusBadRequest) } else { - directUserId = result.Data.(*model.User).Id - channelName = model.GetDMNameFromIds(directUserId, hook.UserId) + if ch, err := GetDirectChannel(hook.UserId, result.Data.(*model.User).Id); err != nil { + return err + } else { + channel = ch + } } } else if channelName[0] == '#' { - channelName = channelName[1:] + cchan = Srv.Store.Channel().GetByName(hook.TeamId, channelName[1:], true) + } else { + cchan = Srv.Store.Channel().GetByName(hook.TeamId, channelName, true) } - - cchan = Srv.Store.Channel().GetByName(hook.TeamId, channelName, true) } else { cchan = Srv.Store.Channel().Get(hook.ChannelId, true) } - overrideUsername := req.Username - overrideIconUrl := req.IconURL - - result := <-cchan - if result.Err != nil && result.Err.Id == store.MISSING_CHANNEL_ERROR && directUserId != "" { - newChanResult := <-Srv.Store.Channel().CreateDirectChannel(directUserId, hook.UserId) - if newChanResult.Err != nil { - return model.NewAppError("HandleIncomingWebhook", "web.incoming_webhook.channel.app_error", nil, "err="+newChanResult.Err.Message, http.StatusBadRequest) + if channel == nil { + result := <-cchan + if result.Err != nil { + return model.NewAppError("HandleIncomingWebhook", "web.incoming_webhook.channel.app_error", nil, "err="+result.Err.Message, result.Err.StatusCode) } else { - channel = newChanResult.Data.(*model.Channel) - InvalidateCacheForUser(directUserId) - InvalidateCacheForUser(hook.UserId) + channel = result.Data.(*model.Channel) } - } else if result.Err != nil { - return model.NewAppError("HandleIncomingWebhook", "web.incoming_webhook.channel.app_error", nil, "err="+result.Err.Message, result.Err.StatusCode) - } else { - channel = result.Data.(*model.Channel) } if channel.Type != model.CHANNEL_OPEN && !HasPermissionToChannel(hook.UserId, channel.Id, model.PERMISSION_READ_CHANNEL) { return model.NewAppError("HandleIncomingWebhook", "web.incoming_webhook.permissions.app_error", nil, "", http.StatusForbidden) } + overrideUsername := req.Username + overrideIconUrl := req.IconURL + if _, err := CreateWebhookPost(hook.UserId, hook.TeamId, channel.Id, text, overrideUsername, overrideIconUrl, req.Props, webhookType); err != nil { return err } diff --git a/cmd/platform/server.go b/cmd/platform/server.go index 6186cbd86..8695129b7 100644 --- a/cmd/platform/server.go +++ b/cmd/platform/server.go @@ -73,6 +73,7 @@ func runServer(configFileLocation string) { wsapi.InitRouter() api4.InitApi(false) api.InitApi() + app.InitPlugins() wsapi.InitApi() web.InitWeb() diff --git a/config/config.json b/config/config.json index ad4dc6120..6db6c772d 100644 --- a/config/config.json +++ b/config/config.json @@ -300,5 +300,8 @@ "JobSettings": { "RunJobs": true, "RunScheduler": true + }, + "PluginSettings": { + "Plugins": {} } -} +}
\ No newline at end of file diff --git a/model/config.go b/model/config.go index 0755f514f..8c7cd0d68 100644 --- a/model/config.go +++ b/model/config.go @@ -449,6 +449,10 @@ type JobSettings struct { RunScheduler *bool } +type PluginSettings struct { + Plugins map[string]interface{} +} + type Config struct { ServiceSettings ServiceSettings TeamSettings TeamSettings @@ -476,6 +480,7 @@ type Config struct { ElasticsearchSettings ElasticsearchSettings DataRetentionSettings DataRetentionSettings JobSettings JobSettings + PluginSettings PluginSettings } func (o *Config) ToJson() string { @@ -1427,6 +1432,10 @@ func (o *Config) SetDefaults() { *o.JobSettings.RunScheduler = true } + if o.PluginSettings.Plugins == nil { + o.PluginSettings.Plugins = make(map[string]interface{}) + } + o.defaultWebrtcSettings() } diff --git a/webapp/components/admin_console/admin_sidebar.jsx b/webapp/components/admin_console/admin_sidebar.jsx index 9b27ab81e..f035424b6 100644 --- a/webapp/components/admin_console/admin_sidebar.jsx +++ b/webapp/components/admin_console/admin_sidebar.jsx @@ -522,6 +522,15 @@ export default class AdminSidebar extends React.Component { /> } /> + <AdminSidebarSection + name='jira' + title={ + <FormattedMessage + id='admin.sidebar.jira' + defaultMessage='JIRA (Beta)' + /> + } + /> {webrtcSettings} <AdminSidebarSection name='external' diff --git a/webapp/components/admin_console/generated_setting.jsx b/webapp/components/admin_console/generated_setting.jsx index b6a495f93..2fed2f42f 100644 --- a/webapp/components/admin_console/generated_setting.jsx +++ b/webapp/components/admin_console/generated_setting.jsx @@ -20,7 +20,8 @@ export default class GeneratedSetting extends React.Component { disabled: PropTypes.bool.isRequired, disabledText: PropTypes.node, helpText: PropTypes.node.isRequired, - regenerateText: PropTypes.node + regenerateText: PropTypes.node, + regenerateHelpText: PropTypes.node }; } @@ -58,6 +59,15 @@ export default class GeneratedSetting extends React.Component { ); } + let regenerateHelpText = null; + if (this.props.regenerateHelpText) { + regenerateHelpText = ( + <div className='help-text'> + {this.props.regenerateHelpText} + </div> + ); + } + return ( <div className='form-group'> <label @@ -88,6 +98,7 @@ export default class GeneratedSetting extends React.Component { {this.props.regenerateText} </button> </div> + {regenerateHelpText} </div> </div> ); diff --git a/webapp/components/suggestion/suggestion_box.jsx b/webapp/components/suggestion/suggestion_box.jsx index e9dc698aa..008fc2ffb 100644 --- a/webapp/components/suggestion/suggestion_box.jsx +++ b/webapp/components/suggestion/suggestion_box.jsx @@ -80,7 +80,12 @@ export default class SuggestionBox extends React.Component { /** * Function called when @mention is clicked */ - popoverMentionKeyClick: PropTypes.bool + popoverMentionKeyClick: PropTypes.bool, + + /** + * The number of characters required to show the suggestion list, defaults to 1 + */ + requiredCharacters: PropTypes.number } static defaultProps = { @@ -88,13 +93,15 @@ export default class SuggestionBox extends React.Component { listStyle: 'top', renderDividers: false, completeOnTab: true, - isRHS: false + isRHS: false, + requiredCharacters: 1 } constructor(props) { super(props); this.handleBlur = this.handleBlur.bind(this); + this.handleFocus = this.handleFocus.bind(this); this.handlePopoverMentionKeyClick = this.handlePopoverMentionKeyClick.bind(this); this.handleCompleteWord = this.handleCompleteWord.bind(this); @@ -163,11 +170,22 @@ export default class SuggestionBox extends React.Component { } } + handleFocus() { + setTimeout(() => { + const textbox = this.getTextbox(); + const pretext = textbox.value.substring(0, textbox.selectionEnd); + + if (pretext.length >= this.props.requiredCharacters) { + GlobalActions.emitSuggestionPretextChanged(this.suggestionId, pretext); + } + }); + } + handleChange(e) { const textbox = this.getTextbox(); const pretext = textbox.value.substring(0, textbox.selectionEnd); - if (!this.composing && SuggestionStore.getPretext(this.suggestionId) !== pretext) { + if (!this.composing && SuggestionStore.getPretext(this.suggestionId) !== pretext && pretext.length >= this.props.requiredCharacters) { GlobalActions.emitSuggestionPretextChanged(this.suggestionId, pretext); } @@ -228,7 +246,8 @@ export default class SuggestionBox extends React.Component { const suffix = text.substring(caret); - this.refs.textbox.value = prefix + term + ' ' + suffix; + const newValue = prefix + term + ' ' + suffix; + this.refs.textbox.value = newValue; if (this.props.onChange) { // fake an input event to send back to parent components @@ -242,9 +261,10 @@ export default class SuggestionBox extends React.Component { if (this.props.onItemSelected) { const items = SuggestionStore.getItems(this.suggestionId); - for (const i of items) { - if (i.name === term) { - this.props.onItemSelected(i); + const terms = SuggestionStore.getTerms(this.suggestionId); + for (let i = 0; i < terms.length; i++) { + if (terms[i] === term) { + this.props.onItemSelected(items[i]); break; } } @@ -254,7 +274,9 @@ export default class SuggestionBox extends React.Component { // set the caret position after the next rendering window.requestAnimationFrame(() => { - Utils.setCaretPosition(textbox, prefix.length + term.length + 1); + if (textbox.value === newValue) { + Utils.setCaretPosition(textbox, newValue.length); + } }); for (const provider of this.props.providers) { @@ -277,7 +299,9 @@ export default class SuggestionBox extends React.Component { e.preventDefault(); } else if (e.which === KeyCodes.ENTER || (this.props.completeOnTab && e.which === KeyCodes.TAB)) { this.handleCompleteWord(SuggestionStore.getSelection(this.suggestionId), SuggestionStore.getSelectedMatchedPretext(this.suggestionId)); - this.props.onKeyDown(e); + if (this.props.onKeyDown) { + this.props.onKeyDown(e); + } e.preventDefault(); } else if (e.which === KeyCodes.ESCAPE) { GlobalActions.emitClearSuggestions(this.suggestionId); @@ -321,11 +345,14 @@ export default class SuggestionBox extends React.Component { Reflect.deleteProperty(props, 'completeOnTab'); Reflect.deleteProperty(props, 'isRHS'); Reflect.deleteProperty(props, 'popoverMentionKeyClick'); + Reflect.deleteProperty(props, 'requiredCharacters'); const childProps = { ref: 'textbox', onBlur: this.handleBlur, + onFocus: this.handleFocus, onInput: this.handleChange, + onChange() { /* this is only here to suppress warnings about onChange not being implemented for read-write inputs */ }, onCompositionStart: this.handleCompositionStart, onCompositionUpdate: this.handleCompositionUpdate, onCompositionEnd: this.handleCompositionEnd, @@ -337,6 +364,7 @@ export default class SuggestionBox extends React.Component { textbox = ( <input type='text' + autoComplete='off' {...props} {...childProps} /> @@ -345,6 +373,7 @@ export default class SuggestionBox extends React.Component { textbox = ( <input type='search' + autoComplete='off' {...props} {...childProps} /> @@ -364,7 +393,7 @@ export default class SuggestionBox extends React.Component { return ( <div ref='container'> {textbox} - {this.props.value && + {this.props.value.length >= this.props.requiredCharacters && <SuggestionListComponent suggestionId={this.suggestionId} location={listStyle} diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 58674dd3c..77206c161 100755 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -481,6 +481,7 @@ "admin.integrations.custom": "Custom Integrations", "admin.integrations.external": "External Services", "admin.integrations.webrtc": "Mattermost WebRTC", + "admin.plugins.jira": "JIRA (Beta)", "admin.ldap.baseDesc": "The Base DN is the Distinguished Name of the location where Mattermost should start its search for users in the AD/LDAP tree.", "admin.ldap.baseEx": "E.g.: \"ou=Unit Name,dc=corp,dc=example,dc=com\"", "admin.ldap.baseTitle": "BaseDN:", @@ -626,6 +627,18 @@ "admin.password.requirementsDescription": "Character types required in a valid password.", "admin.password.symbol": "At least one symbol (e.g. \"~!@#$%^&*()\")", "admin.password.uppercase": "At least one uppercase letter", + "admin.plugins.jira.enabledLabel": "Enabled:", + "admin.plugins.jira.enabledDescription": "When true, you can configure JIRA webhooks to post message in Mattermost. To help combat phishing attacks, all posts are labelled by a BOT tag.", + "admin.plugins.jira.secretLabel": "Secret:", + "admin.plugins.jira.secretDescription": "This secret is used to authenticate to Mattermost.", + "admin.plugins.jira.secretRegenerateDescription": "Regenerates the secret for the webhook URL endpoint. Regenerating the secret invalidates your existing JIRA integrations.", + "admin.plugins.jira.userLabel": "User:", + "admin.plugins.jira.userDescription": "Select the username that this integration is attached to.", + "admin.plugins.jira.setupDescription": "Use this webhook URL to set up the JIRA integration. See {webhookDocsLink} to learn more.", + "admin.plugins.jira.webhookDocsLink": "documentation", + "admin.plugins.jira.secretParamPlaceholder": "secret", + "admin.plugins.jira.teamParamPlaceholder": "teamname", + "admin.plugins.jira.channelParamNamePlaceholder": "channelname", "admin.privacy.showEmailDescription": "When false, hides the email address of members from everyone except System Administrators.", "admin.privacy.showEmailTitle": "Show Email Address: ", "admin.privacy.showFullNameDescription": "When false, hides the full name of members from everyone except System Administrators. Username is shown in place of full name.", @@ -832,6 +845,7 @@ "admin.sidebar.general": "General", "admin.sidebar.gitlab": "GitLab", "admin.sidebar.integrations": "Integrations", + "admin.sidebar.jira": "JIRA (Beta)", "admin.sidebar.ldap": "AD/LDAP", "admin.sidebar.legalAndSupport": "Legal and Support", "admin.sidebar.license": "Edition and License", diff --git a/webapp/plugins/jira/components/settings.jsx b/webapp/plugins/jira/components/settings.jsx new file mode 100644 index 000000000..5e5b5fac6 --- /dev/null +++ b/webapp/plugins/jira/components/settings.jsx @@ -0,0 +1,251 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; + +import crypto from 'crypto'; + +import Suggestion from 'components/suggestion/suggestion.jsx'; +import Provider from 'components/suggestion/provider.jsx'; +import SuggestionBox from 'components/suggestion/suggestion_box.jsx'; +import SuggestionList from 'components/suggestion/suggestion_list.jsx'; +import {autocompleteUsersInTeam} from 'actions/user_actions.jsx'; + +import AppDispatcher from 'dispatcher/app_dispatcher.jsx'; +import {Client4} from 'mattermost-redux/client'; +import {ActionTypes} from 'utils/constants.jsx'; +import * as Utils from 'utils/utils.jsx'; + +import AdminSettings from 'components/admin_console/admin_settings.jsx'; +import {FormattedMessage} from 'react-intl'; +import SettingsGroup from 'components/admin_console/settings_group.jsx'; +import BooleanSetting from 'components/admin_console/boolean_setting.jsx'; +import GeneratedSetting from 'components/admin_console/generated_setting.jsx'; +import Setting from 'components/admin_console/setting.jsx'; + +import './style.scss'; + +class UserSuggestion extends Suggestion { + render() { + const {item, isSelection} = this.props; + + let className = 'jirabots__name'; + if (isSelection) { + className += ' suggestion--selected'; + } + + const username = item.username; + let description = ''; + + if ((item.first_name || item.last_name) && item.nickname) { + description = `- ${Utils.getFullName(item)} (${item.nickname})`; + } else if (item.nickname) { + description = `- (${item.nickname})`; + } else if (item.first_name || item.last_name) { + description = `- ${Utils.getFullName(item)}`; + } + + return ( + <div + className={className} + onClick={this.handleClick} + > + <div className='pull-left'> + <img + className='jirabot__image' + src={Client4.getUsersRoute() + '/' + item.id + '/image?_=' + (item.last_picture_update || 0)} + /> + </div> + <div className='pull-left jirabot--align'> + <span> + {'@' + username} + </span> + <span className='jirabot__fullname'> + {' '} + {description} + </span> + </div> + </div> + ); + } +} + +class UserProvider extends Provider { + handlePretextChanged(suggestionId, pretext) { + const normalizedPretext = pretext.toLowerCase(); + this.startNewRequest(suggestionId, normalizedPretext); + + autocompleteUsersInTeam( + normalizedPretext, + (data) => { + if (this.shouldCancelDispatch(normalizedPretext)) { + return; + } + + const users = Object.assign([], data.users); + + AppDispatcher.handleServerAction({ + type: ActionTypes.SUGGESTION_RECEIVED_SUGGESTIONS, + id: suggestionId, + matchedPretext: normalizedPretext, + terms: users.map((user) => user.username), + items: users, + component: UserSuggestion + }); + } + ); + + return true; + } +} + +export default class JIRASettings extends AdminSettings { + constructor(props) { + super(props); + + this.getConfigFromState = this.getConfigFromState.bind(this); + this.renderSettings = this.renderSettings.bind(this); + this.handleSecretChange = this.handleSecretChange.bind(this); + this.handleEnabledChange = this.handleEnabledChange.bind(this); + this.handleUserSelected = this.handleUserSelected.bind(this); + + this.userSuggestionProviders = [new UserProvider()]; + } + + getConfigFromState(config) { + config.PluginSettings.Plugins = { + jira: { + Enabled: this.state.enabled, + Secret: this.state.secret, + UserName: this.state.userName + } + }; + + return config; + } + + getStateFromConfig(config) { + const settings = config.PluginSettings; + + const ret = { + enabled: false, + secret: '', + userName: '', + siteURL: config.ServiceSettings.SiteURL + }; + + if (typeof settings.Plugins !== 'undefined' && typeof settings.Plugins.jira !== 'undefined') { + ret.enabled = settings.Plugins.jira.Enabled || settings.Plugins.jira.enabled || false; + ret.secret = settings.Plugins.jira.Secret || settings.Plugins.jira.secret || ''; + ret.userName = settings.Plugins.jira.UserName || settings.Plugins.jira.username || ''; + } + + return ret; + } + + handleSecretChange(id, secret) { + this.handleChange(id, secret.replace('+', '-').replace('/', '_')); + } + + handleEnabledChange(enabled) { + if (enabled && this.state.secret === '') { + this.handleSecretChange('secret', crypto.randomBytes(256).toString('base64').substring(0, 32)); + } + this.handleChange('enabled', enabled); + } + + handleUserSelected(user) { + this.handleChange('userName', user.username); + } + + renderTitle() { + return Utils.localizeMessage('admin.plugins.jira', 'JIRA (Beta)'); + } + + renderSettings() { + var webhookDocsLink = ( + <a + href='https://about.mattermost.com/default-jira-plugin' + target='_blank' + rel='noopener noreferrer' + > + <FormattedMessage + id='admin.plugins.jira.webhookDocsLink' + defaultMessage='documentation' + /> + </a> + ); + + return ( + <SettingsGroup> + <BooleanSetting + id='enabled' + label={Utils.localizeMessage('admin.plugins.jira.enabledLabel', 'Enabled:')} + helpText={Utils.localizeMessage('admin.plugins.jira.enabledDescription', 'When true, you can configure JIRA webhooks to post message in Mattermost. To help combat phishing attacks, all posts are labelled by a BOT tag.')} + value={this.state.enabled} + onChange={(id, value) => this.handleEnabledChange(value)} + /> + <Setting + label={Utils.localizeMessage('admin.plugins.jira.userLabel', 'User:')} + helpText={Utils.localizeMessage('admin.plugins.jira.userDescription', 'Select the username that this integration is attached to.')} + inputId='userName' + > + <div + className='jirabots__dropdown' + > + <SuggestionBox + id='userName' + className='form-control' + placeholder={Utils.localizeMessage('search_bar.search', 'Search')} + value={this.state.userName} + onChange={(e) => this.handleChange('userName', e.target.value)} + onItemSelected={this.handleUserSelected} + listComponent={SuggestionList} + listStyle='bottom' + providers={this.userSuggestionProviders} + disabled={!this.state.enabled} + type='input' + requiredCharacters={0} + /> + </div> + </Setting> + <GeneratedSetting + id='secret' + label={Utils.localizeMessage('admin.plugins.jira.secretLabel', 'Secret:')} + helpText={Utils.localizeMessage('admin.plugins.jira.secretDescription', 'This secret is used to authenticate to Mattermost.')} + regenerateHelpText={Utils.localizeMessage('admin.plugins.jira.secretRegenerateDescription', 'Regenerates the secret for the webhook URL endpoint. Regenerating the secret invalidates your existing JIRA integrations.')} + value={this.state.secret} + onChange={this.handleSecretChange} + disabled={!this.state.enabled} + /> + <div className='banner'> + <div className='banner__content'> + <p> + <FormattedMessage + id='admin.plugins.jira.setupDescription' + defaultMessage='Use this webhook URL to set up the JIRA integration. See {webhookDocsLink} to learn more.' + values={{ + webhookDocsLink + }} + /> + </p> + <p> + <code + dangerouslySetInnerHTML={{ + __html: encodeURI(this.state.siteURL) + + '/plugins/jira/webhook?secret=' + + (this.state.secret ? encodeURIComponent(this.state.secret) : ('<b>' + Utils.localizeMessage('admin.plugins.jira.secretParamPlaceholder', 'secret') + '</b>')) + + '&team=<b>' + + Utils.localizeMessage('admin.plugins.jira.teamParamPlaceholder', 'teamname') + + '</b>&channel=<b>' + + Utils.localizeMessage('admin.plugins.jira.channelParamNamePlaceholder', 'channelname') + + '</b>' + }} + /> + </p> + </div> + </div> + </SettingsGroup> + ); + } +} diff --git a/webapp/plugins/jira/components/style.scss b/webapp/plugins/jira/components/style.scss new file mode 100644 index 000000000..477328316 --- /dev/null +++ b/webapp/plugins/jira/components/style.scss @@ -0,0 +1,60 @@ +@charset 'UTF-8'; + +@import 'compass/utilities'; +@import 'compass/css3'; + +.jirabots__dropdown { + position: relative; +} + +.jirabots__dropdown::before { + position: absolute; + top: 13px; + right: 8px; + content: " "; + pointer-events: none; + + width: 0; + height: 0; + + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-top: 8px solid #e2e2e2; +} + +.jirabots__name { + @include clearfix; + cursor: pointer; + font-size: 13px; + line-height: 20px; + margin: 0; + padding: 6px 10px; + position: relative; + white-space: nowrap; + width: 100%; + z-index: 101; +} + +.jirabot__image { + @include border-radius(20px); + display: block; + font-size: 15px; + height: 16px; + line-height: 16px; + margin-right: 7px; + margin-top: 3px; + text-align: center; + width: 16px; + + .jirabot--align { + display: inline-block; + max-width: 80%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.jirabot__fullname { + @include opacity(.5); +} diff --git a/webapp/routes/route_admin_console.jsx b/webapp/routes/route_admin_console.jsx index 17e0290c2..15081a1d9 100644 --- a/webapp/routes/route_admin_console.jsx +++ b/webapp/routes/route_admin_console.jsx @@ -46,6 +46,7 @@ import LicenseSettings from 'components/admin_console/license_settings.jsx'; import Audits from 'components/admin_console/audits'; import Logs from 'components/admin_console/server_logs'; import ElasticsearchSettings from 'components/admin_console/elasticsearch_settings.jsx'; +import JIRASettings from 'plugins/jira/components/settings.jsx'; export default ( <Route> @@ -159,6 +160,10 @@ export default ( path='webrtc' component={WebrtcSettings} /> + <Route + path='jira' + component={JIRASettings} + /> </Route> <Route path='files'> <IndexRedirect to='storage'/> diff --git a/webapp/sass/routes/_admin-console.scss b/webapp/sass/routes/_admin-console.scss index 2d4ca6be1..829ed107e 100644 --- a/webapp/sass/routes/_admin-console.scss +++ b/webapp/sass/routes/_admin-console.scss @@ -305,6 +305,10 @@ .status-icon-error { color: #ea6262; } + + .suggestion--selected { + background-color: #e2e2e2; + } } .brand-img { |