diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000000..3c24100d19 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,14 @@ +name: "CodeQL config" + +query-filters: + - exclude: + problem.severity: + - warning + - recommendation + - exclude: + id: go/log-injection + +paths-ignore: + - templates + - tests + - 'api4/*_local.go' diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 20c839211d..791a45ee95 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -6,7 +6,7 @@ on: branches: [ master ] schedule: - cron: '30 5,17 * * *' - + permissions: contents: read @@ -20,17 +20,24 @@ jobs: strategy: fail-fast: false matrix: - language: [ 'go', 'javascript' ] + language: [ 'go' ] steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 + + # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@v2 with: languages: ${{ matrix.language }} - debug: true + debug: false + config-file: ./.github/codeql/codeql-config.yml + + # Autobuild attempts to build any compiled languages - name: Autobuild - uses: github/codeql-action/autobuild@v1 + uses: github/codeql-action/autobuild@v2 + + # Perform Analysis - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + uses: github/codeql-action/analyze@v2 diff --git a/Makefile b/Makefile index 43ccf306a5..85b7c222ef 100644 --- a/Makefile +++ b/Makefile @@ -160,7 +160,7 @@ PLUGIN_PACKAGES += mattermost-plugin-jira-v2.4.0 PLUGIN_PACKAGES += mattermost-plugin-nps-v1.2.0 PLUGIN_PACKAGES += mattermost-plugin-welcomebot-v1.2.0 PLUGIN_PACKAGES += mattermost-plugin-zoom-v1.6.0 -PLUGIN_PACKAGES += focalboard-v7.4.2 +PLUGIN_PACKAGES += focalboard-v7.4.3 PLUGIN_PACKAGES += mattermost-plugin-apps-v1.1.0 # Prepares the enterprise build if exists. The IGNORE stuff is a hack to get the Makefile to execute the commands outside a target diff --git a/api4/channel_test.go b/api4/channel_test.go index 16e432bf0d..1e1ea4b2e1 100644 --- a/api4/channel_test.go +++ b/api4/channel_test.go @@ -1779,6 +1779,7 @@ func TestSearchGroupChannels(t *testing.T) { } func TestDeleteChannel(t *testing.T) { + t.Skip("MM-47465") th := Setup(t).InitBasic() defer th.TearDown() c := th.Client diff --git a/api4/cloud.go b/api4/cloud.go index e86a9b0aca..a88ac919b3 100644 --- a/api4/cloud.go +++ b/api4/cloud.go @@ -158,14 +158,14 @@ func requestCloudTrial(c *Context, w http.ResponseWriter, r *http.Request) { // check if the email needs to be set bodyBytes, err := io.ReadAll(r.Body) if err != nil { - c.Err = model.NewAppError("Api4.requestCloudTrial", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + c.Err = model.NewAppError("Api4.requestCloudTrial", "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err) return } // this value will not be empty when both emails (user admin and CWS customer) are not business email and - // we need to request a new email from the user via the request business email modal + // a new business email was provided via the request business email modal var startTrialRequest *model.StartCloudTrialRequest if err = json.Unmarshal(bodyBytes, &startTrialRequest); err != nil { - c.Err = model.NewAppError("Api4.requestCloudTrial", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + c.Err = model.NewAppError("Api4.requestCloudTrial", "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err) return } @@ -199,20 +199,20 @@ func validateBusinessEmail(c *Context, w http.ResponseWriter, r *http.Request) { user, appErr := c.App.GetUser(c.AppContext.Session().UserId) if appErr != nil { - c.Err = model.NewAppError("Api4.validateBusinessEmail", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(appErr) + c.Err = model.NewAppError("Api4.validateBusinessEmail", "api.cloud.request_error", nil, "", http.StatusForbidden).Wrap(appErr) return } bodyBytes, err := io.ReadAll(r.Body) if err != nil { - c.Err = model.NewAppError("Api4.requestCloudTrial", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + c.Err = model.NewAppError("Api4.requestCloudTrial", "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err) return } var emailToValidate *model.ValidateBusinessEmailRequest err = json.Unmarshal(bodyBytes, &emailToValidate) if err != nil { - c.Err = model.NewAppError("Api4.requestCloudTrial", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + c.Err = model.NewAppError("Api4.requestCloudTrial", "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err) return } @@ -244,14 +244,14 @@ func validateWorkspaceBusinessEmail(c *Context, w http.ResponseWriter, r *http.R user, userErr := c.App.GetUser(c.AppContext.Session().UserId) if userErr != nil { - c.Err = model.NewAppError("Api4.validateWorkspaceBusinessEmail", "api.cloud.request_error", nil, userErr.Error(), http.StatusInternalServerError) + c.Err = userErr return } // get the cloud customer email to validate if is a valid business email cloudCustomer, err := c.App.Cloud().GetCloudCustomer(user.Id) if err != nil { - c.Err = model.NewAppError("Api4.validateWorkspaceBusinessEmail", "api.cloud.request_error", nil, err.Error(), http.StatusInternalServerError) + c.Err = model.NewAppError("Api4.validateWorkspaceBusinessEmail", "api.cloud.request_error", nil, err.Error(), http.StatusBadRequest) return } emailErr := c.App.Cloud().ValidateBusinessEmail(user.Id, cloudCustomer.Email) diff --git a/api4/cloud_test.go b/api4/cloud_test.go index dd4f7d94ae..15ff7b1831 100644 --- a/api4/cloud_test.go +++ b/api4/cloud_test.go @@ -296,6 +296,20 @@ func Test_requestTrial(t *testing.T) { require.Equal(t, subscriptionChanged, subscription) require.Equal(t, http.StatusOK, r.StatusCode, "Status OK") }) + + t.Run("Empty body returns bad request", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Client.Login(th.BasicUser.Email, th.BasicUser.Password) + + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + + r, err := th.SystemAdminClient.DoAPIPutBytes("/cloud/request-trial", nil) + require.Error(t, err) + closeBody(r) + require.Equal(t, http.StatusBadRequest, r.StatusCode, "Status Bad Request") + }) } func Test_validateBusinessEmail(t *testing.T) { @@ -373,6 +387,20 @@ func Test_validateBusinessEmail(t *testing.T) { require.NoError(t, err) require.Equal(t, http.StatusOK, res.StatusCode, "200") }) + + t.Run("Empty body returns bad request", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Client.Login(th.BasicUser.Email, th.BasicUser.Password) + + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + + r, err := th.SystemAdminClient.DoAPIPostBytes("/cloud/validate-business-email", nil) + require.Error(t, err) + closeBody(r) + require.Equal(t, http.StatusBadRequest, r.StatusCode, "Status Bad Request") + }) } func Test_validateWorkspaceBusinessEmail(t *testing.T) { @@ -442,6 +470,39 @@ func Test_validateWorkspaceBusinessEmail(t *testing.T) { _, err := th.SystemAdminClient.ValidateWorkspaceBusinessEmail() require.NoError(t, err) }) + + t.Run("Error while grabbing the cloud customer returns bad request", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Client.Login(th.BasicUser.Email, th.BasicUser.Password) + + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + + cloud := mocks.CloudInterface{} + + cloudCustomerInfo := model.CloudCustomerInfo{ + Email: "badrequest@gmail.com", + } + + // return an error while getting the cloud customer so we validate the forbidden error return + cloud.Mock.On("GetCloudCustomer", th.SystemAdminUser.Id).Return(nil, errors.New("error while gettings the cloud customer")) + + // required cloud mocks so the request doesn't fail + cloud.Mock.On("ValidateBusinessEmail", th.SystemAdminUser.Id, cloudCustomerInfo.Email).Return(errors.New("invalid email")) + cloud.Mock.On("ValidateBusinessEmail", th.SystemAdminUser.Id, th.SystemAdminUser.Email).Return(nil) + + cloudImpl := th.App.Srv().Cloud + defer func() { + th.App.Srv().Cloud = cloudImpl + }() + th.App.Srv().Cloud = &cloud + + r, err := th.SystemAdminClient.DoAPIPostBytes("/cloud/validate-workspace-business-email", nil) + require.Error(t, err) + closeBody(r) + require.Equal(t, http.StatusBadRequest, r.StatusCode, "Status Bad Request") + }) } func TestGetCloudProducts(t *testing.T) { diff --git a/api4/group.go b/api4/group.go index f8b65aa5bb..abcde27459 100644 --- a/api4/group.go +++ b/api4/group.go @@ -18,82 +18,88 @@ import ( func (api *API) InitGroup() { // GET /api/v4/groups - api.BaseRoutes.Groups.Handle("", api.APISessionRequired(requireLicense(getGroups))).Methods("GET") + api.BaseRoutes.Groups.Handle("", api.APISessionRequired(getGroups)).Methods("GET") // POST /api/v4/groups - api.BaseRoutes.Groups.Handle("", api.APISessionRequired(requireLicense(createGroup))).Methods("POST") + api.BaseRoutes.Groups.Handle("", api.APISessionRequired(createGroup)).Methods("POST") // GET /api/v4/groups/:group_id api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}", - api.APISessionRequired(requireLicense(getGroup))).Methods("GET") + api.APISessionRequired(getGroup)).Methods("GET") // PUT /api/v4/groups/:group_id/patch api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/patch", - api.APISessionRequired(requireLicense(patchGroup))).Methods("PUT") + api.APISessionRequired(patchGroup)).Methods("PUT") // POST /api/v4/groups/:group_id/teams/:team_id/link // POST /api/v4/groups/:group_id/channels/:channel_id/link api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/{syncable_type:teams|channels}/{syncable_id:[A-Za-z0-9]+}/link", - api.APISessionRequired(requireLicense(linkGroupSyncable))).Methods("POST") + api.APISessionRequired(linkGroupSyncable)).Methods("POST") // DELETE /api/v4/groups/:group_id/teams/:team_id/link // DELETE /api/v4/groups/:group_id/channels/:channel_id/link api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/{syncable_type:teams|channels}/{syncable_id:[A-Za-z0-9]+}/link", - api.APISessionRequired(requireLicense(unlinkGroupSyncable))).Methods("DELETE") + api.APISessionRequired(unlinkGroupSyncable)).Methods("DELETE") // GET /api/v4/groups/:group_id/teams/:team_id // GET /api/v4/groups/:group_id/channels/:channel_id api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/{syncable_type:teams|channels}/{syncable_id:[A-Za-z0-9]+}", - api.APISessionRequired(requireLicense(getGroupSyncable))).Methods("GET") + api.APISessionRequired(getGroupSyncable)).Methods("GET") // GET /api/v4/groups/:group_id/teams // GET /api/v4/groups/:group_id/channels api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/{syncable_type:teams|channels}", - api.APISessionRequired(requireLicense(getGroupSyncables))).Methods("GET") + api.APISessionRequired(getGroupSyncables)).Methods("GET") // PUT /api/v4/groups/:group_id/teams/:team_id/patch // PUT /api/v4/groups/:group_id/channels/:channel_id/patch api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/{syncable_type:teams|channels}/{syncable_id:[A-Za-z0-9]+}/patch", - api.APISessionRequired(requireLicense(patchGroupSyncable))).Methods("PUT") + api.APISessionRequired(patchGroupSyncable)).Methods("PUT") // GET /api/v4/groups/:group_id/stats api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/stats", - api.APISessionRequired(requireLicense(getGroupStats))).Methods("GET") + api.APISessionRequired(getGroupStats)).Methods("GET") // GET /api/v4/groups/:group_id/members api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/members", - api.APISessionRequired(requireLicense(getGroupMembers))).Methods("GET") + api.APISessionRequired(getGroupMembers)).Methods("GET") // GET /api/v4/users/:user_id/groups api.BaseRoutes.Users.Handle("/{user_id:[A-Za-z0-9]+}/groups", - api.APISessionRequired(requireLicense(getGroupsByUserId))).Methods("GET") + api.APISessionRequired(getGroupsByUserId)).Methods("GET") // GET /api/v4/channels/:channel_id/groups api.BaseRoutes.Channels.Handle("/{channel_id:[A-Za-z0-9]+}/groups", - api.APISessionRequired(requireLicense(getGroupsByChannel))).Methods("GET") + api.APISessionRequired(getGroupsByChannel)).Methods("GET") // GET /api/v4/teams/:team_id/groups api.BaseRoutes.Teams.Handle("/{team_id:[A-Za-z0-9]+}/groups", - api.APISessionRequired(requireLicense(getGroupsByTeam))).Methods("GET") + api.APISessionRequired(getGroupsByTeam)).Methods("GET") // GET /api/v4/teams/:team_id/groups_by_channels api.BaseRoutes.Teams.Handle("/{team_id:[A-Za-z0-9]+}/groups_by_channels", - api.APISessionRequired(requireLicense(getGroupsAssociatedToChannelsByTeam))).Methods("GET") + api.APISessionRequired(getGroupsAssociatedToChannelsByTeam)).Methods("GET") // DELETE /api/v4/groups/:group_id api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}", - api.APISessionRequired(requireLicense(deleteGroup))).Methods("DELETE") + api.APISessionRequired(deleteGroup)).Methods("DELETE") // POST /api/v4/groups/:group_id/members api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/members", - api.APISessionRequired(requireLicense(addGroupMembers))).Methods("POST") + api.APISessionRequired(addGroupMembers)).Methods("POST") // DELETE /api/v4/groups/:group_id/members api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/members", - api.APISessionRequired(requireLicense(deleteGroupMembers))).Methods("DELETE") + api.APISessionRequired(deleteGroupMembers)).Methods("DELETE") } func getGroup(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + c.RequireGroupId() if c.Err != nil { return @@ -130,6 +136,11 @@ func getGroup(c *Context, w http.ResponseWriter, r *http.Request) { } func createGroup(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } var group *model.GroupWithUserIds if err := json.NewDecoder(r.Body).Decode(&group); err != nil { c.SetInvalidParamWithErr("group", err) @@ -185,6 +196,11 @@ func createGroup(c *Context, w http.ResponseWriter, r *http.Request) { } func patchGroup(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } c.RequireGroupId() if c.Err != nil { return @@ -277,6 +293,11 @@ func patchGroup(c *Context, w http.ResponseWriter, r *http.Request) { } func linkGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } c.RequireGroupId() if c.Err != nil { return @@ -368,6 +389,11 @@ func linkGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) { } func getGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } c.RequireGroupId() if c.Err != nil { return @@ -411,6 +437,11 @@ func getGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) { } func getGroupSyncables(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } c.RequireGroupId() if c.Err != nil { return @@ -448,6 +479,11 @@ func getGroupSyncables(c *Context, w http.ResponseWriter, r *http.Request) { } func patchGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } c.RequireGroupId() if c.Err != nil { return @@ -529,6 +565,11 @@ func patchGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) { } func unlinkGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } c.RequireGroupId() if c.Err != nil { return @@ -606,6 +647,11 @@ func verifyLinkUnlinkPermission(c *Context, syncableType model.GroupSyncableType } func getGroupMembers(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } c.RequireGroupId() if c.Err != nil { return @@ -651,6 +697,11 @@ func getGroupMembers(c *Context, w http.ResponseWriter, r *http.Request) { } func getGroupStats(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } c.RequireGroupId() if c.Err != nil { return @@ -686,6 +737,11 @@ func getGroupStats(c *Context, w http.ResponseWriter, r *http.Request) { } func getGroupsByUserId(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } c.RequireUserId() if c.Err != nil { return @@ -717,72 +773,46 @@ func getGroupsByUserId(c *Context, w http.ResponseWriter, r *http.Request) { } func getGroupsByChannel(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } c.RequireChannelId() if c.Err != nil { return } - - if c.App.Channels().License() == nil || !*c.App.Channels().License().Features.LDAPGroups { - c.Err = model.NewAppError("Api4.getGroupsByChannel", "api.ldap_groups.license_error", nil, "", http.StatusForbidden) - return - } - - channel, appErr := c.App.GetChannel(c.AppContext, c.Params.ChannelId) + b, appErr := getGroupsByChannelCommon(c, r) if appErr != nil { c.Err = appErr return } - - var permission *model.Permission - if channel.Type == model.ChannelTypePrivate { - permission = model.PermissionReadPrivateChannelGroups - } else { - permission = model.PermissionReadPublicChannelGroups - } - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, permission) { - c.SetPermissionError(permission) - return - } - - opts := model.GroupSearchOpts{ - Q: c.Params.Q, - IncludeMemberCount: c.Params.IncludeMemberCount, - FilterAllowReference: c.Params.FilterAllowReference, - } - if c.Params.Paginate == nil || *c.Params.Paginate { - opts.PageOpts = &model.PageOpts{Page: c.Params.Page, PerPage: c.Params.PerPage} - } - - groups, totalCount, appErr := c.App.GetGroupsByChannel(c.Params.ChannelId, opts) - if appErr != nil { - c.Err = appErr - return - } - - b, err := json.Marshal(struct { - Groups []*model.GroupWithSchemeAdmin `json:"groups"` - Count int `json:"total_group_count"` - }{ - Groups: groups, - Count: totalCount, - }) - if err != nil { - c.Err = model.NewAppError("Api4.getGroupsByChannel", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) - return - } - w.Write(b) } func getGroupsByTeam(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } c.RequireTeamId() if c.Err != nil { return } - if c.App.Channels().License() == nil || !*c.App.Channels().License().Features.LDAPGroups { - c.Err = model.NewAppError("Api4.getGroupsByTeam", "api.ldap_groups.license_error", nil, "", http.StatusForbidden) + + b, appError := getGroupsByTeamCommon(c, r) + if appError != nil { + c.Err = appError return } + w.Write(b) +} + +func getGroupsByTeamCommon(c *Context, r *http.Request) ([]byte, *model.AppError) { + if c.App.Channels().License() == nil || !*c.App.Channels().License().Features.LDAPGroups { + return nil, model.NewAppError("Api4.getGroupsByTeam", "api.ldap_groups.license_error", nil, "", http.StatusForbidden) + } opts := model.GroupSearchOpts{ Q: c.Params.Q, @@ -795,8 +825,7 @@ func getGroupsByTeam(c *Context, w http.ResponseWriter, r *http.Request) { groups, totalCount, appErr := c.App.GetGroupsByTeam(c.Params.TeamId, opts) if appErr != nil { - c.Err = appErr - return + return nil, appErr } b, err := json.Marshal(struct { @@ -808,14 +837,64 @@ func getGroupsByTeam(c *Context, w http.ResponseWriter, r *http.Request) { }) if err != nil { - c.Err = model.NewAppError("Api4.getGroupsByTeam", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) - return + return nil, model.NewAppError("Api4.getGroupsByTeam", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) } - w.Write(b) + return b, nil +} +func getGroupsByChannelCommon(c *Context, r *http.Request) ([]byte, *model.AppError) { + if c.App.Channels().License() == nil || !*c.App.Channels().License().Features.LDAPGroups { + return nil, model.NewAppError("Api4.getGroupsByChannel", "api.ldap_groups.license_error", nil, "", http.StatusForbidden) + } + + channel, appErr := c.App.GetChannel(c.AppContext, c.Params.ChannelId) + if appErr != nil { + return nil, appErr + } + + var permission *model.Permission + if channel.Type == model.ChannelTypePrivate { + permission = model.PermissionReadPrivateChannelGroups + } else { + permission = model.PermissionReadPublicChannelGroups + } + if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, permission) { + return nil, c.App.MakePermissionError(c.AppContext.Session(), []*model.Permission{permission}) + } + + opts := model.GroupSearchOpts{ + Q: c.Params.Q, + IncludeMemberCount: c.Params.IncludeMemberCount, + FilterAllowReference: c.Params.FilterAllowReference, + } + if c.Params.Paginate == nil || *c.Params.Paginate { + opts.PageOpts = &model.PageOpts{Page: c.Params.Page, PerPage: c.Params.PerPage} + } + + groups, totalCount, appErr := c.App.GetGroupsByChannel(c.Params.ChannelId, opts) + if appErr != nil { + return nil, appErr + } + + b, err := json.Marshal(struct { + Groups []*model.GroupWithSchemeAdmin `json:"groups"` + Count int `json:"total_group_count"` + }{ + Groups: groups, + Count: totalCount, + }) + if err != nil { + return nil, model.NewAppError("Api4.getGroupsByChannel", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return b, nil } func getGroupsAssociatedToChannelsByTeam(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } c.RequireTeamId() if c.Err != nil { return @@ -855,6 +934,11 @@ func getGroupsAssociatedToChannelsByTeam(c *Context, w http.ResponseWriter, r *h } func getGroups(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } var teamID, channelID string source := c.Params.GroupSource @@ -961,6 +1045,11 @@ func getGroups(c *Context, w http.ResponseWriter, r *http.Request) { } func deleteGroup(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } c.RequireGroupId() if c.Err != nil { return @@ -1004,6 +1093,11 @@ func deleteGroup(c *Context, w http.ResponseWriter, r *http.Request) { } func addGroupMembers(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } c.RequireGroupId() if c.Err != nil { return @@ -1058,6 +1152,11 @@ func addGroupMembers(c *Context, w http.ResponseWriter, r *http.Request) { } func deleteGroupMembers(c *Context, w http.ResponseWriter, r *http.Request) { + permissionErr := requireLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } c.RequireGroupId() if c.Err != nil { return diff --git a/api4/group_local.go b/api4/group_local.go index 5ede4d7bf3..1964fdbed4 100644 --- a/api4/group_local.go +++ b/api4/group_local.go @@ -3,7 +3,39 @@ package api4 +import ( + "net/http" +) + func (api *API) InitGroupLocal() { - api.BaseRoutes.Channels.Handle("/{channel_id:[A-Za-z0-9]+}/groups", api.APILocal(getGroupsByChannel)).Methods("GET") - api.BaseRoutes.Teams.Handle("/{team_id:[A-Za-z0-9]+}/groups", api.APILocal(getGroupsByTeam)).Methods("GET") + api.BaseRoutes.Channels.Handle("/{channel_id:[A-Za-z0-9]+}/groups", api.APILocal(getGroupsByChannelLocal)).Methods("GET") + api.BaseRoutes.Teams.Handle("/{team_id:[A-Za-z0-9]+}/groups", api.APILocal(getGroupsByTeamLocal)).Methods("GET") +} + +func getGroupsByChannelLocal(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireChannelId() + if c.Err != nil { + return + } + b, appErr := getGroupsByChannelCommon(c, r) + if appErr != nil { + c.Err = appErr + return + } + + w.Write(b) +} + +func getGroupsByTeamLocal(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireTeamId() + if c.Err != nil { + return + } + b, appError := getGroupsByTeamCommon(c, r) + if appError != nil { + c.Err = appError + return + } + + w.Write(b) } diff --git a/api4/handlers.go b/api4/handlers.go index 9ec6be4aae..250dac32e7 100644 --- a/api4/handlers.go +++ b/api4/handlers.go @@ -200,33 +200,27 @@ func (api *API) APILocal(h handlerFunc) http.Handler { return handler } -func requireLicense(f handlerFunc) handlerFunc { - return func(c *Context, w http.ResponseWriter, r *http.Request) { - if c.App.Channels().License() == nil { - c.Err = model.NewAppError("", "api.license_error", nil, "", http.StatusNotImplemented) - return - } - f(c, w, r) +func requireLicense(c *Context) *model.AppError { + if c.App.Channels().License() == nil { + err := model.NewAppError("", "api.license_error", nil, "", http.StatusNotImplemented) + return err } + return nil } -func minimumProfessionalLicense(f handlerFunc) handlerFunc { - return func(c *Context, w http.ResponseWriter, r *http.Request) { - lic := c.App.Srv().License() - if lic == nil || (lic.SkuShortName != model.LicenseShortSkuProfessional && lic.SkuShortName != model.LicenseShortSkuEnterprise) { - c.Err = model.NewAppError("", model.NoTranslation, nil, "license is neither professional nor enterprise", http.StatusNotImplemented) - return - } - f(c, w, r) +func minimumProfessionalLicense(c *Context) *model.AppError { + lic := c.App.Srv().License() + if lic == nil || (lic.SkuShortName != model.LicenseShortSkuProfessional && lic.SkuShortName != model.LicenseShortSkuEnterprise) { + err := model.NewAppError("", model.NoTranslation, nil, "license is neither professional nor enterprise", http.StatusNotImplemented) + return err } + return nil } -func rejectGuests(f handlerFunc) handlerFunc { - return func(c *Context, w http.ResponseWriter, r *http.Request) { - if c.AppContext.Session().Props[model.SessionPropIsGuest] == "true" { - c.Err = model.NewAppError("", model.NoTranslation, nil, "insufficient permissions as a guest user", http.StatusNotImplemented) - return - } - f(c, w, r) +func rejectGuests(c *Context) *model.AppError { + if c.AppContext.Session().Props[model.SessionPropIsGuest] == "true" { + err := model.NewAppError("", model.NoTranslation, nil, "insufficient permissions as a guest user", http.StatusNotImplemented) + return err } + return nil } diff --git a/api4/insights.go b/api4/insights.go index 3c90294ec7..9d74a57c2f 100644 --- a/api4/insights.go +++ b/api4/insights.go @@ -13,31 +13,44 @@ import ( func (api *API) InitInsights() { // Reactions - api.BaseRoutes.InsightsForTeam.Handle("/reactions", api.APISessionRequired(minimumProfessionalLicense(rejectGuests(getTopReactionsForTeamSince)))).Methods("GET") - api.BaseRoutes.InsightsForUser.Handle("/reactions", api.APISessionRequired(minimumProfessionalLicense(rejectGuests(getTopReactionsForUserSince)))).Methods("GET") + api.BaseRoutes.InsightsForTeam.Handle("/reactions", api.APISessionRequired(getTopReactionsForTeamSince)).Methods("GET") + api.BaseRoutes.InsightsForUser.Handle("/reactions", api.APISessionRequired(getTopReactionsForUserSince)).Methods("GET") // Channels - api.BaseRoutes.InsightsForTeam.Handle("/channels", api.APISessionRequired(minimumProfessionalLicense(rejectGuests(getTopChannelsForTeamSince)))).Methods("GET") - api.BaseRoutes.InsightsForUser.Handle("/channels", api.APISessionRequired(minimumProfessionalLicense(rejectGuests(getTopChannelsForUserSince)))).Methods("GET") + api.BaseRoutes.InsightsForTeam.Handle("/channels", api.APISessionRequired(getTopChannelsForTeamSince)).Methods("GET") + api.BaseRoutes.InsightsForUser.Handle("/channels", api.APISessionRequired(getTopChannelsForUserSince)).Methods("GET") // Threads - api.BaseRoutes.InsightsForTeam.Handle("/threads", api.APISessionRequired(minimumProfessionalLicense(rejectGuests(getTopThreadsForTeamSince)))).Methods("GET") - api.BaseRoutes.InsightsForUser.Handle("/threads", api.APISessionRequired(minimumProfessionalLicense(rejectGuests(getTopThreadsForUserSince)))).Methods("GET") + api.BaseRoutes.InsightsForTeam.Handle("/threads", api.APISessionRequired(getTopThreadsForTeamSince)).Methods("GET") + api.BaseRoutes.InsightsForUser.Handle("/threads", api.APISessionRequired(getTopThreadsForUserSince)).Methods("GET") // user DMs - api.BaseRoutes.InsightsForUser.Handle("/dms", api.APISessionRequired(minimumProfessionalLicense(rejectGuests(getTopDMsForUserSince)))).Methods("GET") + api.BaseRoutes.InsightsForUser.Handle("/dms", api.APISessionRequired(getTopDMsForUserSince)).Methods("GET") // Inactive channels - api.BaseRoutes.InsightsForTeam.Handle("/inactive_channels", api.APISessionRequired(minimumProfessionalLicense(rejectGuests(getTopInactiveChannelsForTeamSince)))).Methods("GET") - api.BaseRoutes.InsightsForUser.Handle("/inactive_channels", api.APISessionRequired(minimumProfessionalLicense(rejectGuests(getTopInactiveChannelsForUserSince)))).Methods("GET") + api.BaseRoutes.InsightsForTeam.Handle("/inactive_channels", api.APISessionRequired(getTopInactiveChannelsForTeamSince)).Methods("GET") + api.BaseRoutes.InsightsForUser.Handle("/inactive_channels", api.APISessionRequired(getTopInactiveChannelsForUserSince)).Methods("GET") // New teammembers - api.BaseRoutes.InsightsForTeam.Handle("/team_members", api.APISessionRequired(minimumProfessionalLicense(rejectGuests(getNewTeamMembersSince)))).Methods("GET") + api.BaseRoutes.InsightsForTeam.Handle("/team_members", api.APISessionRequired(getNewTeamMembersSince)).Methods("GET") } // Top Reactions func getTopReactionsForTeamSince(c *Context, w http.ResponseWriter, r *http.Request) { + + // license and guest user check + permissionErr := minimumProfessionalLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + permissionErr = rejectGuests(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + c.RequireTeamId() if c.Err != nil { return @@ -60,7 +73,11 @@ func getTopReactionsForTeamSince(c *Context, w http.ResponseWriter, r *http.Requ return } - startTime := model.StartOfDayForTimeRange(c.Params.TimeRange, user.GetTimezoneLocation()) + startTime, appErr := model.GetStartOfDayForTimeRange(c.Params.TimeRange, user.GetTimezoneLocation()) + if appErr != nil { + c.Err = appErr + return + } topReactionList, appErr := c.App.GetTopReactionsForTeamSince(c.Params.TeamId, c.AppContext.Session().UserId, &model.InsightsOpts{ StartUnixMilli: startTime.UnixMilli(), @@ -82,6 +99,18 @@ func getTopReactionsForTeamSince(c *Context, w http.ResponseWriter, r *http.Requ } func getTopReactionsForUserSince(c *Context, w http.ResponseWriter, r *http.Request) { + // license and guest user check + permissionErr := minimumProfessionalLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + permissionErr = rejectGuests(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + c.Params.TeamId = r.URL.Query().Get("team_id") // TeamId is an optional parameter @@ -109,7 +138,11 @@ func getTopReactionsForUserSince(c *Context, w http.ResponseWriter, r *http.Requ return } - startTime := model.StartOfDayForTimeRange(c.Params.TimeRange, user.GetTimezoneLocation()) + startTime, appErr := model.GetStartOfDayForTimeRange(c.Params.TimeRange, user.GetTimezoneLocation()) + if appErr != nil { + c.Err = appErr + return + } topReactionList, appErr := c.App.GetTopReactionsForUserSince(c.AppContext.Session().UserId, c.Params.TeamId, &model.InsightsOpts{ StartUnixMilli: startTime.UnixMilli(), @@ -133,6 +166,18 @@ func getTopReactionsForUserSince(c *Context, w http.ResponseWriter, r *http.Requ // Top Channels func getTopChannelsForTeamSince(c *Context, w http.ResponseWriter, r *http.Request) { + // license and guest user check + permissionErr := minimumProfessionalLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + permissionErr = rejectGuests(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + c.RequireTeamId() if c.Err != nil { return @@ -156,7 +201,11 @@ func getTopChannelsForTeamSince(c *Context, w http.ResponseWriter, r *http.Reque } loc := user.GetTimezoneLocation() - startTime := model.StartOfDayForTimeRange(c.Params.TimeRange, loc) + startTime, appErr := model.GetStartOfDayForTimeRange(c.Params.TimeRange, loc) + if appErr != nil { + c.Err = appErr + return + } topChannels, appErr := c.App.GetTopChannelsForTeamSince(c.AppContext, c.Params.TeamId, c.AppContext.Session().UserId, &model.InsightsOpts{ StartUnixMilli: startTime.UnixMilli(), @@ -184,6 +233,18 @@ func getTopChannelsForTeamSince(c *Context, w http.ResponseWriter, r *http.Reque } func getTopChannelsForUserSince(c *Context, w http.ResponseWriter, r *http.Request) { + // license and guest user check + permissionErr := minimumProfessionalLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + permissionErr = rejectGuests(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + c.Params.TeamId = r.URL.Query().Get("team_id") // TeamId is an optional parameter @@ -212,7 +273,11 @@ func getTopChannelsForUserSince(c *Context, w http.ResponseWriter, r *http.Reque } loc := user.GetTimezoneLocation() - startTime := model.StartOfDayForTimeRange(c.Params.TimeRange, loc) + startTime, appErr := model.GetStartOfDayForTimeRange(c.Params.TimeRange, loc) + if appErr != nil { + c.Err = appErr + return + } topChannels, appErr := c.App.GetTopChannelsForUserSince(c.AppContext, c.AppContext.Session().UserId, c.Params.TeamId, &model.InsightsOpts{ StartUnixMilli: startTime.UnixMilli(), @@ -241,6 +306,18 @@ func getTopChannelsForUserSince(c *Context, w http.ResponseWriter, r *http.Reque // Top Threads func getTopThreadsForTeamSince(c *Context, w http.ResponseWriter, r *http.Request) { + // license and guest user check + permissionErr := minimumProfessionalLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + permissionErr = rejectGuests(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + c.RequireTeamId() if c.Err != nil { return @@ -264,7 +341,11 @@ func getTopThreadsForTeamSince(c *Context, w http.ResponseWriter, r *http.Reques return } - startTime := model.StartOfDayForTimeRange(c.Params.TimeRange, user.GetTimezoneLocation()) + startTime, appErr := model.GetStartOfDayForTimeRange(c.Params.TimeRange, user.GetTimezoneLocation()) + if appErr != nil { + c.Err = appErr + return + } topThreads, appErr := c.App.GetTopThreadsForTeamSince(c.AppContext, c.Params.TeamId, c.AppContext.Session().UserId, &model.InsightsOpts{ StartUnixMilli: startTime.UnixMilli(), @@ -286,6 +367,18 @@ func getTopThreadsForTeamSince(c *Context, w http.ResponseWriter, r *http.Reques } func getTopThreadsForUserSince(c *Context, w http.ResponseWriter, r *http.Request) { + // license and guest user check + permissionErr := minimumProfessionalLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + permissionErr = rejectGuests(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + c.Params.TeamId = r.URL.Query().Get("team_id") // restrict users with no access to team @@ -313,7 +406,11 @@ func getTopThreadsForUserSince(c *Context, w http.ResponseWriter, r *http.Reques } } - startTime := model.StartOfDayForTimeRange(c.Params.TimeRange, user.GetTimezoneLocation()) + startTime, appErr := model.GetStartOfDayForTimeRange(c.Params.TimeRange, user.GetTimezoneLocation()) + if appErr != nil { + c.Err = appErr + return + } topThreads, appErr := c.App.GetTopThreadsForUserSince(c.AppContext, c.Params.TeamId, c.AppContext.Session().UserId, &model.InsightsOpts{ StartUnixMilli: startTime.UnixMilli(), @@ -336,13 +433,29 @@ func getTopThreadsForUserSince(c *Context, w http.ResponseWriter, r *http.Reques // Top DMs func getTopDMsForUserSince(c *Context, w http.ResponseWriter, r *http.Request) { + // license and guest user check + permissionErr := minimumProfessionalLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + permissionErr = rejectGuests(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + user, err := c.App.GetUser(c.AppContext.Session().UserId) if err != nil { c.Err = err return } - startTime := model.StartOfDayForTimeRange(c.Params.TimeRange, user.GetTimezoneLocation()) + startTime, appErr := model.GetStartOfDayForTimeRange(c.Params.TimeRange, user.GetTimezoneLocation()) + if appErr != nil { + c.Err = appErr + return + } topDMs, err := c.App.GetTopDMsForUserSince(user.Id, &model.InsightsOpts{ StartUnixMilli: startTime.UnixMilli(), @@ -367,6 +480,18 @@ func getTopDMsForUserSince(c *Context, w http.ResponseWriter, r *http.Request) { // Top Channels func getTopInactiveChannelsForTeamSince(c *Context, w http.ResponseWriter, r *http.Request) { + // license and guest user check + permissionErr := minimumProfessionalLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + permissionErr = rejectGuests(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + c.RequireTeamId() if c.Err != nil { return @@ -390,7 +515,11 @@ func getTopInactiveChannelsForTeamSince(c *Context, w http.ResponseWriter, r *ht } loc := user.GetTimezoneLocation() - startTime := model.StartOfDayForTimeRange(c.Params.TimeRange, loc) + startTime, appErr := model.GetStartOfDayForTimeRange(c.Params.TimeRange, loc) + if appErr != nil { + c.Err = appErr + return + } topChannels, err := c.App.GetTopInactiveChannelsForTeamSince(c.AppContext, c.Params.TeamId, c.AppContext.Session().UserId, &model.InsightsOpts{ StartUnixMilli: startTime.UnixMilli(), @@ -411,6 +540,18 @@ func getTopInactiveChannelsForTeamSince(c *Context, w http.ResponseWriter, r *ht // top inactive channels func getTopInactiveChannelsForUserSince(c *Context, w http.ResponseWriter, r *http.Request) { + // license and guest user check + permissionErr := minimumProfessionalLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + permissionErr = rejectGuests(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + c.Params.TeamId = r.URL.Query().Get("team_id") // TeamId is an optional parameter @@ -439,7 +580,11 @@ func getTopInactiveChannelsForUserSince(c *Context, w http.ResponseWriter, r *ht } loc := user.GetTimezoneLocation() - startTime := model.StartOfDayForTimeRange(c.Params.TimeRange, loc) + startTime, appErr := model.GetStartOfDayForTimeRange(c.Params.TimeRange, loc) + if appErr != nil { + c.Err = appErr + return + } topChannels, err := c.App.GetTopInactiveChannelsForUserSince(c.AppContext, c.Params.TeamId, c.AppContext.Session().UserId, &model.InsightsOpts{ StartUnixMilli: startTime.UnixMilli(), @@ -479,6 +624,18 @@ func postCountByDurationViewModel(c *Context, topChannelList *model.TopChannelLi } func getNewTeamMembersSince(c *Context, w http.ResponseWriter, r *http.Request) { + // license and guest user check + permissionErr := minimumProfessionalLicense(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + permissionErr = rejectGuests(c) + if permissionErr != nil { + c.Err = permissionErr + return + } + c.RequireTeamId() if c.Err != nil { return @@ -501,7 +658,11 @@ func getNewTeamMembersSince(c *Context, w http.ResponseWriter, r *http.Request) return } loc := user.GetTimezoneLocation() - startTime := model.StartOfDayForTimeRange(c.Params.TimeRange, loc) + startTime, appErr := model.GetStartOfDayForTimeRange(c.Params.TimeRange, loc) + if appErr != nil { + c.Err = appErr + return + } ntms, count, err := c.App.GetNewTeamMembersSince(c.AppContext, c.Params.TeamId, &model.InsightsOpts{ StartUnixMilli: startTime.UnixMilli(), diff --git a/api4/insights_test.go b/api4/insights_test.go index 089bd26842..2443c1ea26 100644 --- a/api4/insights_test.go +++ b/api4/insights_test.go @@ -230,6 +230,12 @@ func TestGetTopReactionsForTeamSince(t *testing.T) { CheckNotFoundStatus(t, resp) }) + t.Run("get-top-reactions-for-team-since invalid time range", func(t *testing.T) { + _, resp, err := client.GetTopReactionsForTeamSince(teamId, "7_days", 0, 5) + require.Error(t, err) + CheckBadRequestStatus(t, resp) + }) + t.Run("get-top-reactions-for-team-since not a member of team", func(t *testing.T) { th.UnlinkUserFromTeam(th.BasicUser, th.BasicTeam) _, resp, err := client.GetTopReactionsForTeamSince(teamId, model.TimeRangeToday, 0, 5) @@ -417,6 +423,12 @@ func TestGetTopReactionsForUserSince(t *testing.T) { CheckNotFoundStatus(t, resp) }) + t.Run("get-top-reactions-for-user-since invalid time range", func(t *testing.T) { + _, resp, err := client.GetTopReactionsForUserSince(teamId, "7_days", 0, 5) + require.Error(t, err) + CheckBadRequestStatus(t, resp) + }) + t.Run("get-top-reactions-for-user-since not a member of team", func(t *testing.T) { th.UnlinkUserFromTeam(th.BasicUser, th.BasicTeam) _, resp, err := client.GetTopReactionsForUserSince(teamId, model.TimeRangeToday, 0, 5) @@ -515,6 +527,12 @@ func TestGetTopChannelsForTeamSince(t *testing.T) { CheckNotFoundStatus(t, resp) }) + t.Run("get-top-channels-for-team-since invalid time range", func(t *testing.T) { + _, resp, err := client.GetTopChannelsForTeamSince(teamId, "7_days", 0, 5) + assert.Error(t, err) + CheckBadRequestStatus(t, resp) + }) + t.Run("get-top-channels-for-team-since not a member of team", func(t *testing.T) { th.UnlinkUserFromTeam(th.BasicUser, th.BasicTeam) _, resp, err := client.GetTopChannelsForTeamSince(teamId, model.TimeRangeToday, 0, 5) @@ -592,6 +610,12 @@ func TestGetTopChannelsForUserSince(t *testing.T) { CheckNotFoundStatus(t, resp) }) + t.Run("get-top-channels-for-user-since invalid time range", func(t *testing.T) { + _, resp, err := client.GetTopChannelsForUserSince(teamId, "7_days", 0, 5) + assert.Error(t, err) + CheckBadRequestStatus(t, resp) + }) + t.Run("get-top-channels-for-user-since not a member of team", func(t *testing.T) { th.UnlinkUserFromTeam(th.BasicUser, th.BasicTeam) _, resp, err := client.GetTopChannelsForUserSince(teamId, model.TimeRangeToday, 0, 5) diff --git a/api4/license.go b/api4/license.go index e98f859910..4a56ca74ed 100644 --- a/api4/license.go +++ b/api4/license.go @@ -106,7 +106,13 @@ func addLicense(c *Context, w http.ResponseWriter, r *http.Request) { // skip the restrictions if license is a sanctioned trial if !license.IsSanctionedTrial() && license.IsTrialLicense() { - canStartTrialLicense, err := c.App.Srv().Platform().LicenseManager().CanStartTrial() + lm := c.App.Srv().Platform().LicenseManager() + if lm == nil { + c.Err = model.NewAppError("addLicense", "api.license.upgrade_needed.app_error", nil, "", http.StatusInternalServerError) + return + } + + canStartTrialLicense, err := lm.CanStartTrial() if err != nil { c.Err = model.NewAppError("addLicense", "api.license.add_license.open.app_error", nil, "", http.StatusInternalServerError) return diff --git a/api4/license_test.go b/api4/license_test.go index 05acd6378c..dbf0e62bfc 100644 --- a/api4/license_test.go +++ b/api4/license_test.go @@ -91,9 +91,6 @@ func TestUploadLicenseFile(t *testing.T) { mockLicenseValidator := mocks2.LicenseValidatorIface{} defer testutils.ResetLicenseValidator() - //startTimestamp, err := time.Parse("2 Jan 2006 3:04 pm", "1 Jan 2021 12:00 am") - //require.Nil(t, err) - userCount := 100 mills := model.GetMillis() @@ -125,6 +122,37 @@ func TestUploadLicenseFile(t *testing.T) { require.Equal(t, http.StatusBadRequest, resp.StatusCode) }) + t.Run("try to get gone through trial, with TE build", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ExperimentalSettings.RestrictSystemAdmin = false }) + th.App.Srv().Platform().SetLicenseManager(nil) + + mockLicenseValidator := mocks2.LicenseValidatorIface{} + defer testutils.ResetLicenseValidator() + + license := model.License{ + Id: model.NewId(), + Features: &model.Features{ + Users: model.NewInt(100), + }, + Customer: &model.Customer{ + Name: "Test", + }, + StartsAt: model.GetMillis() + 100, + ExpiresAt: model.GetMillis() + 100 + (30*(time.Hour*24) + (time.Hour * 8)).Milliseconds(), + } + + mockLicenseValidator.On("LicenseFromBytes", mock.Anything).Return(&license, nil).Once() + licenseBytes, err := json.Marshal(license) + require.NoError(t, err) + + mockLicenseValidator.On("ValidateLicense", mock.Anything).Return(true, string(licenseBytes)) + utils.LicenseValidator = &mockLicenseValidator + + resp, err := th.SystemAdminClient.UploadLicenseFile([]byte("")) + CheckErrorID(t, err, "api.license.upgrade_needed.app_error") + require.Equal(t, http.StatusInternalServerError, resp.StatusCode) + }) + t.Run("allow uploading sanctioned trials even if server already gone through trial", func(t *testing.T) { mockLicenseValidator := mocks2.LicenseValidatorIface{} defer testutils.ResetLicenseValidator() diff --git a/app/channel_test.go b/app/channel_test.go index 260b46d641..0361122027 100644 --- a/app/channel_test.go +++ b/app/channel_test.go @@ -2498,7 +2498,7 @@ func TestGetTopChannelsForTeamSince(t *testing.T) { {ID: channel5.Id, MessageCount: 2}, } - timeRange := model.StartOfDayForTimeRange(model.TimeRangeToday, time.Now().Location()) + timeRange, _ := model.GetStartOfDayForTimeRange(model.TimeRangeToday, time.Now().Location()) t.Run("get-top-channels-for-team-since", func(t *testing.T) { topChannels, err := th.App.GetTopChannelsForTeamSince(th.Context, th.BasicChannel.TeamId, th.BasicUser.Id, &model.InsightsOpts{StartUnixMilli: timeRange.UnixMilli(), Page: 0, PerPage: 5}) @@ -2576,7 +2576,7 @@ func TestGetTopChannelsForUserSince(t *testing.T) { {ID: channel5.Id, MessageCount: 2}, } - timeRange := model.StartOfDayForTimeRange(model.TimeRangeToday, time.Now().Location()) + timeRange, _ := model.GetStartOfDayForTimeRange(model.TimeRangeToday, time.Now().Location()) t.Run("get-top-channels-for-user-since", func(t *testing.T) { topChannels, err := th.App.GetTopChannelsForUserSince(th.Context, th.BasicUser.Id, "", &model.InsightsOpts{StartUnixMilli: timeRange.UnixMilli(), Page: 0, PerPage: 5}) @@ -2787,7 +2787,7 @@ func TestGetTopInactiveChannelsForTeamSince(t *testing.T) { {ID: channel2.Id, MessageCount: 6}, } - timeRange := model.StartOfDayForTimeRange(model.TimeRangeToday, time.Now().Location()) + timeRange, _ := model.GetStartOfDayForTimeRange(model.TimeRangeToday, time.Now().Location()) t.Run("get-top-channels-for-team-since", func(t *testing.T) { topChannels, err := th.App.GetTopInactiveChannelsForTeamSince(th.Context, th.BasicChannel.TeamId, th.BasicUser.Id, &model.InsightsOpts{StartUnixMilli: timeRange.UnixMilli(), Page: 0, PerPage: 5}) @@ -2885,7 +2885,7 @@ func TestGetTopInactiveChannelsForUserSince(t *testing.T) { {ID: channel2.Id, MessageCount: 6}, } - timeRange := model.StartOfDayForTimeRange(model.TimeRangeToday, time.Now().Location()) + timeRange, _ := model.GetStartOfDayForTimeRange(model.TimeRangeToday, time.Now().Location()) t.Run("get-top-channels-for-user-since", func(t *testing.T) { topChannels, err := th.App.GetTopInactiveChannelsForUserSince(th.Context, th.BasicChannel.TeamId, th.BasicUser.Id, &model.InsightsOpts{StartUnixMilli: timeRange.UnixMilli(), Page: 0, PerPage: 4}) diff --git a/app/notification_email.go b/app/notification_email.go index 914b729a01..965dd9a292 100644 --- a/app/notification_email.go +++ b/app/notification_email.go @@ -201,6 +201,18 @@ func truncateUserNames(name string, i int) string { return name } +type FieldRow struct { + Cells []*model.SlackAttachmentField +} + +type EmailMessageAttachment struct { + model.SlackAttachment + + Pretext template.HTML + Text template.HTML + FieldRows []FieldRow +} + type postData struct { SenderName string ChannelName string @@ -211,6 +223,7 @@ type postData struct { Time string ShowChannelIcon bool OtherChannelMembersCount int + MessageAttachments []*EmailMessageAttachment } /** @@ -245,6 +258,7 @@ func (a *App) getNotificationEmailBody(c request.CTX, recipient *model.User, pos } pData.Message = template.HTML(normalizedPostMessage) pData.Time = translateFunc("app.notification.body.dm.time", messageTime) + pData.MessageAttachments = a.processMessageAttachments(post) } data := a.Srv().EmailService.NewEmailTemplateData(recipient.Locale) @@ -306,6 +320,81 @@ func (a *App) getNotificationEmailBody(c request.CTX, recipient *model.User, pos return a.Srv().TemplatesContainer().RenderToString("messages_notification", data) } +func (a *App) processMessageAttachments(post *model.Post) []*EmailMessageAttachment { + emailMessageAttachments := []*EmailMessageAttachment{} + + for _, messageAttachment := range post.Attachments() { + emailMessageAttachment := &EmailMessageAttachment{ + SlackAttachment: *messageAttachment, + Pretext: a.prepareTextForEmail(messageAttachment.Pretext), + Text: a.prepareTextForEmail(messageAttachment.Text), + } + + stripedTitle, err := utils.StripMarkdown(emailMessageAttachment.Title) + if err != nil { + mlog.Warn("Failed parse to markdown from messageatatchment title", mlog.String("post_id", post.Id), mlog.Err(err)) + stripedTitle = "" + } + + emailMessageAttachment.Title = stripedTitle + + shortFieldRow := FieldRow{} + + for i := range messageAttachment.Fields { + // Create a new instance to avoid altering the original pointer reference + // We update field value to parse markdown. + // If we do that on the original pointer, the rendered text in mattermost + // becomes invalid as its no longer a markdown string, but rather an HTML string. + field := &model.SlackAttachmentField{ + Title: messageAttachment.Fields[i].Title, + Value: messageAttachment.Fields[i].Value, + Short: messageAttachment.Fields[i].Short, + } + + if stringValue, ok := field.Value.(string); ok { + field.Value = a.prepareTextForEmail(stringValue) + } + + if !field.Short { + if len(shortFieldRow.Cells) > 0 { + emailMessageAttachment.FieldRows = append(emailMessageAttachment.FieldRows, shortFieldRow) + shortFieldRow = FieldRow{} + } + + emailMessageAttachment.FieldRows = append(emailMessageAttachment.FieldRows, FieldRow{[]*model.SlackAttachmentField{field}}) + } else { + shortFieldRow.Cells = append(shortFieldRow.Cells, field) + + if len(shortFieldRow.Cells) == 2 { + emailMessageAttachment.FieldRows = append(emailMessageAttachment.FieldRows, shortFieldRow) + shortFieldRow = FieldRow{} + } + } + } + + // collect any leftover short fields + if len(shortFieldRow.Cells) > 0 { + emailMessageAttachment.FieldRows = append(emailMessageAttachment.FieldRows, shortFieldRow) + shortFieldRow = FieldRow{} + } + + emailMessageAttachments = append(emailMessageAttachments, emailMessageAttachment) + } + + return emailMessageAttachments +} + +func (a *App) prepareTextForEmail(text string) template.HTML { + escapedText := html.EscapeString(text) + markdownText, err := utils.MarkdownToHTML(escapedText) + if err != nil { + mlog.Warn("Encountered error while converting markdown to HTML", mlog.Err(err)) + return template.HTML(text) + } + + return template.HTML(markdownText) +} + type formattedPostTime struct { Time time.Time Year string diff --git a/app/notification_email_test.go b/app/notification_email_test.go index 8b48b50035..4d0f77f433 100644 --- a/app/notification_email_test.go +++ b/app/notification_email_test.go @@ -339,6 +339,103 @@ func TestGetNotificationEmailBodyFullNotificationLocaleTime24Hour(t *testing.T) require.Contains(t, body, "14:30", fmt.Sprintf("Expected email text '14:30'. Got %s", body)) } +func TestGetNotificationEmailBodyFullNotificationWithSlackAttachments(t *testing.T) { + th := SetupWithStoreMock(t) + defer th.TearDown() + + recipient := &model.User{} + post := &model.Post{ + Message: "This is the message", + } + + messageAttachments := []*model.SlackAttachment{ + { + Color: "#FF0000", + Pretext: "message attachment 1 pretext", + AuthorName: "author name", + AuthorLink: "https://example.com/slack_attachment_1/author_link", + AuthorIcon: "https://example.com/slack_attachment_1/author_icon", + Title: "message attachment 1 title", + TitleLink: "https://example.com/slack_attachment_1/title_link", + Text: "message attachment 1 text", + ImageURL: "https://example.com/slack_attachment_1/image", + ThumbURL: "https://example.com/slack_attachment_1/thumb", + Fields: []*model.SlackAttachmentField{ + { + Short: true, + Title: "message attachment 1 field 1 title", + Value: "message attachment 1 field 1 value", + }, + { + Short: false, + Title: "message attachment 1 field 2 title", + Value: "message attachment 1 field 2 value", + }, + { + Short: true, + Title: "message attachment 1 field 3 title", + Value: "message attachment 1 field 3 value", + }, + { + Short: true, + Title: "message attachment 1 field 4 title", + Value: "message attachment 1 field 4 value", + }, + }, + }, + { + Color: "#FF0000", + Pretext: "message attachment 2 pretext", + AuthorName: "author name 2", + Text: "message attachment 2 text", + }, + } + + model.ParseSlackAttachment(post, messageAttachments) + + channel := &model.Channel{ + DisplayName: "ChannelName", + Type: model.ChannelTypeOpen, + } + + channelName := "ChannelName" + senderName := "sender" + teamName := "testteam" + teamURL := "http://localhost:8065/testteam" + emailNotificationContentsType := model.EmailNotificationContentsFull + translateFunc := i18n.GetUserTranslations("en") + + storeMock := th.App.Srv().Store().(*mocks.Store) + teamStoreMock := mocks.TeamStore{} + teamStoreMock.On("GetByName", "testteam").Return(&model.Team{Name: "testteam"}, nil) + storeMock.On("Team").Return(&teamStoreMock) + + body, err := th.App.getNotificationEmailBody(th.Context, recipient, post, channel, channelName, senderName, teamName, teamURL, emailNotificationContentsType, true, translateFunc, "user-avatar.png") + require.NoError(t, err) + require.Contains(t, body, "#FF0000") + require.Contains(t, body, "message attachment 1 pretext") + require.Contains(t, body, "author name") + require.Contains(t, body, "https://example.com/slack_attachment_1/author_link") + require.Contains(t, body, "https://example.com/slack_attachment_1/author_icon") + require.Contains(t, body, "message attachment 1 title") + require.Contains(t, body, "https://example.com/slack_attachment_1/title_link") + require.Contains(t, body, "message attachment 1 text") + require.Contains(t, body, "https://example.com/slack_attachment_1/image") + require.Contains(t, body, "https://example.com/slack_attachment_1/thumb") + require.Contains(t, body, "message attachment 1 field 1 title") + require.Contains(t, body, "message attachment 1 field 1 value") + require.Contains(t, body, "message attachment 1 field 2 title") + require.Contains(t, body, "message attachment 1 field 2 value") + require.Contains(t, body, "message attachment 1 field 3 title") + require.Contains(t, body, "message attachment 1 field 3 value") + require.Contains(t, body, "message attachment 1 field 4 title") + require.Contains(t, body, "message attachment 1 field 4 value") + require.Contains(t, body, "https://example.com/slack_attachment_1/thumb") + require.Contains(t, body, "message attachment 2 pretext") + require.Contains(t, body, "author name 2") + require.Contains(t, body, "message attachment 2 text") +} + // from here func TestGetNotificationEmailBodyGenericNotificationPublicChannel(t *testing.T) { th := SetupWithStoreMock(t) diff --git a/app/notification_push_test.go b/app/notification_push_test.go index 6dd1b5a2ff..73bf38e72b 100644 --- a/app/notification_push_test.go +++ b/app/notification_push_test.go @@ -1440,20 +1440,19 @@ func TestPushNotificationRace(t *testing.T) { Return(&model.Preference{Value: "test"}, nil) mockStore.On("Preference").Return(&mockPreferenceStore) s := &Server{ - products: make(map[string]Product), - Router: mux.NewRouter(), - filestore: &fmocks.FileBackend{}, + products: make(map[string]Product), + Router: mux.NewRouter(), } var err error s.platform, err = platform.New(platform.ServiceConfig{ ConfigStore: memoryStore, - }) + }, platform.SetFileStore(&fmocks.FileBackend{})) s.SetStore(mockStore) require.NoError(t, err) serviceMap := map[ServiceKey]any{ ConfigKey: s.platform, LicenseKey: &licenseWrapper{s}, - FilestoreKey: s.filestore, + FilestoreKey: s.FileBackend(), } ch, err := NewChannels(s, serviceMap) require.NoError(t, err) diff --git a/app/options.go b/app/options.go index 15725bf4c3..19133c42fc 100644 --- a/app/options.go +++ b/app/options.go @@ -54,7 +54,7 @@ func ConfigStore(configStore *config.Store) Option { func SetFileStore(filestore filestore.FileBackend) Option { return func(s *Server) error { - s.filestore = filestore + s.platformOptions = append(s.platformOptions, platform.SetFileStore(filestore)) return nil } } diff --git a/app/permissions.go b/app/permissions.go index a992d0a952..2b34b85bb3 100644 --- a/app/permissions.go +++ b/app/permissions.go @@ -29,6 +29,10 @@ type permissionsServiceWrapper struct { app AppIface } +func (s *permissionsServiceWrapper) HasPermissionTo(userID string, permission *model.Permission) bool { + return s.app.HasPermissionTo(userID, permission) +} + func (s *permissionsServiceWrapper) HasPermissionToTeam(userID string, teamID string, permission *model.Permission) bool { return s.app.HasPermissionToTeam(userID, teamID, permission) } diff --git a/app/permissions_migrations.go b/app/permissions_migrations.go index 613adc927f..2b638be162 100644 --- a/app/permissions_migrations.go +++ b/app/permissions_migrations.go @@ -991,6 +991,27 @@ func (a *App) getPlaybooksPermissionsAddManageRoles() (permissionsMap, error) { return transformations, nil } +func (a *App) getProductsBoardsPermissions() (permissionsMap, error) { + transformations := []permissionTransformation{} + + permissionsProductsRead := []string{model.PermissionSysconsoleReadProductsBoards.Id} + permissionsProductsWrite := []string{model.PermissionSysconsoleWriteProductsBoards.Id} + + // Give the new subsection READ permissions to any user with SYSTEM_MANAGER + transformations = append(transformations, permissionTransformation{ + On: permissionOr(isRole(model.SystemManagerRoleId)), + Add: permissionsProductsRead, + }) + + // Give the new subsection WRITE permissions to any user with SYSTEM_ADMIN + transformations = append(transformations, permissionTransformation{ + On: permissionOr(isRole(model.SystemAdminRoleId)), + Add: permissionsProductsWrite, + }) + + return transformations, nil +} + // DoPermissionsMigrations execute all the permissions migrations need by the current version. func (a *App) DoPermissionsMigrations() error { return a.Srv().doPermissionsMigrations() @@ -1032,6 +1053,7 @@ func (s *Server) doPermissionsMigrations() error { {Key: model.MigrationKeyAddPlaybooksPermissions, Migration: a.getAddPlaybooksPermissions}, {Key: model.MigrationKeyAddCustomUserGroupsPermissions, Migration: a.getAddCustomUserGroupsPermissions}, {Key: model.MigrationKeyAddPlayboosksManageRolesPermissions, Migration: a.getPlaybooksPermissionsAddManageRoles}, + {Key: model.MigrationKeyAddProductsBoardsPermissions, Migration: a.getProductsBoardsPermissions}, } roles, err := s.Store().Role().GetAll() diff --git a/app/platform/helper_test.go b/app/platform/helper_test.go index b8d2da4594..9402f8def3 100644 --- a/app/platform/helper_test.go +++ b/app/platform/helper_test.go @@ -4,7 +4,7 @@ package platform import ( - "io/ioutil" + "os" "path/filepath" "sync" "testing" @@ -127,7 +127,7 @@ func SetupWithCluster(tb testing.TB, cluster einterfaces.ClusterInterface) *Test } func setupTestHelper(dbStore store.Store, enterprise bool, includeCacheLayer bool, tb testing.TB, options ...Option) *TestHelper { - tempWorkspace, err := ioutil.TempDir("", "apptest") + tempWorkspace, err := os.MkdirTemp("", "apptest") if err != nil { panic(err) } diff --git a/app/platform/options.go b/app/platform/options.go index 072914e104..65471b63f6 100644 --- a/app/platform/options.go +++ b/app/platform/options.go @@ -11,6 +11,7 @@ import ( "github.com/mattermost/mattermost-server/v6/config" "github.com/mattermost/mattermost-server/v6/einterfaces" "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/shared/filestore" "github.com/mattermost/mattermost-server/v6/shared/mlog" "github.com/mattermost/mattermost-server/v6/store" "github.com/mattermost/mattermost-server/v6/store/localcachelayer" @@ -74,6 +75,13 @@ func Config(dsn string, readOnly bool, configDefaults *model.Config) Option { } } +func SetFileStore(filestore filestore.FileBackend) Option { + return func(ps *PlatformService) error { + ps.filestore = filestore + return nil + } +} + // ConfigStore applies the given config store, typically to replace the traditional sources with a memory store for testing. func ConfigStore(configStore *config.Store) Option { return func(ps *PlatformService) error { diff --git a/app/platform/service.go b/app/platform/service.go index d2104fb2bb..6620b7f487 100644 --- a/app/platform/service.go +++ b/app/platform/service.go @@ -20,6 +20,7 @@ import ( "github.com/mattermost/mattermost-server/v6/services/cache" "github.com/mattermost/mattermost-server/v6/services/searchengine" "github.com/mattermost/mattermost-server/v6/services/searchengine/bleveengine" + "github.com/mattermost/mattermost-server/v6/shared/filestore" "github.com/mattermost/mattermost-server/v6/shared/mlog" "github.com/mattermost/mattermost-server/v6/store" "github.com/mattermost/mattermost-server/v6/store/localcachelayer" @@ -41,6 +42,8 @@ type PlatformService struct { configStore *config.Store + filestore filestore.FileBackend + cacheProvider cache.Provider statusCache cache.Cache sessionCache cache.Cache @@ -213,6 +216,18 @@ func New(sc ServiceConfig, options ...Option) (*PlatformService, error) { } } + license := ps.License() + // Step 3: Initialize filestore + if ps.filestore == nil { + insecure := ps.Config().ServiceSettings.EnableInsecureOutgoingConnections + backend, err2 := filestore.NewFileBackend(ps.Config().FileSettings.ToFileBackendSettings(license != nil && *license.Features.Compliance, insecure != nil && *insecure)) + if err2 != nil { + return nil, fmt.Errorf("failed to initialize filebackend: %w", err2) + } + + ps.filestore = backend + } + var err error ps.Store, err = ps.newStore() if err != nil { @@ -431,3 +446,7 @@ func (ps *PlatformService) GetPluginStatuses() (model.PluginStatuses, *model.App return pluginStatuses, nil } + +func (ps *PlatformService) FileBackend() filestore.FileBackend { + return ps.filestore +} diff --git a/app/plugin_api_test.go b/app/plugin_api_test.go index 1068adaa64..d36c4fadc5 100644 --- a/app/plugin_api_test.go +++ b/app/plugin_api_test.go @@ -196,6 +196,7 @@ func TestPluginAPIGetUserPreferences(t *testing.T) { } func TestPluginAPIDeleteUserPreferences(t *testing.T) { + t.Skip("MM-47612") th := Setup(t) defer th.TearDown() api := th.SetupPluginAPI() diff --git a/app/reaction_test.go b/app/reaction_test.go index d9670e4eeb..33d747136b 100644 --- a/app/reaction_test.go +++ b/app/reaction_test.go @@ -230,7 +230,7 @@ func TestGetTopReactionsForTeamSince(t *testing.T) { expectedTopReactions[3] = &model.TopReaction{EmojiName: "sad", Count: int64(3)} expectedTopReactions[4] = &model.TopReaction{EmojiName: "happy", Count: int64(2)} - timeRange := model.StartOfDayForTimeRange(model.TimeRangeToday, time.Now().Location()) + timeRange, _ := model.GetStartOfDayForTimeRange(model.TimeRangeToday, time.Now().Location()) t.Run("get-top-reactions-for-team-since", func(t *testing.T) { topReactions, err := th.App.GetTopReactionsForTeamSince(teamId, userId, &model.InsightsOpts{StartUnixMilli: timeRange.UnixMilli(), Page: 0, PerPage: 5}) @@ -401,7 +401,7 @@ func TestGetTopReactionsForUserSince(t *testing.T) { expectedTopReactions[3] = &model.TopReaction{EmojiName: "heart", Count: int64(3)} expectedTopReactions[4] = &model.TopReaction{EmojiName: "blush", Count: int64(2)} - timeRange := model.StartOfDayForTimeRange(model.TimeRangeToday, time.Now().Location()) + timeRange, _ := model.GetStartOfDayForTimeRange(model.TimeRangeToday, time.Now().Location()) t.Run("get-top-reactions-for-user-since", func(t *testing.T) { topReactions, err := th.App.GetTopReactionsForUserSince(userId, teamId, &model.InsightsOpts{StartUnixMilli: timeRange.UnixMilli(), Page: 0, PerPage: 5}) diff --git a/app/server.go b/app/server.go index a4688c8b43..0216fa240f 100644 --- a/app/server.go +++ b/app/server.go @@ -137,7 +137,6 @@ type Server struct { openGraphDataCache cache.Cache clusterLeaderListenerId string loggerLicenseListenerId string - filestore filestore.FileBackend platform *platform.PlatformService platformOptions []platform.Option @@ -239,15 +238,6 @@ func NewServer(options ...Option) (*Server, error) { s.LoadLicense() } - license := s.License() - insecure := s.platform.Config().ServiceSettings.EnableInsecureOutgoingConnections - // Step 3: Initialize filestore - backend, err := filestore.NewFileBackend(s.platform.Config().FileSettings.ToFileBackendSettings(license != nil && *license.Features.Compliance, insecure != nil && *insecure)) - if err != nil { - return nil, errors.Wrap(err, "failed to initialize filebackend") - } - s.filestore = backend - s.licenseWrapper = &licenseWrapper{ srv: s, } @@ -272,7 +262,7 @@ func NewServer(options ...Option) (*Server, error) { ChannelKey: &channelsWrapper{srv: s}, ConfigKey: s.platform, LicenseKey: s.licenseWrapper, - FilestoreKey: s.filestore, + FilestoreKey: s.platform.FileBackend(), FileInfoStoreKey: &fileInfoWrapper{srv: s}, ClusterKey: s.platform, UserKey: New(ServerConnector(s.Channels())), @@ -439,6 +429,7 @@ func NewServer(options ...Option) (*Server, error) { mlog.Info("Printing current working", mlog.String("directory", pwd)) mlog.Info("Loaded config", mlog.String("source", s.platform.DescribeConfig())) + license := s.License() allowAdvancedLogging := license != nil && *license.Features.AdvancedLogging if s.Audit == nil { @@ -1425,7 +1416,7 @@ func (s *Server) SendRemoveExpiredLicenseEmail(email string, renewalLink, locale } func (s *Server) FileBackend() filestore.FileBackend { - return s.filestore + return s.platform.FileBackend() } func (s *Server) TotalWebsocketConnections() int { diff --git a/app/server_test.go b/app/server_test.go index f8135d0461..64b85ce938 100644 --- a/app/server_test.go +++ b/app/server_test.go @@ -90,28 +90,29 @@ func TestStartServerNoS3Bucket(t *testing.T) { } s3Endpoint := fmt.Sprintf("%s:%s", s3Host, s3Port) + configStore, _ := config.NewFileStore("config.json", true) + store, _ := config.NewStoreFromBacking(configStore, nil, false) + + cfg := store.Get() + cfg.FileSettings = model.FileSettings{ + DriverName: model.NewString(model.ImageDriverS3), + AmazonS3AccessKeyId: model.NewString(model.MinioAccessKey), + AmazonS3SecretAccessKey: model.NewString(model.MinioSecretKey), + AmazonS3Bucket: model.NewString("nosuchbucket"), + AmazonS3Endpoint: model.NewString(s3Endpoint), + AmazonS3Region: model.NewString(""), + AmazonS3PathPrefix: model.NewString(""), + AmazonS3SSL: model.NewBool(false), + } + *cfg.ServiceSettings.ListenAddress = ":0" + _, _, err := store.Set(cfg) + require.NoError(t, err) s, err := NewServer(func(server *Server) error { - configStore, _ := config.NewFileStore("config.json", true) - store, _ := config.NewStoreFromBacking(configStore, nil, false) - var err error - server.platform, err = platform.New(platform.ServiceConfig{ - ConfigStore: store, - }) - require.NoError(t, err) - server.platform.UpdateConfig(func(cfg *model.Config) { - cfg.FileSettings = model.FileSettings{ - DriverName: model.NewString(model.ImageDriverS3), - AmazonS3AccessKeyId: model.NewString(model.MinioAccessKey), - AmazonS3SecretAccessKey: model.NewString(model.MinioSecretKey), - AmazonS3Bucket: model.NewString("nosuchbucket"), - AmazonS3Endpoint: model.NewString(s3Endpoint), - AmazonS3Region: model.NewString(""), - AmazonS3PathPrefix: model.NewString(""), - AmazonS3SSL: model.NewBool(false), - } - *cfg.ServiceSettings.ListenAddress = ":0" - }) + var err2 error + server.platform, err2 = platform.New(platform.ServiceConfig{}, platform.ConfigStore(store)) + require.NoError(t, err2) + return nil }) require.NoError(t, err) @@ -120,6 +121,8 @@ func TestStartServerNoS3Bucket(t *testing.T) { defer s.Shutdown() // ensure that a new bucket was created + require.IsType(t, &filestore.S3FileBackend{}, s.FileBackend()) + err = s.FileBackend().(*filestore.S3FileBackend).TestConnection() require.NoError(t, err) } diff --git a/build/Dockerfile b/build/Dockerfile index ab99fe0dcd..55183179d0 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -8,7 +8,7 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"] ENV PATH="/mattermost/bin:${PATH}" ARG PUID=2000 ARG PGID=2000 -ARG MM_PACKAGE="https://releases.mattermost.com/7.3.0/mattermost-7.3.0-linux-amd64.tar.gz?src=docker" +ARG MM_PACKAGE="https://releases.mattermost.com/7.4.0/mattermost-7.4.0-linux-amd64.tar.gz?src=docker" # # Install needed packages and indirect dependencies RUN apt-get update \ diff --git a/config/environment.go b/config/environment.go index 12e6727484..4ded8d7399 100644 --- a/config/environment.go +++ b/config/environment.go @@ -4,6 +4,7 @@ package config import ( + "encoding/json" "os" "reflect" "strconv" @@ -73,6 +74,11 @@ func applyEnvKey(key, value string, rValueSubject reflect.Value) { } case reflect.SliceOf(reflect.TypeOf("")).Kind(): rFieldValue.Set(reflect.ValueOf(strings.Split(value, " "))) + case reflect.Map: + target := reflect.New(rFieldValue.Type()).Interface() + if err := json.Unmarshal([]byte(value), target); err == nil { + rFieldValue.Set(reflect.ValueOf(target).Elem()) + } } } diff --git a/config/environment_test.go b/config/environment_test.go index fb4b3261ed..e86bcc36e9 100644 --- a/config/environment_test.go +++ b/config/environment_test.go @@ -31,15 +31,54 @@ func TestRemoveEnvOverrides(t *testing.T) { expectedConfig *model.Config }{ { - name: "basic override", + name: "config override", inputConfig: modifiedDefault(func(in *model.Config) { *in.ServiceSettings.TLSMinVer = "1.4" + in.PluginSettings.PluginStates = map[string]*model.PluginState{ + "plugin1": { + Enable: false, + }, + } + in.PluginSettings.Plugins = map[string]map[string]interface{}{ + "com.mattermost.plugin-1": { + "key1": "value1", + }, + "com_mattermost_plugin-2": { + "key2": "value2", + }, + } }), env: map[string]string{ "MM_SERVICESETTINGS_TLSMINVER": "1.5", + "MM_PLUGINSETTINGS_PLUGINSTATES": `{ + "plugin1": { + "Enable": true + } + }`, + "MM_PLUGINSETTINGS_PLUGINS": `{ + "com.mattermost.plugin-1": { + "key1": "other-value" + }, + "com_mattermost_plugin-2": { + "key2": "other-value" + } + }`, }, expectedConfig: modifiedDefault(func(in *model.Config) { *in.ServiceSettings.TLSMinVer = "1.5" + in.PluginSettings.PluginStates = map[string]*model.PluginState{ + "plugin1": { + Enable: true, + }, + } + in.PluginSettings.Plugins = map[string]map[string]interface{}{ + "com.mattermost.plugin-1": { + "key1": "other-value", + }, + "com_mattermost_plugin-2": { + "key2": "other-value", + }, + } }), }, { @@ -102,6 +141,41 @@ func TestRemoveEnvOverrides(t *testing.T) { in.SqlSettings.DataSourceReplicas = []string{"otherthing", "alsothis"} }), }, + { + name: "complex env settings", + inputConfig: modifiedDefault(func(in *model.Config) { + }), + env: map[string]string{ + "MM_PLUGINSETTINGS_PLUGINSTATES": `{ + "com.mattermost.plugin-1": { + "enable": true + } + }`, + "MM_PLUGINSETTINGS_PLUGINS": `{ + "com.mattermost.plugin-1": { + "key": { + "key": "(?PKEY)-(?P\\d{1,6})(?P[,;]*)", + "value": "[$key-$id](https://example.com/?$project-$id)$comma" + } + } + }`, + }, + expectedConfig: modifiedDefault(func(in *model.Config) { + in.PluginSettings.PluginStates = map[string]*model.PluginState{ + "com.mattermost.plugin-1": { + Enable: true, + }, + } + in.PluginSettings.Plugins = map[string]map[string]interface{}{ + "com.mattermost.plugin-1": { + "key": map[string]interface{}{ + "key": "(?PKEY)-(?P\\d{1,6})(?P[,;]*)", + "value": "[$key-$id](https://example.com/?$project-$id)$comma", + }, + }, + } + }), + }, { name: "bad env", inputConfig: modifiedDefault(func(in *model.Config) { diff --git a/i18n/bg.json b/i18n/bg.json index 7913a2775f..4031bf4bf6 100644 --- a/i18n/bg.json +++ b/i18n/bg.json @@ -4267,14 +4267,6 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "Каналите в поканата трябва да са част от канещият екип." }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "Вашият лиценз не поддържа профили за гости" - }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "Профилите за гости са забранени" - }, { "id": "api.team.invalidate_all_email_invites.app_error", "translation": "Грешка при анулиране на поканите по ел.поща." diff --git a/i18n/de.json b/i18n/de.json index ae6d93035c..f39f0d304e 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1777,7 +1777,7 @@ }, { "id": "api.templates.email_change_verify_body.title", - "translation": "Du hast deine E-Mail-Adresse geändert" + "translation": "Du hast deine E-Mail-Adresse erfolgreich geändert" }, { "id": "api.templates.email_change_verify_subject", @@ -4801,11 +4801,11 @@ }, { "id": "web.error.unsupported_browser.min_browser_version.edge", - "translation": "Version 44+" + "translation": "Version 95+" }, { "id": "web.error.unsupported_browser.min_browser_version.chrome", - "translation": "Version 100+" + "translation": "Version 106+" }, { "id": "web.error.unsupported_browser.learn_more", @@ -5623,14 +5623,6 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "Die Kanäle der Einladung müssen Teil des Teams der Einladung sein." }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "Deine Lizenz unterstützt Gastkonten nicht" - }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "Gastkonten sind deaktiviert" - }, { "id": "api.team.invalidate_all_email_invites.app_error", "translation": "Fehler beim Annulieren von E-Mail-Einladungen." @@ -8380,7 +8372,7 @@ }, { "id": "app.notification.body.dm.subTitle", - "translation": "Währen du weg warst, hat dir {{.SenderName}} eine neue Direktnachricht gesendet." + "translation": "Während du weg warst, hat dir {{.SenderName}} eine neue Direktnachricht gesendet." }, { "id": "app.license.generate_renewal_token.no_license", @@ -9537,5 +9529,33 @@ { "id": "app.user.get_badge_count.app_error", "translation": "Wir konnten den Nachrichtenzähler für den Benutzer nicht abfragen." + }, + { + "id": "app.job.error", + "translation": "Fehler bei der Auftragsausführung." + }, + { + "id": "app.last_accessible_file.app_error", + "translation": "Fehler beim Abrufen der letzten zugänglichen Datei" + }, + { + "id": "app.file.cloud.get.app_error", + "translation": "Die Datei kann nicht abgerufen werden, da sie das Limit des Cloud-Plans überschritten hat." + }, + { + "id": "model.group.name.reserved_name.app_error", + "translation": "Gruppenname existiert bereits als reservierter Name" + }, + { + "id": "app.plugin.product_mode.app_error", + "translation": "Plugin {{.Name}} kann im Produktmodus nicht aktiviert werden." + }, + { + "id": "api.team.invite_guests_to_channels.license.error", + "translation": "Deine Lizenz unterstützt Gastkonten nicht" + }, + { + "id": "api.team.invite_guests_to_channels.disabled.error", + "translation": "Gastkonten sind deaktiviert" } ] diff --git a/i18n/en.json b/i18n/en.json index f5db7894ed..710054bf1a 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -8739,6 +8739,10 @@ "id": "model.incoming_hook.username.app_error", "translation": "Invalid username." }, + { + "id": "model.insights.get_start_of_day_for_time_range.time_range.app_error", + "translation": "Invalid time range." + }, { "id": "model.job.is_valid.create_at.app_error", "translation": "Create at must be a valid time." diff --git a/i18n/en_AU.json b/i18n/en_AU.json index e1dc7cb1c0..6f6f177f60 100644 --- a/i18n/en_AU.json +++ b/i18n/en_AU.json @@ -5459,14 +5459,6 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "The channels of the invite must be part of the team of the invite." }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "Your licence does not support guest accounts" - }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "Guest accounts are disabled" - }, { "id": "api.team.invalidate_all_email_invites.app_error", "translation": "Error invalidating email invites." @@ -9537,5 +9529,21 @@ { "id": "app.cloud.get_current_plan_name.app_error", "translation": "Unable to get current plan name" + }, + { + "id": "model.group.name.reserved_name.app_error", + "translation": "group name already exists as a reserved name" + }, + { + "id": "app.last_accessible_file.app_error", + "translation": "Error fetching last accessible file" + }, + { + "id": "app.job.error", + "translation": "Error during job execution." + }, + { + "id": "app.file.cloud.get.app_error", + "translation": "Unable to retrieve file: cloud storage limit exceeded." } ] diff --git a/i18n/es.json b/i18n/es.json index 97cec02eba..91e6c8d027 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -621,7 +621,7 @@ }, { "id": "api.command_help.desc", - "translation": "Abrir la página de ayuda de Mattermost" + "translation": "Muestra el mensaje de ayuda de Mattermost" }, { "id": "api.command_help.name", @@ -1781,7 +1781,7 @@ }, { "id": "api.templates.email_change_verify_body.title", - "translation": "Haz actualizado tu correo electrónico" + "translation": "Haz actualizado satisfactoriamente tu correo electrónico" }, { "id": "api.templates.email_change_verify_subject", @@ -1857,7 +1857,7 @@ }, { "id": "api.templates.post_body.button", - "translation": "Ver mensaje" + "translation": "Responder en Mattermost" }, { "id": "api.templates.reset_body.button", @@ -3305,7 +3305,7 @@ }, { "id": "ent.migration.migratetoldap.duplicate_field", - "translation": "No se pueden migrar los usuarios de AD/LDAP con el campo especificado. Se ha detectado un entrada. Por favor, retire todos los duplicados e inténtalo de nuevo." + "translation": "No se pueden migrar los usuarios de AD/LDAP con el campo especificado. Se ha detectado un entrada duplicada. Por favor, elimina todos los duplicados e inténtalo de nuevo." }, { "id": "ent.migration.migratetoldap.user_not_found", @@ -4573,7 +4573,7 @@ }, { "id": "oauth.gitlab.tos.error", - "translation": "Los Términos de Servicio de GitLab se han actualizado. Por favor, vaya a gitlab.com a aceptar y, a continuación, intente iniciar sesión en Mattermost de nuevo." + "translation": "Los Términos de Servicio de GitLab se han actualizado. Por favor, visita {{.URL}} para aceptarlos y, a continuación, intenta iniciar sesión en Mattermost nuevamente." }, { "id": "plugin.api.update_user_status.bad_status", @@ -4753,7 +4753,7 @@ }, { "id": "web.error.unsupported_browser.min_os_version.windows", - "translation": "Windows 7+" + "translation": "Windows 8.1+" }, { "id": "web.error.unsupported_browser.min_os_version.mac", @@ -4761,19 +4761,19 @@ }, { "id": "web.error.unsupported_browser.min_browser_version.safari", - "translation": "Versión 12+" + "translation": "Versión 14.1+" }, { "id": "web.error.unsupported_browser.min_browser_version.firefox", - "translation": "Versión 78+" + "translation": "Versión 91+" }, { "id": "web.error.unsupported_browser.min_browser_version.edge", - "translation": "Versión 44+" + "translation": "Versión 95+" }, { "id": "web.error.unsupported_browser.min_browser_version.chrome", - "translation": "Versión 89+" + "translation": "Versión 106+" }, { "id": "web.error.unsupported_browser.learn_more", @@ -5543,14 +5543,6 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "Los canales de la invitación deben ser parte del equipo al cual fue invitado." }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "La licencia actual no es compatible con cuentas de huéspedes" - }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "Las cuentas de huéspedes están deshabilitadas" - }, { "id": "api.team.invalidate_all_email_invites.app_error", "translation": "Error al invalidar las invitaciones por correo electrónico." @@ -5945,7 +5937,7 @@ }, { "id": "api.push_notifications.session.expired", - "translation": "Sesión caducada: Inicie sesión para continuar recibiendo notificaciones. El administrador del sistema configura las sesiones para {{.siteName}} para que caduquen cada {{.daysCount}} día (s)." + "translation": "Sesión vencida: Inicie sesión para continuar recibiendo notificaciones. El administrador del sistema configura las sesiones para {{.siteName}} para que caduquen cada {{.hoursCount}} hora(s)." }, { "id": "api.post.error_get_post_id.pending", @@ -7621,7 +7613,7 @@ }, { "id": "api.user.get_authorization_code.endpoint.app_error", - "translation": "Error all obtener el endpoint para Documento de Descubrimiento." + "translation": "Error al obtener el endpoint desde Documento de Descubrimiento." }, { "id": "api.templates.payment_failed_no_card.title", @@ -7645,15 +7637,15 @@ }, { "id": "api.templates.payment_failed.title", - "translation": "Pago fallido" + "translation": "El pago ha fallado" }, { "id": "api.templates.payment_failed.subject", - "translation": "Se requiere una acción: El pago de Mattermost Cloud no se ha realizado" + "translation": "Se requiere una acción: El pago de {{.Plan}} Mattermost Cloud no se ha realizado" }, { "id": "api.templates.payment_failed.info3", - "translation": "Para asegurar una suscripción ininterrumpida a Mattermost Cloud, por favor, póngase en contacto con su institución financiera para arreglar el problema subyacente o actualizar su información de pago. Una vez actualizada la información de pago, Mattermost intentará liquidar cualquier saldo pendiente." + "translation": "Para asegurar un acceso ininterrumpido a {{.Plan}} Mattermost Cloud, por favor, póngase en contacto con su institución financiera para resolver el problema subyacente o actualizar su información de pago. Una vez actualizada la información de pago, Mattermost intentará liquidar cualquier saldo pendiente." }, { "id": "api.templates.payment_failed.info2", @@ -7981,7 +7973,7 @@ }, { "id": "api.templates.questions_footer.info", - "translation": "Envíenos un correo electrónico en cualquier momento a " + "translation": "¿Necesitas ayuda o tienes alguna pregunta? Envíanos un correo electrónico a " }, { "id": "api.templates.license_up_for_renewal_title", @@ -8025,11 +8017,11 @@ }, { "id": "api.templates.email_footer_v2", - "translation": "© 2021 Mattermost, Inc. 530 Lytton Avenue, Second floor, Palo Alto, CA, 94301" + "translation": "© 2022 Mattermost, Inc. 530 Lytton Avenue, Second floor, Palo Alto, CA, 94301" }, { "id": "api.templates.cloud_welcome_email.title", - "translation": "¡El período de prueba de 14 días de tu espacio de trabajo {{.WorkSpace}} está listo!" + "translation": "¡Tu espacio de trabajo ya está listo para usar!" }, { "id": "api.templates.cloud_welcome_email.subtitle_info", @@ -8089,7 +8081,7 @@ }, { "id": "api.templates.cloud_welcome_email.add_apps_sub_info", - "translation": "Agiliza tu trabajo con herramientas como Github, Jira y Zoom. Explora todas las integraciones que tenemos en nuestro" + "translation": "Agiliza tu trabajo con herramientas como GitHub, Jira y Zoom. Explora todas las integraciones que tenemos en nuestro" }, { "id": "api.command_share.unknown_action", @@ -9153,7 +9145,7 @@ }, { "id": "api.templates.invite_team_and_channel_subject", - "translation": " " + "translation": "[{{ .SiteName }}] {{ .SenderName }} te invitó a unirte al Canal {{ .ChannelName }} en el Equipo {{ .TeamDisplayName }}" }, { "id": "api.templates.invite_team_and_channels_body.title", @@ -9237,7 +9229,7 @@ }, { "id": "api.templates.invite_team_and_channel_body.title", - "translation": " " + "translation": "{{ .SenderName }} te invitó a unirte al Canal {{ .ChannelName }} en el Equipo {{ .TeamDisplayName}}" }, { "id": "api.templates.server_inactivity_info_bullet", @@ -9270,5 +9262,301 @@ { "id": "api.user.authorize_oauth_user.saml_response_too_long.app_error", "translation": " " + }, + { + "id": "app.cloud.get_subscription_delinquency_date.app_error", + "translation": "La suscripción no es morosa" + }, + { + "id": "app.cloud.get_subscription.app_error", + "translation": "No se pudo recuperar la suscripción de la nube" + }, + { + "id": "app.cloud.get_current_plan_name.app_error", + "translation": "No se puede obtener el nombre del plan actual" + }, + { + "id": "app.cloud.get_cloud_products.app_error", + "translation": "No se pudieron obtener los productos de la nube" + }, + { + "id": "api.templates.delinquency_90.title", + "translation": "Tu workspace Mattermost ha sido degradado" + }, + { + "id": "api.templates.delinquency_90.subtitle3", + "translation": "Para desarchivar tus datos y mantener las características de pago, actualiza tu información de pago." + }, + { + "id": "api.templates.delinquency_90.subtitle2", + "translation": "Además, tus datos pueden haber sido archivados debido a las limitaciones de Cloud Starter." + }, + { + "id": "api.templates.delinquency_90.subtitle1", + "translation": "Si usas características Cloud Professional o Enterprise para operaciones importantes de negocio, ya no estarán activas y experimentarás un rendimiento degradado." + }, + { + "id": "api.templates.delinquency_90.subject", + "translation": "Tu workspace Mattermost Cloud ha sido degradado" + }, + { + "id": "api.templates.delinquency_90.secondary_action_button", + "translation": "Ver Planes y Precios" + }, + { + "id": "api.templates.delinquency_90.button", + "translation": "Actualizar pago" + }, + { + "id": "api.templates.delinquency_75.title", + "translation": "Tu workspace será degradado en 15 días" + }, + { + "id": "api.templates.delinquency_75.subtitle3", + "translation": "Actualiza ahora tu información de pago, o degrada a Cloud Starter." + }, + { + "id": "api.templates.delinquency_75.subtitle2", + "translation": "Tu workspace será degradado a Cloud Starter. Las características de tu {{.Plan}} serán bloqueadas y algunos de los datos de tu workspace podrían ser archivados hasta que liquides completamente tu saldo pendiente." + }, + { + "id": "api.templates.delinquency_75.subtitle1", + "translation": "Este es un aviso final de que no hemos recibido el pago por tu workspace Mattermost Cloud desde {{.DelinquencyDate}}" + }, + { + "id": "api.templates.delinquency_75.subject", + "translation": "Tu {{.Plan}} Mattermost será degradado en 15 días" + }, + { + "id": "api.templates.delinquency_75.downgrade_to_starter", + "translation": "Degradar a Cloud Starter" + }, + { + "id": "api.templates.delinquency_75.button", + "translation": "Actualizar pago" + }, + { + "id": "api.templates.delinquency_7.title", + "translation": "Tu pago no se completó" + }, + { + "id": "api.templates.delinquency_7.subtitle2", + "translation": "Para mantener tu plan {{.Plan}} activo, por favor contacta a tu institución financiera tan pronto como sea posible, Luego, actualiza tus detalles de pago según sea necesario." + }, + { + "id": "api.templates.delinquency_7.subtitle1", + "translation": "No pudimos procesar tu pago más reciente" + }, + { + "id": "api.templates.delinquency_7.button", + "translation": "Actualizar pago" + }, + { + "id": "api.templates.delinquency_60.title", + "translation": "Tu workspace Mattermost será degradado en 30 días" + }, + { + "id": "api.templates.delinquency_60.subtitle3", + "translation": "Actualiza tu información de pago ahora o degrada a Cloud Starter abajo." + }, + { + "id": "api.templates.delinquency_60.subtitle2", + "translation": "Degradaremos tu workspace automáticamente en 30 días si no somos capaces de procesar tu pago." + }, + { + "id": "api.templates.delinquency_60.subtitle1", + "translation": "Por favor actualiza pronto tu información de pago para procesar tus facturas pendientes." + }, + { + "id": "api.templates.delinquency_60.subject", + "translation": "Acción requerida: El workspace será degradado en 30 días" + }, + { + "id": "api.templates.delinquency_60.downgrade_to_starter", + "translation": "Degradar a Cloud Starter" + }, + { + "id": "api.templates.delinquency_60.button", + "translation": "Actualizar pago" + }, + { + "id": "api.templates.delinquency_45.title", + "translation": "Tu workspace será degradado pronto" + }, + { + "id": "api.templates.delinquency_45.subtitle3", + "translation": "Actualiza la información de tu tarjeta de crédito ahora." + }, + { + "id": "api.templates.delinquency_45.subtitle2", + "translation": "Un workspace degradado podría afectar negativamente los flujos de trabajo críticos, integraciones y otras actividades críticas de negocio realizadas en tu workspace." + }, + { + "id": "api.templates.delinquency_45.subtitle1", + "translation": "No fuimos capaces de cobrar el pago para las facturas con fecha {{.DelinquencyDate}}. Tu workspace está en riesgo de ser degradado." + }, + { + "id": "api.templates.delinquency_45.subject", + "translation": "Aviso: Tu {{.Plan}} Mattermost será degradado pronto" + }, + { + "id": "api.templates.delinquency_45.button", + "translation": "Actualizar pago" + }, + { + "id": "api.templates.delinquency_30.title", + "translation": "Tu workspace será degradado pronto" + }, + { + "id": "api.templates.delinquency_30.subtitle2", + "translation": "si no se toma ninguna medida, tu workspace será degradado y los siguientes datos serán archivados:" + }, + { + "id": "api.templates.delinquency_30.subtitle1", + "translation": "Tienes tiempo para mantener activo tu {{.Plan}} Mattermost pero necesitarás resolver los problemas con tu método de pago." + }, + { + "id": "api.templates.delinquency_30.subject", + "translation": "Actúa para mantener las característica de tu {{.Plan}} Mattermost" + }, + { + "id": "api.templates.delinquency_30.limits_documentation", + "translation": "Ver toda la documentación de límites." + }, + { + "id": "api.templates.delinquency_30.button", + "translation": "Actualizar pago" + }, + { + "id": "api.templates.delinquency_30.bullet.plugins", + "translation": "Plugins e integraciones activas" + }, + { + "id": "api.templates.delinquency_30.bullet.message_history", + "translation": "Historial de mensajes" + }, + { + "id": "api.templates.delinquency_30.bullet.files", + "translation": "Archivos" + }, + { + "id": "api.templates.delinquency_30.bullet.cards", + "translation": "Tarjetas de tus Boards" + }, + { + "id": "api.templates.delinquency_14.title", + "translation": "Pago no recibido" + }, + { + "id": "api.templates.delinquency_14.subtitle2", + "translation": "Por favor contacta a tu institución financiera para resolver cualquier problema. Luego, actualiza los detalles de pago como sean necesarios." + }, + { + "id": "api.templates.delinquency_14.subtitle1", + "translation": "No pudimos realizar el cargo a la tarjeta de crédito que tenemos registrada. Por lo cual tu workspace está en riesgo de ser degradado a Cloud Starter." + }, + { + "id": "api.templates.delinquency_14.subject", + "translation": "El pago está vencido para tu {{.Plan}} Mattermost." + }, + { + "id": "api.templates.delinquency_14.button", + "translation": "Actualizar pago" + }, + { + "id": "api.team.invite_guests_to_channels.license.error", + "translation": "Tu licencia no admite cuentas de invitado" + }, + { + "id": "api.team.invite_guests_to_channels.disabled.error", + "translation": "Las cuentas de invitado están deshabilitadas" + }, + { + "id": "api.command_marketplace.unsupported.app_error", + "translation": "El comando marketplace no es soportado por tu dispositivo." + }, + { + "id": "api.command_marketplace.name", + "translation": "marketplace" + }, + { + "id": "api.command_marketplace.desc", + "translation": "Abre el Marketplace" + }, + { + "id": "api.command_help.success", + "translation": "Mattermost es una plataforma de código abierto para comunicación segura, colaboración, y orquestación de trabajo entre herramientas y equipos.\nMattermost contiene tres herramientas clave:\n\n**Channels** - Mantente conectado con tu equipo vía 1:1 y en por mensajes grupales.\n**[Playbooks](/playbooks)** - Construye y configura procesos repetibles para lograr resultados específicos y predecibles.\n**[Tableros](/boards)** - Administra proyectos y tareas en una estructura de tablero Kanban para ayudar a que tú equipo logre los hitos clave.\n\n[Ver documentación y guías]({{.HelpLink}})" + }, + { + "id": "app.cloud.trial_plan_bot_message", + "translation": "los miembros {{.UsersNum}} del espacio de trabajo {{.WorkspaceName}} han solicitado comenzar la prueba Enterprise para acceder a: " + }, + { + "id": "api.cloud.delinquency_email.missing_email_to_trigger", + "translation": "Campos faltantes requeridos para enviar el correo electrónico de morosidad." + }, + { + "id": "model.group.name.reserved_name.app_error", + "translation": "el nombre de grupo ya existe como un nombre reservado" + }, + { + "id": "model.config.is_valid.amazons3_timeout.app_error", + "translation": "Valor de timeout inválido {{.Value}}. Debe ser un número positivo." + }, + { + "id": "ent.saml.configure.certificate_parse_error.app_error", + "translation": "SAML no pudo cargar satisfactoriamente el Identity Provider Public Certificate, por favor contacta a tu administrador del sistema." + }, + { + "id": "app.user.get_badge_count.app_error", + "translation": "No pudimos obtener el recuento de insignias para el usuario." + }, + { + "id": "app.team.clear_cache.app_error", + "translation": "Error al limpiar el caché de los miembros del equipo" + }, + { + "id": "app.post_reminder_dm", + "translation": "Hola, este es un recordatorio acerca de este mensaje de @{{.Username}}: {{.SiteURL}}/{{.TeamName}}/pl/{{.PostId}}" + }, + { + "id": "app.post.get_top_dms_for_user_since.app_error", + "translation": "No es posible obtener los DMs top para el usuario." + }, + { + "id": "app.plugin.product_mode.app_error", + "translation": "El plugin {{.Name}} no se pudo activar en el modo producto." + }, + { + "id": "app.notify_admin.send_notification_post.app_error", + "translation": "No es posible enviar la publicación de notificación." + }, + { + "id": "app.notify_admin.save.app_error", + "translation": "No es posible guardar los datos de notificación." + }, + { + "id": "app.last_accessible_file.app_error", + "translation": "Error al obtener el último archivo accesible" + }, + { + "id": "app.job.error", + "translation": "Error durante la ejecución de un job." + }, + { + "id": "app.file.cloud.get.app_error", + "translation": "No se puede obtener el archivo porque superó los límites del plan en la nube." + }, + { + "id": "app.cloud.upgrade_plan_bot_message_single", + "translation": "el miembro {{.UsersNum}} del workspace {{.WorkspaceName}} ha solicitado una actualización del workspace para: " + }, + { + "id": "app.cloud.upgrade_plan_bot_message", + "translation": "los miembros {{.UsersNum}} del workspace {{.WorkspaceName}} han solicitado una actualización del workspace para: " + }, + { + "id": "app.cloud.trial_plan_bot_message_single", + "translation": "el miembro {{.UsersNum}} del workspace {{.WorkspaceName}} ha solicitado comenzar la prueba Enterprise para acceder a: " } ] diff --git a/i18n/fa.json b/i18n/fa.json index b71235d64b..88186a1d34 100644 --- a/i18n/fa.json +++ b/i18n/fa.json @@ -5159,14 +5159,6 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "کانال های دعوت باید بخشی از تیم دعوت باشند." }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "مجوز شما از حساب های مهمان پشتیبانی نمی کند" - }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "حساب های مهمان غیرفعال شده است" - }, { "id": "api.team.invalidate_all_email_invites.app_error", "translation": "خطا در عدم اعتبار دعوت نامه های ایمیل." diff --git a/i18n/fr.json b/i18n/fr.json index 043cda6743..c94978c9da 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -5583,14 +5583,6 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "Les canaux présents dans l'invitation doivent faire partie de la même équipe pour laquelle vous êtes invité." }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "Votre licence ne prend pas en charge les comptes invités." - }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "Les comptes invités sont désactivés" - }, { "id": "api.team.invalidate_all_email_invites.app_error", "translation": "Une erreur s'est produite lors de l'invalidation des e-mails d'invitation." diff --git a/i18n/hu.json b/i18n/hu.json index b10693e9f8..930d7fd5d3 100644 --- a/i18n/hu.json +++ b/i18n/hu.json @@ -3125,7 +3125,7 @@ }, { "id": "oauth.gitlab.tos.error", - "translation": "A GitLab Általános Szerződési Feltételei frissültek. Kérjük, látogassa meg a gitlab.com webhelyet, hogy elfogadja őket, majd próbáljon meg újra bejelentkezni a Mattermost-ba." + "translation": "A GitLab Általános Szerződési Feltételei frissültek. Kérjük, látogassa meg a {{.URL}} webhelyet, hogy elfogadja őket, majd próbáljon meg újra bejelentkezni a Mattermost-ba." }, { "id": "model.websocket_client.connect_fail.app_error", @@ -5469,15 +5469,15 @@ }, { "id": "api.templates.payment_failed.title", - "translation": "Sikertelen fizetés" + "translation": "A fizetési művelet nem sikerült" }, { "id": "api.templates.payment_failed.subject", - "translation": "Művelet szükséges: Sikertelen fizetés a Mattermost Cloudra" + "translation": "Művelet szükséges: Sikertelen fizetés a Mattermost {{.Plan}} csomagra" }, { "id": "api.templates.payment_failed.info3", - "translation": "A Mattermost Cloud zavartalan előfizetésének biztosítása érdekében kérjük, vegye fel a kapcsolatot a pénzintézetével a probléma megoldása érdekében, vagy frissítse fizetési adatait. A fizetési adatok frissítése után a Mattermost megpróbálja rendezni a fennmaradó egyenleget." + "translation": "A Mattermost {{.Plan}} zavartalan elérésének biztosítása érdekében kérjük, vegye fel a kapcsolatot a pénzintézetével a probléma megoldása érdekében, vagy frissítse fizetési adatait. A fizetési adatok frissítése után a Mattermost megpróbálja rendezni a fennmaradó egyenleget." }, { "id": "api.templates.payment_failed.info2", @@ -5795,14 +5795,6 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "A meghívás csatornáinak a meghívó csapat részének kell lenniük." }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "Az Ön licence nem támogatja a vendég fiókokat" - }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "A vendég fiókok le vannak tiltva" - }, { "id": "api.team.invalidate_all_email_invites.app_error", "translation": "Hiba történt az e-mail meghívók érvénytelenítésekor." @@ -9354,5 +9346,45 @@ { "id": "api.templates.delinquency_30.subject", "translation": "Lépjen most, hogy megtarthassa a Mattermost {{.Plan}} funkcióit" + }, + { + "id": "app.notify_admin.send_notification_post.app_error", + "translation": "Nem sikerült elküldeni az értesítési bejegyzést." + }, + { + "id": "app.notify_admin.save.app_error", + "translation": "Nem sikerült elmenteni az értesítési beállításokat." + }, + { + "id": "app.job.error", + "translation": "Hiba a művelet végrehajtása közben." + }, + { + "id": "app.cloud.get_current_plan_name.app_error", + "translation": "Nem sikerült lekérni a jelenlegi előfizetési csomag nevét" + }, + { + "id": "api.templates.delinquency_30.button", + "translation": "Fizetés frissítése" + }, + { + "id": "api.templates.delinquency_30.bullet.plugins", + "translation": "Aktív bővítmények és integrációk" + }, + { + "id": "api.cloud.delinquency_email.missing_email_to_trigger", + "translation": "Hiányzó kötelező mezők a késedelmes e-mail küldéséhez." + }, + { + "id": "app.file.cloud.get.app_error", + "translation": "Nem sikerült lekérni a fájlt, mivel az meghaladta a felhőcsomag határértékét." + }, + { + "id": "app.user.get_badge_count.app_error", + "translation": "Nem sikerült lekérni a felhasználó jelvény számlálóját." + }, + { + "id": "model.group.name.reserved_name.app_error", + "translation": "csoport név létezik mint lefoglalt név" } ] diff --git a/i18n/it.json b/i18n/it.json index 2b4114bad9..f9c6ae0911 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -5211,10 +5211,6 @@ "id": "api.team.add_members.user_denied", "translation": "Questa squadra è gestita dai gruppi. Questo utente non appartiene a un gruppo sincronizzato con questa squadra." }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "Gli account ospite sono disattivati" - }, { "id": "api.team.remove_member.group_constrained.app_error", "translation": "Impossibile rimuovere un utente da una squadra con vincoli di gruppo." @@ -5643,10 +5639,6 @@ "id": "api.team.invalidate_all_email_invites.app_error", "translation": "Errore invalidando gli inviti email." }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "La tua licenza non supporta gli account ospite" - }, { "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "I canali dell'invito devono far parte della squadra dell'invito." diff --git a/i18n/ja.json b/i18n/ja.json index 5918ca411b..2b9e6beead 100644 --- a/i18n/ja.json +++ b/i18n/ja.json @@ -5579,14 +5579,6 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "招待チャンネルは招待チーム内のチャンネルでなくてはなりません。" }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "現在のライセンスではゲストアカウントはサポートされていません" - }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "ゲストアカウントは無効化されています" - }, { "id": "api.team.invalidate_all_email_invites.app_error", "translation": "電子メール招待の無効化でエラーが発生しました。" diff --git a/i18n/ko.json b/i18n/ko.json index 6efc6c5e61..7d4b36ccb3 100644 --- a/i18n/ko.json +++ b/i18n/ko.json @@ -619,7 +619,7 @@ }, { "id": "api.command_help.desc", - "translation": "Mattermost 도움말 페이지 열기" + "translation": "Mattermost 도움말 보기" }, { "id": "api.command_help.name", @@ -5575,14 +5575,6 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "초대 채널은 초대한 팀의 채널이어야 합니다." }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "게스트 계정을 지원하지 않는 라이선스입니다" - }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "게스트 계정이 비활성화되어 있습니다" - }, { "id": "api.team.invalidate_all_email_invites.app_error", "translation": "전자우편 초대에서 유효하지 않는 오류가 발생했습니다." @@ -7934,5 +7926,41 @@ { "id": "api.cloud.teams_limit_reached.create", "translation": "팀 제한에 도달했기 때문에 팀을 만들 수 없습니다" + }, + { + "id": "api.command_marketplace.desc", + "translation": "마켓플레이스 열기" + }, + { + "id": "api.command_help.success", + "translation": "Mattermost는 도구와 팀을 넘나드는 안전한 소통, 협업, 작업 조율을 위한 오픈소스 플랫폼입니다.\nMattermost는 세가지 핵심 도구를 갖고 있습니다:\n\n**채널** - 1:1 및 그룹 채팅을 통해 팀과의 소통을 유지하세요.\n**[플레이북](/playbooks)** - 반복가능한 작업들을 구성하여 구체적이고 예측 가능한 결과를 만드세요.\n**[보드](/boards)** - 팀의 핵심 마일스톤에 도달하기 위해 칸반 보드에서 작업을 만들고 프로젝트를 관리하세요.\n\n[문서 및 가이드 살펴보기]({{.HelpLink}})" + }, + { + "id": "api.cloud.delinquency_email.missing_email_to_trigger", + "translation": "연체 이메일을 보내기 위한 필수 항목이 누락되었습니다." + }, + { + "id": "api.error_set_first_admin_complete_setup", + "translation": "스토어에서 첫 번째 관리자의 설정을 저장하는 동안 오류가 발생했습니다." + }, + { + "id": "api.error_get_first_admin_visit_marketplace_status", + "translation": "스토어에서 첫 번째 관리자의 마켓플레이스 방문 상태를 검색하는 동안 오류가 발생했습니다." + }, + { + "id": "api.error_get_first_admin_complete_setup", + "translation": "스토어에서 첫 번째 관리자 설정을 검색하는 동안 오류가 발생했습니다." + }, + { + "id": "api.command_remote.invite_summary", + "translation": "AES-256으로 암호화된 초대를 비밀번호와 함께 외부 Mattermost 시스템 관리자에게 보냅니다. 초대를 받기 위해 `{{.Command}}` 슬래시 명령어를 사용합니다.\n\n```\n{{.Invitation}}\n```\n\n**보안 연결이 다음을 통해 Mattermost에 액세스할 수 있는지 확인하십시오** {{.SiteURL}}" + }, + { + "id": "api.command_marketplace.unsupported.app_error", + "translation": "마켓플레이스 명령어는 현재 기기에서 지원하지 않습니다." + }, + { + "id": "api.command_marketplace.name", + "translation": "마켓플레이스" } ] diff --git a/i18n/nl.json b/i18n/nl.json index 6f883eaa1a..c23bb81c98 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -1777,7 +1777,7 @@ }, { "id": "api.templates.email_change_verify_body.title", - "translation": "Je hebt je e-mailadres bijgewerkt" + "translation": "Je hebt je e-mailadres succesvol bijgewerkt" }, { "id": "api.templates.email_change_verify_subject", @@ -4325,7 +4325,7 @@ }, { "id": "model.post.is_valid.create_at.app_error", - "translation": "Aangemaakt op moet een geldige tijd bevatten" + "translation": "Aangemaakt op moet een geldige tijd bevatten." }, { "id": "model.post.is_valid.file_ids.app_error", @@ -4781,11 +4781,11 @@ }, { "id": "web.error.unsupported_browser.min_browser_version.edge", - "translation": "Versie 44+" + "translation": "Versie 95+" }, { "id": "web.error.unsupported_browser.min_browser_version.chrome", - "translation": "Versie 100+" + "translation": "Versie 106+" }, { "id": "web.error.unsupported_browser.learn_more", @@ -5607,14 +5607,6 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "De kanalen van de uitgenodigde moet een kanaal dat deel uitmaakt van het team." }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "Je licentie ondersteunt geen gastgebruikers" - }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "Gast accounts zijn uitgeschakeld" - }, { "id": "api.team.invalidate_all_email_invites.app_error", "translation": "Fout bij afkeuren e-mailuitnodigingen." @@ -9537,5 +9529,33 @@ { "id": "api.templates.delinquency_90.title", "translation": "Jouw Mattermost-werkruimte werd gedowngraded" + }, + { + "id": "app.job.error", + "translation": "Fout tijdens het uitvoeren van de opdracht." + }, + { + "id": "model.group.name.reserved_name.app_error", + "translation": "groepsnaam bestaat al als een gereserveerde naam" + }, + { + "id": "app.plugin.product_mode.app_error", + "translation": "Plugin {{.Name}} kan niet worden ingeschakeld in de productmodus." + }, + { + "id": "app.last_accessible_file.app_error", + "translation": "Fout bij het ophalen van het laatst toegankelijke bestand" + }, + { + "id": "app.file.cloud.get.app_error", + "translation": "Kan het bestand niet ophalen omdat dit bestand over de limiet van het cloud plan is." + }, + { + "id": "api.team.invite_guests_to_channels.license.error", + "translation": "Jouw licentie ondersteunt geen gastaccounts" + }, + { + "id": "api.team.invite_guests_to_channels.disabled.error", + "translation": "Gastaccounts zijn uitgeschakeld" } ] diff --git a/i18n/pl.json b/i18n/pl.json index de2e7d8a68..f62b244a3d 100644 --- a/i18n/pl.json +++ b/i18n/pl.json @@ -4761,11 +4761,11 @@ }, { "id": "web.error.unsupported_browser.min_browser_version.edge", - "translation": "Wersja 44+" + "translation": "Wersja 95+" }, { "id": "web.error.unsupported_browser.min_browser_version.chrome", - "translation": "Wersja 100+" + "translation": "Wersja 106+" }, { "id": "web.error.unsupported_browser.learn_more", @@ -5583,14 +5583,6 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "Kanały do których chcesz zaprosić, muszą być częścią zespołu do którego chcesz zaprosić." }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "Twoja licencja nie obsługuje kont gości" - }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "Konta gości są wyłączone" - }, { "id": "api.team.invalidate_all_email_invites.app_error", "translation": "Błąd unieważniania zaproszenia E-Mail." @@ -9542,5 +9534,29 @@ { "id": "app.job.error", "translation": "Błąd podczas wykonywania zadania." + }, + { + "id": "app.last_accessible_file.app_error", + "translation": "Błąd pobierania ostatniego dostępnego pliku" + }, + { + "id": "app.file.cloud.get.app_error", + "translation": "Nie można pobrać pliku, ponieważ przekroczony został limit planu chmury." + }, + { + "id": "model.group.name.reserved_name.app_error", + "translation": "nazwa grupy już istnieje jako nazwa zastrzeżona" + }, + { + "id": "app.plugin.product_mode.app_error", + "translation": "Wtyczka {{.Name}} nie może być włączona w trybie produktu." + }, + { + "id": "api.team.invite_guests_to_channels.license.error", + "translation": "Twoja licencja nie wspiera kont gości" + }, + { + "id": "api.team.invite_guests_to_channels.disabled.error", + "translation": "Konta gości są wyłączone" } ] diff --git a/i18n/pt-BR.json b/i18n/pt-BR.json index 94b78b3e58..2873fe7a1d 100644 --- a/i18n/pt-BR.json +++ b/i18n/pt-BR.json @@ -621,7 +621,7 @@ }, { "id": "api.command_help.desc", - "translation": "Abrir a página de ajuda do Mattermost" + "translation": "Exibir descrição de ajuda do Mattermost" }, { "id": "api.command_help.name", @@ -1173,7 +1173,7 @@ }, { "id": "api.marshal_error", - "translation": "marshal error" + "translation": "Falha para marshal." }, { "id": "api.oauth.allow_oauth.redirect_callback.app_error", @@ -1789,7 +1789,7 @@ }, { "id": "api.templates.email_footer", - "translation": "Para alterar suas preferencias de notificação, faça login no site da sua equipe e vá para Configurações de Conta > Notificações." + "translation": "Para alterar suas preferencias de notificação, faça login no site da sua equipe e vá para Configurações > Notificações." }, { "id": "api.templates.email_info1", @@ -1813,7 +1813,7 @@ }, { "id": "api.templates.invite_body.button", - "translation": "Entrar na Equipe" + "translation": "Entrar agora" }, { "id": "api.templates.invite_body.title", @@ -1857,7 +1857,7 @@ }, { "id": "api.templates.post_body.button", - "translation": "Ir Para a Publicação" + "translation": "Responder no Mattermost" }, { "id": "api.templates.reset_body.button", @@ -1865,7 +1865,7 @@ }, { "id": "api.templates.reset_body.title", - "translation": "Você solicitou uma redefinição de senha" + "translation": "Redefina sua senha" }, { "id": "api.templates.reset_subject", @@ -4333,11 +4333,11 @@ }, { "id": "model.post.is_valid.file_ids.app_error", - "translation": "Ids do arquivo inválido. Observe que os uploads estão limitados a cinco arquivos no máximo. Por favor, use publicações adicionais para mais arquivos." + "translation": "Ids do arquivo inválido. Observe que os uploads estão limitados a 10 arquivos no máximo. Por favor, use publicações adicionais para mais arquivos." }, { "id": "model.post.is_valid.filenames.app_error", - "translation": "Nomes de arquivo inválidos" + "translation": "Nomes de arquivo inválidos." }, { "id": "model.post.is_valid.hashtags.app_error", @@ -4573,7 +4573,7 @@ }, { "id": "oauth.gitlab.tos.error", - "translation": "Os Termos de Serviço do GitLab foram atualizados. Por favor vá até gitlab.com para aceitar os termos e depois tente se logar novamente no Mattermost." + "translation": "Os Termos de Serviço do GitLab foram atualizados. Por favor vá até {{.URL}} para aceitar os termos e depois tente se logar novamente no Mattermost." }, { "id": "plugin.api.update_user_status.bad_status", @@ -4745,19 +4745,19 @@ }, { "id": "web.error.unsupported_browser.min_os_version.windows", - "translation": "Windows 7+" + "translation": "Windows 8.1+" }, { "id": "web.error.unsupported_browser.min_os_version.mac", - "translation": "macOS 10.9+" + "translation": "macOS 10.14+" }, { "id": "web.error.unsupported_browser.min_browser_version.safari", - "translation": "Versão 12+" + "translation": "Versão 14.1+" }, { "id": "web.error.unsupported_browser.min_browser_version.firefox", - "translation": "Versão 60+" + "translation": "Versão 91+" }, { "id": "web.error.unsupported_browser.min_browser_version.edge", @@ -4765,7 +4765,7 @@ }, { "id": "web.error.unsupported_browser.min_browser_version.chrome", - "translation": "Versão 61+" + "translation": "Version 100+" }, { "id": "web.error.unsupported_browser.learn_more", @@ -5587,14 +5587,6 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "Os canais do convite devem ser parte da equipe do convite." }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "Sua licença não suporta contas convidado" - }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "Contas convidado estão desativadas" - }, { "id": "api.team.invalidate_all_email_invites.app_error", "translation": "Erro ao invalidar convites por email." @@ -5945,7 +5937,7 @@ }, { "id": "api.push_notifications.session.expired", - "translation": "Sessão expirada: faça login para continuar recebendo notificações. As sessões para {{.siteName}} estão configuradas pelo Administrador do Sistema para expirar a cada {{.daysCount}} dia(s)." + "translation": "Sessão expirada: faça login para continuar recebendo notificações. As sessões para {{.siteName}} estão configuradas pelo Administrador do Sistema para expirar a cada {{.hoursCount}} hour(s)." }, { "id": "api.post.error_get_post_id.pending", @@ -6189,7 +6181,7 @@ }, { "id": "app.preference.permanent_delete_by_user.app_error", - "translation": "Encontramos um erro enquanto excluía as preferências." + "translation": "Encontramos um erro enquanto excluiamos as preferências." }, { "id": "app.preference.get_category.app_error", @@ -6897,7 +6889,7 @@ }, { "id": "model.upload_session.is_valid.user_id.app_error", - "translation": "Valor inválido para user_id." + "translation": "Valor inválido para UserId" }, { "id": "model.upload_session.is_valid.type.app_error", @@ -6905,15 +6897,15 @@ }, { "id": "model.upload_session.is_valid.path.app_error", - "translation": "Valor inválido para path." + "translation": "Valor inválido para path" }, { "id": "model.upload_session.is_valid.id.app_error", - "translation": "Valor inválido para id." + "translation": "Valor inválido para id" }, { "id": "model.upload_session.is_valid.create_at.app_error", - "translation": "Valor inválido para create_at." + "translation": "Valor inválido para Create_At" }, { "id": "app.upload.upload_data.large_image.app_error", @@ -7601,7 +7593,7 @@ }, { "id": "api.templates.copyright", - "translation": "© 2020 Mattermost, Inc. 855 El Camino Real, 13A-168, Palo Alto, CA, 94301" + "translation": "© 2021 Mattermost, Inc. 530 Lytton Avenue, Second floor, Palo Alto, CA, 94301" }, { "id": "api.roles.patch_roles.not_allowed_permission.error", @@ -7645,15 +7637,15 @@ }, { "id": "api.templates.payment_failed.title", - "translation": "Pagamento Falhou" + "translation": "O Pagamento não foi bem sucedido" }, { "id": "api.templates.payment_failed.subject", - "translation": "Ação necessária: Falha no pagamento para Mattermost Cloud" + "translation": "Ação necessária: Falha no pagamento para Mattermost {{.Plan}}" }, { "id": "api.templates.payment_failed.info3", - "translation": "Para garantir a assinatura ininterrupta do Mattermost Cloud, entre em contato com sua instituição financeira para corrigir o problema subjacente ou atualize suas informações de pagamento. Assim que as informações de pagamento forem atualizadas, a Mattermost tentará liquidar o saldo pendente." + "translation": "Para garantir a acesso ininterrupto do Mattermost {{.Plan}}, entre em contato com sua instituição financeira para corrigir o problema subjacente ou atualize suas informações de pagamento. Assim que as informações de pagamento forem atualizadas, a Mattermost tentará liquidar o saldo pendente." }, { "id": "api.templates.payment_failed.info2", @@ -8069,7 +8061,7 @@ }, { "id": "api.user.get_authorization_code.endpoint.app_error", - "translation": "Erro ao recuperar o endpoint da Descoberta de Documento." + "translation": "Erro ao recuperar o endpoint do Documento de Descoberta." }, { "id": "api.templates.welcome_body.subTitle2", @@ -8117,11 +8109,11 @@ }, { "id": "api.templates.questions_footer.info", - "translation": "Envie-nos um email a qualquer momento em " + "translation": "Precisa de ajuda ou tem dúvidas? Envie-nos um e-mail em " }, { "id": "api.templates.email_footer_v2", - "translation": "© 2020 Mattermost, Inc. 855 El Camino Real, 13A-168, Palo Alto, CA, 94301" + "translation": "© 2022 Mattermost, Inc. 530 Lytton Avenue, Second floor, Palo Alto, CA, 94301" }, { "id": "api.post.search_files.invalid_body.app_error", @@ -8333,7 +8325,7 @@ }, { "id": "api.context.remote_id_invalid.app_error", - "translation": "Não foi possível encontrar o id do cluster remoto {{.RemoteId}}." + "translation": "Não foi possível encontrar o ID de uma conexão segura {{.RemoteId}}." }, { "id": "api.context.json_encoding.app_error", @@ -8357,15 +8349,15 @@ }, { "id": "api.command_share.remote_uninvited", - "translation": "Remoto `{{.RemoteId}}` sem convite." + "translation": "Conexão segura `{{.RemoteId}}` sem convite." }, { "id": "api.command_share.remote_id_invalid.error", - "translation": "O id do cluster remoto é inválido: {{.Error}}" + "translation": "O ID de conexão segura é inválido: {{.Error}}" }, { "id": "api.command_share.remote_id.help", - "translation": "Id de uma instância remota existente. Veja o comando `remote` para adicionar uma instância remota." + "translation": "ID de uma conexão segura existente. Veja o comando `remote` para adicionar uma conexão segura." }, { "id": "api.command_share.permission_required", @@ -8437,7 +8429,7 @@ }, { "id": "api.templates.cloud_welcome_email.signin_sub_info2", - "translation": "para a melhor experiência em PC, Mac, iOS e Android." + "translation": "para a melhor experiência em Windows, Linux, Mac, iOS e Android." }, { "id": "api.templates.cloud_welcome_email.signin_sub_info", @@ -8469,7 +8461,7 @@ }, { "id": "api.templates.cloud_welcome_email.add_apps_sub_info", - "translation": "Otimize seu trabalho com ferramentas como Github, Google Agenda e Chrome. Explore todas as integrações que temos" + "translation": "Otimize seu trabalho com ferramentas como GitHub, Jira e Zoom. Explore todas as integrações que temos em nosso" }, { "id": "api.templates.cloud_welcome_email.add_apps_info", @@ -8481,11 +8473,11 @@ }, { "id": "api.remote_cluster.update_not_unique.app_error", - "translation": "Já existe um cluster remoto com a mesmo url." + "translation": "Já existe uma conexão segura com a mesmo url." }, { "id": "api.remote_cluster.update.app_error", - "translation": "Encontramos um erro ao atualizar o cluster remoto." + "translation": "Encontramos um erro ao atualizar a conexão segura." }, { "id": "api.remote_cluster.service_not_enabled.app_error", @@ -8493,11 +8485,11 @@ }, { "id": "api.remote_cluster.save_not_unique.app_error", - "translation": "O cluster remoto já foi adicionado." + "translation": "A conexão segura já foi adicionada." }, { "id": "api.remote_cluster.save.app_error", - "translation": "Encontramos um erro ao salvar o cluster remoto." + "translation": "Encontramos um erro ao salvar a conexão segura." }, { "id": "api.remote_cluster.invalid_topic.app_error", @@ -8509,11 +8501,11 @@ }, { "id": "api.remote_cluster.get.app_error", - "translation": "Encontramos um erro ao recuperar um cluster remoto." + "translation": "Encontramos um erro ao recuperar uma conexão segura." }, { "id": "api.remote_cluster.delete.app_error", - "translation": "Encontramos um erro ao excluir o cluster remoto." + "translation": "Encontramos um erro ao excluir uma conexão segura." }, { "id": "api.job.unable_to_create_job.incorrect_job_type", @@ -8533,11 +8525,11 @@ }, { "id": "api.context.remote_id_missing.app_error", - "translation": "Falta a id do cluster remoto." + "translation": "Falta a ID de conexão sergura." }, { "id": "api.context.remote_id_mismatch.app_error", - "translation": "Incompatibilidade de id do cluster remoto." + "translation": "Incompatibilidade de ID da conexão segura." }, { "id": "api.context.invitation_expired.error", @@ -8549,11 +8541,11 @@ }, { "id": "api.command_share.uninvite_remote_id.help", - "translation": "Id da instância remota para desconvidar." + "translation": "ID de uma conexão segura para desconvidar." }, { "id": "api.command_share.uninvite_remote.help", - "translation": "Cancela o convite de uma instância remota deste canal compartilhado" + "translation": "Cancela o convite de uma conexão segura deste canal compartilhado" }, { "id": "api.command_share.shared_channel_unavailable", @@ -8569,11 +8561,11 @@ }, { "id": "api.command_share.remote_not_valid", - "translation": "Deve especificar um cluster remoto válido para cancelar o convite" + "translation": "Deve especificar uma ID de conexão segura válida para cancelar o convite" }, { "id": "api.command_share.remote_already_invited", - "translation": "O cluster remoto já foi convidado." + "translation": "A conexão segura já foi convidada." }, { "id": "api.command_share.not_shared_channel_unshare", @@ -8581,15 +8573,15 @@ }, { "id": "api.command_share.no_remote_invited", - "translation": "Nenhum remoto foi convidado para este canal compartilhado." + "translation": "Nenhuma conexão remota foi convidada a este canal." }, { "id": "api.command_share.name", - "translation": "compartilhar" + "translation": "compartilhar-canal" }, { "id": "api.command_share.must_specify_valid_remote", - "translation": "Deve especificar um ID de conjunto remoto válido para convidar." + "translation": "Deve especificar um ID de conjunto válido seguro para convidar." }, { "id": "api.command_share.missing_action", @@ -8650,5 +8642,13 @@ { "id": "Boards", "translation": "Quadros" + }, + { + "id": "Playbooks", + "translation": "Playbooks" + }, + { + "id": "api.cloud.delinquency_email.missing_email_to_trigger", + "translation": "Campos faltando para envio de email." } ] diff --git a/i18n/ro.json b/i18n/ro.json index c51b4666e0..6c9fe29085 100644 --- a/i18n/ro.json +++ b/i18n/ro.json @@ -5587,14 +5587,6 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "Canalele invitației trebuie să facă parte din echipa invitației." }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "Licența dvs. nu acceptă conturile de invitați" - }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "Conturile oaspeților sunt dezactivate" - }, { "id": "api.team.invalidate_all_email_invites.app_error", "translation": "Eroare la invalidarea invitațiilor de e-mail." diff --git a/i18n/ru.json b/i18n/ru.json index 3d371f6a36..0b4fd7d438 100644 --- a/i18n/ru.json +++ b/i18n/ru.json @@ -5235,10 +5235,6 @@ "id": "api.team.demote_user_to_guest.license.error", "translation": "Ваша лицензия не поддерживает гостевые учётные записи" }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "Ваша лицензия не поддерживает гостевые учётные записи" - }, { "id": "api.team.remove_member.group_constrained.app_error", "translation": "Невозможно удалить пользователя из управляемой группами команды." @@ -5675,10 +5671,6 @@ "id": "api.team.invalidate_all_email_invites.app_error", "translation": "Ошибка аннулирования e-mail приглашений." }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "Гостевые учётные записи отключены" - }, { "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "Каналы в приглашении должны быть частью команды в приглашении." diff --git a/i18n/sv.json b/i18n/sv.json index 2374ce62ad..290ff4f7e5 100644 --- a/i18n/sv.json +++ b/i18n/sv.json @@ -5275,14 +5275,6 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "De kanaler som ingår i inbjudan måste tillhöra de team som inbjudan gäller för." }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "Din licens tillåter inte gäståtkomst" - }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "Gäståtkomst har inaktiverats" - }, { "id": "api.team.invalidate_all_email_invites.app_error", "translation": "Fel vid ogiltiggörande av e-postinbjudan." @@ -9541,5 +9533,25 @@ { "id": "app.job.error", "translation": "Fel under utförandet av jobbet." + }, + { + "id": "model.group.name.reserved_name.app_error", + "translation": "gruppnamnet finns redan som ett reserverat namn" + }, + { + "id": "app.last_accessible_file.app_error", + "translation": "Fel vid hämtning av den senast tillgängliga filen" + }, + { + "id": "app.file.cloud.get.app_error", + "translation": "Ditt abonnemang begränsar möjligheten att hämta filen." + }, + { + "id": "api.team.invite_guests_to_channels.license.error", + "translation": "Din licens tillåter inte gäståtkomst" + }, + { + "id": "api.team.invite_guests_to_channels.disabled.error", + "translation": "Gäståtkomst har inaktiverats" } ] diff --git a/i18n/tr.json b/i18n/tr.json index 563015338b..cbe04ebdb2 100644 --- a/i18n/tr.json +++ b/i18n/tr.json @@ -2725,7 +2725,7 @@ }, { "id": "app.import.validate_team_import_data.name_reserved.error", - "translation": "Takım adında ayrılmış bir sözcük var." + "translation": "Takım adında sistem kullanımına ayrılmış bir sözcük var." }, { "id": "app.import.validate_team_import_data.scheme_invalid.error", @@ -5577,7 +5577,7 @@ }, { "id": "api.ldap_groups.existing_reserved_name_error", - "translation": "aynı adlı bir grup zaten ayrılmış bir ad olarak var" + "translation": "aynı adlı bir grup zaten sistem kullanımına ayrılmış bir ad olarak var" }, { "id": "api.ldap_groups.existing_user_name_error", @@ -5655,14 +5655,6 @@ "id": "api.team.invalidate_all_email_invites.app_error", "translation": "E-posta çağrıları geçersiz kılınırken sorun çıktı." }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "Konuk hesapları kullanılmıyor" - }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "Lisansınızda konuk hesaplarını kullanma özelliği yok" - }, { "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "Çağrılan kanallar çağrı yapılan takımın bir parçası olmalıdır." @@ -5849,11 +5841,11 @@ }, { "id": "web.error.unsupported_browser.min_browser_version.chrome", - "translation": "Sürüm 100+" + "translation": "Sürüm 106+" }, { "id": "web.error.unsupported_browser.min_browser_version.edge", - "translation": "Sürüm 44+" + "translation": "Sürüm 95+" }, { "id": "web.error.unsupported_browser.min_browser_version.firefox", @@ -9537,5 +9529,33 @@ { "id": "app.user.get_badge_count.app_error", "translation": "Kullanıcının nişan sayısını alamadık." + }, + { + "id": "app.job.error", + "translation": "Görev yürütülürken sorun çıktı." + }, + { + "id": "app.last_accessible_file.app_error", + "translation": "Erişilebilen son dosya alınırken sorun çıktı" + }, + { + "id": "app.file.cloud.get.app_error", + "translation": "Cloud tarifesinin sınırlarını aştığından dosya alınamadı." + }, + { + "id": "model.group.name.reserved_name.app_error", + "translation": "aynı adlı bir grup zaten sistem kullanımına ayrılmış bir ad olarak var" + }, + { + "id": "app.plugin.product_mode.app_error", + "translation": "{{.Name}} uygulama eki ürün kipinde etkinleştirilemez." + }, + { + "id": "api.team.invite_guests_to_channels.license.error", + "translation": "Lisansınız konuk hesaplarının kullanılmasını desteklemiyor" + }, + { + "id": "api.team.invite_guests_to_channels.disabled.error", + "translation": "Konuk hesapları devre dışı bırakılmış" } ] diff --git a/i18n/uk.json b/i18n/uk.json index 21f427b84f..c05c7895b3 100644 --- a/i18n/uk.json +++ b/i18n/uk.json @@ -5583,14 +5583,6 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "The channels of the invite must be part of the team of the invite." }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "Редакція не підтримує Elasticsearch. " - }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "Guest accounts are disabled" - }, { "id": "api.team.invalidate_all_email_invites.app_error", "translation": "Error invalidating email invites." @@ -6858,5 +6850,17 @@ { "id": "api.admin.add_certificate.parseform.app_error", "translation": "Помилка синтаксісу багато-форматного запиту" + }, + { + "id": "api.channel.patch_channel_moderations.cache_invalidation.error", + "translation": "Помилка валідації кешу" + }, + { + "id": "api.back_to_app", + "translation": "Повернутися до {{.SiteName}}" + }, + { + "id": "Channels", + "translation": "Канали" } ] diff --git a/i18n/zh-CN.json b/i18n/zh-CN.json index 4dba1375ff..cecf6c9252 100644 --- a/i18n/zh-CN.json +++ b/i18n/zh-CN.json @@ -5611,10 +5611,6 @@ "id": "api.team.invalidate_all_email_invites.app_error", "translation": "作废邮件邀请错误。" }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "您的许可证不支持访客帐号" - }, { "id": "api.team.remove_member.group_constrained.app_error", "translation": "无法从组受限团队移除用户。" @@ -5627,10 +5623,6 @@ "id": "api.team.search_teams.pagination_not_implemented.public_team_search", "translation": "仅搜索公共团队不支持分页。" }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "访客帐号已停用" - }, { "id": "api.templates.remove_expired_license.subject", "translation": "Mattermost 企业许可证已停用。" diff --git a/i18n/zh-TW.json b/i18n/zh-TW.json index a24b40c159..11b8fcb9ef 100644 --- a/i18n/zh-TW.json +++ b/i18n/zh-TW.json @@ -421,7 +421,7 @@ }, { "id": "api.command_channel_header.update_channel.app_error", - "translation": "更新當前頻道時錯誤。" + "translation": "更新當前頻道名稱時錯誤。" }, { "id": "api.command_channel_purpose.channel.app_error", @@ -453,7 +453,7 @@ }, { "id": "api.command_channel_purpose.update_channel.app_error", - "translation": "更新當前頻道時錯誤。" + "translation": "更新當前頻道用途時錯誤。" }, { "id": "api.command_channel_remove.channel.app_error", @@ -619,7 +619,7 @@ }, { "id": "api.command_help.desc", - "translation": "開啟 Mattermost 說明頁面" + "translation": "顯示 Mattermost 說明頁面" }, { "id": "api.command_help.name", @@ -5579,14 +5579,6 @@ "id": "api.team.invite_guests.channel_in_invalid_team.app_error", "translation": "邀請的頻道必須屬於邀請的團隊" }, - { - "id": "api.team.invate_guests_to_channels.license.error", - "translation": "授權不支援訪客帳號" - }, - { - "id": "api.team.invate_guests_to_channels.disabled.error", - "translation": "訪客帳號已停用" - }, { "id": "api.team.invalidate_all_email_invites.app_error", "translation": "使電子郵件邀請無效時失敗。" @@ -7302,5 +7294,57 @@ { "id": "api.cloud.teams_limit_reached.create", "translation": "無法建立團隊,已達到團隊數量上限" + }, + { + "id": "api.command_custom_status.success", + "translation": "你的狀態已設為 “{{.EmojiName}} {{.StatusMessage}}”。您可以從頻道側邊欄中的狀態彈出視窗更改您的狀態。" + }, + { + "id": "api.command_custom_status.name", + "translation": "狀態" + }, + { + "id": "api.command_custom_status.hint", + "translation": "[:emoji_name:] [status_message] 或清除" + }, + { + "id": "api.command_custom_status.desc", + "translation": "設定或清除您的狀態" + }, + { + "id": "api.command_custom_status.clear.success", + "translation": "狀態已清除。" + }, + { + "id": "api.command_custom_status.clear.app_error", + "translation": "清除狀態錯誤。" + }, + { + "id": "api.command_custom_status.app_error", + "translation": "設定狀態錯誤。" + }, + { + "id": "api.command_channel_purpose.update_channel.max_length", + "translation": "輸入的文本超出字數限制。頻道用途不得超過 {{.MaxLength}} 個字數。" + }, + { + "id": "api.command_channel_header.update_channel.max_length", + "translation": "輸入的文本超出字數限制。頻道標題不得超過 {{.MaxLength}} 個字數。" + }, + { + "id": "api.cloud.delinquency_email.missing_email_to_trigger", + "translation": "缺少發送拖欠電子郵件的必要欄位。" + }, + { + "id": "api.cloud.cws_webhook_event_missing_error", + "translation": "Webhook 事件未處理。事件可能遺失或無效。" + }, + { + "id": "api.channel.create_channel.direct_channel.team_restricted_error", + "translation": "無法在這些使用者間創建直連頻道因為他們不屬於同一個團隊。" + }, + { + "id": "api.admin.saml.failure_reset_authdata_to_email.app_error", + "translation": "無法寄出重設AuthData欄位失敗的電子郵件。" } ] diff --git a/model/client4.go b/model/client4.go index cfe0f9244e..b569a8fa86 100644 --- a/model/client4.go +++ b/model/client4.go @@ -8004,8 +8004,8 @@ func (c *Client4) ConfirmCustomerPayment(confirmRequest *ConfirmPaymentMethodReq return BuildResponse(r), nil } -func (c *Client4) RequestCloudTrial(email *StartCloudTrialRequest) (*Subscription, *Response, error) { - payload, err := json.Marshal(email) +func (c *Client4) RequestCloudTrial(cloudTrialRequest *StartCloudTrialRequest) (*Subscription, *Response, error) { + payload, err := json.Marshal(cloudTrialRequest) if err != nil { return nil, nil, NewAppError("RequestCloudTrial", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) } diff --git a/model/cloud.go b/model/cloud.go index a67040a947..6d41e1a2a3 100644 --- a/model/cloud.go +++ b/model/cloud.go @@ -110,11 +110,12 @@ type ValidateBusinessEmailResponse struct { // CloudCustomerInfo represents editable info of a customer. type CloudCustomerInfo struct { - Name string `json:"name"` - Email string `json:"email,omitempty"` - ContactFirstName string `json:"contact_first_name,omitempty"` - ContactLastName string `json:"contact_last_name,omitempty"` - NumEmployees int `json:"num_employees"` + Name string `json:"name"` + Email string `json:"email,omitempty"` + ContactFirstName string `json:"contact_first_name,omitempty"` + ContactLastName string `json:"contact_last_name,omitempty"` + NumEmployees int `json:"num_employees"` + MonthlySubscriptionIntentWireTransfer string `json:"monthly_subscription_intent_wire_transfer"` } // Address model represents a customer's address. diff --git a/model/config.go b/model/config.go index 886b7e5820..bccc3904a1 100644 --- a/model/config.go +++ b/model/config.go @@ -2747,6 +2747,16 @@ func (s *CloudSettings) SetDefaults() { } } +type ProductSettings struct { + EnablePublicSharedBoards *bool +} + +func (s *ProductSettings) SetDefaults() { + if s.EnablePublicSharedBoards == nil { + s.EnablePublicSharedBoards = NewBool(false) + } +} + type PluginState struct { Enable bool } @@ -3136,6 +3146,7 @@ type Config struct { DataRetentionSettings DataRetentionSettings MessageExportSettings MessageExportSettings JobSettings JobSettings + ProductSettings ProductSettings PluginSettings PluginSettings DisplaySettings DisplaySettings GuestAccountsSettings GuestAccountsSettings @@ -3235,6 +3246,7 @@ func (o *Config) SetDefaults() { o.ThemeSettings.SetDefaults() o.ClusterSettings.SetDefaults() o.PluginSettings.SetDefaults(o.LogSettings) + o.ProductSettings.SetDefaults() o.AnalyticsSettings.SetDefaults() o.ComplianceSettings.SetDefaults() o.LocalizationSettings.SetDefaults() diff --git a/model/feature_flags.go b/model/feature_flags.go index 9a111d216b..87dbf6b42d 100644 --- a/model/feature_flags.go +++ b/model/feature_flags.go @@ -76,6 +76,8 @@ type FeatureFlags struct { SendWelcomePost bool PostPriority bool + + PeopleProduct bool } func (f *FeatureFlags) SetDefaults() { @@ -104,6 +106,7 @@ func (f *FeatureFlags) SetDefaults() { f.PlanUpgradeButtonText = "upgrade" f.SendWelcomePost = true f.PostPriority = false + f.PeopleProduct = false } func (f *FeatureFlags) Plugins() map[string]string { diff --git a/model/insights.go b/model/insights.go index 438ca9402c..d66d48c283 100644 --- a/model/insights.go +++ b/model/insights.go @@ -4,6 +4,7 @@ package model import ( + "net/http" "time" ) @@ -260,6 +261,23 @@ func StartOfDayForTimeRange(timeRange string, location *time.Location) *time.Tim return &resultTime } +// GetStartOfDayForTimeRange gets the unix start time in milliseconds from the given time range. +// Time range can be one of: "today", "7_day", or "28_day". +func GetStartOfDayForTimeRange(timeRange string, location *time.Location) (*time.Time, *AppError) { + now := time.Now().In(location) + resultTime := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location) + switch timeRange { + case TimeRangeToday: + case TimeRange7Day: + resultTime = resultTime.Add(time.Hour * time.Duration(-144)) + case TimeRange28Day: + resultTime = resultTime.Add(time.Hour * time.Duration(-648)) + default: + return nil, NewAppError("GetStartOfDayForTimeRange", "model.insights.get_start_of_day_for_time_range.time_range.app_error", nil, "", http.StatusBadRequest) + } + return &resultTime, nil +} + // GetTopReactionListWithPagination adds a rank to each item in the given list of TopReaction and checks if there is // another page that can be fetched based on the given limit and offset. The given list of TopReaction is assumed to be // sorted by Count. Returns a TopReactionList. diff --git a/model/migration.go b/model/migration.go index 4958f80e85..e0e9ae2267 100644 --- a/model/migration.go +++ b/model/migration.go @@ -38,4 +38,5 @@ const ( MigrationKeyAddPlaybooksPermissions = "playbooks_permissions" MigrationKeyAddCustomUserGroupsPermissions = "custom_groups_permissions" MigrationKeyAddPlayboosksManageRolesPermissions = "playbooks_manage_roles" + MigrationKeyAddProductsBoardsPermissions = "products_boards" ) diff --git a/model/permission.go b/model/permission.go index 65f8edaca8..76cf07c872 100644 --- a/model/permission.go +++ b/model/permission.go @@ -354,6 +354,9 @@ var PermissionRunManageProperties *Permission var PermissionRunManageMembers *Permission var PermissionRunView *Permission +var PermissionSysconsoleReadProductsBoards *Permission +var PermissionSysconsoleWriteProductsBoards *Permission + // General permission that encompasses all system admin functions // in the future this could be broken up to allow access to some // admin functions but not others @@ -2070,6 +2073,19 @@ func initializePermissions() { PermissionScopeRun, } + PermissionSysconsoleReadProductsBoards = &Permission{ + "sysconsole_read_products_boards", + "", + "", + PermissionScopeSystem, + } + PermissionSysconsoleWriteProductsBoards = &Permission{ + "sysconsole_write_products_boards", + "", + "", + PermissionScopeSystem, + } + SysconsoleReadPermissions = []*Permission{ PermissionSysconsoleReadAboutEditionAndLicense, PermissionSysconsoleReadBilling, @@ -2125,6 +2141,7 @@ func initializePermissions() { PermissionSysconsoleReadExperimentalFeatures, PermissionSysconsoleReadExperimentalFeatureFlags, PermissionSysconsoleReadExperimentalBleve, + PermissionSysconsoleReadProductsBoards, } SysconsoleWritePermissions = []*Permission{ @@ -2182,6 +2199,7 @@ func initializePermissions() { PermissionSysconsoleWriteExperimentalFeatures, PermissionSysconsoleWriteExperimentalFeatureFlags, PermissionSysconsoleWriteExperimentalBleve, + PermissionSysconsoleWriteProductsBoards, } SystemScopedPermissionsMinusSysconsole := []*Permission{ diff --git a/model/role.go b/model/role.go index 7823f133b4..ac3fa3204e 100644 --- a/model/role.go +++ b/model/role.go @@ -261,6 +261,7 @@ func init() { PermissionSysconsoleReadExperimentalFeatures.Id, PermissionSysconsoleReadExperimentalFeatureFlags.Id, PermissionSysconsoleReadExperimentalBleve.Id, + PermissionSysconsoleReadProductsBoards.Id, } SystemManagerDefaultPermissions = []string{ @@ -339,6 +340,8 @@ func init() { PermissionSysconsoleWriteIntegrationsBotAccounts.Id, PermissionSysconsoleWriteIntegrationsGif.Id, PermissionSysconsoleWriteIntegrationsCors.Id, + PermissionSysconsoleReadProductsBoards.Id, + PermissionSysconsoleWriteProductsBoards.Id, } SystemCustomGroupAdminDefaultPermissions = []string{ diff --git a/product/api.go b/product/api.go index 4b4ec8a93c..88eb9744e4 100644 --- a/product/api.go +++ b/product/api.go @@ -39,6 +39,7 @@ type PostService interface { // // The service shall be registered via app.PermissionKey service key. type PermissionService interface { + HasPermissionTo(userID string, permission *model.Permission) bool HasPermissionToTeam(userID, teamID string, permission *model.Permission) bool HasPermissionToChannel(askingUserID string, channelID string, permission *model.Permission) bool } diff --git a/services/telemetry/telemetry.go b/services/telemetry/telemetry.go index f19e5a4188..821b4c8a91 100644 --- a/services/telemetry/telemetry.go +++ b/services/telemetry/telemetry.go @@ -67,6 +67,7 @@ const ( TrackConfigBleve = "config_bleve" TrackConfigExport = "config_export" TrackFeatureFlags = "config_feature_flags" + TrackConfigProducts = "products" TrackPermissionsGeneral = "permissions_general" TrackPermissionsSystemScheme = "permissions_system_scheme" TrackPermissionsTeamSchemes = "permissions_team_schemes" @@ -827,6 +828,10 @@ func (ts *TelemetryService) trackConfig() { "retention_days": *cfg.ExportSettings.RetentionDays, }) + ts.SendTelemetry(TrackConfigProducts, map[string]any{ + "enable_public_shared_boards": *cfg.ProductSettings.EnablePublicSharedBoards, + }) + // Convert feature flags to map[string]any for sending flags := cfg.FeatureFlags.ToMap() interfaceFlags := make(map[string]any) diff --git a/store/searchlayer/user_layer.go b/store/searchlayer/user_layer.go index 2c63dabd3a..268b18cacc 100644 --- a/store/searchlayer/user_layer.go +++ b/store/searchlayer/user_layer.go @@ -37,7 +37,7 @@ func (s *SearchUserStore) deleteUserIndex(user *model.User) { func (s *SearchUserStore) Search(teamId, term string, options *model.UserSearchOptions) ([]*model.User, error) { for _, engine := range s.rootStore.searchEngine.GetActiveEngines() { if engine.IsSearchEnabled() { - listOfAllowedChannels, nErr := s.getListOfAllowedChannelsForTeam(teamId, options.ViewRestrictions) + listOfAllowedChannels, nErr := s.getListOfAllowedChannels(teamId, "", options.ViewRestrictions) if nErr != nil { mlog.Warn("Encountered error on Search.", mlog.String("search_engine", engine.GetName()), mlog.Err(nErr)) continue @@ -148,7 +148,7 @@ func (s *SearchUserStore) autocompleteUsersInChannelByEngine(engine searchengine return autocomplete, nil } -// getListOfAllowedChannelsForTeam return the list of allowed channels to search user based on the +// getListOfAllowedChannels return the list of allowed channels to search user based on the // next scenarios: // - If there isn't view restrictions (team or channel) and no team id to filter them, then all // channels are allowed (nil return) @@ -159,7 +159,7 @@ func (s *SearchUserStore) autocompleteUsersInChannelByEngine(engine searchengine // - If we receive channels restrictions we get: // - If we don't have team id, we get those restricted channels (guest accounts and quick search) // - If we have a team id then we only return those restricted channels that belongs to that team -func (s *SearchUserStore) getListOfAllowedChannelsForTeam(teamId string, viewRestrictions *model.ViewUsersRestrictions) ([]string, error) { +func (s *SearchUserStore) getListOfAllowedChannels(teamId, channelId string, viewRestrictions *model.ViewUsersRestrictions) ([]string, error) { var listOfAllowedChannels []string if viewRestrictions == nil && teamId == "" { // nil return without error means all channels are allowed @@ -174,6 +174,20 @@ func (s *SearchUserStore) getListOfAllowedChannelsForTeam(teamId string, viewRes for _, channel := range channels { listOfAllowedChannels = append(listOfAllowedChannels, channel.Id) } + + if channelId != "" { + ch, err := s.rootStore.Channel().Get(channelId, true) + if err != nil { + return nil, errors.Wrapf(err, "failed to get channel with id: %s", channelId) + } + // Check if DM/GM channel, and add to the list. + // This is because GetTeamChannels does not return DM/GM channels. + // And since the channelId is passed from the API layer, it is already + // auth checked to confirm that the user has permission. + if ch.IsGroupOrDirect() { + listOfAllowedChannels = append(listOfAllowedChannels, channelId) + } + } return listOfAllowedChannels, nil } @@ -196,7 +210,7 @@ func (s *SearchUserStore) getListOfAllowedChannelsForTeam(teamId string, viewRes func (s *SearchUserStore) AutocompleteUsersInChannel(teamId, channelId, term string, options *model.UserSearchOptions) (*model.UserAutocompleteInChannel, error) { for _, engine := range s.rootStore.searchEngine.GetActiveEngines() { if engine.IsAutocompletionEnabled() { - listOfAllowedChannels, nErr := s.getListOfAllowedChannelsForTeam(teamId, options.ViewRestrictions) + listOfAllowedChannels, nErr := s.getListOfAllowedChannels(teamId, channelId, options.ViewRestrictions) if nErr != nil { mlog.Warn("Encountered error on AutocompleteUsersInChannel.", mlog.String("search_engine", engine.GetName()), mlog.Err(nErr)) continue @@ -205,6 +219,7 @@ func (s *SearchUserStore) AutocompleteUsersInChannel(teamId, channelId, term str return &model.UserAutocompleteInChannel{}, nil } options.ListOfAllowedChannels = listOfAllowedChannels + autocomplete, nErr := s.autocompleteUsersInChannelByEngine(engine, teamId, channelId, term, options) if nErr != nil { mlog.Warn("Encountered error on AutocompleteUsersInChannel.", mlog.String("search_engine", engine.GetName()), mlog.Err(nErr)) diff --git a/store/sqlstore/user_store.go b/store/sqlstore/user_store.go index bd89779355..9eb8535d90 100644 --- a/store/sqlstore/user_store.go +++ b/store/sqlstore/user_store.go @@ -742,6 +742,8 @@ func (us SqlUserStore) GetProfilesInChannel(options *model.UserGetOptions) ([]*m query = query.Where("u.DeleteAt = 0") } + query = applyMultiRoleFilters(query, options.Roles, options.TeamRoles, options.ChannelRoles, us.DriverName() == model.DatabaseDriverPostgres) + queryString, args, err := query.ToSql() if err != nil { return nil, errors.Wrap(err, "get_profiles_in_channel_tosql") @@ -1803,7 +1805,16 @@ func (us SqlUserStore) GetUsersBatchForIndexing(startTime int64, startFileID str `). From("ChannelMembers cm"). Join("Channels c ON cm.ChannelId = c.Id"). - Where(sq.Eq{"c.Type": model.ChannelTypeOpen, "cm.UserId": userIds}). + Where(sq.And{ + sq.Eq{ + "cm.UserId": userIds, + }, + sq.Or{ + sq.Eq{"c.Type": model.ChannelTypeOpen}, + sq.Eq{"c.Type": model.ChannelTypeDirect}, + sq.Eq{"c.Type": model.ChannelTypeGroup}, + }, + }). ToSql() if err != nil { return nil, errors.Wrap(err, "GetUsersBatchForIndexing_ToSql2") diff --git a/store/storetest/user_store.go b/store/storetest/user_store.go index 6d704854b4..c938056249 100644 --- a/store/storetest/user_store.go +++ b/store/storetest/user_store.go @@ -1003,6 +1003,36 @@ func testUserStoreGetProfilesInChannel(t *testing.T, ss store.Store) { require.NoError(t, err) assert.Equal(t, []*model.User{sanitized(u1)}, users) }) + + t.Run("Filter by channel members and channel admins", func(t *testing.T) { + // save admin for c1 + user2Admin, err := ss.User().Save(&model.User{ + Email: MakeEmail(), + Username: "bbb" + model.NewId(), + }) + require.NoError(t, err) + defer func() { require.NoError(t, ss.User().PermanentDelete(user2Admin.Id)) }() + _, nErr = ss.Team().SaveMember(&model.TeamMember{TeamId: teamId, UserId: user2Admin.Id}, -1) + require.NoError(t, nErr) + + _, nErr = ss.Channel().SaveMember(&model.ChannelMember{ + ChannelId: c1.Id, + UserId: user2Admin.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + ExplicitRoles: "channel_admin", + }) + require.NoError(t, nErr) + ss.Channel().UpdateMembersRole(c1.Id, []string{user2Admin.Id}) + + users, err := ss.User().GetProfilesInChannel(&model.UserGetOptions{ + InChannelId: c1.Id, + ChannelRoles: []string{model.ChannelAdminRoleId}, + Page: 0, + PerPage: 5, + }) + require.NoError(t, err) + assert.Equal(t, user2Admin.Id, users[0].Id) + }) } func testUserStoreGetProfilesInChannelByAdmin(t *testing.T, ss store.Store, s SqlStore) { @@ -4882,10 +4912,35 @@ func testUserStoreGetUsersBatchForIndexing(t *testing.T, ss store.Store) { }) require.NoError(t, err) + cDM := &model.Channel{ + Name: model.NewId() + "__" + model.NewId(), + Type: model.ChannelTypeDirect, + } + cm1 := &model.ChannelMember{ + UserId: u3.Id, + ChannelId: cDM.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + cm2 := &model.ChannelMember{ + UserId: u2.Id, + ChannelId: cDM.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + } + cDM, nErr = ss.Channel().SaveDirectChannel(cDM, cm1, cm2) + require.NoError(t, nErr) + // Getting all users res1List, err := ss.User().GetUsersBatchForIndexing(u1.CreateAt-1, "", 100) require.NoError(t, err) assert.Len(t, res1List, 3) + for _, user := range res1List { + switch user.Id { + case u2.Id: + assert.ElementsMatch(t, user.ChannelsIds, []string{cPub1.Id, cPub2.Id, cDM.Id}) + case u3.Id: + assert.ElementsMatch(t, user.ChannelsIds, []string{cPub2.Id, cDM.Id}) + } + } // Testing pagination res2List, err := ss.User().GetUsersBatchForIndexing(u1.CreateAt-1, "", 1) diff --git a/templates/invite_body.mjml b/templates/invite_body.mjml index 869a359b2c..d16c20af68 100644 --- a/templates/invite_body.mjml +++ b/templates/invite_body.mjml @@ -8,7 +8,13 @@ {{if .Props.Message}} {{range .Props.Posts}}
- + + + + + + +
{{end}}
{{else}} diff --git a/templates/messages_notification.html b/templates/messages_notification.html index 9c40227203..7b47478cfa 100644 --- a/templates/messages_notification.html +++ b/templates/messages_notification.html @@ -356,7 +356,160 @@ max-width: 100% !important; } } + + .messageAttachments * { + font-family: Open Sans, sans-serif !important; + } + + .messageAttachmentContent { + padding: 0px; + border: 1px solid rgba(63, 67, 80, 0.16); + margin-bottom: 20px; + border-radius: 0 4px 4px 0; + } + + .messageAttachmentContent>table, + .attachment__body { + font-family: Open Sans, sans-serif; + text-align: left; + font-size: 14px; + line-height: 20px; + color: #3F4350; + } + + .messageAttachmentContent .attachment__author-icon { + width: 14px; + height: 14px; + margin-right: 5px; + border-radius: 50px; + vertical-align: middle; + } + + .attachment__author-name { + opacity: 0.6; + } + + .messageAttachmentContent p { + margin: 0; + } + + .attachment__title { + padding: 0; + margin: 5px 0; + font-size: 14px; + font-weight: 600; + line-height: 18px; + } + + .attachment__image { + max-height: 300px; + border: 1px solid transparent; + margin-bottom: 1em; + } + + .attachment__thumb-image { + max-width: 100%; + max-height: 75px; + } + + .attachment__thumb-container { + max-width: 80px; + width: max-content; + } + + .attachment__wrapper { + width: 100%; + display: flex; + flex-direction: row; + gap: 12px; + } + + .attachment__body { + flex: 1; + } + + .messageAttachmentContent>table.attachment__footer-container { + color: #a3a3a3; + font-size: 12px; + } + + .attachment__footer-icon { + width: 16px; + height: 16px; + } + + .attachment__footer-container { + color: #a3a3a3; + font-size: 12px; + } + + .messageAttachment_title { + padding-top: 1em; + font-weight: 600; + margin-bottom: 4px; + } + + .pretext h1 { + font-size: 28px; + line-height: 32px; + } + + .pretext h2 { + font-size: 25px; + line-height: 30px; + } + + .pretext h3 { + font-size: 22px; + line-height: 25px; + } + + .pretext h4 { + font-size: 19px; + line-height: 24px; + } + + .pretext h5 { + font-size: 15px; + line-height: 20px; + } + + .pretext h6 { + font-size: 1em; + line-height: 1.4em; + } + + .pretext>* { + margin-bottom: 16px; + } + + .messageAttachments { + margin-top: 22px; + } + + .messageAttachments h1, + h2, + h3, + h4, + h5, + h6 { + font-weight: 500; + } + + code { + padding: 2px 4px; + font-size: 90%; + background-color: rgba(63, 67, 80, 0.1); + border-radius: 4px; + } + + .messageAttachments a { + background-color: unset !important; + border: none !important; + padding: unset !important; + } + @@ -507,7 +660,96 @@ - + + {{if .MessageAttachments}} +
+ {{range .MessageAttachments}} +
+
+ {{.Pretext}} +
+
+ + + {{if or .AuthorIcon .AuthorName}} + + + + {{end}} + {{if .Title}} + + + + {{end}} + +
+ {{if .AuthorLink}}{{end}} + {{if .AuthorIcon}}attachment author icon{{end}} + {{if .AuthorName}}{{.AuthorName}}{{end}} + {{if .AuthorLink}}{{end}} +
+

+ {{if .TitleLink}}{{end}} + {{.Title}} + {{if .Title}}{{end}} +

+
+
+
+ + + + + + {{if .ImageURL}} + + + + {{end}} + +
+
{{.Text}}
+
+ +
+ + {{range .FieldRows}} + + {{ $length := len .Cells }} + {{range .Cells}} + + {{end}} + + {{end}} +
+
{{.Title}}
+
{{.Value}}
+
+
+ {{if .ThumbURL}} +
+ +
+ {{end}} +
+ {{if .Footer}} + + + + + + + + {{end}} +
+
+ {{end}} +
+ {{end}} +
diff --git a/templates/messages_notification.mjml b/templates/messages_notification.mjml index ad840ff420..a2b53b6b16 100644 --- a/templates/messages_notification.mjml +++ b/templates/messages_notification.mjml @@ -1,13 +1,21 @@ + {{range .Props.Posts}}
- + + + + + + + +
{{end}}
diff --git a/templates/partials/card.mjml b/templates/partials/card.mjml deleted file mode 100644 index f1c835e04c..0000000000 --- a/templates/partials/card.mjml +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - -
- - - - - {{.Message}} - - - - {{if .MessageURL}} - {{$.Props.MessageButton}} - {{end}} - - - diff --git a/templates/partials/message_attachment.css b/templates/partials/message_attachment.css new file mode 100644 index 0000000000..ee65c7f8a3 --- /dev/null +++ b/templates/partials/message_attachment.css @@ -0,0 +1,146 @@ +.messageAttachments * { + font-family: Open Sans, sans-serif !important; +} + +.messageAttachmentContent { + padding: 0px; + border: 1px solid rgba(63, 67, 80, 0.16); + margin-bottom: 20px; + border-radius: 0 4px 4px 0; +} + +.messageAttachmentContent > table, .attachment__body { + font-family: Open Sans, sans-serif; + text-align: left; + font-size: 14px; + line-height: 20px; + color: #3F4350; +} + +.messageAttachmentContent .attachment__author-icon { + width: 14px; + height: 14px; + margin-right: 5px; + border-radius: 50px; + vertical-align: middle; +} + +.attachment__author-name { + opacity: 0.6; +} + +.messageAttachmentContent p { + margin: 0; +} + +.attachment__title { + padding: 0; + margin: 5px 0; + font-size: 14px; + font-weight: 600; + line-height: 18px; +} + +.attachment__image { + max-height: 300px; + border: 1px solid transparent; + margin-bottom: 1em; +} + + +.attachment__thumb-image { + max-width: 100%; + max-height: 75px; +} + +.attachment__thumb-container { + max-width: 80px; + width: max-content; +} + +.attachment__wrapper { + width: 100%; + display: flex; + flex-direction: row; + gap: 12px; +} + +.attachment__body { + flex: 1; +} + +.messageAttachmentContent > table.attachment__footer-container { + color: #a3a3a3; + font-size: 12px; +} + +.attachment__footer-icon { + width: 16px; + height: 16px; +} + +.attachment__footer-container { + color: #a3a3a3; + font-size: 12px; +} + +.messageAttachment_title { + padding-top: 1em; + font-weight: 600; + margin-bottom: 4px; +} + +.pretext h1 { + font-size: 28px; + line-height: 32px; +} + +.pretext h2 { + font-size: 25px; + line-height: 30px; +} + +.pretext h3 { + font-size: 22px; + line-height: 25px; +} + +.pretext h4 { + font-size: 19px; + line-height: 24px; +} + +.pretext h5 { + font-size: 15px; + line-height: 20px; +} + +.pretext h6 { + font-size: 1em; + line-height: 1.4em; +} + +.pretext > * { + margin-bottom: 16px; +} + +.messageAttachments { + margin-top: 22px; +} + +.messageAttachments h1, h2, h3, h4, h5, h6 { + font-weight: 500; +} + +code { + padding: 2px 4px; + font-size: 90%; + background-color: rgba(63, 67, 80, 0.1); + border-radius: 4px; +} + +.messageAttachments a { + background-color: unset !important; + border: none !important; + padding: unset !important; +} diff --git a/templates/partials/message_attachment.html b/templates/partials/message_attachment.html new file mode 100644 index 0000000000..68dd5fe21b --- /dev/null +++ b/templates/partials/message_attachment.html @@ -0,0 +1,95 @@ +{{if .MessageAttachments}} +
+ {{range .MessageAttachments}} +
+
+ {{.Pretext}} +
+
+
-
-
{{.SenderName}}
- {{if .Time}} -
{{.Time}}
- {{end}} - {{if .ChannelName}} -
- {{if .ShowChannelIcon}} - - {{end}} -
- {{if .OtherChannelMembersCount}} - {{.OtherChannelMembersCount}} - {{end}} - {{.ChannelName}} -
-
- {{end}} -
-
+ + {{if or .AuthorIcon .AuthorName}} + + + + {{end}} + {{if .Title}} + + + + {{end}} + +
+ {{if .AuthorLink}}{{end}} + {{if .AuthorIcon}}attachment author icon{{end}} + {{if .AuthorName}}{{.AuthorName}}{{end}} + {{if .AuthorLink}}{{end}} +
+

+ {{if .TitleLink}}{{end}} + {{.Title}} + {{if .Title}}{{end}} +

+
+ +
+
+ + + + + + {{if .ImageURL}} + + + + {{end}} +
+
{{.Text}}
+
+ +
+ + + {{range .FieldRows}} + + {{ $length := len .Cells }} + {{range .Cells}} + + {{end}} + + {{end}} +
+
{{.Title}}
+
{{.Value}}
+
+
+ + {{if .ThumbURL}} +
+ +
+ {{end}} +
+ + {{if .Footer}} + + + + + + + + {{end}} + +
+ + {{end}} + +{{end}} diff --git a/templates/partials/message_attachment_styles.mjml b/templates/partials/message_attachment_styles.mjml new file mode 100644 index 0000000000..0e0de4dd3a --- /dev/null +++ b/templates/partials/message_attachment_styles.mjml @@ -0,0 +1,5 @@ + + + + + diff --git a/templates/partials/message_avatar_col.mjml b/templates/partials/message_avatar_col.mjml new file mode 100644 index 0000000000..dc4686896d --- /dev/null +++ b/templates/partials/message_avatar_col.mjml @@ -0,0 +1,3 @@ + + + diff --git a/templates/partials/message_button.mjml b/templates/partials/message_button.mjml new file mode 100644 index 0000000000..997edac1bc --- /dev/null +++ b/templates/partials/message_button.mjml @@ -0,0 +1,5 @@ + + {{if .MessageURL}} + {{$.Props.MessageButton}} + {{end}} + diff --git a/templates/partials/sender_info_col.mjml b/templates/partials/sender_info_col.mjml new file mode 100644 index 0000000000..69b37d1853 --- /dev/null +++ b/templates/partials/sender_info_col.mjml @@ -0,0 +1,30 @@ + + + + +
+
{{.SenderName}}
+ {{if .Time}} +
{{.Time}}
+ {{end}} + {{if .ChannelName}} +
+ {{if .ShowChannelIcon}} + + {{end}} +
+ {{if .OtherChannelMembersCount}} + {{.OtherChannelMembersCount}} + {{end}} + {{.ChannelName}} +
+
+ {{end}} +
+ + +
+ + {{.Message}} + +
diff --git a/testlib/store.go b/testlib/store.go index 5566989362..800764da07 100644 --- a/testlib/store.go +++ b/testlib/store.go @@ -69,6 +69,7 @@ func GetMockStoreForSetupFunctions() *mocks.Store { systemStore.On("GetByName", model.MigrationKeyAddCustomUserGroupsPermissions).Return(&model.System{Name: model.MigrationKeyAddCustomUserGroupsPermissions, Value: "true"}, nil) systemStore.On("GetByName", model.MigrationKeyAddPlayboosksManageRolesPermissions).Return(&model.System{Name: model.MigrationKeyAddPlayboosksManageRolesPermissions, Value: "true"}, nil) systemStore.On("GetByName", "CustomGroupAdminRoleCreationMigrationComplete").Return(&model.System{Name: model.MigrationKeyAddPlayboosksManageRolesPermissions, Value: "true"}, nil) + systemStore.On("GetByName", "products_boards").Return(&model.System{Name: "products_boards", Value: "true"}, nil) systemStore.On("InsertIfExists", mock.AnythingOfType("*model.System")).Return(&model.System{}, nil).Once() systemStore.On("Save", mock.AnythingOfType("*model.System")).Return(nil)