diff --git a/Makefile b/Makefile index 28f56104f..db6e28a81 100644 --- a/Makefile +++ b/Makefile @@ -81,7 +81,6 @@ endif ## Ensures NPM dependencies are installed without having to run this all the time. webapp/.npminstall: ifneq ($(HAS_WEBAPP),) - git config --global url."ssh://git@".insteadOf git:// cd webapp && $(NPM) install touch $@ endif diff --git a/go.mod b/go.mod index 53804cab2..071761ca3 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/rbriski/atlassian-jwt v0.0.0-20180307182949-7bb4ae273058 github.com/rudderlabs/analytics-go v3.3.2+incompatible github.com/stretchr/testify v1.8.0 + github.com/trivago/tgo v1.0.1 golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 golang.org/x/text v0.3.7 ) @@ -75,7 +76,6 @@ require ( github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect github.com/tinylib/msgp v1.1.6 // indirect - github.com/trivago/tgo v1.0.1 // indirect github.com/ulikunitz/xz v0.5.10 // indirect github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect diff --git a/plugin.json b/plugin.json index d1c89eef3..af56e9776 100644 --- a/plugin.json +++ b/plugin.json @@ -76,6 +76,14 @@ "placeholder": "", "default": "" }, + { + "key": "SecurityLevelEmptyForJiraSubscriptions", + "display_name": "Default Subscription Security Level to Empty:", + "type": "bool", + "help_text": "Subscriptions will only include issues that have a security level assigned if the appropriate security level has been included as a filter", + "placeholder": "", + "default": true + }, { "key": "JiraAdminAdditionalHelpText", "display_name": "Additional Help Text to be shown with Jira Help:", diff --git a/server/issue.go b/server/issue.go index d5854a8d9..81d0713e2 100644 --- a/server/issue.go +++ b/server/issue.go @@ -22,12 +22,13 @@ import ( ) const ( - labelsField = "labels" - statusField = "status" - reporterField = "reporter" - priorityField = "priority" - descriptionField = "description" - resolutionField = "resolution" + labelsField = "labels" + statusField = "status" + reporterField = "reporter" + priorityField = "priority" + descriptionField = "description" + resolutionField = "resolution" + securityLevelField = "security" ) func makePost(userID, channelID, message string) *model.Post { diff --git a/server/issue_test.go b/server/issue_test.go index c82dbf7a3..09d3fea17 100644 --- a/server/issue_test.go +++ b/server/issue_test.go @@ -16,6 +16,7 @@ import ( "github.com/mattermost/mattermost-server/v6/plugin/plugintest/mock" "github.com/pkg/errors" "github.com/stretchr/testify/assert" + "github.com/trivago/tgo/tcontainer" "github.com/mattermost/mattermost-plugin-jira/server/utils/kvstore" ) @@ -85,6 +86,28 @@ func (client testClient) AddComment(issueKey string, comment *jira.Comment) (*ji return nil, nil } +func (client testClient) GetCreateMetaInfo(api plugin.API, options *jira.GetQueryOptions) (*jira.CreateMetaInfo, error) { + return &jira.CreateMetaInfo{ + Projects: []*jira.MetaProject{ + { + IssueTypes: []*jira.MetaIssueType{ + { + Fields: tcontainer.MarshalMap{ + "security": tcontainer.MarshalMap{ + "allowedValues": []interface{}{ + tcontainer.MarshalMap{ + "id": "10001", + }, + }, + }, + }, + }, + }, + }, + }, + }, nil +} + func TestTransitionJiraIssue(t *testing.T) { api := &plugintest.API{} api.On("SendEphemeralPost", mock.AnythingOfType("string"), mock.AnythingOfType("*model.Post")).Return(&model.Post{}) diff --git a/server/plugin.go b/server/plugin.go index 7b5c6d776..74ea64f1a 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -70,6 +70,9 @@ type externalConfig struct { // Additional Help Text to be shown in the output of '/jira help' command JiraAdminAdditionalHelpText string + // When enabled, a subscription without security level rules will filter out an issue that has a security level assigned + SecurityLevelEmptyForJiraSubscriptions bool + // Hide issue descriptions and comments in Webhook and Subscription messages HideDecriptionComment bool diff --git a/server/subscribe.go b/server/subscribe.go index 12073746c..5e448e8d2 100644 --- a/server/subscribe.go +++ b/server/subscribe.go @@ -15,6 +15,8 @@ import ( "github.com/gorilla/mux" "github.com/pkg/errors" + "github.com/trivago/tgo/tcontainer" + "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-plugin-jira/server/utils" @@ -140,37 +142,61 @@ func (p *Plugin) matchesSubsciptionFilters(wh *webhook, filters SubscriptionFilt return false } - if filters.IssueTypes.Len() != 0 && !filters.IssueTypes.ContainsAny(wh.JiraWebhook.Issue.Fields.Type.ID) { + issue := &wh.JiraWebhook.Issue + + if filters.IssueTypes.Len() != 0 && !filters.IssueTypes.ContainsAny(issue.Fields.Type.ID) { return false } - if filters.Projects.Len() != 0 && !filters.Projects.ContainsAny(wh.JiraWebhook.Issue.Fields.Project.Key) { + if filters.Projects.Len() != 0 && !filters.Projects.ContainsAny(issue.Fields.Project.Key) { return false } - validFilter := true - + containsSecurityLevelFilter := false + useEmptySecurityLevel := p.getConfig().SecurityLevelEmptyForJiraSubscriptions for _, field := range filters.Fields { + inclusion := field.Inclusion + // Broken filter, values must be provided - if field.Inclusion == "" || (field.Values.Len() == 0 && field.Inclusion != FilterEmpty) { - validFilter = false - break + if inclusion == "" || (field.Values.Len() == 0 && inclusion != FilterEmpty) { + return false + } + + if field.Key == securityLevelField { + containsSecurityLevelFilter = true + if inclusion == FilterExcludeAny && useEmptySecurityLevel { + inclusion = FilterEmpty + } } - value := getIssueFieldValue(&wh.JiraWebhook.Issue, field.Key) - containsAny := value.ContainsAny(field.Values.Elems()...) - containsAll := value.ContainsAll(field.Values.Elems()...) + value := getIssueFieldValue(issue, field.Key) + if !isValidFieldInclusion(field, value, inclusion) { + return false + } + } - if (field.Inclusion == FilterIncludeAny && !containsAny) || - (field.Inclusion == FilterIncludeAll && !containsAll) || - (field.Inclusion == FilterExcludeAny && containsAny) || - (field.Inclusion == FilterEmpty && value.Len() > 0) { - validFilter = false - break + if !containsSecurityLevelFilter && useEmptySecurityLevel { + securityLevel := getIssueFieldValue(issue, securityLevelField) + if securityLevel.Len() > 0 { + return false } } - return validFilter + return true +} + +func isValidFieldInclusion(field FieldFilter, value StringSet, inclusion string) bool { + containsAny := value.ContainsAny(field.Values.Elems()...) + containsAll := value.ContainsAll(field.Values.Elems()...) + + if (inclusion == FilterIncludeAny && !containsAny) || + (inclusion == FilterIncludeAll && !containsAll) || + (inclusion == FilterExcludeAny && containsAny) || + (inclusion == FilterEmpty && value.Len() > 0) { + return false + } + + return true } func (p *Plugin) getChannelsSubscribed(wh *webhook, instanceID types.ID) ([]ChannelSubscription, error) { @@ -298,6 +324,37 @@ func (p *Plugin) validateSubscription(instanceID types.ID, subscription *Channel return errors.New("please provide a project identifier") } + projectKey := subscription.Filters.Projects.Elems()[0] + + var securityLevels StringSet + useEmptySecurityLevel := p.getConfig().SecurityLevelEmptyForJiraSubscriptions + for _, field := range subscription.Filters.Fields { + if field.Key != securityLevelField { + continue + } + + if field.Inclusion == FilterEmpty { + continue + } + + if field.Inclusion == FilterExcludeAny && useEmptySecurityLevel { + return errors.New("security level does not allow for an \"Exclude\" clause") + } + + if securityLevels == nil { + securityLevelsArray, err := p.getSecurityLevelsForProject(client, projectKey) + if err != nil { + return errors.Wrap(err, "failed to get security levels for project") + } + + securityLevels = NewStringSet(securityLevelsArray...) + } + + if !securityLevels.ContainsAll(field.Values.Elems()...) { + return errors.New("invalid access to security level") + } + } + channelID := subscription.ChannelID subs, err := p.getSubscriptionsForChannel(instanceID, channelID) if err != nil { @@ -310,7 +367,6 @@ func (p *Plugin) validateSubscription(instanceID types.ID, subscription *Channel } } - projectKey := subscription.Filters.Projects.Elems()[0] _, err = client.GetProject(projectKey) if err != nil { return errors.WithMessagef(err, "failed to get project %q", projectKey) @@ -319,6 +375,47 @@ func (p *Plugin) validateSubscription(instanceID types.ID, subscription *Channel return nil } +func (p *Plugin) getSecurityLevelsForProject(client Client, projectKey string) ([]string, error) { + createMeta, err := client.GetCreateMetaInfo(p.API, &jira.GetQueryOptions{ + Expand: "projects.issuetypes.fields", + ProjectKeys: projectKey, + }) + if err != nil { + return nil, errors.Wrap(err, "error fetching user security levels") + } + + if len(createMeta.Projects) == 0 || len(createMeta.Projects[0].IssueTypes) == 0 { + return nil, errors.Wrapf(err, "no project found for project key %s", projectKey) + } + + securityLevels1, err := createMeta.Projects[0].IssueTypes[0].Fields.MarshalMap(securityLevelField) + if err != nil { + return nil, errors.Wrap(err, "error parsing user security levels") + } + + allowed, ok := securityLevels1["allowedValues"].([]interface{}) + if !ok { + return nil, errors.New("error parsing user security levels: failed to type assertion on array") + } + + out := []string{} + for _, level := range allowed { + value, ok := level.(tcontainer.MarshalMap) + if !ok { + return nil, errors.New("error parsing user security levels: failed to type assertion on map") + } + + id, ok := value["id"].(string) + if !ok { + return nil, errors.New("error parsing user security levels: failed to type assertion on string") + } + + out = append(out, id) + } + + return out, nil +} + func (p *Plugin) editChannelSubscription(instanceID types.ID, modifiedSubscription *ChannelSubscription, client Client) error { subKey := keyWithInstanceID(instanceID, JiraSubscriptionsKey) return p.client.KV.SetAtomicWithRetries(subKey, func(initialBytes []byte) (interface{}, error) { diff --git a/server/subscribe_test.go b/server/subscribe_test.go index b60b11d08..393c07dc8 100644 --- a/server/subscribe_test.go +++ b/server/subscribe_test.go @@ -20,6 +20,199 @@ import ( "github.com/stretchr/testify/assert" ) +func TestValidateSubscription(t *testing.T) { + p := &Plugin{} + + p.instanceStore = p.getMockInstanceStoreKV(0) + + api := &plugintest.API{} + p.SetAPI(api) + + for name, tc := range map[string]struct { + subscription *ChannelSubscription + errorMessage string + disableSecurityConfig bool + }{ + "no event selected": { + subscription: &ChannelSubscription{ + ID: "id", + Name: "name", + ChannelID: "channelid", + InstanceID: "instance_id", + Filters: SubscriptionFilters{ + Events: NewStringSet(), + Projects: NewStringSet("project"), + IssueTypes: NewStringSet("10001"), + }, + }, + errorMessage: "please provide at least one event type", + }, + "no project selected": { + subscription: &ChannelSubscription{ + ID: "id", + Name: "name", + ChannelID: "channelid", + InstanceID: "instance_id", + Filters: SubscriptionFilters{ + Events: NewStringSet("issue_created"), + Projects: NewStringSet(), + IssueTypes: NewStringSet("10001"), + }, + }, + errorMessage: "please provide a project identifier", + }, + "no issue type selected": { + subscription: &ChannelSubscription{ + ID: "id", + Name: "name", + ChannelID: "channelid", + InstanceID: "instance_id", + Filters: SubscriptionFilters{ + Events: NewStringSet("issue_created"), + Projects: NewStringSet("project"), + IssueTypes: NewStringSet(), + }, + }, + errorMessage: "please provide at least one issue type", + }, + "valid subscription": { + subscription: &ChannelSubscription{ + ID: "id", + Name: "name", + ChannelID: "channelid", + InstanceID: "instance_id", + Filters: SubscriptionFilters{ + Events: NewStringSet("issue_created"), + Projects: NewStringSet("project"), + IssueTypes: NewStringSet("10001"), + }, + }, + errorMessage: "", + }, + "valid subscription with security level": { + subscription: &ChannelSubscription{ + ID: "id", + Name: "name", + ChannelID: "channelid", + InstanceID: "instance_id", + Filters: SubscriptionFilters{ + Events: NewStringSet("issue_created"), + Projects: NewStringSet("TEST"), + IssueTypes: NewStringSet("10001"), + Fields: []FieldFilter{ + { + Key: "security", + Inclusion: FilterIncludeAll, + Values: NewStringSet("10001"), + }, + }, + }, + }, + errorMessage: "", + }, + "invalid 'Exclude' of security level": { + subscription: &ChannelSubscription{ + ID: "id", + Name: "name", + ChannelID: "channelid", + InstanceID: "instance_id", + Filters: SubscriptionFilters{ + Events: NewStringSet("issue_created"), + Projects: NewStringSet("TEST"), + IssueTypes: NewStringSet("10001"), + Fields: []FieldFilter{ + { + Key: "security", + Inclusion: FilterExcludeAny, + Values: NewStringSet("10001"), + }, + }, + }, + }, + errorMessage: "security level does not allow for an \"Exclude\" clause", + }, + "security config disabled, valid 'Exclude' of security level": { + subscription: &ChannelSubscription{ + ID: "id", + Name: "name", + ChannelID: "channelid", + InstanceID: "instance_id", + Filters: SubscriptionFilters{ + Events: NewStringSet("issue_created"), + Projects: NewStringSet("TEST"), + IssueTypes: NewStringSet("10001"), + Fields: []FieldFilter{ + { + Key: "security", + Inclusion: FilterExcludeAny, + Values: NewStringSet("10001"), + }, + }, + }, + }, + disableSecurityConfig: true, + errorMessage: "", + }, + "invalid access to security level": { + subscription: &ChannelSubscription{ + ID: "id", + Name: "name", + ChannelID: "channelid", + InstanceID: "instance_id", + Filters: SubscriptionFilters{ + Events: NewStringSet("issue_created"), + Projects: NewStringSet("TEST"), + IssueTypes: NewStringSet("10001"), + Fields: []FieldFilter{ + { + Key: "security", + Inclusion: FilterIncludeAll, + Values: NewStringSet("10002"), + }, + }, + }, + }, + errorMessage: "invalid access to security level", + }, + "user does not have read access to the project": { + subscription: &ChannelSubscription{ + ID: "id", + Name: "name", + ChannelID: "channelid", + InstanceID: "instance_id", + Filters: SubscriptionFilters{ + Events: NewStringSet("issue_created"), + Projects: NewStringSet(nonExistantProjectKey), + IssueTypes: NewStringSet("10001"), + }, + }, + errorMessage: "failed to get project \"FP\": Project FP not found", + }, + } { + t.Run(name, func(t *testing.T) { + api := &plugintest.API{} + p.SetAPI(api) + p.client = pluginapi.NewClient(p.API, p.Driver) + + api.On("KVGet", testSubKey).Return(nil, nil) + + p.updateConfig(func(conf *config) { + conf.SecurityLevelEmptyForJiraSubscriptions = !tc.disableSecurityConfig + }) + + client := testClient{} + err := p.validateSubscription(testInstance1.InstanceID, tc.subscription, client) + + if tc.errorMessage == "" { + require.NoError(t, err) + } else { + require.Error(t, err) + require.Equal(t, tc.errorMessage, err.Error()) + } + }) + } +} + func TestListChannelSubscriptions(t *testing.T) { p := &Plugin{} p.updateConfig(func(conf *config) { @@ -278,9 +471,10 @@ func TestGetChannelsSubscribed(t *testing.T) { p.instanceStore = p.getMockInstanceStoreKV(0) for name, tc := range map[string]struct { - WebhookTestData string - Subs *Subscriptions - ChannelSubscriptions []ChannelSubscription + WebhookTestData string + Subs *Subscriptions + ChannelSubscriptions []ChannelSubscription + disableSecurityConfig bool }{ "no filters selected": { WebhookTestData: "webhook-issue-created.json", @@ -1360,12 +1554,90 @@ func TestGetChannelsSubscribed(t *testing.T) { }), ChannelSubscriptions: []ChannelSubscription{{ChannelID: "sampleChannelId"}}, }, + "no security level provided in subscription, but security level is present in issue": { + WebhookTestData: "webhook-issue-created-with-security-level.json", + Subs: withExistingChannelSubscriptions([]ChannelSubscription{ + { + ID: "rg86cd65efdjdjezgisgxaitzh", + ChannelID: "sampleChannelId", + Filters: SubscriptionFilters{ + Events: NewStringSet("event_created"), + Projects: NewStringSet("TES"), + IssueTypes: NewStringSet("10001"), + Fields: []FieldFilter{}, + }, + }, + }), + ChannelSubscriptions: []ChannelSubscription{}, + }, + "security config disabled, no security level provided in subscription, but security level is present in issue": { + WebhookTestData: "webhook-issue-created-with-security-level.json", + Subs: withExistingChannelSubscriptions([]ChannelSubscription{ + { + ID: "rg86cd65efdjdjezgisgxaitzh", + ChannelID: "sampleChannelId", + Filters: SubscriptionFilters{ + Events: NewStringSet("event_created"), + Projects: NewStringSet("TES"), + IssueTypes: NewStringSet("10001"), + Fields: []FieldFilter{}, + }, + }, + }), + ChannelSubscriptions: []ChannelSubscription{{ChannelID: "sampleChannelId"}}, + disableSecurityConfig: true, + }, + "security level provided in subscription, but different security level is present in issue": { + WebhookTestData: "webhook-issue-created-with-security-level.json", + Subs: withExistingChannelSubscriptions([]ChannelSubscription{ + { + ID: "rg86cd65efdjdjezgisgxaitzh", + ChannelID: "sampleChannelId", + Filters: SubscriptionFilters{ + Events: NewStringSet("event_created"), + Projects: NewStringSet("TES"), + IssueTypes: NewStringSet("10001"), + Fields: []FieldFilter{ + { + Key: "security", + Inclusion: FilterIncludeAll, + Values: NewStringSet("10002"), + }, + }, + }, + }, + }), + ChannelSubscriptions: []ChannelSubscription{}, + }, + "security level provided in subscription, and same security level is present in issue": { + WebhookTestData: "webhook-issue-created-with-security-level.json", + Subs: withExistingChannelSubscriptions([]ChannelSubscription{ + { + ID: "rg86cd65efdjdjezgisgxaitzh", + ChannelID: "sampleChannelId", + Filters: SubscriptionFilters{ + Events: NewStringSet("event_created"), + Projects: NewStringSet("TES"), + IssueTypes: NewStringSet("10001"), + Fields: []FieldFilter{ + { + Key: "security", + Inclusion: FilterIncludeAll, + Values: NewStringSet("10001"), + }, + }, + }, + }, + }), + ChannelSubscriptions: []ChannelSubscription{{ChannelID: "sampleChannelId"}}, + }, } { t.Run(name, func(t *testing.T) { api := &plugintest.API{} p.updateConfig(func(conf *config) { conf.Secret = someSecret + conf.SecurityLevelEmptyForJiraSubscriptions = !tc.disableSecurityConfig }) p.SetAPI(api) diff --git a/server/testdata/webhook-issue-created-with-security-level.json b/server/testdata/webhook-issue-created-with-security-level.json new file mode 100644 index 000000000..d01f91231 --- /dev/null +++ b/server/testdata/webhook-issue-created-with-security-level.json @@ -0,0 +1,238 @@ +{ + "timestamp": 1550286113023, + "webhookEvent": "jira:issue_created", + "issue_event_type_name": "issue_created", + "user": { + "self": "https://some-instance-test.atlassian.net/rest/api/2/user?accountId=5c5f880629be9642ba529340", + "name": "admin", + "key": "admin", + "accountId": "5c5f880629be9642ba529340", + "emailAddress": "some-instance-test@gmail.com", + "avatarUrls": { + "48x48": "https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=48&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D48%26noRedirect%3Dtrue", + "24x24": "https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=24&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D24%26noRedirect%3Dtrue", + "16x16": "https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=16&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D16%26noRedirect%3Dtrue", + "32x32": "https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=32&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D32%26noRedirect%3Dtrue" + }, + "displayName": "Test User", + "active": true, + "timeZone": "America/Los_Angeles" + }, + "issue": { + "id": "10040", + "self": "https://some-instance-test.atlassian.net/rest/api/2/issue/10040", + "key": "TES-41", + "fields": { + "issuetype": { + "self": "https://some-instance-test.atlassian.net/rest/api/2/issuetype/10001", + "id": "10001", + "description": "Stories track functionality or features expressed as user goals.", + "iconUrl": "https://some-instance-test.atlassian.net/secure/viewavatar?size=xsmall&avatarId=10315&avatarType=issuetype", + "name": "Story", + "subtask": false, + "avatarId": 10315 + }, + "timespent": null, + "customfield_10030": null, + "project": { + "self": "https://some-instance-test.atlassian.net/rest/api/2/project/10000", + "id": "10000", + "key": "TES", + "name": "test1", + "projectTypeKey": "software", + "avatarUrls": { + "48x48": "https://some-instance-test.atlassian.net/secure/projectavatar?avatarId=10324", + "24x24": "https://some-instance-test.atlassian.net/secure/projectavatar?size=small&avatarId=10324", + "16x16": "https://some-instance-test.atlassian.net/secure/projectavatar?size=xsmall&avatarId=10324", + "32x32": "https://some-instance-test.atlassian.net/secure/projectavatar?size=medium&avatarId=10324" + } + }, + "fixVersions": [], + "aggregatetimespent": null, + "resolution": null, + "customfield_10027": null, + "resolutiondate": null, + "workratio": -1, + "lastViewed": null, + "watches": { + "self": "https://some-instance-test.atlassian.net/rest/api/2/issue/TES-41/watchers", + "watchCount": 0, + "isWatching": true + }, + "created": "2019-02-15T19:01:52.971-0800", + "customfield_10020": null, + "customfield_10021": null, + "customfield_10022": "0|i00067:", + "priority": { + "self": "https://some-instance-test.atlassian.net/rest/api/2/priority/2", + "iconUrl": "https://some-instance-test.atlassian.net/images/icons/priorities/high.svg", + "name": "High", + "id": "2" + }, + "customfield_10023": null, + "customfield_10024": [], + "customfield_10025": null, + "customfield_10026": null, + "labels": [ + "test-label" + ], + "customfield_10016": null, + "customfield_10017": null, + "customfield_10018": { + "hasEpicLinkFieldDependency": false, + "showField": false, + "nonEditableReason": { + "reason": "PLUGIN_LICENSE_ERROR", + "message": "Portfolio for Jira must be licensed for the Parent Link to be available." + } + }, + "customfield_10019": null, + "aggregatetimeoriginalestimate": null, + "timeestimate": null, + "versions": [], + "issuelinks": [], + "assignee": null, + "updated": "2019-02-15T19:01:52.971-0800", + "status": { + "self": "https://some-instance-test.atlassian.net/rest/api/2/status/10001", + "description": "", + "iconUrl": "https://some-instance-test.atlassian.net/", + "name": "To Do", + "id": "10001", + "statusCategory": { + "self": "https://some-instance-test.atlassian.net/rest/api/2/statuscategory/2", + "id": 2, + "key": "new", + "colorName": "blue-gray", + "name": "New" + } + }, + "components": [ + { + "self": "https://some-instance-test.atlassian.net/rest/api/2/component/10000", + "id": "10000", + "name": "COMP-1", + "description": "Component-1" + } + ], + "timeoriginalestimate": null, + "description": "Unit test description, not that long", + "customfield_10010": null, + "customfield_10014": null, + "customfield_10015": null, + "timetracking": {}, + "customfield_10005": null, + "customfield_10006": null, + "security": "10001", + "customfield_10007": null, + "customfield_10008": null, + "attachment": [], + "customfield_10009": null, + "aggregatetimeestimate": null, + "summary": "Unit test summary", + "creator": { + "self": "https://some-instance-test.atlassian.net/rest/api/2/user?accountId=5c5f880629be9642ba529340", + "name": "admin", + "key": "admin", + "accountId": "5c5f880629be9642ba529340", + "emailAddress": "some-instance-test@gmail.com", + "avatarUrls": { + "48x48": "https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=48&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D48%26noRedirect%3Dtrue", + "24x24": "https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=24&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D24%26noRedirect%3Dtrue", + "16x16": "https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=16&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D16%26noRedirect%3Dtrue", + "32x32": "https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=32&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D32%26noRedirect%3Dtrue" + }, + "displayName": "Test User", + "active": true, + "timeZone": "America/Los_Angeles" + }, + "subtasks": [], + "reporter": { + "self": "https://some-instance-test.atlassian.net/rest/api/2/user?accountId=5c5f880629be9642ba529340", + "name": "admin", + "key": "admin", + "accountId": "5c5f880629be9642ba529340", + "emailAddress": "some-instance-test@gmail.com", + "avatarUrls": { + "48x48": "https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=48&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D48%26noRedirect%3Dtrue", + "24x24": "https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=24&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D24%26noRedirect%3Dtrue", + "16x16": "https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=16&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D16%26noRedirect%3Dtrue", + "32x32": "https://avatar-cdn.atlassian.com/d991bc281c0c0ecb0bbb2db3979ddaff?s=32&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fd991bc281c0c0ecb0bbb2db3979ddaff%3Fd%3Dmm%26s%3D32%26noRedirect%3Dtrue" + }, + "displayName": "Test User", + "active": true, + "timeZone": "America/Los_Angeles" + }, + "customfield_10000": "{}", + "aggregateprogress": { + "progress": 0, + "total": 0 + }, + "customfield_10001": null, + "customfield_10002": null, + "customfield_10003": null, + "customfield_10004": null, + "environment": null, + "duedate": null, + "progress": { + "progress": 0, + "total": 0 + }, + "votes": { + "self": "https://some-instance-test.atlassian.net/rest/api/2/issue/TES-41/votes", + "votes": 0, + "hasVoted": false + } + } + }, + "changelog": { + "id": "10222", + "items": [ + { + "field": "description", + "fieldtype": "jira", + "fieldId": "description", + "from": null, + "fromString": null, + "to": null, + "toString": "Unit test description, not that long" + }, + { + "field": "priority", + "fieldtype": "jira", + "fieldId": "priority", + "from": null, + "fromString": null, + "to": "2", + "toString": "High" + }, + { + "field": "reporter", + "fieldtype": "jira", + "fieldId": "reporter", + "from": null, + "fromString": null, + "to": "admin", + "toString": "Test User" + }, + { + "field": "Status", + "fieldtype": "jira", + "fieldId": "status", + "from": null, + "fromString": null, + "to": "10001", + "toString": "To Do" + }, + { + "field": "summary", + "fieldtype": "jira", + "fieldId": "summary", + "from": null, + "fromString": null, + "to": null, + "toString": "Unit test summary" + } + ] + } +} diff --git a/server/user.go b/server/user.go index 38947e8fd..c2a51162f 100644 --- a/server/user.go +++ b/server/user.go @@ -190,10 +190,13 @@ func (p *Plugin) UpdateUserDefaults(mattermostUserID, instanceID types.ID, proje } func (p *Plugin) httpGetSettingsInfo(w http.ResponseWriter, r *http.Request) (int, error) { + conf := p.getConfig() return respondJSON(w, struct { - UIEnabled bool `json:"ui_enabled"` + UIEnabled bool `json:"ui_enabled"` + SecurityLevelEmptyForJiraSubscriptions bool `json:"security_level_empty_for_jira_subscriptions"` }{ - UIEnabled: p.getConfig().EnableJiraUI, + UIEnabled: conf.EnableJiraUI, + SecurityLevelEmptyForJiraSubscriptions: conf.SecurityLevelEmptyForJiraSubscriptions, }) } diff --git a/webapp/src/components/modals/channel_subscriptions/__snapshots__/channel_subscription_filters.test.tsx.snap b/webapp/src/components/modals/channel_subscriptions/__snapshots__/channel_subscription_filters.test.tsx.snap index ef1cfd844..a33535f58 100644 --- a/webapp/src/components/modals/channel_subscriptions/__snapshots__/channel_subscription_filters.test.tsx.snap +++ b/webapp/src/components/modals/channel_subscriptions/__snapshots__/channel_subscription_filters.test.tsx.snap @@ -155,6 +155,7 @@ exports[`components/ChannelSubscriptionFilters should match snapshot 1`] = ` onChange={[Function]} removeFilter={[Function]} removeValidate={[MockFunction]} + securityLevelEmptyForJiraSubscriptions={true} theme={Object {}} value={ Object { diff --git a/webapp/src/components/modals/channel_subscriptions/__snapshots__/edit_channel_subscription.test.tsx.snap b/webapp/src/components/modals/channel_subscriptions/__snapshots__/edit_channel_subscription.test.tsx.snap index 9ff6146e3..3b0054080 100644 --- a/webapp/src/components/modals/channel_subscriptions/__snapshots__/edit_channel_subscription.test.tsx.snap +++ b/webapp/src/components/modals/channel_subscriptions/__snapshots__/edit_channel_subscription.test.tsx.snap @@ -3544,6 +3544,7 @@ exports[`components/EditChannelSubscription should match snapshot after fetching } onChange={[Function]} removeValidate={[Function]} + securityLevelEmptyForJiraSubscriptions={true} theme={ Object { "awayIndicator": "#ffbc42", @@ -3611,13 +3612,22 @@ exports[`components/EditChannelSubscription should match snapshot after fetching "background": "rgba(61,60,64,0.08)", "borderRadius": "4px", "fontSize": "13px", + "marginBottom": "8px", "marginTop": "8px", "padding": "10px 12px", } } > - Project = KT AND IssueType IN (Bug) AND "MJK - Radio Buttons" IN (1) AND affectedVersion IN (d) AND "Epic Link" IN (IDT-24) + Project = KT AND IssueType IN (Bug) AND "MJK - Radio Buttons" IN (1) AND affectedVersion IN (d) AND "Epic Link" IN (IDT-24) AND "Security Level" IS EMPTY + + +
+ + + Note + + that since you have not selected a security level filter, the subscription will only allow issues that have no security level assigned.
diff --git a/webapp/src/components/modals/channel_subscriptions/channel_subscription_filter.test.tsx b/webapp/src/components/modals/channel_subscriptions/channel_subscription_filter.test.tsx index 56da56f7b..5cd103ae9 100644 --- a/webapp/src/components/modals/channel_subscriptions/channel_subscription_filter.test.tsx +++ b/webapp/src/components/modals/channel_subscriptions/channel_subscription_filter.test.tsx @@ -31,6 +31,7 @@ describe('components/ChannelSubscriptionFilter', () => { onChange: jest.fn(), removeFilter: jest.fn(), instanceID: 'https://something.atlassian.net', + securityLevelEmptyForJiraSubscriptions: true, }; test('should match snapshot', () => { @@ -116,4 +117,61 @@ describe('components/ChannelSubscriptionFilter', () => { result = wrapper.instance().checkFieldConflictError(); expect(result).toEqual('FieldName does not exist for issue type(s): Task.'); }); + + test('checkInclusionError should return an error string when there is an invalid inclusion value', () => { + const props: Props = { + ...baseProps, + field: { + ...baseProps.field, + schema: { + ...baseProps.field.schema, + type: 'securitylevel', + }, + }, + }; + const wrapper = shallow( + + ); + + let isValid; + isValid = wrapper.instance().isValid(); + expect(isValid).toBe(true); + + wrapper.setProps({ + ...props, + value: { + inclusion: FilterFieldInclusion.EMPTY, + key: 'securitylevel', + values: [], + }, + }); + + isValid = wrapper.instance().isValid(); + expect(isValid).toBe(true); + + wrapper.setProps({ + ...props, + value: { + inclusion: FilterFieldInclusion.INCLUDE_ANY, + key: 'securitylevel', + values: [], + }, + }); + + isValid = wrapper.instance().isValid(); + expect(isValid).toBe(true); + + wrapper.setProps({ + ...props, + value: { + inclusion: FilterFieldInclusion.EXCLUDE_ANY, + key: 'securitylevel', + values: [], + }, + }); + + isValid = wrapper.instance().isValid(); + expect(isValid).toBe(false); + expect(wrapper.find('.error-text').text()).toEqual('Security level inclusion cannot be "Exclude Any". Note that the default value is now "Empty".'); + }); }); diff --git a/webapp/src/components/modals/channel_subscriptions/channel_subscription_filter.tsx b/webapp/src/components/modals/channel_subscriptions/channel_subscription_filter.tsx index 9abdf12e6..25eddc79b 100644 --- a/webapp/src/components/modals/channel_subscriptions/channel_subscription_filter.tsx +++ b/webapp/src/components/modals/channel_subscriptions/channel_subscription_filter.tsx @@ -5,7 +5,7 @@ import {Theme} from 'mattermost-redux/types/preferences'; import ReactSelectSetting from 'components/react_select_setting'; import JiraEpicSelector from 'components/data_selectors/jira_epic_selector'; -import {isEpicLinkField, isMultiSelectField, isLabelField} from 'utils/jira_issue_metadata'; +import {isEpicLinkField, isMultiSelectField, isLabelField, isSecurityLevelField} from 'utils/jira_issue_metadata'; import {FilterField, FilterValue, ReactSelectOption, IssueMetadata, IssueType, FilterFieldInclusion} from 'types/model'; import ConfirmModal from 'components/confirm_modal'; import JiraAutoCompleteSelector from 'components/data_selectors/jira_autocomplete_selector'; @@ -22,6 +22,7 @@ export type Props = { addValidate: (isValid: () => boolean) => void; removeValidate: (isValid: () => boolean) => void; instanceID: string; + securityLevelEmptyForJiraSubscriptions: boolean; }; export type State = { @@ -103,7 +104,13 @@ export default class ChannelSubscriptionFilter extends React.PureComponent { - const error = this.checkFieldConflictError(); + let error = this.checkFieldConflictError(); + if (error) { + this.setState({error}); + return false; + } + + error = this.checkInclusionError(); if (error) { this.setState({error}); return false; @@ -112,6 +119,16 @@ export default class ChannelSubscriptionFilter extends React.PureComponent { + const inclusion = this.props.value && this.props.value.inclusion; + + if (isSecurityLevelField(this.props.field) && inclusion === FilterFieldInclusion.EXCLUDE_ANY && this.props.securityLevelEmptyForJiraSubscriptions) { + return 'Security level inclusion cannot be "Exclude Any". Note that the default value is now "Empty".'; + } + + return null; + } + checkFieldConflictError = (): string | null => { const conflictIssueTypes = this.getConflictingIssueTypes().map((it) => it.name); if (conflictIssueTypes.length) { @@ -167,13 +184,21 @@ export default class ChannelSubscriptionFilter extends React.PureComponent opt.value === FilterFieldInclusion.INCLUDE_ALL); inclusionSelectOptions.splice(includeAllIndex, 1); @@ -296,7 +321,7 @@ export default class ChannelSubscriptionFilter extends React.PureComponent - {this.checkFieldConflictError()} + {this.state.error}
diff --git a/webapp/src/components/modals/channel_subscriptions/channel_subscription_filters.test.tsx b/webapp/src/components/modals/channel_subscriptions/channel_subscription_filters.test.tsx index ec59a9796..f3f1b5df1 100644 --- a/webapp/src/components/modals/channel_subscriptions/channel_subscription_filters.test.tsx +++ b/webapp/src/components/modals/channel_subscriptions/channel_subscription_filters.test.tsx @@ -58,6 +58,7 @@ describe('components/ChannelSubscriptionFilters', () => { removeValidate: jest.fn(), onChange: jest.fn(), instanceID: 'https://something.atlassian.net', + securityLevelEmptyForJiraSubscriptions: true, }; test('should match snapshot', () => { diff --git a/webapp/src/components/modals/channel_subscriptions/channel_subscription_filters.tsx b/webapp/src/components/modals/channel_subscriptions/channel_subscription_filters.tsx index 8ff79f682..ee75cc00b 100644 --- a/webapp/src/components/modals/channel_subscriptions/channel_subscription_filters.tsx +++ b/webapp/src/components/modals/channel_subscriptions/channel_subscription_filters.tsx @@ -16,6 +16,7 @@ export type Props = { removeValidate: (isValid: () => boolean) => void; onChange: (f: FilterValue[]) => void; instanceID: string; + securityLevelEmptyForJiraSubscriptions: boolean; }; type State = { @@ -32,7 +33,7 @@ export default class ChannelSubscriptionFilters extends React.PureComponent f === oldValue); if (index === -1) { - newValues.push({inclusion: FilterFieldInclusion.INCLUDE_ANY, values: [], ...newValue}); + newValues.push({...newValue, inclusion: FilterFieldInclusion.INCLUDE_ANY, values: []}); this.setState({showCreateRow: false}); } else { newValues.splice(index, 1, newValue); @@ -104,6 +105,7 @@ export default class ChannelSubscriptionFilters extends React.PureComponent
); diff --git a/webapp/src/components/modals/channel_subscriptions/edit_channel_subscription.test.tsx b/webapp/src/components/modals/channel_subscriptions/edit_channel_subscription.test.tsx index b2a920a6c..f2e867680 100644 --- a/webapp/src/components/modals/channel_subscriptions/edit_channel_subscription.test.tsx +++ b/webapp/src/components/modals/channel_subscriptions/edit_channel_subscription.test.tsx @@ -5,8 +5,8 @@ import React from 'react'; import {shallow} from 'enzyme'; import Preferences from 'mattermost-redux/constants/preferences'; +import {Channel} from 'mattermost-redux/types/channels'; -import cloudProjectMetadata from 'testdata/cloud-get-jira-project-metadata.json'; import cloudIssueMetadata from 'testdata/cloud-get-create-issue-metadata-for-project.json'; import serverProjectMetadata from 'testdata/server-get-jira-project-metadata.json'; import serverIssueMetadata from 'testdata/server-get-create-issue-metadata-for-project-many-fields.json'; @@ -14,7 +14,7 @@ import testChannel from 'testdata/channel.json'; import {IssueMetadata, ProjectMetadata, FilterFieldInclusion} from 'types/model'; -import EditChannelSubscription from './edit_channel_subscription'; +import EditChannelSubscription, {Props} from './edit_channel_subscription'; describe('components/EditChannelSubscription', () => { const baseActions = { @@ -71,15 +71,16 @@ describe('components/EditChannelSubscription', () => { instance_id: 'https://something.atlassian.net', }; - const baseProps = { + const baseProps: Props = { ...baseActions, - channel: testChannel, + channel: testChannel as unknown as Channel, theme: Preferences.THEMES.default, finishEditSubscription: jest.fn(), channelSubscriptions: [channelSubscriptionForCloud], close: jest.fn(), selectedSubscription: channelSubscriptionForCloud, creatingSubscription: false, + securityLevelEmptyForJiraSubscriptions: true, }; const baseState = { diff --git a/webapp/src/components/modals/channel_subscriptions/edit_channel_subscription.tsx b/webapp/src/components/modals/channel_subscriptions/edit_channel_subscription.tsx index cbd914ba5..f8163d75f 100644 --- a/webapp/src/components/modals/channel_subscriptions/edit_channel_subscription.tsx +++ b/webapp/src/components/modals/channel_subscriptions/edit_channel_subscription.tsx @@ -19,6 +19,7 @@ import { getConflictingFields, generateJQLStringFromSubscriptionFilters, getIssueTypes, + filterValueIsSecurityField, } from 'utils/jira_issue_metadata'; import {ChannelSubscription, ChannelSubscriptionFilters as ChannelSubscriptionFiltersModel, ReactSelectOption, FilterValue, IssueMetadata} from 'types/model'; @@ -172,6 +173,14 @@ export default class EditChannelSubscription extends PureComponent this.setState({conflictingError: null}); } + shouldShowEmptySecurityLevelMessage = (): boolean => { + if (!this.props.securityLevelEmptyForJiraSubscriptions) { + return false; + } + + return !this.state.filters.fields.some(filterValueIsSecurityField); + } + handleIssueChange = (id: keyof ChannelSubscriptionFiltersModel, value: string[] | null) => { const finalValue = value || []; const filters = {...this.state.filters, issue_types: finalValue}; @@ -392,14 +401,23 @@ export default class EditChannelSubscription extends PureComponent addValidate={this.validator.addComponent} removeValidate={this.validator.removeComponent} instanceID={this.state.instanceID} + securityLevelEmptyForJiraSubscriptions={this.props.securityLevelEmptyForJiraSubscriptions} />
- {generateJQLStringFromSubscriptionFilters(this.state.jiraIssueMetadata, filterFields, this.state.filters)} + {generateJQLStringFromSubscriptionFilters(this.state.jiraIssueMetadata, filterFields, this.state.filters, this.props.securityLevelEmptyForJiraSubscriptions)}
+ {this.shouldShowEmptySecurityLevelMessage() && ( +
+ + {'Note'} + {' that since you have not selected a security level filter, the subscription will only allow issues that have no security level assigned.'} + +
+ )}
); diff --git a/webapp/src/components/modals/channel_subscriptions/index.ts b/webapp/src/components/modals/channel_subscriptions/index.ts index 39606196d..c9133cbb2 100644 --- a/webapp/src/components/modals/channel_subscriptions/index.ts +++ b/webapp/src/components/modals/channel_subscriptions/index.ts @@ -25,7 +25,7 @@ import { getChannelIdWithSettingsOpen, getInstalledInstances, getUserConnectedInstances, - getDefaultUserInstanceID, + getPluginSettings, } from 'selectors'; import ChannelSubscriptionsModal from './channel_subscriptions'; @@ -44,6 +44,8 @@ const mapStateToProps = (state) => { const installedInstances = getInstalledInstances(state); const connectedInstances = getUserConnectedInstances(state); + const pluginSettings = getPluginSettings(state); + const securityLevelEmptyForJiraSubscriptions = pluginSettings.security_level_empty_for_jira_subscriptions; return { omitDisplayName, @@ -51,6 +53,7 @@ const mapStateToProps = (state) => { channel, installedInstances, connectedInstances, + securityLevelEmptyForJiraSubscriptions, }; }; diff --git a/webapp/src/components/modals/channel_subscriptions/shared_props.ts b/webapp/src/components/modals/channel_subscriptions/shared_props.ts index b94d47cb3..61fb43b8e 100644 --- a/webapp/src/components/modals/channel_subscriptions/shared_props.ts +++ b/webapp/src/components/modals/channel_subscriptions/shared_props.ts @@ -22,4 +22,5 @@ export type SharedProps = { getConnected: () => Promise; close: () => void; sendEphemeralPost: (message: string) => void; + securityLevelEmptyForJiraSubscriptions: boolean; }; diff --git a/webapp/src/utils/jira_issue_metadata.tsx b/webapp/src/utils/jira_issue_metadata.tsx index d8247fec4..05ccd7c31 100644 --- a/webapp/src/utils/jira_issue_metadata.tsx +++ b/webapp/src/utils/jira_issue_metadata.tsx @@ -8,12 +8,14 @@ import { IssueType, JiraField, FilterField, + FilterValue, SelectField, StringArrayField, IssueTypeIdentifier, ChannelSubscriptionFilters, FilterFieldInclusion, JiraFieldCustomTypeEnums, + JiraFieldTypeEnums, } from 'types/model'; type FieldWithInfo = JiraField & { @@ -276,6 +278,14 @@ export function isTextField(field: JiraField | FilterField): boolean { return field.schema.type === 'string'; } +export function isSecurityLevelField(field: JiraField | FilterField): boolean { + return field.schema.type === 'securitylevel'; +} + +export function filterValueIsSecurityField(value: FilterValue): boolean { + return value.key === JiraFieldTypeEnums.SECURITY; +} + // Some Jira fields have special names for JQL function getFieldNameForJQL(field: FilterField) { switch (field.key) { @@ -296,7 +306,7 @@ function quoteGuard(s: string) { return s; } -export function generateJQLStringFromSubscriptionFilters(issueMetadata: IssueMetadata, fields: FilterField[], filters: ChannelSubscriptionFilters) { +export function generateJQLStringFromSubscriptionFilters(issueMetadata: IssueMetadata, fields: FilterField[], filters: ChannelSubscriptionFilters, securityLevelEmptyForJiraSubscriptions: boolean) { const projectJQL = `Project = ${quoteGuard(filters.projects[0]) || '?'}`; let issueTypeValueString = '?'; @@ -313,7 +323,7 @@ export function generateJQLStringFromSubscriptionFilters(issueMetadata: IssueMet } const issueTypesJQL = `IssueType IN ${issueTypeValueString}`; - const filterFieldsJQL = filters.fields.map(({key, inclusion, values}): string => { + let filterFieldsJQL = filters.fields.map(({key, inclusion, values}): string => { const field = fields.find((f) => f.key === key); if (!field) { // broken filter @@ -354,5 +364,13 @@ export function generateJQLStringFromSubscriptionFilters(issueMetadata: IssueMet return `${quoteGuard(fieldName)} ${inclusionString} ${valueString}`; }).join(' AND '); + const shouldShowEmptySecurityLevel = securityLevelEmptyForJiraSubscriptions && !filters.fields.some(filterValueIsSecurityField); + if (shouldShowEmptySecurityLevel) { + if (filterFieldsJQL.length) { + filterFieldsJQL += ' AND '; + } + filterFieldsJQL += '"Security Level" IS EMPTY'; + } + return [projectJQL, issueTypesJQL, filterFieldsJQL].filter(Boolean).join(' AND '); } diff --git a/webapp/src/utils/styles.ts b/webapp/src/utils/styles.ts index ab3284c39..2f0744f7c 100644 --- a/webapp/src/utils/styles.ts +++ b/webapp/src/utils/styles.ts @@ -12,6 +12,7 @@ export const getBaseStyles = (theme: Theme) => { background: changeOpacity(theme.centerChannelColor, 0.08), borderRadius: '4px', marginTop: '8px', + marginBottom: '8px', fontSize: '13px', }), };