From 2f886a2ec1d498dca42bf6f45e42082b65aba97c Mon Sep 17 00:00:00 2001 From: Harshil Sharma <18575143+harshilsharma63@users.noreply.github.com> Date: Tue, 22 Jul 2025 14:57:37 +0530 Subject: [PATCH] Reporting config apis (#33378) * Added enable/disable setting and feature flag * added rest of notifgication settings * Added backend for content flagging setting and populated notification values from server side defaults * WIP user selector * Added common reviewers UI * Added additonal reviewers section * WIP * WIP * Team table base * Added search in teams * Added search in teams * Added additional settings section * WIP * Inbtegrated reviewers settings * WIP * WIP * Added server side validation * cleanup * cleanup * [skip ci] * Some refactoring * type fixes * lint fix * test: add content flagging settings test file * test: add comprehensive unit tests for content flagging settings * enhanced tests * test: add test file for content flagging additional settings * test: add comprehensive unit tests for ContentFlaggingAdditionalSettingsSection * Added additoonal settings test * test: add empty test file for team reviewers section * test: add comprehensive unit tests for TeamReviewersSection component * test: update tests to handle async data fetching in team reviewers section * test: add empty test file for content reviewers component * feat: add comprehensive unit tests for ContentFlaggingContentReviewers component * Added ContentFlaggingContentReviewersContentFlaggingContentReviewers test * test: add notification settings test file for content flagging * test: add comprehensive unit tests for content flagging notification settings * Added ContentFlaggingNotificationSettingsSection tests * test: add user profile pill test file * test: add comprehensive unit tests for UserProfilePill component * refactor: Replace enzyme shallow with renderWithContext in user_profile_pill tests * Added UserProfilePill tests * test: add empty test file for content reviewers team option * test: add comprehensive unit tests for TeamOptionComponent * Added TeamOptionComponent tests * test: add empty test file for reason_option component * test: add comprehensive unit tests for ReasonOption component * Added ReasonOption tests * cleanup * Fixed i18n error * fixed e2e test lijnt issues * Updated test cases * Added snaoshot * Updated snaoshot * lint fix * lint fix * review fixes * updated snapshot * CI * Added base APIs * Fetched team status data on load and team switch * WIP * Review fixes * wip * WIP * Removed an test, updated comment * CI * Added tests * Added tests * Lint fix * Added API specs * Fixed types * CI fixes * API tests * lint fixes * Set env variable so API routes are regiustered * Test update * term renaming and disabling API tests on MySQL * typo * Updated store type definition * Minor tweaks * Updated tests and docs * finction rename * Updated tests * refactor * lint fix * Removed unnecesseery nil check * Updated error code order in API docs --- api/Makefile | 1 + api/v4/source/content_flagging.yaml | 63 +++++++++ server/channels/api4/api.go | 5 + server/channels/api4/content_flagging.go | 87 ++++++++++++ server/channels/api4/content_flagging_test.go | 128 ++++++++++++++++++ server/channels/app/content_flagging.go | 29 ++++ server/channels/app/content_flagging_test.go | 72 ++++++++++ server/config/client.go | 4 + server/i18n/en.json | 8 ++ server/public/model/client4.go | 30 ++++ .../public/model/content_flagging_settings.go | 25 ++-- .../model/content_flagging_settings_test.go | 4 +- .../team_controller/team_controller.tsx | 15 ++ .../src/action_types/teams.ts | 2 + .../src/actions/content_flagging.test.ts | 42 ++++++ .../src/actions/content_flagging.ts | 32 +++++ .../src/reducers/entities/teams.ts | 15 ++ .../entities/content_flagging.test.ts | 53 ++++++++ .../selectors/entities/content_flagging.ts | 13 ++ .../src/store/initial_state.ts | 1 + webapp/channels/src/tests/constants/teams.ts | 1 + webapp/platform/client/src/client4.ts | 11 ++ webapp/platform/types/src/config.ts | 2 + webapp/platform/types/src/teams.ts | 1 + 24 files changed, 633 insertions(+), 11 deletions(-) create mode 100644 api/v4/source/content_flagging.yaml create mode 100644 server/channels/api4/content_flagging.go create mode 100644 server/channels/api4/content_flagging_test.go create mode 100644 server/channels/app/content_flagging.go create mode 100644 server/channels/app/content_flagging_test.go create mode 100644 webapp/channels/src/packages/mattermost-redux/src/actions/content_flagging.test.ts create mode 100644 webapp/channels/src/packages/mattermost-redux/src/actions/content_flagging.ts create mode 100644 webapp/channels/src/packages/mattermost-redux/src/selectors/entities/content_flagging.test.ts create mode 100644 webapp/channels/src/packages/mattermost-redux/src/selectors/entities/content_flagging.ts diff --git a/api/Makefile b/api/Makefile index 64c1a186915..8ff884ea62c 100644 --- a/api/Makefile +++ b/api/Makefile @@ -61,6 +61,7 @@ build-v4: node_modules playbooks @cat $(V4_SRC)/custom_profile_attributes.yaml >> $(V4_YAML) @cat $(V4_SRC)/audit_logging.yaml >> $(V4_YAML) @cat $(V4_SRC)/access_control.yaml >> $(V4_YAML) + @cat $(V4_SRC)/content_flagging.yaml >> $(V4_YAML) @if [ -r $(PLAYBOOKS_SRC)/paths.yaml ]; then cat $(PLAYBOOKS_SRC)/paths.yaml >> $(V4_YAML); fi @if [ -r $(PLAYBOOKS_SRC)/merged-definitions.yaml ]; then cat $(PLAYBOOKS_SRC)/merged-definitions.yaml >> $(V4_YAML); else cat $(V4_SRC)/definitions.yaml >> $(V4_YAML); fi @echo Extracting code samples diff --git a/api/v4/source/content_flagging.yaml b/api/v4/source/content_flagging.yaml new file mode 100644 index 00000000000..cd223489dd9 --- /dev/null +++ b/api/v4/source/content_flagging.yaml @@ -0,0 +1,63 @@ + /api/v4/content_flagging/flag/config: + get: + summary: Get content flagging configuration + description: | + Returns the configuration for content flagging, including the list of available reasons for flagging content. This data is used to gather details from the user when they flag content. + An enterprise advanced license is required. + tags: + - Content Flagging + responses: + '200': + description: Configuration retrieved successfully + content: + application/json: + schema: + type: object + properties: + reasons: + type: array + items: + type: string + description: List of reasons for flagging content + reporter_comment_required: + type: boolean + description: Indicates if a comment from the reporter is required when flagging content + '404': + description: Feature is disabled via the feature flag. + '500': + description: Internal server error. + '501': + description: Feature is disabled either via config or an Enterprise Advanced license is not available. + /api/v4/content_flagging/team/{team_id}/status: + get: + summary: Get content flagging status for a team + description: | + Returns the content flagging status for a specific team, indicating whether content flagging is enabled on the specified team or not. + tags: + - Content Flagging + parameters: + - in: path + name: team_id + required: true + schema: + type: string + description: The ID of the team to retrieve the content flagging status for + responses: + '200': + description: Content flagging status retrieved successfully + content: + application/json: + schema: + type: object + properties: + enabled: + type: boolean + description: Indicates if content flagging is enabled for the team + '403': + description: Forbidden - User does not have permission to access this team. + '404': + description: The specified team was not found or the feature is disabled via the feature flag. + '500': + description: Internal server error. + '501': + description: Feature is disabled either via config or an Enterprise Advanced license is not available. diff --git a/server/channels/api4/api.go b/server/channels/api4/api.go index 58c6581f872..5794e924358 100644 --- a/server/channels/api4/api.go +++ b/server/channels/api4/api.go @@ -162,6 +162,8 @@ type Routes struct { AccessControlPolicies *mux.Router // 'api/v4/access_control_policies' AccessControlPolicy *mux.Router // 'api/v4/access_control_policies/{policy_id:[A-Za-z0-9]+}' + + ContentFlagging *mux.Router // 'api/v4/content_flagging' } type API struct { @@ -310,6 +312,8 @@ func Init(srv *app.Server) (*API, error) { api.BaseRoutes.AccessControlPolicies = api.BaseRoutes.APIRoot.PathPrefix("/access_control_policies").Subrouter() api.BaseRoutes.AccessControlPolicy = api.BaseRoutes.APIRoot.PathPrefix("/access_control_policies/{policy_id:[A-Za-z0-9]+}").Subrouter() + api.BaseRoutes.ContentFlagging = api.BaseRoutes.APIRoot.PathPrefix("/content_flagging").Subrouter() + api.InitUser() api.InitBot() api.InitTeam() @@ -363,6 +367,7 @@ func Init(srv *app.Server) (*API, error) { api.InitCustomProfileAttributes() api.InitAuditLogging() api.InitAccessControlPolicy() + api.InitContentFlagging() // If we allow testing then listen for manual testing URL hits if *srv.Config().ServiceSettings.EnableTesting { diff --git a/server/channels/api4/content_flagging.go b/server/channels/api4/content_flagging.go new file mode 100644 index 00000000000..da48f7e66b8 --- /dev/null +++ b/server/channels/api4/content_flagging.go @@ -0,0 +1,87 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "encoding/json" + "net/http" + + "github.com/mattermost/mattermost/server/v8/channels/app" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" +) + +func (api *API) InitContentFlagging() { + if !api.srv.Config().FeatureFlags.ContentFlagging { + return + } + + api.BaseRoutes.ContentFlagging.Handle("/flag/config", api.APISessionRequired(getFlaggingConfiguration)).Methods(http.MethodGet) + api.BaseRoutes.ContentFlagging.Handle("/team/{team_id:[A-Za-z0-9]+}/status", api.APISessionRequired(getTeamPostFlaggingFeatureStatus)).Methods(http.MethodGet) +} + +func requireContentFlaggingEnabled(c *Context) { + if !model.MinimumEnterpriseAdvancedLicense(c.App.License()) { + c.Err = model.NewAppError("requireContentFlaggingEnabled", "api.content_flagging.error.license", nil, "", http.StatusNotImplemented) + return + } + + contentFlaggingEnabled := c.App.Config().ContentFlaggingSettings.EnableContentFlagging + if contentFlaggingEnabled == nil || !*contentFlaggingEnabled { + c.Err = model.NewAppError("requireContentFlaggingEnabled", "api.content_flagging.error.disabled", nil, "", http.StatusNotImplemented) + return + } +} + +func getFlaggingConfiguration(c *Context, w http.ResponseWriter, r *http.Request) { + requireContentFlaggingEnabled(c) + if c.Err != nil { + return + } + + config := getFlaggingConfig(c.App.Config().ContentFlaggingSettings) + + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(config); err != nil { + mlog.Error("failed to encode content flagging configuration to return API response", mlog.Err(err)) + return + } +} + +func getTeamPostFlaggingFeatureStatus(c *Context, w http.ResponseWriter, r *http.Request) { + requireContentFlaggingEnabled(c) + if c.Err != nil { + return + } + + c.RequireTeamId() + if c.Err != nil { + return + } + + teamID := c.Params.TeamId + if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamID, model.PermissionViewTeam) { + c.SetPermissionError(model.PermissionViewTeam) + return + } + + enabled := app.ContentFlaggingEnabledForTeam(c.App.Config(), teamID) + + payload := map[string]bool{ + "enabled": enabled, + } + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(payload); err != nil { + mlog.Error("failed to encode content flagging configuration to return API response", mlog.Err(err)) + return + } +} + +func getFlaggingConfig(contentFlaggingSettings model.ContentFlaggingSettings) *model.ContentFlaggingReportingConfig { + return &model.ContentFlaggingReportingConfig{ + Reasons: contentFlaggingSettings.AdditionalSettings.Reasons, + ReporterCommentRequired: contentFlaggingSettings.AdditionalSettings.ReporterCommentRequired, + } +} diff --git a/server/channels/api4/content_flagging_test.go b/server/channels/api4/content_flagging_test.go new file mode 100644 index 00000000000..ffcc8406cf3 --- /dev/null +++ b/server/channels/api4/content_flagging_test.go @@ -0,0 +1,128 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "context" + "net/http" + "os" + "testing" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/require" +) + +func TestGetFlaggingConfiguration(t *testing.T) { + mainHelper.Parallel(t) + if *mainHelper.GetSQLSettings().DriverName == model.DatabaseDriverMysql { + t.Skip("Content flagging tests are not supported on MySQL") + } + + os.Setenv("MM_FEATUREFLAGS_ContentFlagging", "true") + th := Setup(t) + defer func() { + th.TearDown() + os.Unsetenv("MM_FEATUREFLAGS_ContentFlagging") + }() + + client := th.Client + + t.Run("Should return 501 when Enterprise Advanced license is not present even if feature is enabled", func(t *testing.T) { + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise)) + th.App.UpdateConfig(func(config *model.Config) { + config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true) + config.ContentFlaggingSettings.SetDefaults() + }) + + status, resp, err := client.GetFlaggingConfiguration(context.Background()) + require.Error(t, err) + require.Equal(t, http.StatusNotImplemented, resp.StatusCode) + require.Nil(t, status) + }) + + t.Run("Should return 501 when feature is disabled", func(t *testing.T) { + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + th.App.UpdateConfig(func(config *model.Config) { + config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(false) + config.ContentFlaggingSettings.SetDefaults() + }) + + status, resp, err := client.GetFlaggingConfiguration(context.Background()) + require.Error(t, err) + require.Equal(t, http.StatusNotImplemented, resp.StatusCode) + require.Nil(t, status) + }) +} + +func TestGetTeamPostReportingFeatureStatus(t *testing.T) { + mainHelper.Parallel(t) + if *mainHelper.GetSQLSettings().DriverName == model.DatabaseDriverMysql { + t.Skip("Content flagging tests are not supported on MySQL") + } + + os.Setenv("MM_FEATUREFLAGS_ContentFlagging", "true") + th := Setup(t) + defer func() { + th.TearDown() + os.Unsetenv("MM_FEATUREFLAGS_ContentFlagging") + }() + + client := th.Client + + t.Run("Should return 501 when Enterprise Advanced license is not present even if feature is enabled", func(t *testing.T) { + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise)) + th.App.UpdateConfig(func(config *model.Config) { + config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true) + config.ContentFlaggingSettings.SetDefaults() + }) + + status, resp, err := client.GetTeamPostFlaggingFeatureStatus(context.Background(), model.NewId()) + require.Error(t, err) + require.Equal(t, http.StatusNotImplemented, resp.StatusCode) + require.Nil(t, status) + }) + + t.Run("Should return 501 when feature is disabled", func(t *testing.T) { + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + th.App.UpdateConfig(func(config *model.Config) { + config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(false) + config.ContentFlaggingSettings.SetDefaults() + }) + + status, resp, err := client.GetTeamPostFlaggingFeatureStatus(context.Background(), model.NewId()) + require.Error(t, err) + require.Equal(t, http.StatusNotImplemented, resp.StatusCode) + require.Nil(t, status) + }) + + t.Run("Should return Forbidden error when calling for a team without the team membership", func(t *testing.T) { + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced)) + th.App.UpdateConfig(func(config *model.Config) { + config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true) + config.ContentFlaggingSettings.SetDefaults() + config.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(true) + config.ContentFlaggingSettings.ReviewerSettings.CommonReviewerIds = &[]string{"reviewer_user_id_1", "reviewer_user_id_2"} + }) + + // using basic user because the default user is a system admin, and they have + // access to all teams even without being an explicit team member + th.LoginBasic() + team := th.CreateTeam() + // unlinking from the created team as by default the team's creator is + // a team member, so we need to leave the team explicitly + th.UnlinkUserFromTeam(th.BasicUser, team) + + status, resp, err := client.GetTeamPostFlaggingFeatureStatus(context.Background(), team.Id) + require.Error(t, err) + require.Equal(t, http.StatusForbidden, resp.StatusCode) + require.Nil(t, status) + + // now we will join the team and that will allow us to call the endpoint without error + th.LinkUserToTeam(th.BasicUser, team) + status, resp, err = client.GetTeamPostFlaggingFeatureStatus(context.Background(), team.Id) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.True(t, status["enabled"]) + }) +} diff --git a/server/channels/app/content_flagging.go b/server/channels/app/content_flagging.go new file mode 100644 index 00000000000..960e6c5a8d4 --- /dev/null +++ b/server/channels/app/content_flagging.go @@ -0,0 +1,29 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import "github.com/mattermost/mattermost/server/public/model" + +func ContentFlaggingEnabledForTeam(config *model.Config, teamId string) bool { + reviewerSettings := config.ContentFlaggingSettings.ReviewerSettings + + hasCommonReviewers := *reviewerSettings.CommonReviewers + if hasCommonReviewers { + return true + } + + teamSettings, exist := (*reviewerSettings.TeamReviewersSetting)[teamId] + if !exist || (teamSettings.Enabled != nil && !*teamSettings.Enabled) { + return false + } + + if teamSettings.ReviewerIds != nil && len(*teamSettings.ReviewerIds) > 0 { + return true + } + + hasAdditionalReviewers := (reviewerSettings.TeamAdminsAsReviewers != nil && *reviewerSettings.TeamAdminsAsReviewers) || + (reviewerSettings.SystemAdminsAsReviewers != nil && *reviewerSettings.SystemAdminsAsReviewers) + + return hasAdditionalReviewers +} diff --git a/server/channels/app/content_flagging_test.go b/server/channels/app/content_flagging_test.go new file mode 100644 index 00000000000..b9ac7b7f42c --- /dev/null +++ b/server/channels/app/content_flagging_test.go @@ -0,0 +1,72 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "testing" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/require" +) + +func TestContentFlaggingEnabledForTeam(t *testing.T) { + getBaseConfig := func() *model.Config { + contentFlaggingSettings := model.ContentFlaggingSettings{} + contentFlaggingSettings.SetDefaults() + + return &model.Config{ + ContentFlaggingSettings: contentFlaggingSettings, + } + } + t.Run("should return true for common reviewers", func(t *testing.T) { + config := getBaseConfig() + config.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(true) + config.ContentFlaggingSettings.ReviewerSettings.CommonReviewerIds = &[]string{"reviewer_user_id_1", "reviewer_user_id_2"} + + status := ContentFlaggingEnabledForTeam(config, "team1") + require.True(t, status, "expected team post reporting feature to be enabled for common reviewers") + }) + + t.Run("should return true when configured for specified team", func(t *testing.T) { + config := getBaseConfig() + config.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(false) + config.ContentFlaggingSettings.ReviewerSettings.TeamReviewersSetting = &map[string]model.TeamReviewerSetting{ + "team1": { + Enabled: model.NewPointer(true), + ReviewerIds: model.NewPointer([]string{"reviewer_user_id_1"}), + }, + } + + status := ContentFlaggingEnabledForTeam(config, "team1") + require.True(t, status, "expected team post reporting feature to be disabled for team without reviewers") + }) + + t.Run("should return true when using Additional Reviewers", func(t *testing.T) { + config := getBaseConfig() + config.ContentFlaggingSettings.ReviewerSettings.CommonReviewers = model.NewPointer(false) + config.ContentFlaggingSettings.ReviewerSettings.TeamAdminsAsReviewers = model.NewPointer(true) + config.ContentFlaggingSettings.ReviewerSettings.TeamReviewersSetting = &map[string]model.TeamReviewerSetting{ + "team1": { + Enabled: model.NewPointer(true), + }, + } + + status := ContentFlaggingEnabledForTeam(config, "team1") + require.True(t, status) + + config = getBaseConfig() + config.ContentFlaggingSettings.ReviewerSettings.TeamAdminsAsReviewers = model.NewPointer(false) + config.ContentFlaggingSettings.ReviewerSettings.SystemAdminsAsReviewers = model.NewPointer(true) + + status = ContentFlaggingEnabledForTeam(config, "team1") + require.True(t, status) + + config = getBaseConfig() + config.ContentFlaggingSettings.ReviewerSettings.TeamAdminsAsReviewers = model.NewPointer(true) + config.ContentFlaggingSettings.ReviewerSettings.SystemAdminsAsReviewers = model.NewPointer(true) + + status = ContentFlaggingEnabledForTeam(config, "team1") + require.True(t, status) + }) +} diff --git a/server/config/client.go b/server/config/client.go index 7ee4e7b4f0c..fad23660888 100644 --- a/server/config/client.go +++ b/server/config/client.go @@ -239,6 +239,10 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li props["MobilePreventScreenCapture"] = strconv.FormatBool(*c.NativeAppSettings.MobilePreventScreenCapture) props["MobileJailbreakProtection"] = strconv.FormatBool(*c.NativeAppSettings.MobileJailbreakProtection) } + + if model.MinimumEnterpriseAdvancedLicense(license) { + props["ContentFlaggingEnabled"] = strconv.FormatBool(c.FeatureFlags.ContentFlagging && *c.ContentFlaggingSettings.EnableContentFlagging) + } } return props diff --git a/server/i18n/en.json b/server/i18n/en.json index cca5f2684f3..fe5be35bfc8 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -1737,6 +1737,14 @@ "id": "api.config.update_config.translations.app_error", "translation": "Failed to update server translations." }, + { + "id": "api.content_flagging.error.disabled", + "translation": "Content flagging feature is disabled." + }, + { + "id": "api.content_flagging.error.license", + "translation": "Your license does not support content flagging." + }, { "id": "api.context.404.app_error", "translation": "Sorry, we could not find the page." diff --git a/server/public/model/client4.go b/server/public/model/client4.go index fa1e036b2dc..15ee118c1c7 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -294,6 +294,10 @@ func (c *Client4) postsRoute() string { return "/posts" } +func (c *Client4) contentFlaggingRoute() string { + return "/content_flagging" +} + func (c *Client4) postsEphemeralRoute() string { return "/posts/ephemeral" } @@ -721,6 +725,32 @@ func (c *Client4) DeleteScheduledPost(ctx context.Context, scheduledPostId strin return &deletedScheduledPost, BuildResponse(r), nil } +func (c *Client4) GetFlaggingConfiguration(ctx context.Context) (*ContentFlaggingReportingConfig, *Response, error) { + r, err := c.DoAPIGet(ctx, c.contentFlaggingRoute()+"/flag/config", "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + var config ContentFlaggingReportingConfig + if err := json.NewDecoder(r.Body).Decode(&config); err != nil { + return nil, nil, NewAppError("GetFlaggingConfiguration", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return &config, BuildResponse(r), nil +} + +func (c *Client4) GetTeamPostFlaggingFeatureStatus(ctx context.Context, teamId string) (map[string]bool, *Response, error) { + r, err := c.DoAPIGet(ctx, c.contentFlaggingRoute()+"/team/"+teamId+"/status", "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + var status map[string]bool + if err := json.NewDecoder(r.Body).Decode(&status); err != nil { + return nil, nil, NewAppError("GetFlaggingConfiguration", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return status, BuildResponse(r), nil +} + func (c *Client4) bookmarksRoute(channelId string) string { return c.channelRoute(channelId) + "/bookmarks" } diff --git a/server/public/model/content_flagging_settings.go b/server/public/model/content_flagging_settings.go index 775ed300139..ed0243c46fe 100644 --- a/server/public/model/content_flagging_settings.go +++ b/server/public/model/content_flagging_settings.go @@ -25,6 +25,14 @@ const ( TargetReporter NotificationTarget = "reporter" ) +var ContentFlaggingDefaultReasons = []string{ + "Inappropriate content", + "Sensitive data", + "Security concern", + "Harassment or abuse", + "Spam or phishing", +} + type ContentFlaggingNotificationSettings struct { EventTargetMapping map[ContentFlaggingEvent][]NotificationTarget } @@ -124,9 +132,9 @@ func (rs *ReviewerSettings) IsValid() *AppError { return NewAppError("Config.IsValid", "model.config.is_valid.content_flagging.common_reviewers_not_set.app_error", nil, "", http.StatusBadRequest) } - // if additional reviewers are specified, no extra validation is needed in team specific settings as + // if Additional Reviewers are specified, no extra validation is needed in team specific settings as // settings team reviewers keeping team feature disabled is valid, as well as - // enabling team feature and not specified reviews is fine as well (since additional reviewers are set) + // enabling team feature and not specified reviews is fine as well (since Additional Reviewers are set) if !additionalReviewersEnabled { for _, setting := range *rs.TeamReviewersSetting { if *setting.Enabled && (setting.ReviewerIds == nil || len(*setting.ReviewerIds) == 0) { @@ -147,13 +155,7 @@ type AdditionalContentFlaggingSettings struct { func (acfs *AdditionalContentFlaggingSettings) SetDefaults() { if acfs.Reasons == nil { - acfs.Reasons = &[]string{ - "Inappropriate content", - "Sensitive data", - "Security concern", - "Harassment or abuse", - "Spam or phishing", - } + acfs.Reasons = &ContentFlaggingDefaultReasons } if acfs.ReporterCommentRequired == nil { @@ -223,3 +225,8 @@ func (cfs *ContentFlaggingSettings) IsValid() *AppError { return nil } + +type ContentFlaggingReportingConfig struct { + Reasons *[]string `json:"reasons"` + ReporterCommentRequired *bool `json:"reporter_comment_required"` +} diff --git a/server/public/model/content_flagging_settings_test.go b/server/public/model/content_flagging_settings_test.go index 20449192d43..d9f02eddf3a 100644 --- a/server/public/model/content_flagging_settings_test.go +++ b/server/public/model/content_flagging_settings_test.go @@ -140,7 +140,7 @@ func TestReviewerSettings_IsValid(t *testing.T) { require.Nil(t, err) }) - t.Run("should be valid when common reviewers enabled with additional reviewers", func(t *testing.T) { + t.Run("should be valid when common reviewers enabled with Additional Reviewers", func(t *testing.T) { settings := &ReviewerSettings{ CommonReviewers: NewPointer(true), CommonReviewerIds: &[]string{}, @@ -204,7 +204,7 @@ func TestReviewerSettings_IsValid(t *testing.T) { require.Equal(t, "model.config.is_valid.content_flagging.team_reviewers_not_set.app_error", err.Id) }) - t.Run("should be valid when team reviewers enabled but no reviewer IDs with additional reviewers", func(t *testing.T) { + t.Run("should be valid when team reviewers enabled but no reviewer IDs with Additional Reviewers", func(t *testing.T) { settings := &ReviewerSettings{ CommonReviewers: NewPointer(false), CommonReviewerIds: &[]string{}, diff --git a/webapp/channels/src/components/team_controller/team_controller.tsx b/webapp/channels/src/components/team_controller/team_controller.tsx index a05cb84be7f..7502803453d 100644 --- a/webapp/channels/src/components/team_controller/team_controller.tsx +++ b/webapp/channels/src/components/team_controller/team_controller.tsx @@ -3,11 +3,16 @@ import iNoBounce from 'inobounce'; import React, {lazy, memo, useEffect, useRef, useState} from 'react'; +import {useDispatch, useSelector} from 'react-redux'; import {Route, Switch, useHistory, useParams} from 'react-router-dom'; import type {ServerError} from '@mattermost/types/errors'; import type {Team} from '@mattermost/types/teams'; +import {getTeamContentFlaggingStatus} from 'mattermost-redux/actions/content_flagging'; +import { + contentFlaggingFeatureEnabled, +} from 'mattermost-redux/selectors/entities/content_flagging'; import type {ActionResult} from 'mattermost-redux/types/actions'; import {reconnect} from 'actions/websocket_actions.jsx'; @@ -42,6 +47,7 @@ declare global { type Props = PropsFromRedux & OwnProps; function TeamController(props: Props) { + const dispatch = useDispatch(); const history = useHistory(); const {team: teamNameParam} = useParams(); @@ -49,6 +55,8 @@ function TeamController(props: Props) { const [team, setTeam] = useState(getTeamFromTeamList(props.teamsList, teamNameParam)); + const contentFlaggingEnabled = useSelector(contentFlaggingFeatureEnabled); + const blurTime = useRef(Date.now()); const lastTime = useRef(Date.now()); @@ -131,6 +139,13 @@ function TeamController(props: Props) { }; }, [props.currentTeamId]); + // Load team content flagging status on team switch + useEffect(() => { + if (contentFlaggingEnabled && props.currentTeamId) { + dispatch(getTeamContentFlaggingStatus(props.currentTeamId)); + } + }, [contentFlaggingEnabled, dispatch, props.currentTeamId]); + // Effect runs on mount, adds active state to window useEffect(() => { const browserIsIosSafari = isIosSafari(); diff --git a/webapp/channels/src/packages/mattermost-redux/src/action_types/teams.ts b/webapp/channels/src/packages/mattermost-redux/src/action_types/teams.ts index 3e853e4c8fd..05864e4e6f3 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/action_types/teams.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/action_types/teams.ts @@ -49,4 +49,6 @@ export default keyMirror({ RECEIVED_TEAM_MEMBERS_MINUS_GROUP_MEMBERS: null, RECEIVED_TOTAL_TEAM_COUNT: null, + + RECEIVED_CONTENT_FLAGGING_STATUS: null, }); diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/content_flagging.test.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/content_flagging.test.ts new file mode 100644 index 00000000000..38f4fd5d993 --- /dev/null +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/content_flagging.test.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import nock from 'nock'; + +import * as Actions from 'mattermost-redux/actions/content_flagging'; +import {Client4} from 'mattermost-redux/client'; + +import TestHelper from 'packages/mattermost-redux/test/test_helper'; +import configureStore from 'packages/mattermost-redux/test/test_store'; + +describe('Actions.getTeamContentFlaggingStatus', () => { + const store = configureStore(); + beforeAll(() => { + TestHelper.initBasic(Client4); + }); + + afterAll(() => { + TestHelper.tearDown(); + }); + + it('should dispatch RECEIVED_CONTENT_FLAGGING_STATUS on success', async () => { + nock(Client4.getContentFlaggingRoute()). + get('/team/team_id/status'). + reply(200, {enabled: true}); + + await store.dispatch(Actions.getTeamContentFlaggingStatus('team_id')); + + let enabled = store.getState().entities.teams.contentFlaggingStatus.team_id; + expect(enabled).toEqual(true); + + // Changing value for same team + nock(Client4.getContentFlaggingRoute()). + get('/team/team_id/status'). + reply(200, {enabled: false}); + + await store.dispatch(Actions.getTeamContentFlaggingStatus('team_id')); + + enabled = store.getState().entities.teams.contentFlaggingStatus.team_id; + expect(enabled).toEqual(false); + }); +}); diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/content_flagging.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/content_flagging.ts new file mode 100644 index 00000000000..4a165d09cd7 --- /dev/null +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/content_flagging.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {TeamTypes} from 'mattermost-redux/action_types'; +import {logError} from 'mattermost-redux/actions/errors'; +import {forceLogoutIfNecessary} from 'mattermost-redux/actions/helpers'; +import {Client4} from 'mattermost-redux/client'; +import type {ActionFuncAsync} from 'mattermost-redux/types/actions'; + +export function getTeamContentFlaggingStatus(teamId: string): ActionFuncAsync<{enabled: boolean}> { + return async (dispatch, getState) => { + let response; + + try { + response = await Client4.getTeamContentFlaggingStatus(teamId); + + dispatch({ + type: TeamTypes.RECEIVED_CONTENT_FLAGGING_STATUS, + data: { + teamId, + status: response.enabled, + }, + }); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + return {error}; + } + + return {data: response}; + }; +} diff --git a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/teams.ts b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/teams.ts index 3371debc0e3..6ee327f4183 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/teams.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/teams.ts @@ -494,6 +494,19 @@ function totalCount(state = 0, action: MMReduxAction) { } } +function contentFlaggingStatus(state = {}, action: MMReduxAction) { + switch (action.type) { + case TeamTypes.RECEIVED_CONTENT_FLAGGING_STATUS: { + return { + ...state, + [action.data.teamId]: action.data.status, + }; + } + default: + return state; + } +} + export default combineReducers({ // the current selected team @@ -514,4 +527,6 @@ export default combineReducers({ groupsAssociatedToTeam, totalCount, + + contentFlaggingStatus, }); diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/content_flagging.test.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/content_flagging.test.ts new file mode 100644 index 00000000000..a43544eeaaa --- /dev/null +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/content_flagging.test.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {DeepPartial} from 'redux'; + +import type {GlobalState} from '@mattermost/types/store'; + +import {contentFlaggingFeatureEnabled} from './content_flagging'; + +describe('Selectors.ContentFlagging', () => { + test('should return true when config and feature flag both are set', () => { + const state: DeepPartial = { + entities: { + general: { + config: { + ContentFlaggingEnabled: 'true', + FeatureFlagContentFlagging: 'true', + }, + }, + }, + }; + + expect(contentFlaggingFeatureEnabled(state as GlobalState)).toBe(true); + }); + + test('should return false when either config or feature flag are not set', () => { + let state: DeepPartial = { + entities: { + general: { + config: { + ContentFlaggingEnabled: 'false', + FeatureFlagContentFlagging: 'true', + }, + }, + }, + }; + + expect(contentFlaggingFeatureEnabled(state as GlobalState)).toBe(false); + + state = { + entities: { + general: { + config: { + ContentFlaggingEnabled: 'true', + FeatureFlagContentFlagging: 'false', + }, + }, + }, + }; + + expect(contentFlaggingFeatureEnabled(state as GlobalState)).toBe(false); + }); +}); diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/content_flagging.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/content_flagging.ts new file mode 100644 index 00000000000..a47391ea1fb --- /dev/null +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/content_flagging.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {GlobalState} from '@mattermost/types/store'; + +import {getFeatureFlagValue} from 'mattermost-redux/selectors/entities/general'; + +export const contentFlaggingFeatureEnabled = (state: GlobalState): boolean => { + const featureFlagEnabled = getFeatureFlagValue(state, 'ContentFlagging') === 'true'; + const featureEnabled = state.entities.general.config.ContentFlaggingEnabled === 'true'; + + return featureFlagEnabled && featureEnabled; +}; diff --git a/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts b/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts index e5438eeafeb..263a552a2bc 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts @@ -50,6 +50,7 @@ const state: GlobalState = { stats: {}, groupsAssociatedToTeam: {}, totalCount: 0, + contentFlaggingStatus: {}, }, channels: { currentChannelId: '', diff --git a/webapp/channels/src/tests/constants/teams.ts b/webapp/channels/src/tests/constants/teams.ts index 1c1df2a469e..3cdf1c8e47b 100644 --- a/webapp/channels/src/tests/constants/teams.ts +++ b/webapp/channels/src/tests/constants/teams.ts @@ -15,4 +15,5 @@ export const emptyTeams: () => TeamsState = () => ({ stats: {}, groupsAssociatedToTeam: {}, totalCount: 0, + contentFlaggingStatus: {}, }); diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 1606655a1e1..56a94cedf59 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -539,6 +539,10 @@ export default class Client4 { return `${this.getBaseRoute()}/client_perf`; } + getContentFlaggingRoute() { + return `${this.getBaseRoute()}/content_flagging`; + } + getCSRFFromCookie() { if (typeof document !== 'undefined' && typeof document.cookie !== 'undefined') { const cookies = document.cookie.split(';'); @@ -4587,6 +4591,13 @@ export default class Client4 { {method: 'get'}, ); }; + + getTeamContentFlaggingStatus = (teamId: string) => { + return this.doFetch<{enabled: boolean}>( + `${this.getContentFlaggingRoute()}/team/${teamId}/status`, + {method: 'get'}, + ); + }; } export function parseAndMergeNestedHeaders(originalHeaders: any) { diff --git a/webapp/platform/types/src/config.ts b/webapp/platform/types/src/config.ts index cf5d805c5b3..165719b1e89 100644 --- a/webapp/platform/types/src/config.ts +++ b/webapp/platform/types/src/config.ts @@ -130,6 +130,7 @@ export type ClientConfig = { FeatureFlagCustomProfileAttributes: string; FeatureFlagAttributeBasedAccessControl: string; FeatureFlagWebSocketEventScope: string; + FeatureFlagContentFlagging: string; ForgotPasswordLink: string; GiphySdkKey: string; GoogleDeveloperKey: string; @@ -225,6 +226,7 @@ export type ClientConfig = { YoutubeReferrerPolicy: 'true' | 'false'; ScheduledPosts: string; DeleteAccountLink: string; + ContentFlaggingEnabled: 'true' | 'false'; }; export type License = { diff --git a/webapp/platform/types/src/teams.ts b/webapp/platform/types/src/teams.ts index 3f8379a3ecf..551582d7b1c 100644 --- a/webapp/platform/types/src/teams.ts +++ b/webapp/platform/types/src/teams.ts @@ -50,6 +50,7 @@ export type TeamsState = { stats: RelationOneToOne; groupsAssociatedToTeam: any; totalCount: number; + contentFlaggingStatus: Record; }; export type TeamUnread = {