[CLD-7567] Deprecate Self Serve: Second Pass (#26853)
* Deprecate Self Serve: First Pass * Fix ci * Fix more ci * Remmove outdated server tests * Fix a missed spot opening purchase modal in Self Hosted * Fix i18n * Clean up some more server code, fix webapp test * Fix alignment of button * Fix linter * Fix i18n server side * Deprecate in product true up * Add back translation * Remove client functions * Put back client functions * webapp deprecation * Deprecate Self Serve: Second Pass * Fix various pipeline issues * Fix linter * Fix pipelines * Fix handlers_test.go * Fix console.error around hostedCustomer in reducer * PICKY LINTER PLEASE * Fix webapp tests, various other fixes for the CI pipelines * Fix i18n * Updates to accomadate enterprise code removal * Fix mocks * More removal * Fix * Adjustments from PR * Fixes for QA Feedback * Update * Add migrations to remove true up review history * Fix migrations check --------- Co-authored-by: Mattermost Build <build@mattermost.com> Co-authored-by: maria.nunez <maria.nunez@mattermost.com>
Этот коммит содержится в:
@@ -35,7 +35,6 @@ func (api *API) InitCloud() {
|
||||
api.BaseRoutes.Cloud.Handle("/subscription", api.APISessionRequired(getSubscription)).Methods("GET")
|
||||
api.BaseRoutes.Cloud.Handle("/subscription/invoices", api.APISessionRequired(getInvoicesForSubscription)).Methods("GET")
|
||||
api.BaseRoutes.Cloud.Handle("/subscription/invoices/{invoice_id:[_A-Za-z0-9]+}/pdf", api.APISessionRequired(getSubscriptionInvoicePDF)).Methods("GET")
|
||||
api.BaseRoutes.Cloud.Handle("/subscription/self-serve-status", api.APISessionRequired(getLicenseSelfServeStatus)).Methods("GET")
|
||||
|
||||
// GET /api/v4/cloud/validate-business-email
|
||||
api.BaseRoutes.Cloud.Handle("/validate-business-email", api.APISessionRequired(validateBusinessEmail)).Methods("POST")
|
||||
@@ -378,40 +377,6 @@ func getInstallation(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// getLicenseSelfServeStatus makes check for the license in the CWS self-serve portal and establishes if the license is renewable, expandable etc.
|
||||
func getLicenseSelfServeStatus(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
ensured := ensureCloudInterface(c, "Api4.getLicenseSelfServeStatus")
|
||||
if !ensured {
|
||||
return
|
||||
}
|
||||
|
||||
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageLicenseInformation) {
|
||||
c.SetPermissionError(model.PermissionManageLicenseInformation)
|
||||
return
|
||||
}
|
||||
|
||||
_, token, err := c.App.Srv().GenerateLicenseRenewalLink()
|
||||
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
status, cloudErr := c.App.Cloud().GetLicenseSelfServeStatus(c.AppContext.Session().UserId, token)
|
||||
if cloudErr != nil {
|
||||
c.Err = model.NewAppError("Api4.getLicenseSelfServeStatus", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(cloudErr)
|
||||
return
|
||||
}
|
||||
|
||||
json, jsonErr := json.Marshal(status)
|
||||
if jsonErr != nil {
|
||||
c.Err = model.NewAppError("Api4.getLicenseSelfServeStatus", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr)
|
||||
return
|
||||
}
|
||||
|
||||
w.Write(json)
|
||||
}
|
||||
|
||||
func updateCloudCustomer(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
ensured := ensureCloudInterface(c, "Api4.updateCloudCustomer")
|
||||
if !ensured {
|
||||
|
||||
@@ -5,9 +5,7 @@ package api4
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
b64 "encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
@@ -23,10 +21,7 @@ func (api *API) InitLicense() {
|
||||
api.BaseRoutes.APIRoot.Handle("/trial-license/prev", api.APISessionRequired(getPrevTrialLicense)).Methods("GET")
|
||||
api.BaseRoutes.APIRoot.Handle("/license", api.APISessionRequired(addLicense, handlerParamFileAPI)).Methods("POST")
|
||||
api.BaseRoutes.APIRoot.Handle("/license", api.APISessionRequired(removeLicense)).Methods("DELETE")
|
||||
api.BaseRoutes.APIRoot.Handle("/license/renewal", api.APISessionRequired(requestRenewalLink)).Methods("GET")
|
||||
api.BaseRoutes.APIRoot.Handle("/license/client", api.APIHandler(getClientLicense)).Methods("GET")
|
||||
api.BaseRoutes.APIRoot.Handle("/license/review", api.APISessionRequired(requestTrueUpReview)).Methods("POST")
|
||||
api.BaseRoutes.APIRoot.Handle("/license/review/status", api.APISessionRequired(trueUpReviewStatus)).Methods("GET")
|
||||
}
|
||||
|
||||
func getClientLicense(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
@@ -238,54 +233,6 @@ func requestTrialLicense(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
ReturnStatusOK(w)
|
||||
}
|
||||
|
||||
func requestRenewalLink(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
auditRec := c.MakeAuditRecord("requestRenewalLink", audit.Fail)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
c.LogAudit("attempt")
|
||||
|
||||
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageLicenseInformation) {
|
||||
c.SetPermissionError(model.PermissionManageLicenseInformation)
|
||||
return
|
||||
}
|
||||
|
||||
if *c.App.Config().ExperimentalSettings.RestrictSystemAdmin {
|
||||
c.Err = model.NewAppError("requestRenewalLink", "api.restricted_system_admin", nil, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
renewalLink, token, err := c.App.Srv().GenerateLicenseRenewalLink()
|
||||
if err != nil {
|
||||
c.Err = err
|
||||
return
|
||||
}
|
||||
|
||||
if c.App.Cloud() == nil {
|
||||
c.Err = model.NewAppError("requestRenewalLink", "api.license.upgrade_needed.app_error", nil, "", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// check if it is possible to renew license on the portal with generated token
|
||||
status, e := c.App.Cloud().GetLicenseSelfServeStatus(c.AppContext.Session().UserId, token)
|
||||
if e != nil {
|
||||
c.Err = model.NewAppError("requestRenewalLink", "api.license.request_renewal_link.cannot_renew_on_cws", nil, "", http.StatusInternalServerError).Wrap(e)
|
||||
return
|
||||
}
|
||||
|
||||
if !status.IsRenewable {
|
||||
c.Err = model.NewAppError("requestRenewalLink", "api.license.request_renewal_link.cannot_renew_on_cws", nil, "License is not self-serve renewable", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
c.LogAudit("success")
|
||||
|
||||
_, werr := w.Write([]byte(fmt.Sprintf(`{"renewal_link": "%s"}`, renewalLink)))
|
||||
if werr != nil {
|
||||
c.Err = model.NewAppError("requestRenewalLink", "api.license.request_renewal_link.app_error", nil, "", http.StatusForbidden).Wrap(werr)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func getPrevTrialLicense(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
if c.App.Srv().Platform().LicenseManager() == nil {
|
||||
c.Err = model.NewAppError("getPrevTrialLicense", "api.license.upgrade_needed.app_error", nil, "", http.StatusForbidden)
|
||||
@@ -308,102 +255,3 @@ func getPrevTrialLicense(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Write([]byte(model.MapToJSON(clientLicense)))
|
||||
}
|
||||
|
||||
func requestTrueUpReview(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
// Only admins can request a true up review.
|
||||
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
|
||||
c.SetPermissionError(model.PermissionManageLicenseInformation)
|
||||
return
|
||||
}
|
||||
|
||||
license := c.App.Channels().License()
|
||||
if license == nil {
|
||||
c.Err = model.NewAppError("requestTrueUpReview", "api.license.true_up_review.license_required", nil, "", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
if license.IsCloud() {
|
||||
c.Err = model.NewAppError("requestTrueUpReview", "api.license.true_up_review.not_allowed_for_cloud", nil, "", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
status, appErr := c.App.GetOrCreateTrueUpReviewStatus(c.AppContext)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
// If a true up review has already been submitted for the current due date, complete the request
|
||||
// with no errors.
|
||||
if status.Completed {
|
||||
ReturnStatusOK(w)
|
||||
}
|
||||
|
||||
profileMap, err := c.App.GetTrueUpProfile()
|
||||
if err != nil {
|
||||
c.Err = model.NewAppError("requestTrueUpReview", "api.license.true_up_review.get_status_error", nil, "", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
profileMapJson, err := json.Marshal(profileMap)
|
||||
if err != nil {
|
||||
c.SetJSONEncodingError(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Only report the true up review to CWS if the connection is available.
|
||||
if err := c.App.Cloud().CheckCWSConnection(c.AppContext.Session().UserId); err == nil {
|
||||
err = c.App.Cloud().SubmitTrueUpReview(c.AppContext.Session().UserId, profileMap)
|
||||
if err != nil {
|
||||
c.Err = model.NewAppError("requestTrueUpReview", "api.license.true_up_review.failed_to_submit", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Update the review status to reflect the completion.
|
||||
status.Completed = true
|
||||
c.App.Srv().Store().TrueUpReview().Update(status)
|
||||
|
||||
// Encode to string rather than byte[] otherwise json.Marshal will encode it further.
|
||||
encodedData := b64.StdEncoding.EncodeToString(profileMapJson)
|
||||
responseContent := struct {
|
||||
Content string `json:"content"`
|
||||
}{Content: encodedData}
|
||||
response, _ := json.Marshal(responseContent)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(response)
|
||||
}
|
||||
|
||||
func trueUpReviewStatus(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
// Only admins can request a true up review.
|
||||
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
|
||||
c.SetPermissionError(model.PermissionManageLicenseInformation)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for license
|
||||
license := c.App.Channels().License()
|
||||
if license == nil {
|
||||
c.Err = model.NewAppError("cloudTrueUpReviewNotAllowed", "api.license.true_up_review.license_required", nil, "True up review requires a license", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
if license.IsCloud() {
|
||||
c.Err = model.NewAppError("cloudTrueUpReviewNotAllowed", "api.license.true_up_review.not_allowed_for_cloud", nil, "True up review is not allowed for cloud instances", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
status, appErr := c.App.GetOrCreateTrueUpReviewStatus(c.AppContext)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
}
|
||||
|
||||
json, err := json.Marshal(status)
|
||||
if err != nil {
|
||||
c.Err = model.NewAppError("trueUpReviewStatus", "api.marshal_error", nil, "", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Write(json)
|
||||
}
|
||||
|
||||
@@ -459,116 +459,3 @@ func TestRequestTrialLicense(t *testing.T) {
|
||||
CheckForbiddenStatus(t, resp)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRequestRenewalLink(t *testing.T) {
|
||||
th := Setup(t)
|
||||
defer th.TearDown()
|
||||
|
||||
require.NotPanics(t, func() {
|
||||
cloudImpl := th.App.Srv().Cloud
|
||||
defer func() {
|
||||
th.App.Srv().Cloud = cloudImpl
|
||||
}()
|
||||
th.App.Srv().Cloud = nil
|
||||
resp, err := th.SystemAdminClient.DoAPIGet(context.Background(), "/license/renewal", "")
|
||||
CheckErrorID(t, err, "app.license.generate_renewal_token.no_license")
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRequestTrueUpReview(t *testing.T) {
|
||||
t.Run("returns status 200 when telemetry data sent", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
th.App.Srv().SetLicense(model.NewTestLicense())
|
||||
|
||||
th.Client.Login(context.Background(), th.SystemAdminUser.Email, th.SystemAdminUser.Password)
|
||||
|
||||
cloud := mocks.CloudInterface{}
|
||||
cloud.Mock.On("SubmitTrueUpReview", mock.Anything, mock.Anything).Return(nil)
|
||||
cloud.Mock.On("CheckCWSConnection", mock.Anything).Return(nil)
|
||||
|
||||
cloudImpl := th.App.Srv().Cloud
|
||||
defer func() {
|
||||
th.App.Srv().Cloud = cloudImpl
|
||||
}()
|
||||
th.App.Srv().Cloud = &cloud
|
||||
|
||||
var reviewProfile map[string]any
|
||||
resp, err := th.Client.SubmitTrueUpReview(context.Background(), reviewProfile)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("returns 501 when ran by cloud user", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
th.App.Srv().SetLicense(model.NewTestLicense())
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
|
||||
|
||||
resp, err := th.SystemAdminClient.DoAPIPost(context.Background(), "/license/review", "")
|
||||
require.Error(t, err)
|
||||
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicense())
|
||||
})
|
||||
|
||||
t.Run("returns 403 when user does not have permissions", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
th.App.Srv().SetLicense(model.NewTestLicense())
|
||||
|
||||
resp, err := th.Client.DoAPIPost(context.Background(), "/license/review", "")
|
||||
require.Error(t, err)
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("returns 400 when license is nil", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
defer th.TearDown()
|
||||
|
||||
th.App.Srv().SetLicense(nil)
|
||||
|
||||
resp, err := th.SystemAdminClient.DoAPIPost(context.Background(), "/license/review", "")
|
||||
require.Error(t, err)
|
||||
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestTrueUpReviewStatus(t *testing.T) {
|
||||
th := Setup(t)
|
||||
|
||||
defer th.TearDown()
|
||||
th.App.Srv().SetLicense(model.NewTestLicense())
|
||||
|
||||
t.Run("returns 200 when status retrieved", func(t *testing.T) {
|
||||
resp, err := th.SystemAdminClient.DoAPIGet(context.Background(), "/license/review/status", "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("returns 501 when ran by cloud user", func(t *testing.T) {
|
||||
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
|
||||
|
||||
resp, err := th.SystemAdminClient.DoAPIGet(context.Background(), "/license/review/status", "")
|
||||
require.Error(t, err)
|
||||
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicense())
|
||||
})
|
||||
|
||||
t.Run("returns 403 when user does not have permissions", func(t *testing.T) {
|
||||
resp, err := th.Client.DoAPIGet(context.Background(), "/license/review/status", "")
|
||||
require.Error(t, err)
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("returns 400 when license is nil", func(t *testing.T) {
|
||||
th.App.Srv().SetLicense(nil)
|
||||
|
||||
resp, err := th.SystemAdminClient.DoAPIGet(context.Background(), "/license/review/status", "")
|
||||
require.Error(t, err)
|
||||
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -747,7 +747,6 @@ type AppIface interface {
|
||||
GetOnboarding() (*model.System, *model.AppError)
|
||||
GetOpenGraphMetadata(requestURL string) ([]byte, error)
|
||||
GetOrCreateDirectChannel(c request.CTX, userID, otherUserID string, channelOptions ...model.ChannelOption) (*model.Channel, *model.AppError)
|
||||
GetOrCreateTrueUpReviewStatus(rctx request.CTX) (*model.TrueUpReviewStatus, *model.AppError)
|
||||
GetOutgoingWebhook(hookID string) (*model.OutgoingWebhook, *model.AppError)
|
||||
GetOutgoingWebhooksForChannelPageByUser(channelID string, userID string, page, perPage int) ([]*model.OutgoingWebhook, *model.AppError)
|
||||
GetOutgoingWebhooksForTeamPage(teamID string, page, perPage int) ([]*model.OutgoingWebhook, *model.AppError)
|
||||
@@ -852,7 +851,6 @@ type AppIface interface {
|
||||
GetThreadMembershipsForUser(userID, teamID string) ([]*model.ThreadMembership, error)
|
||||
GetThreadsForUser(userID, teamID string, options model.GetUserThreadsOpts) (*model.Threads, *model.AppError)
|
||||
GetTokenById(token string) (*model.Token, *model.AppError)
|
||||
GetTrueUpProfile() (map[string]any, error)
|
||||
GetUploadSession(c request.CTX, uploadId string) (*model.UploadSession, *model.AppError)
|
||||
GetUploadSessionsForUser(userID string) ([]*model.UploadSession, *model.AppError)
|
||||
GetUser(userID string) (*model.User, *model.AppError)
|
||||
|
||||
@@ -156,13 +156,3 @@ func (s *Server) RemoveLicenseListener(id string) {
|
||||
func (s *Server) GetSanitizedClientLicense() map[string]string {
|
||||
return s.platform.GetSanitizedClientLicense()
|
||||
}
|
||||
|
||||
// GenerateRenewalToken returns a renewal token that expires after duration expiration
|
||||
func (s *Server) GenerateRenewalToken(expiration time.Duration) (string, *model.AppError) {
|
||||
return s.platform.GenerateRenewalToken(expiration)
|
||||
}
|
||||
|
||||
// GenerateLicenseRenewalLink returns a link that points to the CWS where clients can renew license
|
||||
func (s *Server) GenerateLicenseRenewalLink() (string, string, *model.AppError) {
|
||||
return s.platform.GenerateLicenseRenewalLink()
|
||||
}
|
||||
|
||||
@@ -73,24 +73,6 @@ func TestGetSanitizedClientLicense(t *testing.T) {
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestGenerateRenewalToken(t *testing.T) {
|
||||
th := Setup(t)
|
||||
defer th.TearDown()
|
||||
|
||||
t.Run("renewal token generated correctly", func(t *testing.T) {
|
||||
setLicense(th, nil)
|
||||
token, appErr := th.App.Srv().GenerateRenewalToken(JWTDefaultTokenExpiration)
|
||||
require.Nil(t, appErr)
|
||||
require.NotEmpty(t, token)
|
||||
})
|
||||
|
||||
t.Run("return error if there is no active license", func(t *testing.T) {
|
||||
th.App.Srv().SetLicense(nil)
|
||||
_, appErr := th.App.Srv().GenerateRenewalToken(JWTDefaultTokenExpiration)
|
||||
require.NotNil(t, appErr)
|
||||
})
|
||||
}
|
||||
|
||||
func setLicense(th *TestHelper, customer *model.Customer) {
|
||||
l1 := &model.License{}
|
||||
l1.Features = &model.Features{}
|
||||
|
||||
@@ -8005,28 +8005,6 @@ func (a *OpenTracingAppLayer) GetOrCreateDirectChannel(c request.CTX, userID str
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) GetOrCreateTrueUpReviewStatus(rctx request.CTX) (*model.TrueUpReviewStatus, *model.AppError) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetOrCreateTrueUpReviewStatus")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store().SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store().SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0, resultVar1 := a.app.GetOrCreateTrueUpReviewStatus(rctx)
|
||||
|
||||
if resultVar1 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar1))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) GetOutgoingWebhook(hookID string) (*model.OutgoingWebhook, *model.AppError) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetOutgoingWebhook")
|
||||
@@ -10617,28 +10595,6 @@ func (a *OpenTracingAppLayer) GetTotalUsersStats(viewRestrictions *model.ViewUse
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) GetTrueUpProfile() (map[string]any, error) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetTrueUpProfile")
|
||||
|
||||
a.ctx = newCtx
|
||||
a.app.Srv().Store().SetContext(newCtx)
|
||||
defer func() {
|
||||
a.app.Srv().Store().SetContext(origCtx)
|
||||
a.ctx = origCtx
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
resultVar0, resultVar1 := a.app.GetTrueUpProfile()
|
||||
|
||||
if resultVar1 != nil {
|
||||
span.LogFields(spanlog.Error(resultVar1))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return resultVar0, resultVar1
|
||||
}
|
||||
|
||||
func (a *OpenTracingAppLayer) GetUploadSession(c request.CTX, uploadId string) (*model.UploadSession, *model.AppError) {
|
||||
origCtx := a.ctx
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetUploadSession")
|
||||
|
||||
@@ -343,54 +343,6 @@ func (ps *PlatformService) RequestTrialLicense(trialRequest *model.TrialLicenseR
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateRenewalToken returns a renewal token that expires after duration expiration
|
||||
func (ps *PlatformService) GenerateRenewalToken(expiration time.Duration) (string, *model.AppError) {
|
||||
license := ps.License()
|
||||
if license == nil {
|
||||
return "", model.NewAppError("GenerateRenewalToken", "app.license.generate_renewal_token.no_license", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if license.IsCloud() {
|
||||
return "", model.NewAppError("GenerateRenewalToken", "app.license.generate_renewal_token.bad_license", nil, "", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
activeUsers, err := ps.Store.User().Count(model.UserCountOptions{})
|
||||
if err != nil {
|
||||
return "", model.NewAppError("GenerateRenewalToken", "app.license.generate_renewal_token.app_error",
|
||||
nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
expirationTime := time.Now().UTC().Add(expiration)
|
||||
claims := &JWTClaims{
|
||||
LicenseID: license.Id,
|
||||
ActiveUsers: activeUsers,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString([]byte(license.Customer.Email))
|
||||
if err != nil {
|
||||
return "", model.NewAppError("GenerateRenewalToken", "app.license.generate_renewal_token.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
return tokenString, nil
|
||||
}
|
||||
|
||||
// GenerateLicenseRenewalLink returns a link that points to the CWS where clients can renew license
|
||||
func (ps *PlatformService) GenerateLicenseRenewalLink() (string, string, *model.AppError) {
|
||||
renewalToken, err := ps.GenerateRenewalToken(JWTDefaultTokenExpiration)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return fmt.Sprintf("%s?token=%s", ps.getLicenseRenewalURL(), renewalToken), renewalToken, nil
|
||||
}
|
||||
|
||||
func (ps *PlatformService) getLicenseRenewalURL() string {
|
||||
return fmt.Sprintf("%s/subscribe/renew", *ps.Config().CloudSettings.CWSURL)
|
||||
}
|
||||
|
||||
func (ps *PlatformService) getRequestTrialURL() string {
|
||||
return fmt.Sprintf("%s/api/v1/trials", *ps.Config().CloudSettings.CWSURL)
|
||||
}
|
||||
|
||||
@@ -73,24 +73,6 @@ func TestGetSanitizedClientLicense(t *testing.T) {
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestGenerateRenewalToken(t *testing.T) {
|
||||
th := Setup(t)
|
||||
defer th.TearDown()
|
||||
|
||||
t.Run("renewal token generated correctly", func(t *testing.T) {
|
||||
setLicense(th, nil)
|
||||
token, appErr := th.Service.GenerateRenewalToken(JWTDefaultTokenExpiration)
|
||||
require.Nil(t, appErr)
|
||||
require.NotEmpty(t, token)
|
||||
})
|
||||
|
||||
t.Run("return error if there is no active license", func(t *testing.T) {
|
||||
th.Service.SetLicense(nil)
|
||||
_, appErr := th.Service.GenerateRenewalToken(JWTDefaultTokenExpiration)
|
||||
require.NotNil(t, appErr)
|
||||
})
|
||||
}
|
||||
|
||||
func setLicense(th *TestHelper, customer *model.Customer) {
|
||||
l1 := &model.License{}
|
||||
l1.Features = &model.Features{}
|
||||
|
||||
@@ -1296,16 +1296,6 @@ func (s *Server) sendLicenseUpForRenewalEmail(users map[string]*model.User, lice
|
||||
|
||||
daysToExpiration := license.DaysToExpiration()
|
||||
|
||||
ctaLink, tokenToBeUsedForRenew, appErr := s.GenerateLicenseRenewalLink()
|
||||
if appErr != nil {
|
||||
return model.NewAppError("s.sendLicenseUpForRenewalEmail", "api.server.license_up_for_renewal.error_generating_link", nil, "", http.StatusInternalServerError).Wrap(appErr)
|
||||
}
|
||||
|
||||
status, err := s.Cloud.GetLicenseSelfServeStatus("", tokenToBeUsedForRenew)
|
||||
if err != nil {
|
||||
return model.NewAppError("s.sendLicenseUpForRenewalEmail", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
// we want to at least have one email sent out to an admin
|
||||
countNotOks := 0
|
||||
|
||||
@@ -1315,13 +1305,10 @@ func (s *Server) sendLicenseUpForRenewalEmail(users map[string]*model.User, lice
|
||||
name = user.Username
|
||||
}
|
||||
T := i18n.GetUserTranslations(user.Locale)
|
||||
ctaTitle := T("api.templates.license_up_for_renewal_subtitle_two")
|
||||
ctaText := T("api.templates.license_up_for_renewal_renew_now")
|
||||
if !status.IsRenewable {
|
||||
ctaTitle = ""
|
||||
ctaText = T("api.templates.license_up_for_renewal_contact_sales")
|
||||
ctaLink = "https://mattermost.com/contact-sales/"
|
||||
}
|
||||
|
||||
ctaTitle := ""
|
||||
ctaText := T("api.templates.license_up_for_renewal_contact_sales")
|
||||
ctaLink := "https://mattermost.com/contact-sales/"
|
||||
|
||||
if err := s.EmailService.SendLicenseUpForRenewalEmail(user.Email, name, user.Locale, *s.platform.Config().ServiceSettings.SiteURL, ctaTitle, ctaLink, ctaText, daysToExpiration); err != nil {
|
||||
mlog.Error("Error sending license up for renewal email to", mlog.String("user_email", user.Email), mlog.Err(err))
|
||||
@@ -1383,18 +1370,6 @@ func (s *Server) doLicenseExpirationCheck() {
|
||||
return
|
||||
}
|
||||
|
||||
ctaLink, tokenToBeUsedForRenew, appErr := s.GenerateLicenseRenewalLink()
|
||||
if appErr != nil {
|
||||
mlog.Debug(model.NewAppError("s.sendLicenseUpForRenewalEmail", "api.server.license_up_for_renewal.error_generating_link", nil, "", http.StatusInternalServerError).Wrap(appErr).Error())
|
||||
return
|
||||
}
|
||||
|
||||
status, err := s.Cloud.GetLicenseSelfServeStatus("", tokenToBeUsedForRenew)
|
||||
if err != nil {
|
||||
mlog.Debug(model.NewAppError("s.sendLicenseUpForRenewalEmail", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err).Error())
|
||||
return
|
||||
}
|
||||
|
||||
//send email to admin(s)
|
||||
for _, user := range users {
|
||||
user := user
|
||||
@@ -1404,11 +1379,8 @@ func (s *Server) doLicenseExpirationCheck() {
|
||||
}
|
||||
|
||||
T := i18n.GetUserTranslations(user.Locale)
|
||||
ctaText := T("api.templates.remove_expired_license.body.renew_button")
|
||||
if !status.IsRenewable {
|
||||
ctaText = T("api.templates.license_up_for_renewal_contact_sales")
|
||||
ctaLink = "https://mattermost.com/contact-sales/"
|
||||
}
|
||||
ctaText := T("api.templates.license_up_for_renewal_contact_sales")
|
||||
ctaLink := "https://mattermost.com/contact-sales/"
|
||||
|
||||
mlog.Debug("Sending license expired email.", mlog.String("user_email", user.Email))
|
||||
s.Go(func() {
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/utils"
|
||||
"github.com/mattermost/mattermost/server/v8/platform/services/telemetry"
|
||||
)
|
||||
|
||||
func pluginActivated(pluginStates map[string]*model.PluginState, pluginId string) bool {
|
||||
state, ok := pluginStates[pluginId]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return state.Enable
|
||||
}
|
||||
|
||||
func (a *App) getMarketplacePlugins() ([]string, error) {
|
||||
ts := a.Srv().telemetryService
|
||||
config := a.Srv().Config()
|
||||
|
||||
marketplacePlugins, err := ts.GetAllMarketplacePlugins(model.PluginSettingsDefaultMarketplaceURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
activePlugins := []string{}
|
||||
for _, p := range marketplacePlugins {
|
||||
id := p.Manifest.Id
|
||||
if pluginActivated(config.PluginSettings.PluginStates, id) {
|
||||
activePlugins = append(activePlugins, id)
|
||||
}
|
||||
}
|
||||
|
||||
return activePlugins, nil
|
||||
}
|
||||
|
||||
func (a *App) getTrueUpProfile() (*model.TrueUpReviewProfile, error) {
|
||||
license := a.Channels().License()
|
||||
if license == nil {
|
||||
return nil, model.NewAppError("requestTrueUpReview", "api.license.true_up_review.license_required", nil, "Could not get the total active users count", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// Customer Info & Usage Analytics
|
||||
|
||||
// active registered users
|
||||
activatedUsers, err := a.Srv().Store().User().Count(model.UserCountOptions{})
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("requestTrueUpReview", "api.license.true_up_review.user_count_fail", nil, "Could not get the total activated users count", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
// daily active users
|
||||
dau, err := a.Srv().Store().User().AnalyticsActiveCount(DayMilliseconds, model.UserCountOptions{IncludeBotAccounts: false, IncludeDeleted: false})
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("requestTrueUpReview", "api.license.true_up_review.user_count_fail", nil, "Could not get the total daily active users count", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// monthly active users
|
||||
mau, err := a.Srv().Store().User().AnalyticsActiveCount(MonthMilliseconds, model.UserCountOptions{IncludeBotAccounts: false, IncludeDeleted: false})
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("requestTrueUpReview", "api.license.true_up_review.user_count_fail", nil, "Could not get the total monthly active users count", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
// Webhook, calls, boards, and playbook counts
|
||||
incomingWebhookCount, err := a.Srv().Store().Webhook().AnalyticsIncomingCount("")
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("requestTrueUpReview", "api.license.true_up_review.webhook_in_count_fail", nil, "Could not get the total incoming webhook count", http.StatusInternalServerError)
|
||||
}
|
||||
outgoingWebhookCount, err := a.Srv().Store().Webhook().AnalyticsOutgoingCount("")
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("requestTrueUpReview", "api.license.true_up_review.webhook_out_count_fail", nil, "Could not get the total outgoing webhook count", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// Plugin Data
|
||||
trueUpReviewPlugins := model.TrueUpReviewPlugins{
|
||||
PluginNames: []string{},
|
||||
}
|
||||
|
||||
if plugins, err := a.getMarketplacePlugins(); err == nil {
|
||||
trueUpReviewPlugins.PluginNames = plugins
|
||||
trueUpReviewPlugins.TotalPlugins = len(plugins)
|
||||
}
|
||||
|
||||
// Authentication Features
|
||||
config := a.Config()
|
||||
mfaUsed := config.ServiceSettings.EnforceMultifactorAuthentication
|
||||
ldapUsed := config.LdapSettings.Enable
|
||||
samlUsed := config.SamlSettings.Enable
|
||||
openIdUsed := config.OpenIdSettings.Enable
|
||||
guestAccessAllowed := config.GuestAccountsSettings.Enable
|
||||
|
||||
authFeatures := map[string]*bool{
|
||||
model.TrueUpReviewAuthFeaturesMfa: mfaUsed,
|
||||
model.TrueUpReviewAuthFeaturesADLdap: ldapUsed,
|
||||
model.TrueUpReviewAuthFeaturesSaml: samlUsed,
|
||||
model.TrueUpReviewAuthFeatureOpenId: openIdUsed,
|
||||
model.TrueUpReviewAuthFeatureGuestAccess: guestAccessAllowed,
|
||||
}
|
||||
|
||||
authFeatureList := []string{}
|
||||
for feature, used := range authFeatures {
|
||||
if used != nil && *used {
|
||||
authFeatureList = append(authFeatureList, feature)
|
||||
}
|
||||
}
|
||||
|
||||
reviewProfile := model.TrueUpReviewProfile{
|
||||
ServerId: a.TelemetryId(),
|
||||
ServerVersion: model.CurrentVersion,
|
||||
ServerInstallationType: os.Getenv(telemetry.EnvVarInstallType),
|
||||
LicenseId: license.Id,
|
||||
LicensedSeats: *license.Features.Users,
|
||||
LicensePlan: license.SkuName,
|
||||
CustomerName: license.Customer.Name,
|
||||
ActivatedUsers: activatedUsers,
|
||||
DailyActiveUsers: dau,
|
||||
MonthlyActiveUsers: mau,
|
||||
TotalIncomingWebhooks: incomingWebhookCount,
|
||||
TotalOutgoingWebhooks: outgoingWebhookCount,
|
||||
Plugins: trueUpReviewPlugins,
|
||||
AuthenticationFeatures: authFeatureList,
|
||||
}
|
||||
|
||||
return &reviewProfile, nil
|
||||
}
|
||||
|
||||
func (a *App) GetTrueUpProfile() (map[string]any, error) {
|
||||
profile, err := a.getTrueUpProfile()
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
profileJson, err := json.Marshal(profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
telemetryProperties := map[string]any{}
|
||||
|
||||
json.Unmarshal(profileJson, &telemetryProperties)
|
||||
delete(telemetryProperties, "plugins")
|
||||
plugins := profile.Plugins.ToMap()
|
||||
for key, pluginValue := range plugins {
|
||||
telemetryProperties[key] = pluginValue
|
||||
}
|
||||
|
||||
delete(telemetryProperties, "authentication_features")
|
||||
telemetryProperties["authentication_features"] = strings.Join(profile.AuthenticationFeatures, ",")
|
||||
|
||||
return telemetryProperties, nil
|
||||
}
|
||||
|
||||
func (a *App) GetOrCreateTrueUpReviewStatus(rctx request.CTX) (*model.TrueUpReviewStatus, *model.AppError) {
|
||||
nextDueDate := utils.GetNextTrueUpReviewDueDate(time.Now())
|
||||
status, err := a.Srv().Store().TrueUpReview().GetTrueUpReviewStatus(nextDueDate.UnixMilli())
|
||||
if err != nil {
|
||||
var nfErr *store.ErrNotFound
|
||||
switch {
|
||||
case errors.As(err, &nfErr):
|
||||
rctx.Logger().Warn("Could not find true up review status")
|
||||
default:
|
||||
return nil, model.NewAppError("requestTrueUpReview", "api.license.true_up_review.get_status_error", nil, "Could not get true up status records", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
status, err = a.Srv().Store().TrueUpReview().CreateTrueUpReviewStatusRecord(&model.TrueUpReviewStatus{DueDate: nextDueDate.UnixMilli(), Completed: false})
|
||||
if err != nil {
|
||||
return nil, model.NewAppError("requestTrueUpReview", "api.license.true_up_review.create_error", nil, "Could not create true up status record", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetTrueUpProfile(t *testing.T) {
|
||||
th := SetupWithStoreMock(t)
|
||||
defer th.TearDown()
|
||||
|
||||
mockStore := th.App.Srv().Store().(*mocks.Store)
|
||||
mockUserStore := mocks.UserStore{}
|
||||
//Activated userss set to 10
|
||||
mockUserStore.On("Count", mock.Anything).Return(int64(10), nil)
|
||||
//Mau set to 5
|
||||
mockUserStore.On("AnalyticsActiveCount", int64(MonthMilliseconds), model.UserCountOptions{IncludeBotAccounts: false, IncludeDeleted: false}).Return(int64(5), nil)
|
||||
//dau set to 2
|
||||
mockUserStore.On("AnalyticsActiveCount", int64(DayMilliseconds), model.UserCountOptions{IncludeBotAccounts: false, IncludeDeleted: false}).Return(int64(2), nil)
|
||||
mockStore.On("User").Return(&mockUserStore)
|
||||
|
||||
mockWebhookStore := mocks.WebhookStore{}
|
||||
mockWebhookStore.On("AnalyticsIncomingCount", mock.Anything).Return(int64(1), nil)
|
||||
mockWebhookStore.On("AnalyticsOutgoingCount", mock.Anything).Return(int64(1), nil)
|
||||
mockStore.On("Webhook").Return(&mockWebhookStore)
|
||||
|
||||
t.Run("missing license", func(t *testing.T) {
|
||||
_, err := th.App.GetTrueUpProfile()
|
||||
require.Error(t, err)
|
||||
require.True(t, strings.Contains(err.Error(), "True up review requires a license"))
|
||||
})
|
||||
|
||||
t.Run("happy path - returns correct mau and activated users", func(t *testing.T) {
|
||||
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional))
|
||||
|
||||
profile, err := th.App.GetTrueUpProfile()
|
||||
assert.NoError(t, err, "Unexpected error")
|
||||
|
||||
require.NotNil(t, profile)
|
||||
assert.Equal(t, float64(5), profile["monthly_active_users"])
|
||||
assert.Equal(t, float64(2), profile["daily_active_users"])
|
||||
assert.Equal(t, float64(10), profile["total_activated_users"])
|
||||
assert.Equal(t, float64(1), profile["incoming_webhooks_count"])
|
||||
assert.Equal(t, float64(1), profile["outgoing_webhooks_count"])
|
||||
})
|
||||
}
|
||||
@@ -238,6 +238,8 @@ channels/db/migrations/mysql/000119_msteams_shared_channels_opts.down.sql
|
||||
channels/db/migrations/mysql/000119_msteams_shared_channels_opts.up.sql
|
||||
channels/db/migrations/mysql/000120_create_channelbookmarks_table.down.sql
|
||||
channels/db/migrations/mysql/000120_create_channelbookmarks_table.up.sql
|
||||
channels/db/migrations/mysql/000121_remove_true_up_review_history.down.sql
|
||||
channels/db/migrations/mysql/000121_remove_true_up_review_history.up.sql
|
||||
channels/db/migrations/postgres/000001_create_teams.down.sql
|
||||
channels/db/migrations/postgres/000001_create_teams.up.sql
|
||||
channels/db/migrations/postgres/000002_create_team_members.down.sql
|
||||
@@ -476,3 +478,5 @@ channels/db/migrations/postgres/000119_msteams_shared_channels_opts.down.sql
|
||||
channels/db/migrations/postgres/000119_msteams_shared_channels_opts.up.sql
|
||||
channels/db/migrations/postgres/000120_create_channelbookmarks_table.down.sql
|
||||
channels/db/migrations/postgres/000120_create_channelbookmarks_table.up.sql
|
||||
channels/db/migrations/postgres/000121_remove_true_up_review_history.down.sql
|
||||
channels/db/migrations/postgres/000121_remove_true_up_review_history.up.sql
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE IF NOT EXISTS TrueUpReviewHistory (
|
||||
DueDate bigint(20),
|
||||
Completed boolean,
|
||||
PRIMARY KEY (DueDate)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS TrueUpReviewHistory;
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE IF NOT EXISTS trueupreviewhistory (
|
||||
duedate bigint,
|
||||
completed boolean,
|
||||
PRIMARY KEY (duedate)
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS trueupreviewhistory;
|
||||
@@ -59,7 +59,6 @@ type OpenTracingLayer struct {
|
||||
TermsOfServiceStore store.TermsOfServiceStore
|
||||
ThreadStore store.ThreadStore
|
||||
TokenStore store.TokenStore
|
||||
TrueUpReviewStore store.TrueUpReviewStore
|
||||
UploadSessionStore store.UploadSessionStore
|
||||
UserStore store.UserStore
|
||||
UserAccessTokenStore store.UserAccessTokenStore
|
||||
@@ -227,10 +226,6 @@ func (s *OpenTracingLayer) Token() store.TokenStore {
|
||||
return s.TokenStore
|
||||
}
|
||||
|
||||
func (s *OpenTracingLayer) TrueUpReview() store.TrueUpReviewStore {
|
||||
return s.TrueUpReviewStore
|
||||
}
|
||||
|
||||
func (s *OpenTracingLayer) UploadSession() store.UploadSessionStore {
|
||||
return s.UploadSessionStore
|
||||
}
|
||||
@@ -451,11 +446,6 @@ type OpenTracingLayerTokenStore struct {
|
||||
Root *OpenTracingLayer
|
||||
}
|
||||
|
||||
type OpenTracingLayerTrueUpReviewStore struct {
|
||||
store.TrueUpReviewStore
|
||||
Root *OpenTracingLayer
|
||||
}
|
||||
|
||||
type OpenTracingLayerUploadSessionStore struct {
|
||||
store.UploadSessionStore
|
||||
Root *OpenTracingLayer
|
||||
@@ -11117,60 +11107,6 @@ func (s *OpenTracingLayerTokenStore) Save(recovery *model.Token) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *OpenTracingLayerTrueUpReviewStore) CreateTrueUpReviewStatusRecord(reviewStatus *model.TrueUpReviewStatus) (*model.TrueUpReviewStatus, error) {
|
||||
origCtx := s.Root.Store.Context()
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "TrueUpReviewStore.CreateTrueUpReviewStatusRecord")
|
||||
s.Root.Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
s.Root.Store.SetContext(origCtx)
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
result, err := s.TrueUpReviewStore.CreateTrueUpReviewStatusRecord(reviewStatus)
|
||||
if err != nil {
|
||||
span.LogFields(spanlog.Error(err))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *OpenTracingLayerTrueUpReviewStore) GetTrueUpReviewStatus(dueDate int64) (*model.TrueUpReviewStatus, error) {
|
||||
origCtx := s.Root.Store.Context()
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "TrueUpReviewStore.GetTrueUpReviewStatus")
|
||||
s.Root.Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
s.Root.Store.SetContext(origCtx)
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
result, err := s.TrueUpReviewStore.GetTrueUpReviewStatus(dueDate)
|
||||
if err != nil {
|
||||
span.LogFields(spanlog.Error(err))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *OpenTracingLayerTrueUpReviewStore) Update(reviewStatus *model.TrueUpReviewStatus) (*model.TrueUpReviewStatus, error) {
|
||||
origCtx := s.Root.Store.Context()
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "TrueUpReviewStore.Update")
|
||||
s.Root.Store.SetContext(newCtx)
|
||||
defer func() {
|
||||
s.Root.Store.SetContext(origCtx)
|
||||
}()
|
||||
|
||||
defer span.Finish()
|
||||
result, err := s.TrueUpReviewStore.Update(reviewStatus)
|
||||
if err != nil {
|
||||
span.LogFields(spanlog.Error(err))
|
||||
ext.Error.Set(span, true)
|
||||
}
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *OpenTracingLayerUploadSessionStore) Delete(id string) error {
|
||||
origCtx := s.Root.Store.Context()
|
||||
span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "UploadSessionStore.Delete")
|
||||
@@ -13475,7 +13411,6 @@ func New(childStore store.Store, ctx context.Context) *OpenTracingLayer {
|
||||
newStore.TermsOfServiceStore = &OpenTracingLayerTermsOfServiceStore{TermsOfServiceStore: childStore.TermsOfService(), Root: &newStore}
|
||||
newStore.ThreadStore = &OpenTracingLayerThreadStore{ThreadStore: childStore.Thread(), Root: &newStore}
|
||||
newStore.TokenStore = &OpenTracingLayerTokenStore{TokenStore: childStore.Token(), Root: &newStore}
|
||||
newStore.TrueUpReviewStore = &OpenTracingLayerTrueUpReviewStore{TrueUpReviewStore: childStore.TrueUpReview(), Root: &newStore}
|
||||
newStore.UploadSessionStore = &OpenTracingLayerUploadSessionStore{UploadSessionStore: childStore.UploadSession(), Root: &newStore}
|
||||
newStore.UserStore = &OpenTracingLayerUserStore{UserStore: childStore.User(), Root: &newStore}
|
||||
newStore.UserAccessTokenStore = &OpenTracingLayerUserAccessTokenStore{UserAccessTokenStore: childStore.UserAccessToken(), Root: &newStore}
|
||||
|
||||
@@ -63,7 +63,6 @@ type RetryLayer struct {
|
||||
TermsOfServiceStore store.TermsOfServiceStore
|
||||
ThreadStore store.ThreadStore
|
||||
TokenStore store.TokenStore
|
||||
TrueUpReviewStore store.TrueUpReviewStore
|
||||
UploadSessionStore store.UploadSessionStore
|
||||
UserStore store.UserStore
|
||||
UserAccessTokenStore store.UserAccessTokenStore
|
||||
@@ -231,10 +230,6 @@ func (s *RetryLayer) Token() store.TokenStore {
|
||||
return s.TokenStore
|
||||
}
|
||||
|
||||
func (s *RetryLayer) TrueUpReview() store.TrueUpReviewStore {
|
||||
return s.TrueUpReviewStore
|
||||
}
|
||||
|
||||
func (s *RetryLayer) UploadSession() store.UploadSessionStore {
|
||||
return s.UploadSessionStore
|
||||
}
|
||||
@@ -455,11 +450,6 @@ type RetryLayerTokenStore struct {
|
||||
Root *RetryLayer
|
||||
}
|
||||
|
||||
type RetryLayerTrueUpReviewStore struct {
|
||||
store.TrueUpReviewStore
|
||||
Root *RetryLayer
|
||||
}
|
||||
|
||||
type RetryLayerUploadSessionStore struct {
|
||||
store.UploadSessionStore
|
||||
Root *RetryLayer
|
||||
@@ -12717,69 +12707,6 @@ func (s *RetryLayerTokenStore) Save(recovery *model.Token) error {
|
||||
|
||||
}
|
||||
|
||||
func (s *RetryLayerTrueUpReviewStore) CreateTrueUpReviewStatusRecord(reviewStatus *model.TrueUpReviewStatus) (*model.TrueUpReviewStatus, error) {
|
||||
|
||||
tries := 0
|
||||
for {
|
||||
result, err := s.TrueUpReviewStore.CreateTrueUpReviewStatusRecord(reviewStatus)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
if !isRepeatableError(err) {
|
||||
return result, err
|
||||
}
|
||||
tries++
|
||||
if tries >= 3 {
|
||||
err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures")
|
||||
return result, err
|
||||
}
|
||||
timepkg.Sleep(100 * timepkg.Millisecond)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (s *RetryLayerTrueUpReviewStore) GetTrueUpReviewStatus(dueDate int64) (*model.TrueUpReviewStatus, error) {
|
||||
|
||||
tries := 0
|
||||
for {
|
||||
result, err := s.TrueUpReviewStore.GetTrueUpReviewStatus(dueDate)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
if !isRepeatableError(err) {
|
||||
return result, err
|
||||
}
|
||||
tries++
|
||||
if tries >= 3 {
|
||||
err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures")
|
||||
return result, err
|
||||
}
|
||||
timepkg.Sleep(100 * timepkg.Millisecond)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (s *RetryLayerTrueUpReviewStore) Update(reviewStatus *model.TrueUpReviewStatus) (*model.TrueUpReviewStatus, error) {
|
||||
|
||||
tries := 0
|
||||
for {
|
||||
result, err := s.TrueUpReviewStore.Update(reviewStatus)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
if !isRepeatableError(err) {
|
||||
return result, err
|
||||
}
|
||||
tries++
|
||||
if tries >= 3 {
|
||||
err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures")
|
||||
return result, err
|
||||
}
|
||||
timepkg.Sleep(100 * timepkg.Millisecond)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (s *RetryLayerUploadSessionStore) Delete(id string) error {
|
||||
|
||||
tries := 0
|
||||
@@ -15372,7 +15299,6 @@ func New(childStore store.Store) *RetryLayer {
|
||||
newStore.TermsOfServiceStore = &RetryLayerTermsOfServiceStore{TermsOfServiceStore: childStore.TermsOfService(), Root: &newStore}
|
||||
newStore.ThreadStore = &RetryLayerThreadStore{ThreadStore: childStore.Thread(), Root: &newStore}
|
||||
newStore.TokenStore = &RetryLayerTokenStore{TokenStore: childStore.Token(), Root: &newStore}
|
||||
newStore.TrueUpReviewStore = &RetryLayerTrueUpReviewStore{TrueUpReviewStore: childStore.TrueUpReview(), Root: &newStore}
|
||||
newStore.UploadSessionStore = &RetryLayerUploadSessionStore{UploadSessionStore: childStore.UploadSession(), Root: &newStore}
|
||||
newStore.UserStore = &RetryLayerUserStore{UserStore: childStore.User(), Root: &newStore}
|
||||
newStore.UserAccessTokenStore = &RetryLayerUserAccessTokenStore{UserAccessTokenStore: childStore.UserAccessToken(), Root: &newStore}
|
||||
|
||||
@@ -60,7 +60,6 @@ func genStore() *mocks.Store {
|
||||
mock.On("PostPriority").Return(&mocks.PostPriorityStore{})
|
||||
mock.On("PostAcknowledgement").Return(&mocks.PostAcknowledgementStore{})
|
||||
mock.On("PostPersistentNotification").Return(&mocks.PostPersistentNotificationStore{})
|
||||
mock.On("TrueUpReview").Return(&mocks.TrueUpReviewStore{})
|
||||
mock.On("DesktopTokens").Return(&mocks.DesktopTokensStore{})
|
||||
mock.On("ChannelBookmark").Return(&mocks.ChannelBookmarkStore{})
|
||||
return mock
|
||||
|
||||
@@ -109,7 +109,6 @@ type SqlStoreStores struct {
|
||||
postPriority store.PostPriorityStore
|
||||
postAcknowledgement store.PostAcknowledgementStore
|
||||
postPersistentNotification store.PostPersistentNotificationStore
|
||||
trueUpReview store.TrueUpReviewStore
|
||||
desktopTokens store.DesktopTokensStore
|
||||
channelBookmarks store.ChannelBookmarkStore
|
||||
}
|
||||
@@ -235,7 +234,6 @@ func New(settings model.SqlSettings, logger mlog.LoggerIFace, metrics einterface
|
||||
store.stores.postPriority = newSqlPostPriorityStore(store)
|
||||
store.stores.postAcknowledgement = newSqlPostAcknowledgementStore(store)
|
||||
store.stores.postPersistentNotification = newSqlPostPersistentNotificationStore(store)
|
||||
store.stores.trueUpReview = newSqlTrueUpReviewStore(store)
|
||||
store.stores.desktopTokens = newSqlDesktopTokensStore(store, metrics)
|
||||
store.stores.channelBookmarks = newSqlChannelBookmarkStore(store)
|
||||
|
||||
@@ -1033,10 +1031,6 @@ func (ss *SqlStore) PostPersistentNotification() store.PostPersistentNotificatio
|
||||
return ss.stores.postPersistentNotification
|
||||
}
|
||||
|
||||
func (ss *SqlStore) TrueUpReview() store.TrueUpReviewStore {
|
||||
return ss.stores.trueUpReview
|
||||
}
|
||||
|
||||
func (ss *SqlStore) DesktopTokens() store.DesktopTokensStore {
|
||||
return ss.stores.desktopTokens
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strconv"
|
||||
|
||||
sq "github.com/mattermost/squirrel"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
)
|
||||
|
||||
// SqlLicenseStore encapsulates the database writes and reads for
|
||||
// model.LicenseRecord objects.
|
||||
type SqlTrueUpReviewStore struct {
|
||||
*SqlStore
|
||||
}
|
||||
|
||||
func newSqlTrueUpReviewStore(sqlStore *SqlStore) store.TrueUpReviewStore {
|
||||
return &SqlTrueUpReviewStore{sqlStore}
|
||||
}
|
||||
|
||||
func trueUpReviewStatusColumns() []string {
|
||||
return []string{
|
||||
"DueDate",
|
||||
"Completed",
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SqlTrueUpReviewStore) GetTrueUpReviewStatus(dueDate int64) (*model.TrueUpReviewStatus, error) {
|
||||
query := s.getQueryBuilder().
|
||||
Select("*").
|
||||
From("TrueUpReviewHistory").
|
||||
Where(sq.Eq{"DueDate": dueDate})
|
||||
|
||||
queryString, args, err := query.ToSql()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "get_trueUpReviewStatusRecord_tosql")
|
||||
}
|
||||
var trueUpReviewStatus model.TrueUpReviewStatus
|
||||
if err := s.GetReplicaX().Get(&trueUpReviewStatus, queryString, args...); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, store.NewErrNotFound("TrueUpReviewStatus", strconv.FormatInt(dueDate, 10))
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &trueUpReviewStatus, nil
|
||||
}
|
||||
|
||||
func (s *SqlTrueUpReviewStore) CreateTrueUpReviewStatusRecord(reviewStatus *model.TrueUpReviewStatus) (*model.TrueUpReviewStatus, error) {
|
||||
builder := s.getQueryBuilder().Insert("TrueUpReviewHistory").Columns(trueUpReviewStatusColumns()...).Values(reviewStatus.ToSlice()...)
|
||||
query, args, err := builder.ToSql()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "create_trueUpReviewStatusRecord_tosql")
|
||||
}
|
||||
|
||||
if _, err = s.GetMasterX().Exec(query, args...); err != nil {
|
||||
return nil, errors.Wrap(err, "fail to create true up review status record")
|
||||
}
|
||||
|
||||
return reviewStatus, nil
|
||||
}
|
||||
|
||||
func (s *SqlTrueUpReviewStore) Update(reviewStatus *model.TrueUpReviewStatus) (*model.TrueUpReviewStatus, error) {
|
||||
query := s.getQueryBuilder().
|
||||
Update("TrueUpReviewHistory").
|
||||
Set("Completed", reviewStatus.Completed).
|
||||
Where(sq.Eq{"DueDate": reviewStatus.DueDate})
|
||||
|
||||
if _, err := s.GetMasterX().ExecBuilder(query); err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to update true up review status with DueDate=%d", reviewStatus.DueDate)
|
||||
}
|
||||
|
||||
return reviewStatus, nil
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
|
||||
)
|
||||
|
||||
func TestTrueUpReviewStore(t *testing.T) {
|
||||
StoreTestWithSqlStore(t, storetest.TestTrueUpReviewStatusStore)
|
||||
}
|
||||
@@ -89,7 +89,6 @@ type Store interface {
|
||||
PostPriority() PostPriorityStore
|
||||
PostAcknowledgement() PostAcknowledgementStore
|
||||
PostPersistentNotification() PostPersistentNotificationStore
|
||||
TrueUpReview() TrueUpReviewStore
|
||||
DesktopTokens() DesktopTokensStore
|
||||
ChannelBookmark() ChannelBookmarkStore
|
||||
}
|
||||
@@ -1028,13 +1027,6 @@ type PostPersistentNotificationStore interface {
|
||||
DeleteByChannel(channelIds []string) error
|
||||
DeleteByTeam(teamIds []string) error
|
||||
}
|
||||
|
||||
type TrueUpReviewStore interface {
|
||||
GetTrueUpReviewStatus(dueDate int64) (*model.TrueUpReviewStatus, error)
|
||||
CreateTrueUpReviewStatusRecord(reviewStatus *model.TrueUpReviewStatus) (*model.TrueUpReviewStatus, error)
|
||||
Update(reviewStatus *model.TrueUpReviewStatus) (*model.TrueUpReviewStatus, error)
|
||||
}
|
||||
|
||||
type ChannelBookmarkStore interface {
|
||||
ErrorIfBookmarkFileInfoAlreadyAttached(fileId string) error
|
||||
Get(Id string, includeDeleted bool) (b *model.ChannelBookmarkWithFileInfo, err error)
|
||||
|
||||
@@ -1158,26 +1158,6 @@ func (_m *Store) TotalSearchDbConnections() int {
|
||||
return r0
|
||||
}
|
||||
|
||||
// TrueUpReview provides a mock function with given fields:
|
||||
func (_m *Store) TrueUpReview() store.TrueUpReviewStore {
|
||||
ret := _m.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for TrueUpReview")
|
||||
}
|
||||
|
||||
var r0 store.TrueUpReviewStore
|
||||
if rf, ok := ret.Get(0).(func() store.TrueUpReviewStore); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(store.TrueUpReviewStore)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// UnlockFromMaster provides a mock function with given fields:
|
||||
func (_m *Store) UnlockFromMaster() {
|
||||
_m.Called()
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
// Code generated by mockery v2.42.2. DO NOT EDIT.
|
||||
|
||||
// Regenerate this file using `make store-mocks`.
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
model "github.com/mattermost/mattermost/server/public/model"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// TrueUpReviewStore is an autogenerated mock type for the TrueUpReviewStore type
|
||||
type TrueUpReviewStore struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// CreateTrueUpReviewStatusRecord provides a mock function with given fields: reviewStatus
|
||||
func (_m *TrueUpReviewStore) CreateTrueUpReviewStatusRecord(reviewStatus *model.TrueUpReviewStatus) (*model.TrueUpReviewStatus, error) {
|
||||
ret := _m.Called(reviewStatus)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for CreateTrueUpReviewStatusRecord")
|
||||
}
|
||||
|
||||
var r0 *model.TrueUpReviewStatus
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(*model.TrueUpReviewStatus) (*model.TrueUpReviewStatus, error)); ok {
|
||||
return rf(reviewStatus)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(*model.TrueUpReviewStatus) *model.TrueUpReviewStatus); ok {
|
||||
r0 = rf(reviewStatus)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.TrueUpReviewStatus)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(*model.TrueUpReviewStatus) error); ok {
|
||||
r1 = rf(reviewStatus)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetTrueUpReviewStatus provides a mock function with given fields: dueDate
|
||||
func (_m *TrueUpReviewStore) GetTrueUpReviewStatus(dueDate int64) (*model.TrueUpReviewStatus, error) {
|
||||
ret := _m.Called(dueDate)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetTrueUpReviewStatus")
|
||||
}
|
||||
|
||||
var r0 *model.TrueUpReviewStatus
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(int64) (*model.TrueUpReviewStatus, error)); ok {
|
||||
return rf(dueDate)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(int64) *model.TrueUpReviewStatus); ok {
|
||||
r0 = rf(dueDate)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.TrueUpReviewStatus)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(int64) error); ok {
|
||||
r1 = rf(dueDate)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Update provides a mock function with given fields: reviewStatus
|
||||
func (_m *TrueUpReviewStore) Update(reviewStatus *model.TrueUpReviewStatus) (*model.TrueUpReviewStatus, error) {
|
||||
ret := _m.Called(reviewStatus)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Update")
|
||||
}
|
||||
|
||||
var r0 *model.TrueUpReviewStatus
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(*model.TrueUpReviewStatus) (*model.TrueUpReviewStatus, error)); ok {
|
||||
return rf(reviewStatus)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(*model.TrueUpReviewStatus) *model.TrueUpReviewStatus); ok {
|
||||
r0 = rf(reviewStatus)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.TrueUpReviewStatus)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(*model.TrueUpReviewStatus) error); ok {
|
||||
r1 = rf(reviewStatus)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// NewTrueUpReviewStore creates a new instance of TrueUpReviewStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewTrueUpReviewStore(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *TrueUpReviewStore {
|
||||
mock := &TrueUpReviewStore{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
@@ -63,7 +63,6 @@ type Store struct {
|
||||
PostPriorityStore mocks.PostPriorityStore
|
||||
PostAcknowledgementStore mocks.PostAcknowledgementStore
|
||||
PostPersistentNotificationStore mocks.PostPersistentNotificationStore
|
||||
TrueUpReviewStore mocks.TrueUpReviewStore
|
||||
DesktopTokensStore mocks.DesktopTokensStore
|
||||
ChannelBookmarkStore mocks.ChannelBookmarkStore
|
||||
}
|
||||
@@ -112,7 +111,6 @@ func (s *Store) ChannelMemberHistory() store.ChannelMemberHistoryStore {
|
||||
return &s.ChannelMemberHistoryStore
|
||||
}
|
||||
func (s *Store) ChannelBookmark() store.ChannelBookmarkStore { return &s.ChannelBookmarkStore }
|
||||
func (s *Store) TrueUpReview() store.TrueUpReviewStore { return &s.TrueUpReviewStore }
|
||||
func (s *Store) DesktopTokens() store.DesktopTokensStore { return &s.DesktopTokensStore }
|
||||
func (s *Store) NotifyAdmin() store.NotifyAdminStore { return &s.NotifyAdminStore }
|
||||
func (s *Store) Group() store.GroupStore { return &s.GroupStore }
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package storetest
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/utils"
|
||||
)
|
||||
|
||||
func TestTrueUpReviewStatusStore(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore) {
|
||||
t.Run("CreateTrueUpReviewStatusRecord", func(t *testing.T) { testCreateTrueUpReviewStatus(t, rctx, ss) })
|
||||
t.Run("GetTrueUpReviewStatus", func(t *testing.T) { testGetTrueUpReviewStatus(t, rctx, ss) })
|
||||
t.Run("Update", func(t *testing.T) { testUpdateTrueUpReviewStatus(t, rctx, ss) })
|
||||
}
|
||||
|
||||
func testCreateTrueUpReviewStatus(t *testing.T, rctx request.CTX, ss store.Store) {
|
||||
now := time.Date(time.Now().Year(), time.January, 1, 0, 0, 0, 0, time.Local)
|
||||
|
||||
reviewStatus := model.TrueUpReviewStatus{
|
||||
Completed: true,
|
||||
DueDate: utils.GetNextTrueUpReviewDueDate(now).UnixMilli(),
|
||||
}
|
||||
|
||||
t.Run("create true up review status", func(t *testing.T) {
|
||||
resp, err := ss.TrueUpReview().CreateTrueUpReviewStatusRecord(&reviewStatus)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, reviewStatus.Completed, resp.Completed)
|
||||
assert.Equal(t, reviewStatus.DueDate, resp.DueDate)
|
||||
})
|
||||
}
|
||||
|
||||
func testGetTrueUpReviewStatus(t *testing.T, rctx request.CTX, ss store.Store) {
|
||||
now := time.Date(time.Now().Year(), time.August, 1, 0, 0, 0, 0, time.Local)
|
||||
dueDate := utils.GetNextTrueUpReviewDueDate(now).UnixMilli()
|
||||
|
||||
reviewStatus := model.TrueUpReviewStatus{
|
||||
Completed: true,
|
||||
DueDate: dueDate,
|
||||
}
|
||||
|
||||
_, err := ss.TrueUpReview().CreateTrueUpReviewStatusRecord(&reviewStatus)
|
||||
assert.NoError(t, err)
|
||||
|
||||
t.Run("get true up review status", func(t *testing.T) {
|
||||
resp, err := ss.TrueUpReview().GetTrueUpReviewStatus(dueDate)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, resp.Completed, resp.Completed)
|
||||
assert.Equal(t, resp.DueDate, resp.DueDate)
|
||||
})
|
||||
}
|
||||
|
||||
func testUpdateTrueUpReviewStatus(t *testing.T, rctx request.CTX, ss store.Store) {
|
||||
now := time.Date(time.Now().Year(), time.April, 1, 0, 0, 0, 0, time.Local)
|
||||
|
||||
reviewStatus := model.TrueUpReviewStatus{
|
||||
Completed: false,
|
||||
DueDate: utils.GetNextTrueUpReviewDueDate(now).UnixMilli(),
|
||||
}
|
||||
|
||||
_, err := ss.TrueUpReview().CreateTrueUpReviewStatusRecord(&reviewStatus)
|
||||
assert.NoError(t, err)
|
||||
|
||||
t.Run("save ", func(t *testing.T) {
|
||||
reviewStatus.Completed = true
|
||||
resp, err := ss.TrueUpReview().Update(&reviewStatus)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, resp.Completed, resp.Completed)
|
||||
assert.Equal(t, resp.DueDate, resp.DueDate)
|
||||
})
|
||||
}
|
||||
@@ -59,7 +59,6 @@ type TimerLayer struct {
|
||||
TermsOfServiceStore store.TermsOfServiceStore
|
||||
ThreadStore store.ThreadStore
|
||||
TokenStore store.TokenStore
|
||||
TrueUpReviewStore store.TrueUpReviewStore
|
||||
UploadSessionStore store.UploadSessionStore
|
||||
UserStore store.UserStore
|
||||
UserAccessTokenStore store.UserAccessTokenStore
|
||||
@@ -227,10 +226,6 @@ func (s *TimerLayer) Token() store.TokenStore {
|
||||
return s.TokenStore
|
||||
}
|
||||
|
||||
func (s *TimerLayer) TrueUpReview() store.TrueUpReviewStore {
|
||||
return s.TrueUpReviewStore
|
||||
}
|
||||
|
||||
func (s *TimerLayer) UploadSession() store.UploadSessionStore {
|
||||
return s.UploadSessionStore
|
||||
}
|
||||
@@ -451,11 +446,6 @@ type TimerLayerTokenStore struct {
|
||||
Root *TimerLayer
|
||||
}
|
||||
|
||||
type TimerLayerTrueUpReviewStore struct {
|
||||
store.TrueUpReviewStore
|
||||
Root *TimerLayer
|
||||
}
|
||||
|
||||
type TimerLayerUploadSessionStore struct {
|
||||
store.UploadSessionStore
|
||||
Root *TimerLayer
|
||||
@@ -10000,54 +9990,6 @@ func (s *TimerLayerTokenStore) Save(recovery *model.Token) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TimerLayerTrueUpReviewStore) CreateTrueUpReviewStatusRecord(reviewStatus *model.TrueUpReviewStatus) (*model.TrueUpReviewStatus, error) {
|
||||
start := time.Now()
|
||||
|
||||
result, err := s.TrueUpReviewStore.CreateTrueUpReviewStatusRecord(reviewStatus)
|
||||
|
||||
elapsed := float64(time.Since(start)) / float64(time.Second)
|
||||
if s.Root.Metrics != nil {
|
||||
success := "false"
|
||||
if err == nil {
|
||||
success = "true"
|
||||
}
|
||||
s.Root.Metrics.ObserveStoreMethodDuration("TrueUpReviewStore.CreateTrueUpReviewStatusRecord", success, elapsed)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *TimerLayerTrueUpReviewStore) GetTrueUpReviewStatus(dueDate int64) (*model.TrueUpReviewStatus, error) {
|
||||
start := time.Now()
|
||||
|
||||
result, err := s.TrueUpReviewStore.GetTrueUpReviewStatus(dueDate)
|
||||
|
||||
elapsed := float64(time.Since(start)) / float64(time.Second)
|
||||
if s.Root.Metrics != nil {
|
||||
success := "false"
|
||||
if err == nil {
|
||||
success = "true"
|
||||
}
|
||||
s.Root.Metrics.ObserveStoreMethodDuration("TrueUpReviewStore.GetTrueUpReviewStatus", success, elapsed)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *TimerLayerTrueUpReviewStore) Update(reviewStatus *model.TrueUpReviewStatus) (*model.TrueUpReviewStatus, error) {
|
||||
start := time.Now()
|
||||
|
||||
result, err := s.TrueUpReviewStore.Update(reviewStatus)
|
||||
|
||||
elapsed := float64(time.Since(start)) / float64(time.Second)
|
||||
if s.Root.Metrics != nil {
|
||||
success := "false"
|
||||
if err == nil {
|
||||
success = "true"
|
||||
}
|
||||
s.Root.Metrics.ObserveStoreMethodDuration("TrueUpReviewStore.Update", success, elapsed)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *TimerLayerUploadSessionStore) Delete(id string) error {
|
||||
start := time.Now()
|
||||
|
||||
@@ -12140,7 +12082,6 @@ func New(childStore store.Store, metrics einterfaces.MetricsInterface) *TimerLay
|
||||
newStore.TermsOfServiceStore = &TimerLayerTermsOfServiceStore{TermsOfServiceStore: childStore.TermsOfService(), Root: &newStore}
|
||||
newStore.ThreadStore = &TimerLayerThreadStore{ThreadStore: childStore.Thread(), Root: &newStore}
|
||||
newStore.TokenStore = &TimerLayerTokenStore{TokenStore: childStore.Token(), Root: &newStore}
|
||||
newStore.TrueUpReviewStore = &TimerLayerTrueUpReviewStore{TrueUpReviewStore: childStore.TrueUpReview(), Root: &newStore}
|
||||
newStore.UploadSessionStore = &TimerLayerUploadSessionStore{UploadSessionStore: childStore.UploadSession(), Root: &newStore}
|
||||
newStore.UserStore = &TimerLayerUserStore{UserStore: childStore.User(), Root: &newStore}
|
||||
newStore.UserAccessTokenStore = &TimerLayerUserAccessTokenStore{UserAccessTokenStore: childStore.UserAccessToken(), Root: &newStore}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package utils
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const trueUpReviewDueDay = 15
|
||||
const day = time.Hour * 24
|
||||
|
||||
type DueDateWindow struct {
|
||||
Start time.Time
|
||||
End time.Time
|
||||
}
|
||||
|
||||
func GetNextTrueUpReviewDueDate(now time.Time) time.Time {
|
||||
nowYear := now.Year()
|
||||
nowMonth := now.Month()
|
||||
nowDay := now.Day()
|
||||
finalQuarterYear := nowYear
|
||||
if nowMonth >= time.October && nowMonth <= time.December {
|
||||
finalQuarterYear = nowYear + 1
|
||||
}
|
||||
trueUpSubmissionWindows := []DueDateWindow{
|
||||
{
|
||||
Start: time.Date(now.Year(), time.January, 16, 0, 0, 0, 0, now.Location()),
|
||||
End: time.Date(now.Year(), time.April, 15, 0, 0, 0, 0, now.Location()),
|
||||
},
|
||||
{
|
||||
Start: time.Date(now.Year(), time.April, 16, 0, 0, 0, 0, now.Location()),
|
||||
End: time.Date(now.Year(), time.July, 15, 0, 0, 0, 0, now.Location()),
|
||||
},
|
||||
{
|
||||
Start: time.Date(now.Year(), time.July, 16, 0, 0, 0, 0, now.Location()),
|
||||
End: time.Date(now.Year(), time.October, 15, 0, 0, 0, 0, now.Location()),
|
||||
},
|
||||
{
|
||||
Start: time.Date(now.Year(), time.October, 16, 0, 0, 0, 0, now.Location()),
|
||||
End: time.Date(finalQuarterYear, time.January, 15, 0, 0, 0, 0, now.Location()),
|
||||
},
|
||||
}
|
||||
|
||||
for _, window := range trueUpSubmissionWindows {
|
||||
withinWindow := false
|
||||
// Our due dates "wrap" around (i.e. can go into the next year), so we'll need to check the months different. Since January = 1 and December = 12, the checks
|
||||
// for the current month being greater or equal to the start month and less than or equal to the end month will not work.
|
||||
if window.End.Month() == time.January {
|
||||
withinWindow = (nowMonth != time.January && nowMonth >= window.Start.Month()) || nowMonth == window.End.Month()
|
||||
} else {
|
||||
withinWindow = nowMonth >= window.Start.Month() && nowMonth <= window.End.Month()
|
||||
}
|
||||
|
||||
// Only check the days if the current month is equal to the start or end months.
|
||||
// The dates of the middle month(s) don't matter so much.
|
||||
isFirstMonth := nowMonth == window.Start.Month()
|
||||
if isFirstMonth {
|
||||
withinWindow = withinWindow && nowDay >= window.Start.Day()
|
||||
}
|
||||
isFinalMonth := nowMonth == window.End.Month()
|
||||
if isFinalMonth {
|
||||
withinWindow = withinWindow && nowDay <= window.End.Day()
|
||||
}
|
||||
|
||||
if withinWindow {
|
||||
return window.End
|
||||
}
|
||||
}
|
||||
|
||||
return trueUpSubmissionWindows[0].End
|
||||
}
|
||||
|
||||
func IsTrueUpReviewDueDateWithinTheNext30Days(now time.Time, dueDate time.Time) bool {
|
||||
dueDateWindow := dueDate.Add(-day * 30)
|
||||
|
||||
if now.Before(dueDateWindow) || now.After(dueDate) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetNextTrueUpReviewDueDate(t *testing.T) {
|
||||
t.Run("Due date always falls on the 15th", func(t *testing.T) {
|
||||
// Before the 15th
|
||||
now := time.Date(2022, time.March, 14, 0, 0, 0, 0, time.Local)
|
||||
due := GetNextTrueUpReviewDueDate(now)
|
||||
assert.Equal(t, trueUpReviewDueDay, due.Day())
|
||||
|
||||
// On the 15th
|
||||
now = time.Date(2022, time.December, 15, 0, 0, 0, 0, time.Local)
|
||||
due = GetNextTrueUpReviewDueDate(now)
|
||||
assert.Equal(t, trueUpReviewDueDay, due.Day())
|
||||
|
||||
// After the 15th
|
||||
now = time.Date(2022, time.September, 16, 0, 0, 0, 0, time.Local)
|
||||
due = GetNextTrueUpReviewDueDate(now)
|
||||
assert.Equal(t, trueUpReviewDueDay, due.Day())
|
||||
})
|
||||
|
||||
t.Run("Due date will always be in next quarter if the current date is past the 15th", func(t *testing.T) {
|
||||
now := time.Date(2022, time.March, 16, 0, 0, 0, 0, time.Local)
|
||||
due := GetNextTrueUpReviewDueDate(now)
|
||||
assert.Equal(t, time.April, due.Month())
|
||||
|
||||
now = time.Date(2022, time.June, 16, 0, 0, 0, 0, time.Local)
|
||||
due = GetNextTrueUpReviewDueDate(now)
|
||||
assert.Equal(t, time.July, due.Month())
|
||||
|
||||
now = time.Date(2022, time.September, 16, 0, 0, 0, 0, time.Local)
|
||||
due = GetNextTrueUpReviewDueDate(now)
|
||||
assert.Equal(t, time.October, due.Month())
|
||||
|
||||
now = time.Date(2022, time.December, 16, 0, 0, 0, 0, time.Local)
|
||||
due = GetNextTrueUpReviewDueDate(now)
|
||||
assert.Equal(t, time.January, due.Month())
|
||||
})
|
||||
|
||||
t.Run("Due date will always be in the current quarter if the current date is before or on the 15th", func(t *testing.T) {
|
||||
now := time.Date(2022, time.April, 15, 0, 0, 0, 0, time.Local)
|
||||
due := GetNextTrueUpReviewDueDate(now)
|
||||
assert.Equal(t, time.April, due.Month())
|
||||
|
||||
now = time.Date(2022, time.July, 15, 0, 0, 0, 0, time.Local)
|
||||
due = GetNextTrueUpReviewDueDate(now)
|
||||
assert.Equal(t, time.July, due.Month())
|
||||
|
||||
now = time.Date(2022, time.October, 14, 0, 0, 0, 0, time.Local)
|
||||
due = GetNextTrueUpReviewDueDate(now)
|
||||
assert.Equal(t, time.October, due.Month())
|
||||
|
||||
now = time.Date(2022, time.January, 14, 0, 0, 0, 0, time.Local)
|
||||
due = GetNextTrueUpReviewDueDate(now)
|
||||
assert.Equal(t, time.January, due.Month())
|
||||
})
|
||||
|
||||
t.Run("Due date will be in the next year if the next quarter is not within the current year", func(t *testing.T) {
|
||||
now := time.Date(2022, time.October, 21, 0, 0, 0, 0, time.Local)
|
||||
due := GetNextTrueUpReviewDueDate(now)
|
||||
assert.Equal(t, time.January, due.Month())
|
||||
assert.Equal(t, 2023, due.Year())
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsTrueUpReviewDueDateWithinTheNext15Days(t *testing.T) {
|
||||
t.Run("Ensure a date within 30 days before the due date returns true", func(t *testing.T) {
|
||||
// 1 Day before the due date
|
||||
now := time.Date(2022, time.March, 16, 0, 0, 0, 0, time.Local)
|
||||
// Due date is December 15th, 2022
|
||||
due := GetNextTrueUpReviewDueDate(now)
|
||||
|
||||
res := IsTrueUpReviewDueDateWithinTheNext30Days(now, due)
|
||||
assert.True(t, res)
|
||||
})
|
||||
|
||||
t.Run("Ensure a date that is more than two weeks before the due date returns false", func(t *testing.T) {
|
||||
// 15 Days before the due date
|
||||
now := time.Date(2022, time.October, 16, 0, 0, 0, 0, time.Local)
|
||||
// Due date is December 15th, 2022
|
||||
due := GetNextTrueUpReviewDueDate(now)
|
||||
|
||||
res := IsTrueUpReviewDueDateWithinTheNext30Days(now, due)
|
||||
assert.False(t, res)
|
||||
})
|
||||
|
||||
t.Run("Ensure a date that is past the due date returns false", func(t *testing.T) {
|
||||
now := time.Date(2022, time.April, 15, 0, 0, 0, 0, time.Local)
|
||||
|
||||
// Due date is April 16th, 2022
|
||||
dueNow := time.Date(2022, time.April, 16, 0, 0, 0, 0, time.Local)
|
||||
due := GetNextTrueUpReviewDueDate(dueNow)
|
||||
|
||||
res := IsTrueUpReviewDueDateWithinTheNext30Days(now, due)
|
||||
assert.False(t, res)
|
||||
})
|
||||
|
||||
t.Run("Ensure a date that is on the due date returns true", func(t *testing.T) {
|
||||
now := time.Date(2022, time.January, 15, 0, 0, 0, 0, time.Local)
|
||||
due := GetNextTrueUpReviewDueDate(now)
|
||||
fmt.Printf("\n\ndue date: %s\n\n", due.Format("2006-Jan-02"))
|
||||
fmt.Printf("\n\nnow: %s\n\n", now.Format("2006-Jan-02"))
|
||||
|
||||
res := IsTrueUpReviewDueDateWithinTheNext30Days(now, due)
|
||||
assert.True(t, res)
|
||||
})
|
||||
|
||||
t.Run("Ensure a date that is on the first day of the due date window returns true", func(t *testing.T) {
|
||||
now := time.Date(2022, time.December, 16, 0, 0, 0, 0, time.Local)
|
||||
due := GetNextTrueUpReviewDueDate(now)
|
||||
|
||||
res := IsTrueUpReviewDueDateWithinTheNext30Days(now, due)
|
||||
assert.True(t, res)
|
||||
})
|
||||
}
|
||||
@@ -258,11 +258,6 @@ func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
|
||||
cloudCSP := ""
|
||||
if c.App.Channels().License().IsCloud() || *c.App.Config().ServiceSettings.SelfHostedPurchase {
|
||||
cloudCSP = " js.stripe.com/v3"
|
||||
}
|
||||
|
||||
if h.IsStatic {
|
||||
// Instruct the browser not to display us in an iframe unless is the same origin for anti-clickjacking
|
||||
w.Header().Set("X-Frame-Options", "SAMEORIGIN")
|
||||
@@ -271,9 +266,8 @@ func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Set content security policy. This is also specified in the root.html of the webapp in a meta tag.
|
||||
w.Header().Set("Content-Security-Policy", fmt.Sprintf(
|
||||
"frame-ancestors %s; script-src 'self' cdn.rudderlabs.com%s%s%s",
|
||||
"frame-ancestors %s; script-src 'self' cdn.rudderlabs.com%s%s",
|
||||
frameAncestors,
|
||||
cloudCSP,
|
||||
h.cspShaDirective,
|
||||
devCSP,
|
||||
))
|
||||
|
||||
@@ -336,29 +336,6 @@ func TestHandlerServeCSPHeader(t *testing.T) {
|
||||
IsStatic: true,
|
||||
}
|
||||
|
||||
request := httptest.NewRequest("POST", "/", nil)
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
assert.Equal(t, 200, response.Code)
|
||||
assert.Equal(t, []string{"frame-ancestors " + frameAncestors + "; script-src 'self' cdn.rudderlabs.com js.stripe.com/v3"}, response.Header()["Content-Security-Policy"])
|
||||
})
|
||||
|
||||
t.Run("static, without subpath or SelfHostedPurchase, does not allow Stripe in CSP", func(t *testing.T) {
|
||||
th := Setup(t).InitBasic()
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SelfHostedPurchase = false })
|
||||
defer th.TearDown()
|
||||
|
||||
web := New(th.Server)
|
||||
|
||||
handler := Handler{
|
||||
Srv: web.srv,
|
||||
HandleFunc: handlerForCSPHeader,
|
||||
RequireSession: false,
|
||||
TrustRequester: false,
|
||||
RequireMfa: false,
|
||||
IsStatic: true,
|
||||
}
|
||||
|
||||
request := httptest.NewRequest("POST", "/", nil)
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
@@ -404,7 +381,7 @@ func TestHandlerServeCSPHeader(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
assert.Equal(t, 200, response.Code)
|
||||
assert.Equal(t, []string{"frame-ancestors " + frameAncestors + "; script-src 'self' cdn.rudderlabs.com js.stripe.com/v3"}, response.Header()["Content-Security-Policy"])
|
||||
assert.Equal(t, []string{"frame-ancestors " + frameAncestors + "; script-src 'self' cdn.rudderlabs.com"}, response.Header()["Content-Security-Policy"])
|
||||
|
||||
// TODO: It's hard to unit test this now that the CSP directive is effectively
|
||||
// decided in Setup(). Circle back to this in master once the memory store is
|
||||
@@ -419,7 +396,7 @@ func TestHandlerServeCSPHeader(t *testing.T) {
|
||||
response = httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
assert.Equal(t, 200, response.Code)
|
||||
assert.Equal(t, []string{"frame-ancestors " + frameAncestors + "; script-src 'self' cdn.rudderlabs.com js.stripe.com/v3"}, response.Header()["Content-Security-Policy"])
|
||||
assert.Equal(t, []string{"frame-ancestors " + frameAncestors + "; script-src 'self' cdn.rudderlabs.com"}, response.Header()["Content-Security-Policy"])
|
||||
// TODO: See above.
|
||||
// assert.Contains(t, response.Header()["Content-Security-Policy"], "frame-ancestors 'self'; script-src 'self' cdn.rudderlabs.com 'sha256-tPOjw+tkVs9axL78ZwGtYl975dtyPHB6LYKAO2R3gR4='", "csp header incorrectly changed after subpath changed")
|
||||
})
|
||||
@@ -449,7 +426,7 @@ func TestHandlerServeCSPHeader(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
assert.Equal(t, 200, response.Code)
|
||||
assert.Equal(t, []string{"frame-ancestors " + frameAncestors + "; script-src 'self' cdn.rudderlabs.com js.stripe.com/v3 'unsafe-eval' 'unsafe-inline'"}, response.Header()["Content-Security-Policy"])
|
||||
assert.Equal(t, []string{"frame-ancestors " + frameAncestors + "; script-src 'self' cdn.rudderlabs.com 'unsafe-eval' 'unsafe-inline'"}, response.Header()["Content-Security-Policy"])
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ package einterfaces
|
||||
|
||||
import (
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/public/shared/request"
|
||||
)
|
||||
|
||||
type CloudInterface interface {
|
||||
@@ -14,11 +13,7 @@ type CloudInterface interface {
|
||||
GetSelfHostedProducts(userID string) ([]*model.Product, error)
|
||||
GetCloudLimits(userID string) (*model.ProductLimits, error)
|
||||
|
||||
CreateCustomerPayment(userID string) (*model.StripeSetupIntent, error)
|
||||
ConfirmCustomerPayment(userID string, confirmRequest *model.ConfirmPaymentMethodRequest) error
|
||||
|
||||
GetCloudCustomer(userID string) (*model.CloudCustomer, error)
|
||||
GetLicenseSelfServeStatus(userID string, token string) (*model.SubscriptionLicenseSelfServeStatusResponse, error)
|
||||
UpdateCloudCustomer(userID string, customerInfo *model.CloudCustomerInfo) (*model.CloudCustomer, error)
|
||||
UpdateCloudCustomerAddress(userID string, address *model.Address) (*model.CloudCustomer, error)
|
||||
|
||||
@@ -28,32 +23,17 @@ type CloudInterface interface {
|
||||
|
||||
ChangeSubscription(userID, subscriptionID string, subscriptionChange *model.SubscriptionChange) (*model.Subscription, error)
|
||||
|
||||
RequestCloudTrial(userID, subscriptionID, newValidBusinessEmail string) (*model.Subscription, error)
|
||||
ValidateBusinessEmail(userID, email string) error
|
||||
|
||||
InvalidateCaches() error
|
||||
|
||||
// hosted customer methods
|
||||
SelfHostedSignupAvailable() error
|
||||
BootstrapSelfHostedSignup(req model.BootstrapSelfHostedSignupRequest) (*model.BootstrapSelfHostedSignupResponse, error)
|
||||
CreateCustomerSelfHostedSignup(req model.SelfHostedCustomerForm, requesterEmail string) (*model.SelfHostedSignupCustomerResponse, error)
|
||||
ConfirmSelfHostedSignup(req model.SelfHostedConfirmPaymentMethodRequest, requesterEmail string) (*model.SelfHostedSignupConfirmResponse, error)
|
||||
ConfirmSelfHostedExpansion(req model.SelfHostedConfirmPaymentMethodRequest, requesterEmail string) (*model.SelfHostedSignupConfirmResponse, error)
|
||||
ConfirmSelfHostedSignupLicenseApplication() error
|
||||
GetSelfHostedInvoices(rctx request.CTX) ([]*model.Invoice, error)
|
||||
GetSelfHostedInvoicePDF(invoiceID string) ([]byte, string, error)
|
||||
|
||||
CreateOrUpdateSubscriptionHistoryEvent(userID string, userCount int) (*model.SubscriptionHistory, error)
|
||||
HandleLicenseChange() error
|
||||
|
||||
CheckCWSConnection(userId string) error
|
||||
|
||||
SelfServeDeleteWorkspace(userID string, deletionRequest *model.WorkspaceDeletionRequest) error
|
||||
SubscribeToNewsletter(userID string, req *model.SubscribeNewsletterRequest) error
|
||||
|
||||
// Used only for when a customer has telemetry disabled. In this scenario, true up review telemetry will be submitted via CWS.
|
||||
SubmitTrueUpReview(userID string, trueUpReviewProfile map[string]any) error
|
||||
|
||||
ApplyIPFilters(userID string, ranges *model.AllowedIPRanges) (*model.AllowedIPRanges, error)
|
||||
GetIPFilters(userID string) (*model.AllowedIPRanges, error)
|
||||
GetInstallation(userID string) (*model.Installation, error)
|
||||
|
||||
@@ -6,7 +6,6 @@ package mocks
|
||||
|
||||
import (
|
||||
model "github.com/mattermost/mattermost/server/public/model"
|
||||
request "github.com/mattermost/mattermost/server/public/shared/request"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
@@ -45,36 +44,6 @@ func (_m *CloudInterface) ApplyIPFilters(userID string, ranges *model.AllowedIPR
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// BootstrapSelfHostedSignup provides a mock function with given fields: req
|
||||
func (_m *CloudInterface) BootstrapSelfHostedSignup(req model.BootstrapSelfHostedSignupRequest) (*model.BootstrapSelfHostedSignupResponse, error) {
|
||||
ret := _m.Called(req)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for BootstrapSelfHostedSignup")
|
||||
}
|
||||
|
||||
var r0 *model.BootstrapSelfHostedSignupResponse
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(model.BootstrapSelfHostedSignupRequest) (*model.BootstrapSelfHostedSignupResponse, error)); ok {
|
||||
return rf(req)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(model.BootstrapSelfHostedSignupRequest) *model.BootstrapSelfHostedSignupResponse); ok {
|
||||
r0 = rf(req)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.BootstrapSelfHostedSignupResponse)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(model.BootstrapSelfHostedSignupRequest) error); ok {
|
||||
r1 = rf(req)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// ChangeSubscription provides a mock function with given fields: userID, subscriptionID, subscriptionChange
|
||||
func (_m *CloudInterface) ChangeSubscription(userID string, subscriptionID string, subscriptionChange *model.SubscriptionChange) (*model.Subscription, error) {
|
||||
ret := _m.Called(userID, subscriptionID, subscriptionChange)
|
||||
@@ -123,162 +92,6 @@ func (_m *CloudInterface) CheckCWSConnection(userId string) error {
|
||||
return r0
|
||||
}
|
||||
|
||||
// ConfirmCustomerPayment provides a mock function with given fields: userID, confirmRequest
|
||||
func (_m *CloudInterface) ConfirmCustomerPayment(userID string, confirmRequest *model.ConfirmPaymentMethodRequest) error {
|
||||
ret := _m.Called(userID, confirmRequest)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for ConfirmCustomerPayment")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(string, *model.ConfirmPaymentMethodRequest) error); ok {
|
||||
r0 = rf(userID, confirmRequest)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// ConfirmSelfHostedExpansion provides a mock function with given fields: req, requesterEmail
|
||||
func (_m *CloudInterface) ConfirmSelfHostedExpansion(req model.SelfHostedConfirmPaymentMethodRequest, requesterEmail string) (*model.SelfHostedSignupConfirmResponse, error) {
|
||||
ret := _m.Called(req, requesterEmail)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for ConfirmSelfHostedExpansion")
|
||||
}
|
||||
|
||||
var r0 *model.SelfHostedSignupConfirmResponse
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(model.SelfHostedConfirmPaymentMethodRequest, string) (*model.SelfHostedSignupConfirmResponse, error)); ok {
|
||||
return rf(req, requesterEmail)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(model.SelfHostedConfirmPaymentMethodRequest, string) *model.SelfHostedSignupConfirmResponse); ok {
|
||||
r0 = rf(req, requesterEmail)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.SelfHostedSignupConfirmResponse)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(model.SelfHostedConfirmPaymentMethodRequest, string) error); ok {
|
||||
r1 = rf(req, requesterEmail)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// ConfirmSelfHostedSignup provides a mock function with given fields: req, requesterEmail
|
||||
func (_m *CloudInterface) ConfirmSelfHostedSignup(req model.SelfHostedConfirmPaymentMethodRequest, requesterEmail string) (*model.SelfHostedSignupConfirmResponse, error) {
|
||||
ret := _m.Called(req, requesterEmail)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for ConfirmSelfHostedSignup")
|
||||
}
|
||||
|
||||
var r0 *model.SelfHostedSignupConfirmResponse
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(model.SelfHostedConfirmPaymentMethodRequest, string) (*model.SelfHostedSignupConfirmResponse, error)); ok {
|
||||
return rf(req, requesterEmail)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(model.SelfHostedConfirmPaymentMethodRequest, string) *model.SelfHostedSignupConfirmResponse); ok {
|
||||
r0 = rf(req, requesterEmail)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.SelfHostedSignupConfirmResponse)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(model.SelfHostedConfirmPaymentMethodRequest, string) error); ok {
|
||||
r1 = rf(req, requesterEmail)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// ConfirmSelfHostedSignupLicenseApplication provides a mock function with given fields:
|
||||
func (_m *CloudInterface) ConfirmSelfHostedSignupLicenseApplication() error {
|
||||
ret := _m.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for ConfirmSelfHostedSignupLicenseApplication")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func() error); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// CreateCustomerPayment provides a mock function with given fields: userID
|
||||
func (_m *CloudInterface) CreateCustomerPayment(userID string) (*model.StripeSetupIntent, error) {
|
||||
ret := _m.Called(userID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for CreateCustomerPayment")
|
||||
}
|
||||
|
||||
var r0 *model.StripeSetupIntent
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(string) (*model.StripeSetupIntent, error)); ok {
|
||||
return rf(userID)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(string) *model.StripeSetupIntent); ok {
|
||||
r0 = rf(userID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.StripeSetupIntent)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(string) error); ok {
|
||||
r1 = rf(userID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// CreateCustomerSelfHostedSignup provides a mock function with given fields: req, requesterEmail
|
||||
func (_m *CloudInterface) CreateCustomerSelfHostedSignup(req model.SelfHostedCustomerForm, requesterEmail string) (*model.SelfHostedSignupCustomerResponse, error) {
|
||||
ret := _m.Called(req, requesterEmail)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for CreateCustomerSelfHostedSignup")
|
||||
}
|
||||
|
||||
var r0 *model.SelfHostedSignupCustomerResponse
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(model.SelfHostedCustomerForm, string) (*model.SelfHostedSignupCustomerResponse, error)); ok {
|
||||
return rf(req, requesterEmail)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(model.SelfHostedCustomerForm, string) *model.SelfHostedSignupCustomerResponse); ok {
|
||||
r0 = rf(req, requesterEmail)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.SelfHostedSignupCustomerResponse)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(model.SelfHostedCustomerForm, string) error); ok {
|
||||
r1 = rf(req, requesterEmail)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// CreateOrUpdateSubscriptionHistoryEvent provides a mock function with given fields: userID, userCount
|
||||
func (_m *CloudInterface) CreateOrUpdateSubscriptionHistoryEvent(userID string, userCount int) (*model.SubscriptionHistory, error) {
|
||||
ret := _m.Called(userID, userCount)
|
||||
@@ -556,103 +369,6 @@ func (_m *CloudInterface) GetInvoicesForSubscription(userID string) ([]*model.In
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetLicenseSelfServeStatus provides a mock function with given fields: userID, token
|
||||
func (_m *CloudInterface) GetLicenseSelfServeStatus(userID string, token string) (*model.SubscriptionLicenseSelfServeStatusResponse, error) {
|
||||
ret := _m.Called(userID, token)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetLicenseSelfServeStatus")
|
||||
}
|
||||
|
||||
var r0 *model.SubscriptionLicenseSelfServeStatusResponse
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(string, string) (*model.SubscriptionLicenseSelfServeStatusResponse, error)); ok {
|
||||
return rf(userID, token)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(string, string) *model.SubscriptionLicenseSelfServeStatusResponse); ok {
|
||||
r0 = rf(userID, token)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.SubscriptionLicenseSelfServeStatusResponse)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(string, string) error); ok {
|
||||
r1 = rf(userID, token)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetSelfHostedInvoicePDF provides a mock function with given fields: invoiceID
|
||||
func (_m *CloudInterface) GetSelfHostedInvoicePDF(invoiceID string) ([]byte, string, error) {
|
||||
ret := _m.Called(invoiceID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetSelfHostedInvoicePDF")
|
||||
}
|
||||
|
||||
var r0 []byte
|
||||
var r1 string
|
||||
var r2 error
|
||||
if rf, ok := ret.Get(0).(func(string) ([]byte, string, error)); ok {
|
||||
return rf(invoiceID)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(string) []byte); ok {
|
||||
r0 = rf(invoiceID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]byte)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(string) string); ok {
|
||||
r1 = rf(invoiceID)
|
||||
} else {
|
||||
r1 = ret.Get(1).(string)
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(2).(func(string) error); ok {
|
||||
r2 = rf(invoiceID)
|
||||
} else {
|
||||
r2 = ret.Error(2)
|
||||
}
|
||||
|
||||
return r0, r1, r2
|
||||
}
|
||||
|
||||
// GetSelfHostedInvoices provides a mock function with given fields: rctx
|
||||
func (_m *CloudInterface) GetSelfHostedInvoices(rctx request.CTX) ([]*model.Invoice, error) {
|
||||
ret := _m.Called(rctx)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for GetSelfHostedInvoices")
|
||||
}
|
||||
|
||||
var r0 []*model.Invoice
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(request.CTX) ([]*model.Invoice, error)); ok {
|
||||
return rf(rctx)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(request.CTX) []*model.Invoice); ok {
|
||||
r0 = rf(rctx)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*model.Invoice)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(request.CTX) error); ok {
|
||||
r1 = rf(rctx)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetSelfHostedProducts provides a mock function with given fields: userID
|
||||
func (_m *CloudInterface) GetSelfHostedProducts(userID string) ([]*model.Product, error) {
|
||||
ret := _m.Called(userID)
|
||||
@@ -749,90 +465,6 @@ func (_m *CloudInterface) InvalidateCaches() error {
|
||||
return r0
|
||||
}
|
||||
|
||||
// RequestCloudTrial provides a mock function with given fields: userID, subscriptionID, newValidBusinessEmail
|
||||
func (_m *CloudInterface) RequestCloudTrial(userID string, subscriptionID string, newValidBusinessEmail string) (*model.Subscription, error) {
|
||||
ret := _m.Called(userID, subscriptionID, newValidBusinessEmail)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for RequestCloudTrial")
|
||||
}
|
||||
|
||||
var r0 *model.Subscription
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(string, string, string) (*model.Subscription, error)); ok {
|
||||
return rf(userID, subscriptionID, newValidBusinessEmail)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(string, string, string) *model.Subscription); ok {
|
||||
r0 = rf(userID, subscriptionID, newValidBusinessEmail)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*model.Subscription)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(string, string, string) error); ok {
|
||||
r1 = rf(userID, subscriptionID, newValidBusinessEmail)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// SelfHostedSignupAvailable provides a mock function with given fields:
|
||||
func (_m *CloudInterface) SelfHostedSignupAvailable() error {
|
||||
ret := _m.Called()
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for SelfHostedSignupAvailable")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func() error); ok {
|
||||
r0 = rf()
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// SelfServeDeleteWorkspace provides a mock function with given fields: userID, deletionRequest
|
||||
func (_m *CloudInterface) SelfServeDeleteWorkspace(userID string, deletionRequest *model.WorkspaceDeletionRequest) error {
|
||||
ret := _m.Called(userID, deletionRequest)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for SelfServeDeleteWorkspace")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(string, *model.WorkspaceDeletionRequest) error); ok {
|
||||
r0 = rf(userID, deletionRequest)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// SubmitTrueUpReview provides a mock function with given fields: userID, trueUpReviewProfile
|
||||
func (_m *CloudInterface) SubmitTrueUpReview(userID string, trueUpReviewProfile map[string]interface{}) error {
|
||||
ret := _m.Called(userID, trueUpReviewProfile)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for SubmitTrueUpReview")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(string, map[string]interface{}) error); ok {
|
||||
r0 = rf(userID, trueUpReviewProfile)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// SubscribeToNewsletter provides a mock function with given fields: userID, req
|
||||
func (_m *CloudInterface) SubscribeToNewsletter(userID string, req *model.SubscribeNewsletterRequest) error {
|
||||
ret := _m.Called(userID, req)
|
||||
|
||||
@@ -2272,14 +2272,6 @@
|
||||
"id": "api.license.request-trial.can-start-trial.not-allowed",
|
||||
"translation": "Failed to apply new trial license. You have previously applied a trial license to this Mattermost instance.. If you would like to extend your trial period please [contact our sales team](https://mattermost.com/contact-us/)."
|
||||
},
|
||||
{
|
||||
"id": "api.license.request_renewal_link.app_error",
|
||||
"translation": "Error getting the license renewal link"
|
||||
},
|
||||
{
|
||||
"id": "api.license.request_renewal_link.cannot_renew_on_cws",
|
||||
"translation": "Renewing this license on the portal is not possible"
|
||||
},
|
||||
{
|
||||
"id": "api.license.request_trial_license.app_error",
|
||||
"translation": "Unable to get a trial license, please try again or contact with support@mattermost.com."
|
||||
@@ -2288,38 +2280,6 @@
|
||||
"id": "api.license.request_trial_license.embargoed",
|
||||
"translation": "We were unable to process the request due to limitations for embargoed countries. [Learn more in our documentation](https://mattermost.com/pl/limitations-for-embargoed-countries), or reach out to legal@mattermost.com for questions around export limitations."
|
||||
},
|
||||
{
|
||||
"id": "api.license.true_up_review.create_error",
|
||||
"translation": "Could not create true up status record"
|
||||
},
|
||||
{
|
||||
"id": "api.license.true_up_review.failed_to_submit",
|
||||
"translation": "Failed to submit true up review profile to CWS."
|
||||
},
|
||||
{
|
||||
"id": "api.license.true_up_review.get_status_error",
|
||||
"translation": "Could not get true up status records"
|
||||
},
|
||||
{
|
||||
"id": "api.license.true_up_review.license_required",
|
||||
"translation": "True up review requires a license"
|
||||
},
|
||||
{
|
||||
"id": "api.license.true_up_review.not_allowed_for_cloud",
|
||||
"translation": "True up review is not allowed for cloud instances"
|
||||
},
|
||||
{
|
||||
"id": "api.license.true_up_review.user_count_fail",
|
||||
"translation": "Could not get the total active users count"
|
||||
},
|
||||
{
|
||||
"id": "api.license.true_up_review.webhook_in_count_fail",
|
||||
"translation": "Could not get the total incoming webhook count"
|
||||
},
|
||||
{
|
||||
"id": "api.license.true_up_review.webhook_out_count_fail",
|
||||
"translation": "Could not get the total outgoing webhook count"
|
||||
},
|
||||
{
|
||||
"id": "api.license.upgrade_needed.app_error",
|
||||
"translation": "Feature requires an upgrade to Enterprise Edition."
|
||||
@@ -2854,10 +2814,6 @@
|
||||
"id": "api.server.hosted_signup_unavailable.error",
|
||||
"translation": "Portal unavailable for self-hosted signup."
|
||||
},
|
||||
{
|
||||
"id": "api.server.license_up_for_renewal.error_generating_link",
|
||||
"translation": "Failed to generate the license renewal link"
|
||||
},
|
||||
{
|
||||
"id": "api.server.license_up_for_renewal.error_sending_email",
|
||||
"translation": "Failed to send license up for renewal emails"
|
||||
@@ -3490,10 +3446,6 @@
|
||||
"id": "api.templates.license_up_for_renewal_contact_sales",
|
||||
"translation": "Contact sales"
|
||||
},
|
||||
{
|
||||
"id": "api.templates.license_up_for_renewal_renew_now",
|
||||
"translation": "Renew now"
|
||||
},
|
||||
{
|
||||
"id": "api.templates.license_up_for_renewal_subject",
|
||||
"translation": "Your license is up for renewal"
|
||||
@@ -3502,10 +3454,6 @@
|
||||
"id": "api.templates.license_up_for_renewal_subtitle",
|
||||
"translation": "{{.UserName}}, your subscription is set to expire in {{.Days}} days. We hope you’re experiencing the flexible, secure team collaboration that Mattermost enables. Renew soon to ensure your team can keep enjoying these benefits."
|
||||
},
|
||||
{
|
||||
"id": "api.templates.license_up_for_renewal_subtitle_two",
|
||||
"translation": "Log in to your Customer Account to renew"
|
||||
},
|
||||
{
|
||||
"id": "api.templates.license_up_for_renewal_title",
|
||||
"translation": "Your Mattermost subscription is up for renewal"
|
||||
@@ -3554,10 +3502,6 @@
|
||||
"id": "api.templates.questions_footer.title",
|
||||
"translation": "Questions?"
|
||||
},
|
||||
{
|
||||
"id": "api.templates.remove_expired_license.body.renew_button",
|
||||
"translation": "Renew License Now"
|
||||
},
|
||||
{
|
||||
"id": "api.templates.remove_expired_license.body.title",
|
||||
"translation": "Your Enterprise Edition license has expired and some features may be disabled. Please renew your license now."
|
||||
@@ -5734,18 +5678,6 @@
|
||||
"id": "app.last_accessible_post.app_error",
|
||||
"translation": "Error fetching last accessible post"
|
||||
},
|
||||
{
|
||||
"id": "app.license.generate_renewal_token.app_error",
|
||||
"translation": "Failed to generate a new renewal token."
|
||||
},
|
||||
{
|
||||
"id": "app.license.generate_renewal_token.bad_license",
|
||||
"translation": "This type of license doesn't support renewal token generation"
|
||||
},
|
||||
{
|
||||
"id": "app.license.generate_renewal_token.no_license",
|
||||
"translation": "No license present"
|
||||
},
|
||||
{
|
||||
"id": "app.limits.get_app_limits.user_count.store_error",
|
||||
"translation": "Failed to get user count"
|
||||
|
||||
@@ -490,7 +490,6 @@ func (ts *TelemetryService) trackConfig() {
|
||||
"persistent_notification_interval_minutes": *cfg.ServiceSettings.PersistentNotificationIntervalMinutes,
|
||||
"persistent_notification_max_count": *cfg.ServiceSettings.PersistentNotificationMaxCount,
|
||||
"persistent_notification_max_recipients": *cfg.ServiceSettings.PersistentNotificationMaxRecipients,
|
||||
"self_hosted_purchase": *cfg.ServiceSettings.SelfHostedPurchase,
|
||||
"allow_synced_drafts": *cfg.ServiceSettings.AllowSyncedDrafts,
|
||||
"refresh_post_stats_run_time": *cfg.ServiceSettings.RefreshPostStatsRunTime,
|
||||
"maximum_payload_size": *cfg.ServiceSettings.MaximumPayloadSizeBytes,
|
||||
|
||||
@@ -340,10 +340,6 @@ func (c *Client4) cloudRoute() string {
|
||||
return "/cloud"
|
||||
}
|
||||
|
||||
func (c *Client4) hostedCustomerRoute() string {
|
||||
return "/hosted_customer"
|
||||
}
|
||||
|
||||
func (c *Client4) testEmailRoute() string {
|
||||
return "/email/test"
|
||||
}
|
||||
@@ -584,10 +580,6 @@ func (c *Client4) permissionsRoute() string {
|
||||
return "/permissions"
|
||||
}
|
||||
|
||||
func (c *Client4) limitsRoute() string {
|
||||
return "/limits"
|
||||
}
|
||||
|
||||
func (c *Client4) bookmarksRoute(channelId string) string {
|
||||
return c.channelRoute(channelId) + "/bookmarks"
|
||||
}
|
||||
@@ -8305,50 +8297,6 @@ func (c *Client4) GetMyIP(ctx context.Context) (*GetIPAddressResponse, *Response
|
||||
return response, BuildResponse(r), nil
|
||||
}
|
||||
|
||||
func (c *Client4) CreateCustomerPayment(ctx context.Context) (*StripeSetupIntent, *Response, error) {
|
||||
r, err := c.DoAPIPost(ctx, c.cloudRoute()+"/payment", "")
|
||||
if err != nil {
|
||||
return nil, BuildResponse(r), err
|
||||
}
|
||||
defer closeBody(r)
|
||||
|
||||
var setupIntent *StripeSetupIntent
|
||||
json.NewDecoder(r.Body).Decode(&setupIntent)
|
||||
|
||||
return setupIntent, BuildResponse(r), nil
|
||||
}
|
||||
|
||||
func (c *Client4) ConfirmCustomerPayment(ctx context.Context, confirmRequest *ConfirmPaymentMethodRequest) (*Response, error) {
|
||||
json, err := json.Marshal(confirmRequest)
|
||||
if err != nil {
|
||||
return nil, NewAppError("ConfirmCustomerPayment", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
r, err := c.DoAPIPostBytes(ctx, c.cloudRoute()+"/payment/confirm", json)
|
||||
if err != nil {
|
||||
return BuildResponse(r), err
|
||||
}
|
||||
defer closeBody(r)
|
||||
|
||||
return BuildResponse(r), nil
|
||||
}
|
||||
|
||||
func (c *Client4) RequestCloudTrial(ctx context.Context, 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)
|
||||
}
|
||||
r, err := c.DoAPIPutBytes(ctx, c.cloudRoute()+"/request-trial", payload)
|
||||
if err != nil {
|
||||
return nil, BuildResponse(r), err
|
||||
}
|
||||
defer closeBody(r)
|
||||
|
||||
var subscription *Subscription
|
||||
json.NewDecoder(r.Body).Decode(&subscription)
|
||||
|
||||
return subscription, BuildResponse(r), nil
|
||||
}
|
||||
|
||||
func (c *Client4) ValidateWorkspaceBusinessEmail(ctx context.Context) (*Response, error) {
|
||||
r, err := c.DoAPIPost(ctx, c.cloudRoute()+"/validate-workspace-business-email", "")
|
||||
if err != nil {
|
||||
@@ -8415,19 +8363,6 @@ func (c *Client4) GetCloudCustomer(ctx context.Context) (*CloudCustomer, *Respon
|
||||
return cloudCustomer, BuildResponse(r), nil
|
||||
}
|
||||
|
||||
func (c *Client4) GetSubscriptionStatus(ctx context.Context, licenseId string) (*SubscriptionLicenseSelfServeStatusResponse, *Response, error) {
|
||||
r, err := c.DoAPIGet(ctx, fmt.Sprintf("%s%s?licenseID=%s", c.cloudRoute(), "/subscription/self-serve-status", licenseId), "")
|
||||
if err != nil {
|
||||
return nil, BuildResponse(r), err
|
||||
}
|
||||
defer closeBody(r)
|
||||
|
||||
var status *SubscriptionLicenseSelfServeStatusResponse
|
||||
json.NewDecoder(r.Body).Decode(&status)
|
||||
|
||||
return status, BuildResponse(r), nil
|
||||
}
|
||||
|
||||
func (c *Client4) GetSubscription(ctx context.Context) (*Subscription, *Response, error) {
|
||||
r, err := c.DoAPIGet(ctx, c.cloudRoute()+"/subscription", "")
|
||||
if err != nil {
|
||||
@@ -8488,23 +8423,6 @@ func (c *Client4) UpdateCloudCustomerAddress(ctx context.Context, address *Addre
|
||||
return customer, BuildResponse(r), nil
|
||||
}
|
||||
|
||||
func (c *Client4) BootstrapSelfHostedSignup(ctx context.Context, req BootstrapSelfHostedSignupRequest) (*BootstrapSelfHostedSignupResponse, *Response, error) {
|
||||
reqBytes, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, nil, NewAppError("BootstrapSelfHostedSignup", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
r, err := c.DoAPIPostBytes(ctx, c.hostedCustomerRoute()+"/bootstrap", reqBytes)
|
||||
if err != nil {
|
||||
return nil, BuildResponse(r), err
|
||||
}
|
||||
defer closeBody(r)
|
||||
|
||||
var res *BootstrapSelfHostedSignupResponse
|
||||
json.NewDecoder(r.Body).Decode(&res)
|
||||
|
||||
return res, BuildResponse(r), nil
|
||||
}
|
||||
|
||||
func (c *Client4) ListImports(ctx context.Context) ([]string, *Response, error) {
|
||||
r, err := c.DoAPIGet(ctx, c.importsRoute(), "")
|
||||
if err != nil {
|
||||
@@ -8793,96 +8711,6 @@ func (c *Client4) GetTeamsUsage(ctx context.Context) (*TeamsUsage, *Response, er
|
||||
return usage, BuildResponse(r), err
|
||||
}
|
||||
|
||||
func (c *Client4) SelfHostedSignupAvailable(ctx context.Context) (*Response, error) {
|
||||
r, err := c.DoAPIGet(ctx, c.hostedCustomerRoute()+"/signup_available", "")
|
||||
|
||||
if err != nil {
|
||||
return BuildResponse(r), err
|
||||
}
|
||||
defer closeBody(r)
|
||||
|
||||
return BuildResponse(r), nil
|
||||
}
|
||||
|
||||
func (c *Client4) SelfHostedSignupCustomer(ctx context.Context, form *SelfHostedCustomerForm) (*Response, *SelfHostedSignupCustomerResponse, error) {
|
||||
payloadBytes, err := json.Marshal(form)
|
||||
if err != nil {
|
||||
return nil, nil, NewAppError("SelfHostedSignupCustomer", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
r, err := c.DoAPIPost(ctx, c.hostedCustomerRoute()+"/customer", string(payloadBytes))
|
||||
|
||||
if err != nil {
|
||||
return BuildResponse(r), nil, err
|
||||
}
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return BuildResponse(r), nil, err
|
||||
}
|
||||
defer closeBody(r)
|
||||
|
||||
response := SelfHostedSignupCustomerResponse{}
|
||||
err = json.Unmarshal(data, &response)
|
||||
if err != nil {
|
||||
return BuildResponse(r), nil, err
|
||||
}
|
||||
|
||||
return BuildResponse(r), &response, nil
|
||||
}
|
||||
|
||||
func (c *Client4) SelfHostedSignupConfirm(ctx context.Context, form *SelfHostedConfirmPaymentMethodRequest) (*Response, *SelfHostedSignupConfirmClientResponse, error) {
|
||||
payloadBytes, err := json.Marshal(form)
|
||||
if err != nil {
|
||||
return nil, nil, NewAppError("SelfHostedSignupConfirm", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
|
||||
r, err := c.DoAPIPost(ctx, c.hostedCustomerRoute()+"/confirm", string(payloadBytes))
|
||||
|
||||
if err != nil {
|
||||
return BuildResponse(r), nil, err
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return BuildResponse(r), nil, err
|
||||
}
|
||||
defer closeBody(r)
|
||||
|
||||
response := SelfHostedSignupConfirmClientResponse{}
|
||||
err = json.Unmarshal(data, &response)
|
||||
if err != nil {
|
||||
return BuildResponse(r), nil, err
|
||||
}
|
||||
|
||||
defer closeBody(r)
|
||||
|
||||
return BuildResponse(r), &response, nil
|
||||
}
|
||||
|
||||
func (c *Client4) GetSelfHostedInvoices(ctx context.Context) (*Response, []*Invoice, error) {
|
||||
r, err := c.DoAPIGet(ctx, c.hostedCustomerRoute()+"/invoices", "")
|
||||
|
||||
if err != nil {
|
||||
return BuildResponse(r), nil, err
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return BuildResponse(r), nil, err
|
||||
}
|
||||
defer closeBody(r)
|
||||
|
||||
invoices := []*Invoice{}
|
||||
err = json.Unmarshal(data, &invoices)
|
||||
if err != nil {
|
||||
return BuildResponse(r), nil, err
|
||||
}
|
||||
|
||||
defer closeBody(r)
|
||||
|
||||
return BuildResponse(r), invoices, nil
|
||||
}
|
||||
|
||||
func (c *Client4) GetPostInfo(ctx context.Context, postId string) (*PostInfo, *Response, error) {
|
||||
r, err := c.DoAPIGet(ctx, c.postRoute(postId)+"/info", "")
|
||||
if err != nil {
|
||||
@@ -8939,36 +8767,6 @@ func (c *Client4) CheckCWSConnection(ctx context.Context, userId string) (*Respo
|
||||
return BuildResponse(r), nil
|
||||
}
|
||||
|
||||
func (c *Client4) SubmitTrueUpReview(ctx context.Context, req map[string]any) (*Response, error) {
|
||||
reqBytes, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, NewAppError("SubmitTrueUpReview", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
r, err := c.DoAPIPostBytes(ctx, c.licenseRoute()+"/review", reqBytes)
|
||||
if err != nil {
|
||||
return BuildResponse(r), nil
|
||||
}
|
||||
defer closeBody(r)
|
||||
|
||||
return BuildResponse(r), nil
|
||||
}
|
||||
|
||||
func (c *Client4) GetServerLimits(ctx context.Context) (*ServerLimits, *Response, error) {
|
||||
r, err := c.DoAPIGet(ctx, c.limitsRoute()+"/users", "")
|
||||
if err != nil {
|
||||
return nil, BuildResponse(r), err
|
||||
}
|
||||
defer closeBody(r)
|
||||
var serverLimits ServerLimits
|
||||
if r.StatusCode == http.StatusNotModified {
|
||||
return &serverLimits, BuildResponse(r), nil
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&serverLimits); err != nil {
|
||||
return nil, nil, NewAppError("GetServerLimits", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
}
|
||||
return &serverLimits, BuildResponse(r), nil
|
||||
}
|
||||
|
||||
// CreateChannelBookmark creates a channel bookmark based on the provided struct.
|
||||
func (c *Client4) CreateChannelBookmark(ctx context.Context, channelBookmark *ChannelBookmark) (*ChannelBookmark, *Response, error) {
|
||||
channelBookmarkJSON, err := json.Marshal(channelBookmark)
|
||||
|
||||
@@ -403,7 +403,6 @@ type ServiceSettings struct {
|
||||
CollapsedThreads *string `access:"experimental_features"`
|
||||
ManagedResourcePaths *string `access:"environment_web_server,write_restrictable,cloud_restrictable"`
|
||||
EnableCustomGroups *bool `access:"site_users_and_teams"`
|
||||
SelfHostedPurchase *bool `access:"write_restrictable,cloud_restrictable"`
|
||||
AllowSyncedDrafts *bool `access:"site_posts"`
|
||||
UniqueEmojiReactionLimitPerPost *int `access:"site_posts"`
|
||||
RefreshPostStatsRunTime *string `access:"site_users_and_teams"`
|
||||
@@ -903,10 +902,6 @@ func (s *ServiceSettings) SetDefaults(isUpdate bool) {
|
||||
s.AllowSyncedDrafts = NewBool(true)
|
||||
}
|
||||
|
||||
if s.SelfHostedPurchase == nil {
|
||||
s.SelfHostedPurchase = NewBool(true)
|
||||
}
|
||||
|
||||
if s.UniqueEmojiReactionLimitPerPost == nil {
|
||||
s.UniqueEmojiReactionLimitPerPost = NewInt(ServiceSettingsDefaultUniqueReactionsPerPost)
|
||||
}
|
||||
|
||||
@@ -3,71 +3,8 @@
|
||||
|
||||
package model
|
||||
|
||||
type BootstrapSelfHostedSignupRequest struct {
|
||||
Email string `json:"email"`
|
||||
Reset bool `json:"reset"`
|
||||
}
|
||||
|
||||
type SubscribeNewsletterRequest struct {
|
||||
Email string `json:"email"`
|
||||
ServerID string `json:"server_id"`
|
||||
SubscribedContent string `json:"subscribed_content"`
|
||||
}
|
||||
|
||||
type BootstrapSelfHostedSignupResponse struct {
|
||||
Progress string `json:"progress"`
|
||||
// email listed on the JWT claim
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type BootstrapSelfHostedSignupResponseInternal struct {
|
||||
Progress string `json:"progress"`
|
||||
License string `json:"license"`
|
||||
}
|
||||
|
||||
// email contained in token, so not in the request body.
|
||||
type SelfHostedCustomerForm struct {
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
BillingAddress *Address `json:"billing_address"`
|
||||
ShippingAddress *Address `json:"shipping_address"`
|
||||
Organization string `json:"organization"`
|
||||
}
|
||||
|
||||
type SelfHostedConfirmPaymentMethodRequest struct {
|
||||
StripeSetupIntentID string `json:"stripe_setup_intent_id"`
|
||||
Subscription *CreateSubscriptionRequest `json:"subscription"`
|
||||
ExpandRequest *SelfHostedExpansionRequest `json:"expand_request"`
|
||||
}
|
||||
|
||||
// SelfHostedSignupPaymentResponse contains feels needed for self hosted signup to confirm payment and receive license.
|
||||
type SelfHostedSignupCustomerResponse struct {
|
||||
CustomerId string `json:"customer_id"`
|
||||
SetupIntentId string `json:"setup_intent_id"`
|
||||
SetupIntentSecret string `json:"setup_intent_secret"`
|
||||
Progress string `json:"progress"`
|
||||
}
|
||||
|
||||
// SelfHostedSignupConfirmResponse contains data received on successful self hosted signup
|
||||
type SelfHostedSignupConfirmResponse struct {
|
||||
License string `json:"license"`
|
||||
Progress string `json:"progress"`
|
||||
}
|
||||
|
||||
type SelfHostedSignupConfirmClientResponse struct {
|
||||
License map[string]string `json:"license"`
|
||||
Progress string `json:"progress"`
|
||||
}
|
||||
|
||||
type SelfHostedBillingAccessRequest struct {
|
||||
LicenseId string `json:"license_id"`
|
||||
}
|
||||
|
||||
type SelfHostedBillingAccessResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type SelfHostedExpansionRequest struct {
|
||||
Seats int `json:"seats"`
|
||||
LicenseId string `json:"license_id"`
|
||||
}
|
||||
|
||||
@@ -39,15 +39,6 @@ var (
|
||||
sanctionedTrialDurationUpperBound = 29*(time.Hour*24) + (time.Hour * 23) + (time.Minute * 59) + (time.Second * 59) // 696 hours (29 days) + 23 hours, 59 mins and 59 seconds
|
||||
)
|
||||
|
||||
const (
|
||||
TrueUpReviewTelemetryName = "true_up_review_sent"
|
||||
TrueUpReviewAuthFeaturesMfa = "multi_factor_authentication"
|
||||
TrueUpReviewAuthFeaturesADLdap = "ad_ldap_sign_in"
|
||||
TrueUpReviewAuthFeaturesSaml = "saml_sign_in"
|
||||
TrueUpReviewAuthFeatureOpenId = "openid_connect"
|
||||
TrueUpReviewAuthFeatureGuestAccess = "guest_access"
|
||||
)
|
||||
|
||||
type LicenseRecord struct {
|
||||
Id string `json:"id"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package model
|
||||
|
||||
import "strings"
|
||||
|
||||
type TrueUpReviewProfile struct {
|
||||
ServerId string `json:"server_id"`
|
||||
ServerVersion string `json:"server_version"`
|
||||
ServerInstallationType string `json:"server_installation_type"`
|
||||
LicenseId string `json:"license_id"`
|
||||
LicensedSeats int `json:"licensed_seats"`
|
||||
LicensePlan string `json:"license_plan"`
|
||||
CustomerName string `json:"customer_name"`
|
||||
ActivatedUsers int64 `json:"total_activated_users"`
|
||||
DailyActiveUsers int64 `json:"daily_active_users"`
|
||||
MonthlyActiveUsers int64 `json:"monthly_active_users"`
|
||||
AuthenticationFeatures []string `json:"authentication_features"`
|
||||
Plugins TrueUpReviewPlugins `json:"plugins"`
|
||||
TotalIncomingWebhooks int64 `json:"incoming_webhooks_count"`
|
||||
TotalOutgoingWebhooks int64 `json:"outgoing_webhooks_count"`
|
||||
}
|
||||
|
||||
type TrueUpReviewPlugins struct {
|
||||
TotalPlugins int `json:"total_plugins"`
|
||||
PluginNames []string `json:"plugin_names"`
|
||||
}
|
||||
|
||||
func (t *TrueUpReviewPlugins) ToMap() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"total_plugins": t.TotalPlugins,
|
||||
"plugin_names": strings.Join(t.PluginNames, ","),
|
||||
}
|
||||
}
|
||||
|
||||
type TrueUpReviewStatus struct {
|
||||
Completed bool `json:"complete"`
|
||||
DueDate int64 `json:"due_date"`
|
||||
}
|
||||
|
||||
func (t *TrueUpReviewStatus) ToSlice() []interface{} {
|
||||
return []interface{}{
|
||||
t.DueDate,
|
||||
t.Completed,
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,6 @@
|
||||
"@mui/base": "5.0.0-alpha.127",
|
||||
"@mui/material": "5.11.16",
|
||||
"@mui/styled-engine-sc": "5.11.11",
|
||||
"@stripe/react-stripe-js": "1.13.0",
|
||||
"@stripe/stripe-js": "1.41.0",
|
||||
"@tanstack/react-table": "8.10.7",
|
||||
"@tippyjs/react": "4.2.6",
|
||||
"@types/color-hash": "1.0.2",
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Stripe} from '@stripe/stripe-js';
|
||||
|
||||
import type {Address, CloudCustomerPatch, Feedback, WorkspaceDeletionRequest} from '@mattermost/types/cloud';
|
||||
import type {ServerError} from '@mattermost/types/errors';
|
||||
|
||||
import {CloudTypes} from 'mattermost-redux/action_types';
|
||||
@@ -14,77 +11,8 @@ import type {ActionFunc, ThunkActionFunc} from 'mattermost-redux/types/actions';
|
||||
|
||||
import {trackEvent} from 'actions/telemetry_actions.jsx';
|
||||
|
||||
import {getConfirmCardSetup} from 'components/payment_form/stripe';
|
||||
|
||||
import {getBlankAddressWithCountry} from 'utils/utils';
|
||||
|
||||
import type {StripeSetupIntent, BillingDetails} from 'types/cloud/sku';
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
// Returns true for success, and false for any error
|
||||
export function completeStripeAddPaymentMethod(
|
||||
stripe: Stripe,
|
||||
billingDetails: BillingDetails,
|
||||
cwsMockMode: boolean,
|
||||
) {
|
||||
return async () => {
|
||||
let paymentSetupIntent: StripeSetupIntent;
|
||||
try {
|
||||
paymentSetupIntent = await Client4.createPaymentMethod() as StripeSetupIntent;
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
const cardSetupFunction = getConfirmCardSetup(cwsMockMode);
|
||||
const confirmCardSetup = cardSetupFunction(stripe.confirmCardSetup);
|
||||
|
||||
const result = await confirmCardSetup(
|
||||
paymentSetupIntent.client_secret,
|
||||
{
|
||||
payment_method: {
|
||||
card: billingDetails.card,
|
||||
billing_details: {
|
||||
name: billingDetails.name,
|
||||
address: {
|
||||
line1: billingDetails.address,
|
||||
line2: billingDetails.address2,
|
||||
city: billingDetails.city,
|
||||
state: billingDetails.state,
|
||||
country: billingDetails.country,
|
||||
postal_code: billingDetails.postalCode,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const {setupIntent, error: stripeError} = result;
|
||||
|
||||
if (stripeError) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (setupIntent == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (setupIntent.status !== 'succeeded') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await Client4.confirmPaymentMethod(setupIntent.id);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
export function getInstallation() {
|
||||
return async () => {
|
||||
try {
|
||||
@@ -96,47 +24,6 @@ export function getInstallation() {
|
||||
};
|
||||
}
|
||||
|
||||
export function subscribeCloudSubscription(
|
||||
productId: string,
|
||||
shippingAddress: Address = getBlankAddressWithCountry(),
|
||||
seats = 0,
|
||||
downgradeFeedback?: Feedback,
|
||||
customerPatch?: CloudCustomerPatch,
|
||||
) {
|
||||
return async () => {
|
||||
try {
|
||||
const subscription = await Client4.subscribeCloudProduct(
|
||||
productId,
|
||||
shippingAddress,
|
||||
seats,
|
||||
downgradeFeedback,
|
||||
customerPatch,
|
||||
);
|
||||
|
||||
return {data: subscription};
|
||||
} catch (e: any) {
|
||||
// In the event that the status code returned is 422, this request has been blocked by export compliance
|
||||
return {data: false, error: {error: e.message, status: e.status_code}};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function requestCloudTrial(page: string, subscriptionId: string, email = ''): ThunkActionFunc<Promise<boolean>> {
|
||||
trackEvent('api', 'api_request_cloud_trial_license', {from_page: page});
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
const newSubscription = await Client4.requestCloudTrial(subscriptionId, email);
|
||||
dispatch({
|
||||
type: CloudTypes.RECEIVED_CLOUD_SUBSCRIPTION,
|
||||
data: newSubscription.data,
|
||||
});
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
export function validateBusinessEmail(email = '') {
|
||||
trackEvent('api', 'api_validate_business_email');
|
||||
return async () => {
|
||||
@@ -238,17 +125,6 @@ export function getTeamsUsage(): ThunkActionFunc<Promise<boolean | ServerError>>
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteWorkspace(deletionRequest: WorkspaceDeletionRequest) {
|
||||
return async () => {
|
||||
try {
|
||||
await Client4.deleteWorkspace(deletionRequest);
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
export function retryFailedCloudFetches(): ActionFunc<boolean, GlobalState> {
|
||||
return (dispatch, getState) => {
|
||||
const errors = getCloudErrors(getState());
|
||||
|
||||
@@ -1,124 +1,11 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Stripe} from '@stripe/stripe-js';
|
||||
import {getCode} from 'country-list';
|
||||
|
||||
import type {CreateSubscriptionRequest} from '@mattermost/types/cloud';
|
||||
import type {ServerError} from '@mattermost/types/errors';
|
||||
import type {SelfHostedExpansionRequest, SelfHostedSignupSuccessResponse} from '@mattermost/types/hosted_customer';
|
||||
import {SelfHostedSignupProgress} from '@mattermost/types/hosted_customer';
|
||||
import type {ValueOf} from '@mattermost/types/utilities';
|
||||
|
||||
import {HostedCustomerTypes} from 'mattermost-redux/action_types';
|
||||
import {bindClientFunc} from 'mattermost-redux/actions/helpers';
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
import {getSelfHostedErrors} from 'mattermost-redux/selectors/entities/hosted_customer';
|
||||
import type {ActionFunc, ActionFuncAsync, ThunkActionFunc} from 'mattermost-redux/types/actions';
|
||||
|
||||
import {getConfirmCardSetup} from 'components/payment_form/stripe';
|
||||
|
||||
import type {StripeSetupIntent, BillingDetails} from 'types/cloud/sku';
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
function selfHostedNeedsConfirmation(progress: ValueOf<typeof SelfHostedSignupProgress>): boolean {
|
||||
switch (progress) {
|
||||
case SelfHostedSignupProgress.START:
|
||||
case SelfHostedSignupProgress.CREATED_CUSTOMER:
|
||||
case SelfHostedSignupProgress.CREATED_INTENT:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const STRIPE_UNEXPECTED_STATE = 'setup_intent_unexpected_state';
|
||||
const STRIPE_ALREADY_SUCCEEDED = 'You cannot update this SetupIntent because it has already succeeded.';
|
||||
|
||||
export function confirmSelfHostedSignup(
|
||||
stripe: Stripe,
|
||||
stripeSetupIntent: StripeSetupIntent,
|
||||
cwsMockMode: boolean,
|
||||
billingDetails: BillingDetails,
|
||||
initialProgress: ValueOf<typeof SelfHostedSignupProgress>,
|
||||
subscriptionRequest: CreateSubscriptionRequest,
|
||||
): ActionFuncAsync<SelfHostedSignupSuccessResponse['license'] | false> {
|
||||
return async (dispatch) => {
|
||||
const cardSetupFunction = getConfirmCardSetup(cwsMockMode);
|
||||
const confirmCardSetup = cardSetupFunction(stripe.confirmCardSetup);
|
||||
|
||||
const shouldConfirmCard = selfHostedNeedsConfirmation(initialProgress);
|
||||
if (shouldConfirmCard) {
|
||||
const result = await confirmCardSetup(
|
||||
stripeSetupIntent.client_secret,
|
||||
{
|
||||
payment_method: {
|
||||
card: billingDetails.card,
|
||||
billing_details: {
|
||||
name: billingDetails.name,
|
||||
address: {
|
||||
line1: billingDetails.address,
|
||||
line2: billingDetails.address2,
|
||||
city: billingDetails.city,
|
||||
state: billingDetails.state,
|
||||
country: getCode(billingDetails.country),
|
||||
postal_code: billingDetails.postalCode,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!result) {
|
||||
return {data: false, error: 'failed to confirm card with Stripe'};
|
||||
}
|
||||
|
||||
const {setupIntent, error: stripeError} = result;
|
||||
|
||||
if (stripeError) {
|
||||
if (stripeError.code === STRIPE_UNEXPECTED_STATE && stripeError.message === STRIPE_ALREADY_SUCCEEDED && stripeError.setup_intent?.status === 'succeeded') {
|
||||
dispatch({
|
||||
type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS,
|
||||
data: SelfHostedSignupProgress.CONFIRMED_INTENT,
|
||||
});
|
||||
} else {
|
||||
return {data: false, error: stripeError.message || 'Stripe failed to confirm payment method'};
|
||||
}
|
||||
} else {
|
||||
if (setupIntent === null || setupIntent === undefined) {
|
||||
return {data: false, error: 'Stripe did not return successful setup intent'};
|
||||
}
|
||||
|
||||
if (setupIntent.status !== 'succeeded') {
|
||||
return {data: false, error: `Stripe setup intent status was: ${setupIntent.status}`};
|
||||
}
|
||||
dispatch({
|
||||
type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS,
|
||||
data: SelfHostedSignupProgress.CONFIRMED_INTENT,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let confirmResult;
|
||||
try {
|
||||
confirmResult = await Client4.confirmSelfHostedSignup(stripeSetupIntent.id, subscriptionRequest);
|
||||
dispatch({
|
||||
type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS,
|
||||
data: confirmResult.progress,
|
||||
});
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
|
||||
// unprocessable entity, e.g. failed export compliance
|
||||
if (error.status_code === 422) {
|
||||
return {data: false, error: error.status_code};
|
||||
}
|
||||
return {data: false, error};
|
||||
}
|
||||
|
||||
return {data: confirmResult.license};
|
||||
};
|
||||
}
|
||||
import type {ThunkActionFunc} from 'mattermost-redux/types/actions';
|
||||
|
||||
export function getSelfHostedProducts(): ThunkActionFunc<Promise<boolean | ServerError>> {
|
||||
return async (dispatch) => {
|
||||
@@ -143,147 +30,3 @@ export function getSelfHostedProducts(): ThunkActionFunc<Promise<boolean | Serve
|
||||
};
|
||||
}
|
||||
|
||||
export function getSelfHostedInvoices(): ThunkActionFunc<Promise<boolean | ServerError>> {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
dispatch({
|
||||
type: HostedCustomerTypes.SELF_HOSTED_INVOICES_REQUEST,
|
||||
});
|
||||
const result = await Client4.getSelfHostedInvoices();
|
||||
if (result) {
|
||||
dispatch({
|
||||
type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_INVOICES,
|
||||
data: result,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: HostedCustomerTypes.SELF_HOSTED_INVOICES_FAILED,
|
||||
});
|
||||
return error;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
export function retryFailedHostedCustomerFetches(): ActionFunc<boolean, GlobalState> {
|
||||
return (dispatch, getState) => {
|
||||
const errors = getSelfHostedErrors(getState());
|
||||
if (Object.keys(errors).length === 0) {
|
||||
return {data: true};
|
||||
}
|
||||
|
||||
if (errors.products) {
|
||||
dispatch(getSelfHostedProducts());
|
||||
}
|
||||
|
||||
if (errors.invoices) {
|
||||
dispatch(getSelfHostedInvoices());
|
||||
}
|
||||
|
||||
return {data: true};
|
||||
};
|
||||
}
|
||||
|
||||
export function submitTrueUpReview() {
|
||||
return bindClientFunc({
|
||||
clientFunc: Client4.submitTrueUpReview,
|
||||
onSuccess: [HostedCustomerTypes.RECEIVED_TRUE_UP_REVIEW_BUNDLE],
|
||||
onFailure: HostedCustomerTypes.TRUE_UP_REVIEW_PROFILE_FAILED,
|
||||
onRequest: HostedCustomerTypes.TRUE_UP_REVIEW_PROFILE_REQUEST,
|
||||
});
|
||||
}
|
||||
|
||||
export function getTrueUpReviewStatus() {
|
||||
return bindClientFunc({
|
||||
clientFunc: Client4.getTrueUpReviewStatus,
|
||||
onSuccess: [HostedCustomerTypes.RECEIVED_TRUE_UP_REVIEW_STATUS],
|
||||
onFailure: HostedCustomerTypes.TRUE_UP_REVIEW_STATUS_FAILED,
|
||||
onRequest: HostedCustomerTypes.TRUE_UP_REVIEW_STATUS_REQUEST,
|
||||
});
|
||||
}
|
||||
|
||||
export function confirmSelfHostedExpansion(
|
||||
stripe: Stripe,
|
||||
stripeSetupIntent: StripeSetupIntent,
|
||||
cwsMockMode: boolean,
|
||||
billingDetails: BillingDetails,
|
||||
initialProgress: ValueOf<typeof SelfHostedSignupProgress>,
|
||||
expansionRequest: SelfHostedExpansionRequest,
|
||||
): ActionFuncAsync<SelfHostedSignupSuccessResponse['license'] | false> {
|
||||
return async (dispatch) => {
|
||||
const cardSetupFunction = getConfirmCardSetup(cwsMockMode);
|
||||
const confirmCardSetup = cardSetupFunction(stripe.confirmCardSetup);
|
||||
|
||||
const shouldConfirmCard = selfHostedNeedsConfirmation(initialProgress);
|
||||
if (shouldConfirmCard) {
|
||||
const result = await confirmCardSetup(
|
||||
stripeSetupIntent.client_secret,
|
||||
{
|
||||
payment_method: {
|
||||
card: billingDetails.card,
|
||||
billing_details: {
|
||||
name: billingDetails.name,
|
||||
address: {
|
||||
line1: billingDetails.address,
|
||||
line2: billingDetails.address2,
|
||||
city: billingDetails.city,
|
||||
state: billingDetails.state,
|
||||
country: getCode(billingDetails.country),
|
||||
postal_code: billingDetails.postalCode,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return {data: false, error: 'failed to confirm card with Stripe'};
|
||||
}
|
||||
|
||||
const {setupIntent, error: stripeError} = result;
|
||||
|
||||
if (stripeError) {
|
||||
if (stripeError.code === STRIPE_UNEXPECTED_STATE && stripeError.message === STRIPE_ALREADY_SUCCEEDED && stripeError.setup_intent?.status === 'succeeded') {
|
||||
dispatch({
|
||||
type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS,
|
||||
data: SelfHostedSignupProgress.CONFIRMED_INTENT,
|
||||
});
|
||||
} else {
|
||||
return {data: false, error: stripeError.message || 'Stripe failed to confirm payment method'};
|
||||
}
|
||||
} else {
|
||||
if (setupIntent === null || setupIntent === undefined) {
|
||||
return {data: false, error: 'Stripe did not return successful setup intent'};
|
||||
}
|
||||
|
||||
if (setupIntent.status !== 'succeeded') {
|
||||
return {data: false, error: `Stripe setup intent status was: ${setupIntent.status}`};
|
||||
}
|
||||
dispatch({
|
||||
type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS,
|
||||
data: SelfHostedSignupProgress.CONFIRMED_INTENT,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let confirmResult;
|
||||
try {
|
||||
confirmResult = await Client4.confirmSelfHostedExpansion(stripeSetupIntent.id, expansionRequest);
|
||||
dispatch({
|
||||
type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS,
|
||||
data: confirmResult.progress,
|
||||
});
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
|
||||
// unprocessable entity, e.g. failed export compliance
|
||||
if (error.status_code === 422) {
|
||||
return {data: false, error: error.status_code};
|
||||
}
|
||||
return {data: false, error};
|
||||
}
|
||||
|
||||
return {data: confirmResult.license};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -326,15 +326,7 @@ const AdminDefinition: AdminDefinitionType = {
|
||||
/>
|
||||
),
|
||||
sectionTitle: defineMessage({id: 'admin.sidebar.billing', defaultMessage: 'Billing & Account'}),
|
||||
isHidden: it.any(
|
||||
it.not(it.enterpriseReady),
|
||||
it.not(it.userHasReadPermissionOnResource('billing')),
|
||||
it.not(it.licensed),
|
||||
it.all(
|
||||
it.not(it.licensedForFeature('Cloud')),
|
||||
it.configIsFalse('ServiceSettings', 'SelfHostedPurchase'),
|
||||
),
|
||||
),
|
||||
isHidden: it.not(it.licensedForFeature('Cloud')),
|
||||
subsections: {
|
||||
subscription: {
|
||||
url: 'billing/subscription',
|
||||
@@ -357,6 +349,7 @@ const AdminDefinition: AdminDefinitionType = {
|
||||
id: 'BillingHistory',
|
||||
component: BillingHistory,
|
||||
},
|
||||
isHidden: it.not(it.licensedForFeature('Cloud')),
|
||||
isDisabled: it.not(it.userHasWritePermissionOnResource('billing')),
|
||||
},
|
||||
company_info: {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {SelfHostedSignupProgress} from '@mattermost/types/cloud';
|
||||
import type {ExperimentalSettings, PluginSettings, SSOSettings, Office365Settings} from '@mattermost/types/config';
|
||||
|
||||
import {RESOURCE_KEYS} from 'mattermost-redux/constants/permissions_sysconsole';
|
||||
@@ -93,9 +92,6 @@ describe('components/AdminSidebar', () => {
|
||||
limits: {},
|
||||
},
|
||||
errors: {},
|
||||
selfHostedSignup: {
|
||||
progress: SelfHostedSignupProgress.START,
|
||||
},
|
||||
},
|
||||
showTaskList: false,
|
||||
};
|
||||
|
||||
@@ -163,79 +163,6 @@ describe('components/admin_console/billing/billing_history', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('BillingHistory -- self-hosted', () => {
|
||||
// required state to mount using the provider
|
||||
const state = {
|
||||
entities: {
|
||||
general: {
|
||||
license: {
|
||||
IsLicensed: 'true',
|
||||
Cloud: 'false',
|
||||
},
|
||||
config: {
|
||||
DiagnosticsEnabled: 'false',
|
||||
},
|
||||
},
|
||||
users: {
|
||||
currentUserId: 'current_user_id',
|
||||
profiles: {
|
||||
current_user_id: {roles: 'system_role'},
|
||||
},
|
||||
},
|
||||
hostedCustomer: {
|
||||
errors: {},
|
||||
invoices: {
|
||||
invoices: {
|
||||
in_1KNb3DI67GP2qpb4ueaJYBt8: invoiceA,
|
||||
in_1KIWNTI67GP2qpb4KjGj1KAy: invoiceB,
|
||||
},
|
||||
invoicesLoaded: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
views: {},
|
||||
};
|
||||
|
||||
test('Billing history section shows template when no invoices have been emitted yet', () => {
|
||||
const noBillingHistoryState = {
|
||||
...state,
|
||||
entities: {...state.entities, hostedCustomer: {invoices: {invoices: {}, invoicesLoaded: true}, errors: {}}},
|
||||
};
|
||||
renderWithContext(
|
||||
<BillingHistory/>,
|
||||
noBillingHistoryState,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Date')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Description')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Total')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Status')).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByTestId(invoiceA.number)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(invoiceB.number)).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByTestId(invoiceA.id)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(invoiceB.id)).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole('link')).toHaveAttribute('href', HostedCustomerLinks.SELF_HOSTED_BILLING + '?utm_source=mattermost&utm_medium=in-product&utm_content=billing_history&uid=current_user_id&sid=');
|
||||
expect(screen.getByRole('link')).toHaveTextContent('See how billing works');
|
||||
expect(screen.getByTestId('no-invoices')).toHaveTextContent(NO_INVOICES_LEGEND);
|
||||
});
|
||||
|
||||
test('Billing history section shows two invoices to download', () => {
|
||||
renderWithContext(
|
||||
<BillingHistory/>,
|
||||
state,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Date')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Description')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Total')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Status')).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId('billingHistoryTableRow')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoBillingHistorySection', () => {
|
||||
const state = {entities: {users: {}, general: {config: {}, license: {}}}} as any;
|
||||
test('goes to cloud docs on cloud', () => {
|
||||
|
||||
@@ -7,9 +7,7 @@ import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
import {getInvoices} from 'mattermost-redux/actions/cloud';
|
||||
import {getCloudErrors, getCloudInvoices, isCurrentLicenseCloud} from 'mattermost-redux/selectors/entities/cloud';
|
||||
import {getSelfHostedErrors, getSelfHostedInvoices} from 'mattermost-redux/selectors/entities/hosted_customer';
|
||||
|
||||
import {getSelfHostedInvoices as getSelfHostedInvoicesAction} from 'actions/hosted_customer';
|
||||
import {pageVisited, trackEvent} from 'actions/telemetry_actions';
|
||||
|
||||
import CloudFetchError from 'components/cloud_fetch_error';
|
||||
@@ -65,14 +63,14 @@ export const NoBillingHistorySection = (props: NoBillingHistorySectionProps) =>
|
||||
const BillingHistory = () => {
|
||||
const dispatch = useDispatch();
|
||||
const isCloud = useSelector(isCurrentLicenseCloud);
|
||||
const invoices = useSelector(isCloud ? getCloudInvoices : getSelfHostedInvoices);
|
||||
const {invoices: invoicesError} = useSelector(isCloud ? getCloudErrors : getSelfHostedErrors);
|
||||
const invoices = useSelector(getCloudInvoices);
|
||||
const {invoices: invoicesError} = useSelector(getCloudErrors);
|
||||
|
||||
useEffect(() => {
|
||||
pageVisited('cloud_admin', 'pageview_billing_history');
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
dispatch(isCloud ? getInvoices() : getSelfHostedInvoicesAction());
|
||||
dispatch(getInvoices());
|
||||
}, [isCloud]);
|
||||
const billingHistoryTable = invoices && <BillingHistoryTable invoices={invoices}/>;
|
||||
const areInvoicesEmpty = Object.keys(invoices || {}).length === 0;
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
|
||||
import React, {useState, useEffect} from 'react';
|
||||
import {FormattedDate, FormattedMessage, FormattedNumber} from 'react-intl';
|
||||
import {useSelector, useDispatch} from 'react-redux';
|
||||
import {useDispatch} from 'react-redux';
|
||||
|
||||
import type {Invoice} from '@mattermost/types/cloud';
|
||||
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
import {isCurrentLicenseCloud} from 'mattermost-redux/selectors/entities/cloud';
|
||||
|
||||
import {openModal} from 'actions/views/modals';
|
||||
|
||||
@@ -61,8 +60,6 @@ const getPaymentStatus = (status: string) => {
|
||||
|
||||
export default function BillingHistoryTable({invoices}: BillingHistoryTableProps) {
|
||||
const dispatch = useDispatch();
|
||||
const isCloud = useSelector(isCurrentLicenseCloud);
|
||||
|
||||
const [billingHistory, setBillingHistory] = useState<Invoice[] | undefined>(
|
||||
undefined,
|
||||
);
|
||||
@@ -161,7 +158,7 @@ export default function BillingHistoryTable({invoices}: BillingHistoryTableProps
|
||||
<th>{''}</th>
|
||||
</tr>
|
||||
{billingHistory?.map((invoice: Invoice) => {
|
||||
const url = isCloud ? Client4.getInvoicePdfUrl(invoice.id) : Client4.getSelfHostedInvoicePdfUrl(invoice.id);
|
||||
const url = Client4.getInvoicePdfUrl(invoice.id);
|
||||
return (
|
||||
<tr
|
||||
className='BillingHistory__table-row'
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
@import 'utils/mixins';
|
||||
|
||||
.UpsellCard {
|
||||
&__illustration {
|
||||
text-align: center;
|
||||
|
||||
svg {
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 10px 0;
|
||||
color: var(--center-channel-color);
|
||||
font-family: Metropolis;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
&__advantages {
|
||||
margin: 7px 0;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
|
||||
.advantage {
|
||||
margin: 12px 0;
|
||||
|
||||
i {
|
||||
margin-right: 7px;
|
||||
}
|
||||
|
||||
&--more {
|
||||
color: rgba(63, 69, 80, 0.75);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__cta {
|
||||
@include secondary-button;
|
||||
|
||||
width: fit-content;
|
||||
padding: 13px 20px;
|
||||
// override cloud start trial border used in other contexts
|
||||
border: 1px solid var(--denim-button-bg) !important;
|
||||
border: none;
|
||||
background: var(--sys-center-channel-bg);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 14px;
|
||||
|
||||
&.btn-primary {
|
||||
@include primary-button;
|
||||
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
margin: 5px 0;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.75);
|
||||
font-size: 10px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 14px;
|
||||
text-align: justify;
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import CloudStartTrialButton from 'components/cloud_start_trial/cloud_start_trial_btn';
|
||||
import WomanUpArrowsAndCloudsSvg from 'components/common/svg_images_components/woman_up_arrows_and_clouds_svg';
|
||||
import StartTrialCaution from 'components/pricing_modal/start_trial_caution';
|
||||
|
||||
import {openExternalPricingLink, FREEMIUM_TO_ENTERPRISE_TRIAL_LENGTH_DAYS} from 'utils/cloud_utils';
|
||||
import {t} from 'utils/i18n';
|
||||
import type {Message} from 'utils/i18n';
|
||||
|
||||
import './upsell_card.scss';
|
||||
|
||||
const enterpriseAdvantages = [
|
||||
{
|
||||
id: t('upsell_advantages.onelogin_saml'),
|
||||
defaultMessage: 'OneLogin/ADFS SAML 2.0',
|
||||
},
|
||||
{
|
||||
id: t('upsell_advantages.openid'),
|
||||
defaultMessage: 'OpenID Connect',
|
||||
},
|
||||
{
|
||||
id: t('upsell_advantages.office365'),
|
||||
defaultMessage: 'Office365 suite integration',
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
advantages: Message[];
|
||||
title: Message;
|
||||
andMore: boolean;
|
||||
cta: Message;
|
||||
ctaAction?: () => void;
|
||||
ctaPrimary?: boolean;
|
||||
upsellIsTrial?: boolean;
|
||||
}
|
||||
|
||||
const andMore = {
|
||||
id: t('upsell_advantages.more'),
|
||||
defaultMessage: 'And more...',
|
||||
};
|
||||
|
||||
export default function UpsellCard(props: Props) {
|
||||
const intl = useIntl();
|
||||
|
||||
const ctaClassname = classNames(
|
||||
'UpsellCard__cta',
|
||||
{
|
||||
btn: props.ctaPrimary,
|
||||
'btn-primary': props.ctaPrimary,
|
||||
},
|
||||
);
|
||||
|
||||
let callToAction = (
|
||||
<button
|
||||
className={ctaClassname}
|
||||
onClick={props.ctaAction}
|
||||
>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: props.cta.id,
|
||||
defaultMessage: props.cta.defaultMessage,
|
||||
},
|
||||
props.cta.values,
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
if (props.upsellIsTrial) {
|
||||
callToAction = (
|
||||
<>
|
||||
<CloudStartTrialButton
|
||||
message={
|
||||
intl.formatMessage(
|
||||
{
|
||||
id: props.cta.id,
|
||||
defaultMessage: props.cta.defaultMessage,
|
||||
},
|
||||
props.cta.values,
|
||||
)
|
||||
}
|
||||
telemetryId={'start_cloud_trial_billing_subscription'}
|
||||
extraClass={ctaClassname}
|
||||
/>
|
||||
<p className='disclaimer'>
|
||||
<StartTrialCaution/>
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className='UpsellCard'>
|
||||
<div className='UpsellCard__illustration'>
|
||||
<WomanUpArrowsAndCloudsSvg
|
||||
width={200}
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
<div className='UpsellCard__title'>
|
||||
{intl.formatMessage(props.title)}
|
||||
</div>
|
||||
<div className='UpsellCard__advantages'>
|
||||
{props.advantages.map((message: Message) => {
|
||||
return (
|
||||
<div
|
||||
className='advantage'
|
||||
key={message.id}
|
||||
>
|
||||
<i className='fa fa-lock'/>{intl.formatMessage(message)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{props.andMore && <div className='advantage advantage--more'>
|
||||
<i className='fa fa-lock'/>{intl.formatMessage(andMore)}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
{callToAction}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const tryEnterpriseCard = (
|
||||
<UpsellCard
|
||||
title={{
|
||||
id: t('admin.billing.subscriptions.billing_summary.try_enterprise'),
|
||||
defaultMessage: 'Try Enterprise features for free',
|
||||
}}
|
||||
cta={{
|
||||
id: t('admin.billing.subscriptions.billing_summary.try_enterprise.cta'),
|
||||
defaultMessage: 'Try free for {trialLength} days',
|
||||
values: {
|
||||
trialLength: FREEMIUM_TO_ENTERPRISE_TRIAL_LENGTH_DAYS,
|
||||
},
|
||||
}}
|
||||
andMore={true}
|
||||
advantages={enterpriseAdvantages}
|
||||
upsellIsTrial={true}
|
||||
/>
|
||||
);
|
||||
|
||||
export const ExploreEnterpriseCard = () => {
|
||||
return (
|
||||
<UpsellCard
|
||||
title={{
|
||||
|
||||
id: t('admin.billing.subscriptions.billing_summary.explore_enterprise'),
|
||||
defaultMessage: 'Explore Enterprise features',
|
||||
}}
|
||||
cta={{
|
||||
id: t('admin.billing.subscriptions.billing_summary.explore_enterprise.cta'),
|
||||
defaultMessage: 'View all features',
|
||||
}}
|
||||
ctaAction={openExternalPricingLink}
|
||||
andMore={true}
|
||||
advantages={enterpriseAdvantages}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,75 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {injectIntl} from 'react-intl';
|
||||
import type {WrappedComponentProps} from 'react-intl';
|
||||
|
||||
import type {Feedback} from '@mattermost/types/cloud';
|
||||
|
||||
import FeedbackModal from 'components/feedback_modal/feedback';
|
||||
import type {FeedbackOption} from 'components/feedback_modal/feedback';
|
||||
|
||||
type Props = {
|
||||
onSubmit: (deleteFeedback: Feedback) => void;
|
||||
} &WrappedComponentProps
|
||||
|
||||
const DeleteFeedbackModal = (props: Props) => {
|
||||
const deleteFeedbackModalTitle = props.intl.formatMessage({
|
||||
id: 'feedback.deleteWorkspace.feedbackTitle',
|
||||
defaultMessage: 'Please share your reason for deleting',
|
||||
});
|
||||
|
||||
const placeHolder = props.intl.formatMessage({
|
||||
id: 'feedback.deleteWorkspace.feedbackPlaceholder',
|
||||
defaultMessage: 'Please tell us why you are deleting',
|
||||
});
|
||||
|
||||
const deleteButtonText = props.intl.formatMessage({
|
||||
id: 'feedback.deleteWorkspace.submitText',
|
||||
defaultMessage: 'Delete Workspace',
|
||||
});
|
||||
|
||||
const deleteFeedbackOptions: FeedbackOption[] = [
|
||||
{
|
||||
translatedMessage: props.intl.formatMessage({
|
||||
id: 'feedback.deleteWorkspace.feedbackNoValue',
|
||||
defaultMessage: 'No longer found value',
|
||||
}),
|
||||
submissionValue: 'No longer found value',
|
||||
},
|
||||
{
|
||||
translatedMessage: props.intl.formatMessage({
|
||||
id: 'feedback.deleteWorkspace.feedbackMoving',
|
||||
defaultMessage: 'Moving to a different solution',
|
||||
}),
|
||||
submissionValue: 'Moving to a different solution',
|
||||
},
|
||||
{
|
||||
translatedMessage: props.intl.formatMessage({
|
||||
id: 'feedback.deleteWorkspace.feedbackMistake',
|
||||
defaultMessage: 'Created a workspace by mistake',
|
||||
}),
|
||||
submissionValue: 'Created a workspace by mistake',
|
||||
},
|
||||
{
|
||||
translatedMessage: props.intl.formatMessage({
|
||||
id: 'feedback.deleteWorkspace.feedbackHosting',
|
||||
defaultMessage: 'Moving to hosting my own Mattermost instance (self-hosted)',
|
||||
}),
|
||||
submissionValue: 'Moving to hosting my own Mattermost instance (self-hosted)',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<FeedbackModal
|
||||
title={deleteFeedbackModalTitle}
|
||||
feedbackOptions={deleteFeedbackOptions}
|
||||
freeformTextPlaceholder={placeHolder}
|
||||
submitText={deleteButtonText}
|
||||
onSubmit={props.onSubmit}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default injectIntl(DeleteFeedbackModal);
|
||||
@@ -1,92 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {FormattedMessage, defineMessages} from 'react-intl';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
import {getCloudSubscription, getSubscriptionProduct} from 'mattermost-redux/selectors/entities/cloud';
|
||||
import {getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
|
||||
import {trackEvent} from 'actions/telemetry_actions';
|
||||
import {openModal} from 'actions/views/modals';
|
||||
|
||||
import {CloudProducts, ModalIdentifiers} from 'utils/constants';
|
||||
import {isCloudLicense} from 'utils/license_utils';
|
||||
|
||||
import DeleteWorkspaceModal from './delete_workspace_modal';
|
||||
|
||||
export const messages = defineMessages({
|
||||
title: {id: 'admin.billing.subscription.deleteWorkspaceSection.title', defaultMessage: 'Delete your workspace'},
|
||||
});
|
||||
export default function DeleteWorkspaceCTA() {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const workspaceUrl = window.location.host;
|
||||
|
||||
const license = useSelector(getLicense);
|
||||
const subscription = useSelector(getCloudSubscription);
|
||||
const product = useSelector(getSubscriptionProduct);
|
||||
|
||||
const isNotCloud = !isCloudLicense(license);
|
||||
const isFreeTrial = subscription?.is_free_trial === 'true';
|
||||
const isEnterprise = product?.sku === CloudProducts.ENTERPRISE;
|
||||
|
||||
const handleOnClickDelete = () => {
|
||||
trackEvent('cloud_admin', 'click_delete_workspace');
|
||||
|
||||
dispatch(
|
||||
openModal({
|
||||
modalId: ModalIdentifiers.DELETE_WORKSPACE,
|
||||
dialogType: DeleteWorkspaceModal,
|
||||
dialogProps: {
|
||||
callerCTA: 'system_console > billing > subscription > delete_workspace_cta',
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
// Can only delete or downgrade via workspace deletion modal if:
|
||||
// - the user has a cloud product
|
||||
// - the user is on a free trial (enterprise product with trial status)
|
||||
// - the user is on a starter subscription
|
||||
// - the user is on a monthly professional subscription
|
||||
//
|
||||
// For clarity, workspaces with the following subscriptions may be deleted:
|
||||
// - Cloud-Starter
|
||||
// - Cloud-Professional (monthly)
|
||||
// - Enterprise Free Trial
|
||||
if (isNotCloud || (isEnterprise && !isFreeTrial)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='cancelSubscriptionSection'>
|
||||
<div className='cancelSubscriptionSection__text'>
|
||||
<div className='cancelSubscriptionSection__text-title'>
|
||||
<FormattedMessage {...messages.title}/>
|
||||
</div>
|
||||
<div className='cancelSubscriptionSection__text-description'>
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscription.deleteWorkspaceSection.description'
|
||||
defaultMessage='Deleting {workspaceLink} is final and cannot be reversed.'
|
||||
values={{
|
||||
workspaceLink: (
|
||||
<a href={`${workspaceUrl}`}>{workspaceUrl}</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className='btn cancelSubscriptionSection__contactUs'
|
||||
onClick={handleOnClickDelete}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscription.deleteWorkspaceSection.delete'
|
||||
defaultMessage='Delete Workspace'
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
.DeleteWorkspaceModal {
|
||||
width: 600px;
|
||||
|
||||
.modal-body {
|
||||
.GenericModal__body {
|
||||
padding: 24px 24px 0 24px;
|
||||
text-align: center;
|
||||
|
||||
* {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__Icon {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
&__Title {
|
||||
color: var(--sys-denim-center-channel-text);
|
||||
font-family: Metropolis;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
&__Usage {
|
||||
color: var(--center-channel-color);
|
||||
text-align: left;
|
||||
|
||||
&-Highlighted {
|
||||
color: black;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
&__Warning {
|
||||
color: var(--center-channel-color);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&__Buttons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
button {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&-Delete {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--dnd-indicator);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&-Downgrade {
|
||||
border-color: var(--denim-button-bg);
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
color: var(--denim-button-bg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&-Cancel {
|
||||
margin-left: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {FormattedMessage, defineMessages} from 'react-intl';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
import {GenericModal} from '@mattermost/components';
|
||||
import type {Feedback} from '@mattermost/types/cloud';
|
||||
|
||||
import {getSubscriptionProduct} from 'mattermost-redux/selectors/entities/cloud';
|
||||
import {getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
|
||||
import {subscribeCloudSubscription, deleteWorkspace as deleteWorkspaceRequest} from 'actions/cloud';
|
||||
import {closeModal, openModal} from 'actions/views/modals';
|
||||
|
||||
import DeleteFeedbackModal from 'components/admin_console/billing/delete_workspace/delete_feedback';
|
||||
import DeleteWorkspaceProgressModal from 'components/admin_console/billing/delete_workspace/progress_modal';
|
||||
import ErrorModal from 'components/cloud_subscribe_result_modal/error';
|
||||
import SuccessModal from 'components/cloud_subscribe_result_modal/success';
|
||||
import useGetSubscription from 'components/common/hooks/useGetSubscription';
|
||||
import useGetUsage from 'components/common/hooks/useGetUsage';
|
||||
import useOpenDowngradeModal from 'components/common/hooks/useOpenDowngradeModal';
|
||||
import LaptopAlertSVG from 'components/common/svg_images_components/laptop_alert_svg';
|
||||
import DowngradeFeedbackModal from 'components/feedback_modal/downgrade_feedback';
|
||||
|
||||
import {CloudProducts, ModalIdentifiers, StatTypes} from 'utils/constants';
|
||||
import {isCloudLicense} from 'utils/license_utils';
|
||||
import {fileSizeToString} from 'utils/utils';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
import DeleteWorkspaceFailureModal from './failure_modal';
|
||||
import DeleteWorkspaceSuccessModal from './success_modal';
|
||||
|
||||
import './delete_workspace_modal.scss';
|
||||
|
||||
type Props = {
|
||||
callerCTA: string;
|
||||
}
|
||||
|
||||
export const messages = defineMessages({
|
||||
deleteButton: {id: 'admin.billing.subscription.deleteWorkspaceModal.deleteButton', defaultMessage: 'Delete Workspace'},
|
||||
});
|
||||
|
||||
export default function DeleteWorkspaceModal(props: Props) {
|
||||
const dispatch = useDispatch();
|
||||
const openDowngradeModal = useOpenDowngradeModal();
|
||||
|
||||
// License/product checks.
|
||||
const subscription = useGetSubscription();
|
||||
const product = useSelector(getSubscriptionProduct);
|
||||
const isStarter = product?.sku === CloudProducts.STARTER;
|
||||
const isEnterprise = product?.sku === CloudProducts.ENTERPRISE;
|
||||
const license = useSelector(getLicense);
|
||||
const isNotCloud = !isCloudLicense(license);
|
||||
|
||||
// Starter product for downgrade purposes.
|
||||
const starterProduct = useSelector((state: GlobalState) => {
|
||||
return Object.values(state.entities.cloud.products || {}).find((product) => {
|
||||
return product.sku === CloudProducts.STARTER;
|
||||
});
|
||||
});
|
||||
|
||||
// Get usage information in an attempt to defer customer from deleting.
|
||||
const usage = useGetUsage();
|
||||
const totalFileSize = fileSizeToString(usage.files.totalStorage);
|
||||
const totalMessages = useSelector((state: GlobalState) => {
|
||||
if (!state.entities.admin.analytics) {
|
||||
return 0;
|
||||
}
|
||||
return state.entities.admin.analytics[StatTypes.TOTAL_POSTS];
|
||||
});
|
||||
|
||||
// Handles the delete button clicks.
|
||||
const handleClickDeleteWorkspace = () => {
|
||||
// Close the delete workspace modal and ope na feedback modal, with a workspace
|
||||
// deletion upon completion of the feedback.
|
||||
dispatch(closeModal(ModalIdentifiers.DELETE_WORKSPACE));
|
||||
dispatch(openModal({
|
||||
modalId: ModalIdentifiers.FEEDBACK,
|
||||
dialogType: DeleteFeedbackModal,
|
||||
dialogProps: {
|
||||
onSubmit: deleteWorkspace,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
// Handles the downgrade button clicks.
|
||||
const handleClickDowngradeWorkspace = () => {
|
||||
// Close the delete workspace modal and ope na feedback modal, with a workspace
|
||||
// downgrade upon completion of the feedback.
|
||||
dispatch(closeModal(ModalIdentifiers.DELETE_WORKSPACE));
|
||||
dispatch(openModal({
|
||||
modalId: ModalIdentifiers.FEEDBACK,
|
||||
dialogType: DowngradeFeedbackModal,
|
||||
dialogProps: {
|
||||
onSubmit: downgradeWorkspace,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
// Handles the cancel button clicks.
|
||||
const handleClickCancel = () => {
|
||||
dispatch(closeModal(ModalIdentifiers.DELETE_WORKSPACE));
|
||||
dispatch(closeModal(ModalIdentifiers.FEEDBACK));
|
||||
};
|
||||
|
||||
// Processes the workspace deletion, opening and closing the appropriate modals (progress, success/failure).
|
||||
const deleteWorkspace = async (deleteFeedback: Feedback) => {
|
||||
dispatch(openModal({
|
||||
modalId: ModalIdentifiers.DELETE_WORKSPACE_PROGRESS,
|
||||
dialogType: DeleteWorkspaceProgressModal,
|
||||
}));
|
||||
dispatch(closeModal(ModalIdentifiers.DOWNGRADE_MODAL));
|
||||
|
||||
if (subscription === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await dispatch(deleteWorkspaceRequest({subscription_id: subscription?.id, delete_feedback: deleteFeedback}));
|
||||
|
||||
if (typeof result === 'boolean' && result) {
|
||||
dispatch(closeModal(ModalIdentifiers.DELETE_WORKSPACE_PROGRESS));
|
||||
dispatch(openModal({
|
||||
modalId: ModalIdentifiers.DELETE_WORKSPACE_RESULT,
|
||||
dialogType: DeleteWorkspaceSuccessModal,
|
||||
}));
|
||||
} else { // Failure
|
||||
dispatch(openModal({
|
||||
modalId: ModalIdentifiers.DELETE_WORKSPACE_RESULT,
|
||||
dialogType: DeleteWorkspaceFailureModal,
|
||||
}));
|
||||
dispatch(closeModal(ModalIdentifiers.DELETE_WORKSPACE_PROGRESS));
|
||||
}
|
||||
};
|
||||
|
||||
// Processes the workspace downgrade, opening and closing the appropriate modals (progress, success/failure).
|
||||
const downgradeWorkspace = async (downgradeFeedback: Feedback) => {
|
||||
if (!starterProduct) {
|
||||
return;
|
||||
}
|
||||
|
||||
const telemetryInfo = props.callerCTA + ' > delete_workspace_modal';
|
||||
openDowngradeModal({trackingLocation: telemetryInfo});
|
||||
|
||||
const result = await dispatch(subscribeCloudSubscription(starterProduct.id, undefined, 0, downgradeFeedback));
|
||||
|
||||
// Success
|
||||
if (result.data) {
|
||||
dispatch(closeModal(ModalIdentifiers.DOWNGRADE_MODAL));
|
||||
dispatch(
|
||||
openModal({
|
||||
modalId: ModalIdentifiers.SUCCESS_MODAL,
|
||||
dialogType: SuccessModal,
|
||||
dialogProps: {
|
||||
newProductName: starterProduct.name,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else { // Failure
|
||||
dispatch(closeModal(ModalIdentifiers.DOWNGRADE_MODAL));
|
||||
dispatch(
|
||||
openModal({
|
||||
modalId: ModalIdentifiers.ERROR_MODAL,
|
||||
dialogType: ErrorModal,
|
||||
dialogProps: {
|
||||
backButtonAction: () => {
|
||||
dispatch(openModal({
|
||||
modalId: ModalIdentifiers.DELETE_WORKSPACE,
|
||||
dialogType: DeleteWorkspaceModal,
|
||||
dialogProps: {
|
||||
callerCTA: props.callerCTA,
|
||||
},
|
||||
}));
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (isNotCloud) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<GenericModal
|
||||
compassDesign={true}
|
||||
className='DeleteWorkspaceModal'
|
||||
onExited={handleClickCancel}
|
||||
>
|
||||
<div className='DeleteWorkspaceModal__Icon'>
|
||||
<LaptopAlertSVG height={156}/>
|
||||
</div>
|
||||
<div className='DeleteWorkspaceModal__Title'>
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscription.deleteWorkspaceModal.title'
|
||||
defaultMessage='Are you sure you want to delete?'
|
||||
/>
|
||||
</div>
|
||||
<div className='DeleteWorkspaceModal__Usage'>
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscription.deleteWorkspaceModal.usage'
|
||||
defaultMessage='As part of your subscription to Mattermost {sku} you have created '
|
||||
values={{
|
||||
sku: product?.name,
|
||||
}}
|
||||
/>
|
||||
<span className='DeleteWorkspaceModal__Usage-Highlighted'>
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscription.deleteWorkspaceModal.usageDetails'
|
||||
defaultMessage='{messageCount} messages and {fileSize} of files'
|
||||
values={{
|
||||
messageCount: totalMessages,
|
||||
fileSize: totalFileSize,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className='DeleteWorkspaceModal__Warning'>
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscription.deleteWorkspaceModal.warning'
|
||||
defaultMessage="Deleting your workspace is final. Upon deleting, you'll lose all of the above with no ability to recover. If you downgrade to Free, you will not lose this information."
|
||||
/>
|
||||
</div>
|
||||
<div className='DeleteWorkspaceModal__Buttons'>
|
||||
<button
|
||||
className='btn DeleteWorkspaceModal__Buttons-Delete'
|
||||
onClick={handleClickDeleteWorkspace}
|
||||
>
|
||||
<FormattedMessage {...messages.deleteButton}/>
|
||||
</button>
|
||||
{!isStarter && !isEnterprise &&
|
||||
<button
|
||||
className='btn DeleteWorkspaceModal__Buttons-Downgrade'
|
||||
onClick={handleClickDowngradeWorkspace}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscription.deleteWorkspaceModal.downgradeButton'
|
||||
defaultMessage='Downgrade To Free'
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
className='btn btn-primary DeleteWorkspaceModal__Buttons-Cancel'
|
||||
onClick={handleClickCancel}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscription.deleteWorkspaceModal.cancelButton'
|
||||
defaultMessage='Keep Subscription'
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</GenericModal>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {useDispatch} from 'react-redux';
|
||||
|
||||
import {closeModal, openModal} from 'actions/views/modals';
|
||||
|
||||
import PaymentFailedSvg from 'components/common/svg_images_components/payment_failed_svg';
|
||||
|
||||
import {ModalIdentifiers} from 'utils/constants';
|
||||
|
||||
import DeleteWorkspaceModal from './delete_workspace_modal';
|
||||
import ResultModal from './result_modal';
|
||||
|
||||
export default function DeleteWorkspaceFailureModal() {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleButtonClick = () => {
|
||||
dispatch(closeModal(ModalIdentifiers.DELETE_WORKSPACE_RESULT));
|
||||
dispatch(openModal({
|
||||
modalId: ModalIdentifiers.DELETE_WORKSPACE,
|
||||
dialogType: DeleteWorkspaceModal,
|
||||
dialogProps: {
|
||||
callerCTA: 'delete_workspace_failure_modal',
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const title = (
|
||||
<FormattedMessage
|
||||
defaultMessage={'Workspace deletion failed'}
|
||||
id={'admin.billing.deleteWorkspace.failureModal.title'}
|
||||
/>
|
||||
);
|
||||
|
||||
const subtitle = (
|
||||
<FormattedMessage
|
||||
id={'admin.billing.deleteWorkspace.failureModal.subtitle'}
|
||||
defaultMessage={'We ran into an issue deleting your workspace. Please try again or contact support.'}
|
||||
/>
|
||||
);
|
||||
|
||||
const buttonText = (
|
||||
<FormattedMessage
|
||||
id='admin.billing.deleteWorkspace.failureModal.buttonText'
|
||||
defaultMessage={'Try Again'}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ResultModal
|
||||
primaryButtonText={buttonText}
|
||||
primaryButtonHandler={handleButtonClick}
|
||||
identifier={ModalIdentifiers.DELETE_WORKSPACE_RESULT}
|
||||
subtitle={subtitle}
|
||||
title={title}
|
||||
ignoreExit={false}
|
||||
resultType='failure'
|
||||
icon={
|
||||
<PaymentFailedSvg
|
||||
width={444}
|
||||
height={313}
|
||||
/>
|
||||
}
|
||||
contactSupportButtonVisible={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -113,13 +113,10 @@ describe('components/feature_discovery', () => {
|
||||
expect(screen.queryByText('Foo')).toBeInTheDocument();
|
||||
|
||||
//this option is visible only when it is cloud environment
|
||||
expect(screen.getByRole('button', {name: 'Try free for 30 days'})).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Try free for 30 days')).toHaveLength(2);
|
||||
expect(screen.getByRole('button', {name: 'Contact sales'})).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByTestId('featureDiscovery_secondaryCallToAction')).toHaveAttribute('href', 'https://test.mattermost.com/secondary/?utm_source=mattermost&utm_medium=in-product&utm_content=feature_discovery&uid=&sid=');
|
||||
|
||||
expect(screen.getByText('Privacy Policy')).toHaveAttribute('href', 'https://mattermost.com/pl/privacy-policy/?utm_source=mattermost&utm_medium=in-product&utm_content=feature_discovery&uid=&sid=');
|
||||
|
||||
const featureLink = screen.getByTestId('featureDiscovery_secondaryCallToAction');
|
||||
|
||||
expect(featureLink).toBeInTheDocument();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import type {AnalyticsState} from '@mattermost/types/admin';
|
||||
import type {CloudCustomer} from '@mattermost/types/cloud';
|
||||
@@ -13,13 +13,11 @@ import {trackEvent} from 'actions/telemetry_actions';
|
||||
import {EmbargoedEntityTrialError} from 'components/admin_console/license_settings/trial_banner/trial_banner';
|
||||
import AlertBanner from 'components/alert_banner';
|
||||
import PurchaseLink from 'components/announcement_bar/purchase_link/purchase_link';
|
||||
import CloudStartTrialButton from 'components/cloud_start_trial/cloud_start_trial_btn';
|
||||
import ExternalLink from 'components/external_link';
|
||||
import FormattedMarkdownMessage from 'components/formatted_markdown_message';
|
||||
import StartTrialBtn from 'components/learn_more_trial_modal/start_trial_btn';
|
||||
import LoadingSpinner from 'components/widgets/loading/loading_spinner';
|
||||
|
||||
import {FREEMIUM_TO_ENTERPRISE_TRIAL_LENGTH_DAYS} from 'utils/cloud_utils';
|
||||
import {TELEMETRY_CATEGORIES, AboutLinks, LicenseLinks, LicenseSkus} from 'utils/constants';
|
||||
import {goToMattermostContactSalesForm} from 'utils/contact_support_sales';
|
||||
import * as Utils from 'utils/utils';
|
||||
@@ -147,13 +145,8 @@ export default class FeatureDiscovery extends React.PureComponent<Props, State>
|
||||
renderStartTrial = (learnMoreURL: string, gettingTrialError: React.ReactNode) => {
|
||||
const {
|
||||
isCloud,
|
||||
isCloudTrial,
|
||||
hadPrevCloudTrial,
|
||||
isPaidSubscription,
|
||||
} = this.props;
|
||||
|
||||
const canRequestCloudFreeTrial = isCloud && !isCloudTrial && !hadPrevCloudTrial && !isPaidSubscription;
|
||||
|
||||
// by default we assume is not cloud, so the cta button is Start Trial (which will request a trial license)
|
||||
let ctaPrimaryButton = (
|
||||
<StartTrialBtn
|
||||
@@ -169,32 +162,22 @@ export default class FeatureDiscovery extends React.PureComponent<Props, State>
|
||||
);
|
||||
|
||||
if (isCloud) {
|
||||
// if all conditions are set for being able to request a cloud trial, then show the cta start cloud trial button
|
||||
if (canRequestCloudFreeTrial) {
|
||||
ctaPrimaryButton = (
|
||||
<FeatureDiscoveryCloudStartTrialButton
|
||||
telemetryId={`start_cloud_trial_from_${this.props.featureName}`}
|
||||
extraClass='btn btn-primary'
|
||||
// In cloud, only option is to contact sales.
|
||||
ctaPrimaryButton = (
|
||||
<button
|
||||
className='btn btn-primary'
|
||||
data-testid='featureDiscovery_primaryCallToAction'
|
||||
onClick={() => {
|
||||
trackEvent(TELEMETRY_CATEGORIES.CLOUD_ADMIN, 'click_enterprise_contact_sales_feature_discovery');
|
||||
this.contactSalesFunc();
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='admin.ldap_feature_discovery_cloud.call_to_action.primary_sales'
|
||||
defaultMessage='Contact sales'
|
||||
/>
|
||||
);
|
||||
if (this.props.cloudFreeDeprecated) {
|
||||
ctaPrimaryButton = (
|
||||
<button
|
||||
className='btn btn-primary'
|
||||
data-testid='featureDiscovery_primaryCallToAction'
|
||||
onClick={() => {
|
||||
trackEvent(TELEMETRY_CATEGORIES.SELF_HOSTED_ADMIN, 'click_enterprise_contact_sales_feature_discovery');
|
||||
this.contactSalesFunc();
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='admin.ldap_feature_discovery_cloud.call_to_action.primary_sales'
|
||||
defaultMessage='Contact sales'
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -212,62 +195,35 @@ export default class FeatureDiscovery extends React.PureComponent<Props, State>
|
||||
/>
|
||||
</ExternalLink>
|
||||
{gettingTrialError}
|
||||
{((!this.props.isCloud || canRequestCloudFreeTrial) && !this.props.cloudFreeDeprecated) && <p className='trial-legal-terms'>
|
||||
{canRequestCloudFreeTrial ? (
|
||||
<FormattedMessage
|
||||
id='admin.feature_discovery.trial-request.accept-terms.cloudFree'
|
||||
defaultMessage='By selecting <highlight>Try free for {trialLength} days</highlight>, I agree to the <linkEvaluation>Mattermost Software and Services License Agreement</linkEvaluation>, <linkPrivacy>Privacy Policy</linkPrivacy>, and receiving product emails.'
|
||||
values={{
|
||||
trialLength: FREEMIUM_TO_ENTERPRISE_TRIAL_LENGTH_DAYS,
|
||||
highlight: (msg: React.ReactNode) => (
|
||||
<strong>{msg}</strong>
|
||||
),
|
||||
linkEvaluation: (msg: React.ReactNode) => (
|
||||
<ExternalLink
|
||||
location='feature_discovery'
|
||||
href={LicenseLinks.SOFTWARE_SERVICES_LICENSE_AGREEMENT}
|
||||
>
|
||||
{msg}
|
||||
</ExternalLink>
|
||||
),
|
||||
linkPrivacy: (msg: React.ReactNode) => (
|
||||
<ExternalLink
|
||||
location='feature_discovery'
|
||||
href={AboutLinks.PRIVACY_POLICY}
|
||||
>
|
||||
{msg}
|
||||
</ExternalLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='admin.feature_discovery.trial-request.accept-terms'
|
||||
defaultMessage='By clicking <highlight>Start trial</highlight>, I agree to the <linkEvaluation>Mattermost Software and Services License Agreement</linkEvaluation>, <linkPrivacy>Privacy Policy</linkPrivacy> and receiving product emails.'
|
||||
values={{
|
||||
highlight: (msg: React.ReactNode) => (
|
||||
<strong>{msg}</strong>
|
||||
),
|
||||
linkEvaluation: (msg: React.ReactNode) => (
|
||||
<ExternalLink
|
||||
location='feature_discovery'
|
||||
href={LicenseLinks.SOFTWARE_SERVICES_LICENSE_AGREEMENT}
|
||||
>
|
||||
{msg}
|
||||
</ExternalLink>
|
||||
),
|
||||
linkPrivacy: (msg: React.ReactNode) => (
|
||||
<ExternalLink
|
||||
location='feature_discovery'
|
||||
href={AboutLinks.PRIVACY_POLICY}
|
||||
>
|
||||
{msg}
|
||||
</ExternalLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</p>}
|
||||
{(!this.props.isCloud) && (<p className='trial-legal-terms'>
|
||||
|
||||
<FormattedMessage
|
||||
id='admin.feature_discovery.trial-request.accept-terms'
|
||||
defaultMessage='By clicking <highlight>Start trial</highlight>, I agree to the <linkEvaluation>Mattermost Software and Services License Agreement</linkEvaluation>, <linkPrivacy>Privacy Policy</linkPrivacy> and receiving product emails.'
|
||||
values={{
|
||||
highlight: (msg: React.ReactNode) => (
|
||||
<strong>{msg}</strong>
|
||||
),
|
||||
linkEvaluation: (msg: React.ReactNode) => (
|
||||
<ExternalLink
|
||||
location='feature_discovery'
|
||||
href={LicenseLinks.SOFTWARE_SERVICES_LICENSE_AGREEMENT}
|
||||
>
|
||||
{msg}
|
||||
</ExternalLink>
|
||||
),
|
||||
linkPrivacy: (msg: React.ReactNode) => (
|
||||
<ExternalLink
|
||||
location='feature_discovery'
|
||||
href={AboutLinks.PRIVACY_POLICY}
|
||||
>
|
||||
{msg}
|
||||
</ExternalLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
</p>)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -375,22 +331,3 @@ export default class FeatureDiscovery extends React.PureComponent<Props, State>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function FeatureDiscoveryCloudStartTrialButton(props: Omit<React.ComponentProps<typeof CloudStartTrialButton>, 'message'>) {
|
||||
const message = useIntl().formatMessage(
|
||||
{
|
||||
id: 'admin.ldap_feature_discovery.call_to_action.primary.cloudFree',
|
||||
defaultMessage: 'Try free for {trialLength} days',
|
||||
},
|
||||
{
|
||||
trialLength: FREEMIUM_TO_ENTERPRISE_TRIAL_LENGTH_DAYS,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<CloudStartTrialButton
|
||||
{...props}
|
||||
message={message}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ exports[`components/admin_console/license_settings/LicenseSettings load screen a
|
||||
className="admin-console__banner_section"
|
||||
>
|
||||
<RenewLicenseCard
|
||||
isDisabled={false}
|
||||
isLicenseExpired={false}
|
||||
license={
|
||||
Object {
|
||||
@@ -125,7 +124,6 @@ exports[`components/admin_console/license_settings/LicenseSettings load screen w
|
||||
className="admin-console__banner_section"
|
||||
>
|
||||
<RenewLicenseCard
|
||||
isDisabled={false}
|
||||
isLicenseExpired={false}
|
||||
license={
|
||||
Object {
|
||||
@@ -230,7 +228,6 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="admin-console__banner_section"
|
||||
>
|
||||
<RenewLicenseCard
|
||||
isDisabled={false}
|
||||
isLicenseExpired={false}
|
||||
license={
|
||||
Object {
|
||||
@@ -366,7 +363,6 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="admin-console__banner_section"
|
||||
>
|
||||
<RenewLicenseCard
|
||||
isDisabled={false}
|
||||
isLicenseExpired={false}
|
||||
license={
|
||||
Object {
|
||||
@@ -490,7 +486,6 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="admin-console__banner_section"
|
||||
>
|
||||
<RenewLicenseCard
|
||||
isDisabled={false}
|
||||
isLicenseExpired={false}
|
||||
license={
|
||||
Object {
|
||||
@@ -614,7 +609,6 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="admin-console__banner_section"
|
||||
>
|
||||
<RenewLicenseCard
|
||||
isDisabled={false}
|
||||
isLicenseExpired={false}
|
||||
license={
|
||||
Object {
|
||||
@@ -738,7 +732,6 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="admin-console__banner_section"
|
||||
>
|
||||
<RenewLicenseCard
|
||||
isDisabled={true}
|
||||
isLicenseExpired={false}
|
||||
license={
|
||||
Object {
|
||||
@@ -862,7 +855,6 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="admin-console__banner_section"
|
||||
>
|
||||
<RenewLicenseCard
|
||||
isDisabled={false}
|
||||
isLicenseExpired={false}
|
||||
license={
|
||||
Object {
|
||||
@@ -1311,7 +1303,6 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="admin-console__banner_section"
|
||||
>
|
||||
<RenewLicenseCard
|
||||
isDisabled={false}
|
||||
isLicenseExpired={false}
|
||||
license={
|
||||
Object {
|
||||
@@ -1735,7 +1726,6 @@ exports[`components/admin_console/license_settings/LicenseSettings should match
|
||||
className="admin-console__banner_section"
|
||||
>
|
||||
<RenewLicenseCard
|
||||
isDisabled={false}
|
||||
isLicenseExpired={false}
|
||||
license={
|
||||
Object {
|
||||
|
||||
@@ -10,8 +10,6 @@ import type {DeepPartial} from '@mattermost/types/utilities';
|
||||
|
||||
import {General} from 'mattermost-redux/constants';
|
||||
|
||||
import * as useCanSelfHostedExpand from 'components/common/hooks/useCanSelfHostedExpand';
|
||||
|
||||
import mergeObjects from 'packages/mattermost-redux/test/merge_objects';
|
||||
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
|
||||
import {renderWithContext, screen} from 'tests/react_testing_utils';
|
||||
@@ -240,34 +238,4 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris
|
||||
|
||||
expect(screen.getByText('Expires in 5 days')).toHaveClass('expiration-days-danger');
|
||||
});
|
||||
|
||||
test('should display add seats button when there are more than 60 days until expiry and self hosted expansion is available', () => {
|
||||
const testLicense = {
|
||||
...license,
|
||||
ExpiresAt: moment().add(61, 'days').valueOf().toString(),
|
||||
};
|
||||
|
||||
const testState = mergeObjects(initialState, {
|
||||
entities: {
|
||||
general: {
|
||||
license: testLicense,
|
||||
},
|
||||
},
|
||||
});
|
||||
const props = {
|
||||
...baseProps,
|
||||
license: testLicense,
|
||||
};
|
||||
|
||||
jest.spyOn(useCanSelfHostedExpand, 'default').mockImplementation(() => true);
|
||||
|
||||
renderWithContext(
|
||||
<EnterpriseEditionLeftPanel
|
||||
{...props}
|
||||
/>,
|
||||
testState,
|
||||
);
|
||||
|
||||
expect(screen.getByText('+ Add seats')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -394,8 +394,6 @@ export default class LicenseSettings extends React.PureComponent<Props, State> {
|
||||
}
|
||||
|
||||
renewLicenseCard = () => {
|
||||
const {isDisabled} = this.props;
|
||||
|
||||
if (isTrialLicense(this.props.license)) {
|
||||
return (
|
||||
<TrialLicenseCard
|
||||
@@ -409,7 +407,6 @@ export default class LicenseSettings extends React.PureComponent<Props, State> {
|
||||
license={this.props.license}
|
||||
isLicenseExpired={isLicenseExpired(this.props.license)}
|
||||
totalUsers={this.props.totalUsers}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ import React from 'react';
|
||||
import {act} from 'react-dom/test-utils';
|
||||
import {Provider} from 'react-redux';
|
||||
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
|
||||
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
|
||||
import mockStore from 'tests/test_store';
|
||||
|
||||
@@ -71,31 +69,7 @@ describe('components/RenewalLicenseCard', () => {
|
||||
isDisabled: false,
|
||||
};
|
||||
|
||||
test('should show Renew and Contact sales buttons when a renewal link is successfully returned', async () => {
|
||||
const getRenewalLinkSpy = jest.spyOn(Client4, 'getRenewalLink');
|
||||
const promise = new Promise<{renewal_link: string}>((resolve) => {
|
||||
resolve({
|
||||
renewal_link: 'https://testrenewallink',
|
||||
});
|
||||
});
|
||||
getRenewalLinkSpy.mockImplementation(() => promise);
|
||||
const store = mockStore(initialState);
|
||||
const wrapper = mountWithIntl(<Provider store={store}><RenewalLicenseCard {...props}/></Provider>);
|
||||
|
||||
// wait for the promise to resolve and component to update
|
||||
await actImmediate(wrapper);
|
||||
|
||||
expect(wrapper.find('button').length).toEqual(2);
|
||||
expect(wrapper.find('button').at(0).text().includes('Renew')).toBe(true);
|
||||
expect(wrapper.find('button').at(1).text().includes('Contact sales')).toBe(true);
|
||||
});
|
||||
|
||||
test('should show only Contact sales button when a renewal link is not able to renew license', async () => {
|
||||
const getRenewalLinkSpy = jest.spyOn(Client4, 'getRenewalLink');
|
||||
const promise = new Promise<{renewal_link: string}>((resolve, reject) => {
|
||||
reject(new Error('License cannot be renewed from portal'));
|
||||
});
|
||||
getRenewalLinkSpy.mockImplementation(() => promise);
|
||||
test('should show Contact sales button', async () => {
|
||||
const store = mockStore(initialState);
|
||||
const wrapper = mountWithIntl(<Provider store={store}><RenewalLicenseCard {...props}/></Provider>);
|
||||
|
||||
|
||||
@@ -2,16 +2,13 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import moment from 'moment';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import React from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import type {ClientLicense} from '@mattermost/types/config';
|
||||
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
|
||||
import AlertBanner from 'components/alert_banner';
|
||||
import ContactUsButton from 'components/announcement_bar/contact_sales/contact_us';
|
||||
import RenewalLink from 'components/announcement_bar/renewal_link/';
|
||||
import FormattedMarkdownMessage from 'components/formatted_markdown_message';
|
||||
|
||||
import {getSkuDisplayName} from 'utils/subscription';
|
||||
@@ -23,23 +20,12 @@ export interface RenewLicenseCardProps {
|
||||
license: ClientLicense;
|
||||
isLicenseExpired: boolean;
|
||||
totalUsers: number;
|
||||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
const RenewLicenseCard: React.FC<RenewLicenseCardProps> = ({license, totalUsers, isLicenseExpired, isDisabled}: RenewLicenseCardProps) => {
|
||||
const [showContactSalesBtn, setShowContactSalesBtn] = useState(true);
|
||||
useEffect(() => {
|
||||
Client4.getRenewalLink().catch(() => {
|
||||
// if we have an error with getting the renewal link, do not show contact sales button because
|
||||
// it is already shown by the RenewalLink component
|
||||
setShowContactSalesBtn(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const RenewLicenseCard: React.FC<RenewLicenseCardProps> = ({license, totalUsers, isLicenseExpired}: RenewLicenseCardProps) => {
|
||||
let bannerType: 'info' | 'warning' | 'danger' = 'info';
|
||||
const endOfLicense = moment.utc(new Date(parseInt(license?.ExpiresAt, 10)));
|
||||
const daysToEndLicense = getRemainingDaysFromFutureTimestamp(parseInt(license?.ExpiresAt, 10));
|
||||
const renewLinkTelemetry = {success: 'renew_license_admin_console_success', error: 'renew_license_admin_console_fail'};
|
||||
const contactSalesBtn = (
|
||||
<div className='purchase-card'>
|
||||
<ContactUsButton
|
||||
@@ -71,18 +57,12 @@ const RenewLicenseCard: React.FC<RenewLicenseCardProps> = ({license, totalUsers,
|
||||
/>
|
||||
);
|
||||
}
|
||||
const customBtnText = (
|
||||
<FormattedMessage
|
||||
id='admin.license.warn.renew'
|
||||
defaultMessage='Renew'
|
||||
/>
|
||||
);
|
||||
const message = (
|
||||
<div className='RenewLicenseCard__text'>
|
||||
<div className='RenewLicenseCard__text-description bolder'>
|
||||
<FormattedMessage
|
||||
id='admin.license.renewalCard.description'
|
||||
defaultMessage='Renew your {licenseSku} license through the Customer Portal to avoid any disruption.'
|
||||
id='admin.license.renewalCard.description.contact_sales'
|
||||
defaultMessage='Renew your {licenseSku} license by contacting sales to avoid any disruption.'
|
||||
values={{
|
||||
licenseSku: getSkuDisplayName(license.SkuShortName, license.IsGovSku === 'true'),
|
||||
}}
|
||||
@@ -113,12 +93,7 @@ const RenewLicenseCard: React.FC<RenewLicenseCardProps> = ({license, totalUsers,
|
||||
/>
|
||||
</div>
|
||||
<div className='RenewLicenseCard__buttons'>
|
||||
<RenewalLink
|
||||
isDisabled={isDisabled}
|
||||
telemetryInfo={renewLinkTelemetry}
|
||||
customBtnText={customBtnText}
|
||||
/>
|
||||
{showContactSalesBtn && contactSalesBtn}
|
||||
{contactSalesBtn}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,8 +11,11 @@
|
||||
}
|
||||
|
||||
.RenewLicenseCard__buttons {
|
||||
button {
|
||||
padding: 6px 12px !important;
|
||||
.contact_us_primary_cta {
|
||||
padding: 6px 12px;
|
||||
margin-left: 0px;
|
||||
background-color: var(--sys-button-bg);
|
||||
color: var(--sys-center-channel-bg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +31,5 @@
|
||||
|
||||
button.contact-us {
|
||||
padding: 11px 19px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {ClientLicense} from '@mattermost/types/config';
|
||||
|
||||
import AlertBanner from 'components/alert_banner';
|
||||
import ContactUsButton from 'components/announcement_bar/contact_sales/contact_us';
|
||||
import PurchaseLink from 'components/announcement_bar/purchase_link/purchase_link';
|
||||
import FormattedMarkdownMessage from 'components/formatted_markdown_message';
|
||||
|
||||
import {daysToLicenseExpire} from 'utils/license_utils';
|
||||
@@ -57,16 +56,8 @@ const TrialLicenseCard: React.FC<Props> = ({license}: Props) => {
|
||||
{messageBody()}
|
||||
</div>
|
||||
<div className='RenewLicenseCard__buttons'>
|
||||
<PurchaseLink
|
||||
buttonTextElement={
|
||||
<FormattedMessage
|
||||
id='admin.license.trialCard.purchase_license'
|
||||
defaultMessage='Purchase a license'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ContactUsButton
|
||||
customClass='light-blue-btn'
|
||||
customClass='contact_us_primary_cta'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,6 @@ import type {ClientLicense} from '@mattermost/types/config';
|
||||
import * as AdminActions from 'actions/admin_actions.jsx';
|
||||
|
||||
import ActivatedUserCard from 'components/analytics/activated_users_card';
|
||||
import TrueUpReview from 'components/analytics/true_up_review';
|
||||
import ExternalLink from 'components/external_link';
|
||||
import AdminHeader from 'components/widgets/admin_console/admin_header';
|
||||
|
||||
@@ -444,7 +443,6 @@ export default class SystemAnalytics extends React.PureComponent<Props, State> {
|
||||
<div className='admin-console__wrapper'>
|
||||
<div className='admin-console__content'>
|
||||
{banner}
|
||||
<TrueUpReview/>
|
||||
<div className='grid-statistics'>
|
||||
{systemCards}
|
||||
{dailyActiveUsers}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {messages as activatedUsersCardsMessages} from 'components/analytics/acti
|
||||
import LineChart from 'components/analytics/line_chart';
|
||||
import StatisticCount from 'components/analytics/statistic_count';
|
||||
import TableChart from 'components/analytics/table_chart';
|
||||
import TrueUpReview from 'components/analytics/true_up_review';
|
||||
import ExternalLink from 'components/external_link';
|
||||
import LoadingScreen from 'components/loading_screen';
|
||||
import AdminHeader from 'components/widgets/admin_console/admin_header';
|
||||
@@ -316,7 +315,6 @@ export default class TeamAnalytics extends React.PureComponent<Props, State> {
|
||||
|
||||
<div className='admin-console__wrapper'>
|
||||
<div className='admin-console__content'>
|
||||
<TrueUpReview/>
|
||||
{banner}
|
||||
<div className='grid-statistics'>
|
||||
<ActivatedUserCard
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
.TrueUpReview {
|
||||
&__card {
|
||||
width: 100%;
|
||||
height: '463px';
|
||||
border: 1px solid rgba(var(--sys-center-channel-color-rgb), 0.08);
|
||||
border-radius: 4px;
|
||||
background-color: var(--sys-center-channel-bg);
|
||||
box-shadow: var(--elevation-1);
|
||||
color: var(--sys-center-channel-color);
|
||||
}
|
||||
|
||||
&__cardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 28px 32px 24px 32px;
|
||||
border-bottom: 1px solid rgba(var(--sys-center-channel-color-rgb), 0.08);
|
||||
}
|
||||
|
||||
&__cardHeaderText-top {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
&__cardBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
&__cardBody > * {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
&__cardBody > svg {
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
&__dueDate {
|
||||
:first-child {
|
||||
color: rgba(var(--sys-center-channel-color-rgb), 0.75);
|
||||
}
|
||||
}
|
||||
|
||||
&__warning {
|
||||
color: var(--warning-text);
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
&__submit {
|
||||
font-weight: 600;
|
||||
|
||||
&--error {
|
||||
background: rgba(var(--button-bg-rgb), 0.16) !important;
|
||||
color: var(--button-bg) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type {GlobalState} from '@mattermost/types/store';
|
||||
import type {DeepPartial} from '@mattermost/types/utilities';
|
||||
|
||||
import * as useCWSAvailabilityCheckAll from 'components/common/hooks/useCWSAvailabilityCheck';
|
||||
|
||||
import {renderWithContext, screen} from 'tests/react_testing_utils';
|
||||
import {LicenseSkus} from 'utils/constants';
|
||||
import {TestHelper as TH} from 'utils/test_helper';
|
||||
|
||||
import TrueUpReview from './true_up_review';
|
||||
|
||||
describe('TrueUpReview', () => {
|
||||
const showsTrueUpReviewState: DeepPartial<GlobalState> = {
|
||||
entities: {
|
||||
general: {
|
||||
license: TH.getLicenseMock({
|
||||
IsGovSku: 'false',
|
||||
Cloud: 'false',
|
||||
SkuShortName: LicenseSkus.Enterprise,
|
||||
IsLicensed: 'true',
|
||||
}),
|
||||
config: {
|
||||
EnableDiagnostics: 'true',
|
||||
},
|
||||
},
|
||||
users: {
|
||||
currentUserId: 'userId',
|
||||
profiles: {
|
||||
userId: TH.getUserMock({
|
||||
id: 'userId',
|
||||
roles: 'system_admin',
|
||||
}),
|
||||
},
|
||||
},
|
||||
hostedCustomer: {
|
||||
trueUpReviewStatus: {
|
||||
|
||||
// one day in future so we're sure it will display,
|
||||
// regardless of future changes to "do we show it if it already passed"
|
||||
due_date: Date.now() + (1000 * 60 * 60 * 24),
|
||||
complete: false,
|
||||
getRequestState: 'IDLE',
|
||||
},
|
||||
trueUpReviewProfile: {
|
||||
getRequestState: 'IDLE',
|
||||
content: '',
|
||||
},
|
||||
errors: {},
|
||||
},
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
it('regular self hosted license (NOT air-gapped) in the true up window sees content', () => {
|
||||
jest.spyOn(useCWSAvailabilityCheckAll, 'default').mockImplementation(() => useCWSAvailabilityCheckAll.CSWAvailabilityCheckTypes.Available);
|
||||
|
||||
renderWithContext(<TrueUpReview/>, showsTrueUpReviewState);
|
||||
screen.getByText('Share to Mattermost');
|
||||
});
|
||||
|
||||
it('regular self hosted license thats air gapped sees download button only', () => {
|
||||
jest.spyOn(useCWSAvailabilityCheckAll, 'default').mockImplementation(() => useCWSAvailabilityCheckAll.CSWAvailabilityCheckTypes.Unavailable);
|
||||
|
||||
renderWithContext(<TrueUpReview/>, showsTrueUpReviewState);
|
||||
screen.getByText('Download Data');
|
||||
expect(screen.queryByText('Share to Mattermost')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays the panel regardless of the config value for EnableDiagnostic', () => {
|
||||
const store = JSON.parse(JSON.stringify(showsTrueUpReviewState));
|
||||
store.entities.general.config.EnableDiagnostics = 'false';
|
||||
jest.spyOn(useCWSAvailabilityCheckAll, 'default').mockImplementation(() => useCWSAvailabilityCheckAll.CSWAvailabilityCheckTypes.Available);
|
||||
|
||||
renderWithContext(<TrueUpReview/>, store);
|
||||
screen.getByText('Share to Mattermost');
|
||||
});
|
||||
|
||||
it('gov sku self-hosted license does not see true up content', () => {
|
||||
const store = JSON.parse(JSON.stringify(showsTrueUpReviewState));
|
||||
store.entities.general.license.IsGovSku = 'true';
|
||||
jest.spyOn(useCWSAvailabilityCheckAll, 'default').mockImplementation(() => useCWSAvailabilityCheckAll.CSWAvailabilityCheckTypes.Available);
|
||||
|
||||
renderWithContext(<TrueUpReview/>, store);
|
||||
expect(screen.queryByText('Share to Mattermost')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,246 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import React, {useEffect} from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
import type {GlobalState} from '@mattermost/types/store';
|
||||
|
||||
import {isCurrentLicenseCloud} from 'mattermost-redux/selectors/entities/cloud';
|
||||
import {getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
import {
|
||||
getSelfHostedErrors,
|
||||
getTrueUpReviewProfile as trueUpReviewProfileSelector,
|
||||
getTrueUpReviewStatus as trueUpReviewStatusSelector,
|
||||
} from 'mattermost-redux/selectors/entities/hosted_customer';
|
||||
import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
|
||||
|
||||
import {submitTrueUpReview, getTrueUpReviewStatus} from 'actions/hosted_customer';
|
||||
import {pageVisited} from 'actions/telemetry_actions';
|
||||
|
||||
import useCWSAvailabilityCheck, {CSWAvailabilityCheckTypes} from 'components/common/hooks/useCWSAvailabilityCheck';
|
||||
import ExternalLink from 'components/external_link';
|
||||
import CheckMarkSvg from 'components/widgets/icons/check_mark_icon';
|
||||
import WarningIcon from 'components/widgets/icons/fa_warning_icon';
|
||||
|
||||
import {DocLinks, TELEMETRY_CATEGORIES} from 'utils/constants';
|
||||
import {getIsStarterLicense, getIsGovSku} from 'utils/license_utils';
|
||||
|
||||
import './true_up_review.scss';
|
||||
|
||||
const TrueUpReview: React.FC = () => {
|
||||
const dispatch = useDispatch();
|
||||
const isCloud = useSelector(isCurrentLicenseCloud);
|
||||
const cwsAvailability = useCWSAvailabilityCheck();
|
||||
const isAirGapped = cwsAvailability !== CSWAvailabilityCheckTypes.Available;
|
||||
const reviewProfile = useSelector(trueUpReviewProfileSelector);
|
||||
const reviewStatus = useSelector(trueUpReviewStatusSelector);
|
||||
const isSystemAdmin = useSelector(isCurrentUserSystemAdmin);
|
||||
const license = useSelector(getLicense);
|
||||
const isLicensed = license.IsLicensed === 'true';
|
||||
const isStarter = getIsStarterLicense(license);
|
||||
const isGovSku = getIsGovSku(license);
|
||||
|
||||
// A license is eligible for true up if:
|
||||
// * a license exists for the customer
|
||||
// * are self-hosted (not cloud)
|
||||
// * are not on starter/free
|
||||
// * are not a government sku
|
||||
const licenseIsTrueUpEligible = isLicensed && !isCloud && !isStarter && !isGovSku;
|
||||
const trueUpReviewError = useSelector((state: GlobalState) => {
|
||||
const errors = getSelfHostedErrors(state);
|
||||
return Boolean(errors.trueUpReview);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (reviewStatus.getRequestState !== 'IDLE' || !licenseIsTrueUpEligible) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(getTrueUpReviewStatus());
|
||||
}, [dispatch, reviewStatus.getRequestState, licenseIsTrueUpEligible]);
|
||||
|
||||
// Download the review profile as a base64 encoded json file when the review request is submitted.
|
||||
useEffect(() => {
|
||||
if (reviewProfile.getRequestState === 'LOADING') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (reviewProfile.getRequestState === 'OK' && !reviewStatus.complete && isAirGapped && !trueUpReviewError && reviewProfile.content.length > 0) {
|
||||
// Create the bundle as a blob containing base64 encoded json data and assign it to a link element.
|
||||
const blob = new Blob([reviewProfile.content], {type: 'application/text'});
|
||||
const href = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement('a');
|
||||
const date = moment().format('MM-DD-YYYY');
|
||||
link.href = href;
|
||||
link.download = `True Up-${license.Id}-${date}.txt`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// Remove link and revoke object url to avoid memory leaks.
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(href);
|
||||
dispatch(getTrueUpReviewStatus());
|
||||
}
|
||||
}, [isAirGapped, reviewProfile, reviewProfile.getRequestState, trueUpReviewError]);
|
||||
|
||||
const formattedDueDate = (): string => {
|
||||
if (!reviewStatus.due_date) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Convert from milliseconds
|
||||
const date = new Date(reviewStatus.due_date);
|
||||
return moment(date).format('MMMM DD, YYYY');
|
||||
};
|
||||
|
||||
const handleSubmitReview = () => {
|
||||
dispatch(submitTrueUpReview());
|
||||
};
|
||||
|
||||
const dueDate = (
|
||||
<div className='TrueUpReview__dueDate'>
|
||||
<span>
|
||||
<FormattedMessage
|
||||
id='admin.billing.trueUpReview.due_date'
|
||||
defaultMessage='Due '
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
{formattedDueDate()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const submitButton = (
|
||||
<button
|
||||
className={classNames('btn btn-primary TrueUpReview__submit', {'TrueUpReview__submit--error': trueUpReviewError})}
|
||||
onClick={handleSubmitReview}
|
||||
>
|
||||
{isAirGapped ? (
|
||||
<FormattedMessage
|
||||
id='admin.billing.trueUpReview.button_download'
|
||||
defaultMessage='Download Data'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='admin.billing.trueUpReview.button_share'
|
||||
defaultMessage='Share to Mattermost'
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
const errorStatus = (
|
||||
<>
|
||||
<WarningIcon additionalClassName={'TrueUpReview__warning'}/>
|
||||
<FormattedMessage
|
||||
id='admin.billing.trueUpReview.submit_error'
|
||||
defaultMessage='There was an issue sending your True Up Review. Please try again.'
|
||||
/>
|
||||
{submitButton}
|
||||
</>
|
||||
);
|
||||
|
||||
const successStatus = (
|
||||
<>
|
||||
<CheckMarkSvg/>
|
||||
<FormattedMessage
|
||||
id='admin.billing.trueUpReview.submit_success'
|
||||
defaultMessage='Success!'
|
||||
/>
|
||||
<FormattedMessage
|
||||
id='admin.billing.trueUpReview.submit.thanks_for_sharing'
|
||||
defaultMessage='Thanks for sharing data needed for your true-up review.'
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const trueUpDocsLink = (
|
||||
<ExternalLink
|
||||
href={DocLinks.TRUE_UP_REVIEW}
|
||||
location='true_up_review'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='admin.billing.trueUpReview.docsLinkCTA'
|
||||
defaultMessage='Learn more about true-up.'
|
||||
/>
|
||||
</ExternalLink>
|
||||
);
|
||||
|
||||
const reviewDetails = (
|
||||
<>
|
||||
{dueDate}
|
||||
<FormattedMessage
|
||||
id='admin.billing.trueUpReview.share_data_for_review'
|
||||
defaultMessage='Share your system statistics with Mattermost for your quarterly true-up Review. {link}'
|
||||
values={{
|
||||
link: trueUpDocsLink,
|
||||
}}
|
||||
/>
|
||||
{submitButton}
|
||||
</>
|
||||
);
|
||||
|
||||
const cardContent = () => {
|
||||
if (reviewProfile.getRequestState !== 'OK' && trueUpReviewError) {
|
||||
return errorStatus;
|
||||
}
|
||||
|
||||
// If we just submitted and the review status is set as complete, show the success
|
||||
// status details.
|
||||
if (reviewProfile.getRequestState === 'OK') {
|
||||
return successStatus;
|
||||
}
|
||||
|
||||
// If the due date is empty we still have the default state.
|
||||
if (!reviewStatus.due_date) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return reviewDetails;
|
||||
};
|
||||
|
||||
// Only show the true up review section if the user is an admin and we're not using a cloud instance.
|
||||
if (!licenseIsTrueUpEligible || !isSystemAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only display the review details if we are within 2 weeks of the review due date.
|
||||
const visibilityStart = moment(reviewStatus.due_date).startOf('day').subtract(30, 'days');
|
||||
if (moment().isSameOrBefore(visibilityStart)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If the review has already been submitted, don't show anything.
|
||||
if (reviewStatus.complete) {
|
||||
return null;
|
||||
}
|
||||
|
||||
pageVisited(TELEMETRY_CATEGORIES.TRUE_UP_REVIEW, 'pageview_true_up_review');
|
||||
|
||||
return (
|
||||
<div className='TrueUpReview__card'>
|
||||
<div className='TrueUpReview__cardHeader'>
|
||||
<div className='TrueUpReview__cardHeaderText'>
|
||||
<div className='TrueUpReview__cardHeaderText-top'>
|
||||
<FormattedMessage
|
||||
id='admin.billing.trueUpReview.title'
|
||||
defaultMessage='True Up Review'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='TrueUpReview__cardBody'>
|
||||
{cardContent()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrueUpReview;
|
||||
|
||||
@@ -8,20 +8,17 @@ import {useDispatch, useSelector} from 'react-redux';
|
||||
import type {PreferenceType} from '@mattermost/types/preferences';
|
||||
|
||||
import {savePreferences} from 'mattermost-redux/actions/preferences';
|
||||
import {getConfig} from 'mattermost-redux/selectors/entities/admin';
|
||||
import {isCurrentLicenseCloud} from 'mattermost-redux/selectors/entities/cloud';
|
||||
import {getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
import {makeGetCategory} from 'mattermost-redux/selectors/entities/preferences';
|
||||
import {getCurrentUser, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
|
||||
|
||||
import AnnouncementBar from 'components/announcement_bar/default_announcement_bar';
|
||||
import useCanSelfHostedExpand from 'components/common/hooks/useCanSelfHostedExpand';
|
||||
import {useExpandOverageUsersCheck} from 'components/common/hooks/useExpandOverageUsersCheck';
|
||||
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
|
||||
|
||||
import {StatTypes, Preferences, AnnouncementBarTypes, ConsolePages} from 'utils/constants';
|
||||
import {StatTypes, Preferences, AnnouncementBarTypes} from 'utils/constants';
|
||||
import {calculateOverageUserActivated} from 'utils/overage_team';
|
||||
import {getSiteURL} from 'utils/url';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
@@ -60,9 +57,6 @@ const OverageUsersBanner = () => {
|
||||
activeUsers,
|
||||
seatsPurchased,
|
||||
});
|
||||
const isSelfHostedExpansionEnabled = useSelector(getConfig)?.ServiceSettings?.SelfHostedPurchase;
|
||||
const canSelfHostedExpand = useCanSelfHostedExpand() && isSelfHostedExpansionEnabled;
|
||||
const siteURL = getSiteURL();
|
||||
const prefixPreferences = isOver10PercerntPurchasedSeats ? 'error' : 'warn';
|
||||
const prefixLicenseId = (license.Id || '').substring(0, 8);
|
||||
const preferenceName = `${prefixPreferences}_overage_seats_${prefixLicenseId}`;
|
||||
@@ -73,16 +67,10 @@ const OverageUsersBanner = () => {
|
||||
const hasPermission = isAdmin && isOverageState && !isCloud;
|
||||
const {
|
||||
cta,
|
||||
expandableLink,
|
||||
trackEventFn,
|
||||
getRequestState,
|
||||
isExpandable,
|
||||
} = useExpandOverageUsersCheck({
|
||||
shouldRequest: hasPermission && !adminHasDismissed({isWarningBanner: isBetween5PercerntAnd10PercentPurchasedSeats, overagePreferences, preferenceName}),
|
||||
licenseId: license.Id,
|
||||
isWarningState: isBetween5PercerntAnd10PercentPurchasedSeats,
|
||||
banner: 'global banner',
|
||||
canSelfHostedExpand: canSelfHostedExpand || false,
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -94,31 +82,19 @@ const OverageUsersBanner = () => {
|
||||
}]));
|
||||
};
|
||||
|
||||
const handleUpdateSeatsSelfServeClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.preventDefault();
|
||||
trackEventFn('Self Serve');
|
||||
|
||||
if (canSelfHostedExpand) {
|
||||
window.open(`${siteURL}/${ConsolePages.LICENSE}?action=show_expansion_modal`);
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(expandableLink(license.Id), '_blank');
|
||||
};
|
||||
|
||||
const handleContactSalesClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.preventDefault();
|
||||
trackEventFn('Contact Sales');
|
||||
openContactSales();
|
||||
};
|
||||
|
||||
const handleClick = isExpandable ? handleUpdateSeatsSelfServeClick : handleContactSalesClick;
|
||||
const handleClick = handleContactSalesClick;
|
||||
|
||||
if (!hasPermission || adminHasDismissed({isWarningBanner: isBetween5PercerntAnd10PercentPurchasedSeats, overagePreferences, preferenceName})) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let message = (
|
||||
const message = (
|
||||
<FormattedMessage
|
||||
id='licensingPage.overageUsersBanner.text'
|
||||
defaultMessage='(Only visible to admins) Your workspace user count has exceeded your paid license seat count by {seats, number} {seats, plural, one {seat} other {seats}}. Purchase additional seats to remain compliant.'
|
||||
@@ -127,17 +103,6 @@ const OverageUsersBanner = () => {
|
||||
}}
|
||||
/>);
|
||||
|
||||
if (canSelfHostedExpand) {
|
||||
message = (
|
||||
<FormattedMessage
|
||||
id='licensingPage.overageUsersBanner.textSelfHostedExpand'
|
||||
defaultMessage='(Only visible to admins) Your workspace user count has exceeded your paid license seat count. Update your seat count to stay compliant.'
|
||||
values={{
|
||||
seats: overageByUsers,
|
||||
}}
|
||||
/>);
|
||||
}
|
||||
|
||||
return (
|
||||
<AnnouncementBar
|
||||
type={isBetween5PercerntAnd10PercentPurchasedSeats ? AnnouncementBarTypes.ADVISOR : AnnouncementBarTypes.CRITICAL}
|
||||
@@ -150,7 +115,6 @@ const OverageUsersBanner = () => {
|
||||
isTallBanner={true}
|
||||
icon={<i className='icon icon-alert-outline'/>}
|
||||
handleClose={handleClose}
|
||||
showCTA={getRequestState !== 'IDLE' && getRequestState !== 'LOADING'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,6 @@ import React from 'react';
|
||||
|
||||
import type {DeepPartial} from '@mattermost/types/utilities';
|
||||
|
||||
import {getLicenseSelfServeStatus} from 'mattermost-redux/actions/cloud';
|
||||
import {savePreferences} from 'mattermost-redux/actions/preferences';
|
||||
import {General} from 'mattermost-redux/constants';
|
||||
|
||||
@@ -48,7 +47,6 @@ const text5PercentageState = `(Only visible to admins) Your workspace user count
|
||||
const text10PercentageState = `(Only visible to admins) Your workspace user count has exceeded your paid license seat count by ${seatsMinimumFor10PercentageState - seatsPurchased} seats. Purchase additional seats to remain compliant.`;
|
||||
|
||||
const contactSalesTextLink = 'Contact Sales';
|
||||
const expandSeatsTextLink = 'Purchase additional seats';
|
||||
|
||||
const licenseId = generateId();
|
||||
|
||||
@@ -98,10 +96,6 @@ describe('components/overage_users_banner', () => {
|
||||
myPreferences: {},
|
||||
},
|
||||
cloud: {
|
||||
subscriptionStats: {
|
||||
is_expandable: false,
|
||||
getRequestState: 'IDLE',
|
||||
},
|
||||
},
|
||||
hostedCustomer: {
|
||||
products: {
|
||||
@@ -134,7 +128,6 @@ describe('components/overage_users_banner', () => {
|
||||
renderWithContext(<OverageUsersBanner/>);
|
||||
|
||||
expect(screen.queryByText('(Only visible to admins) Your workspace user count has exceeded your paid license seat count by', {exact: false})).not.toBeInTheDocument();
|
||||
expect(getLicenseSelfServeStatus).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not render the banner because we are not admins', () => {
|
||||
@@ -154,7 +147,6 @@ describe('components/overage_users_banner', () => {
|
||||
renderWithContext(<OverageUsersBanner/>, store);
|
||||
|
||||
expect(screen.queryByText('Your workspace user count has exceeded your paid license seat count by', {exact: false})).not.toBeInTheDocument();
|
||||
expect(getLicenseSelfServeStatus).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not render the banner because it\'s cloud licenese', () => {
|
||||
@@ -168,7 +160,6 @@ describe('components/overage_users_banner', () => {
|
||||
renderWithContext(<OverageUsersBanner/>, store);
|
||||
|
||||
expect(screen.queryByText('Your workspace user count has exceeded your paid license seat count by', {exact: false})).not.toBeInTheDocument();
|
||||
expect(getLicenseSelfServeStatus).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should not render the 5% banner because we have dissmised it', () => {
|
||||
@@ -194,7 +185,6 @@ describe('components/overage_users_banner', () => {
|
||||
renderWithContext(<OverageUsersBanner/>, store);
|
||||
|
||||
expect(screen.queryByText(text5PercentageState)).not.toBeInTheDocument();
|
||||
expect(getLicenseSelfServeStatus).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should render the banner because we are over 5% and we don\'t have any preferences', () => {
|
||||
@@ -202,10 +192,6 @@ describe('components/overage_users_banner', () => {
|
||||
|
||||
store.entities.cloud = {
|
||||
...store.entities.cloud,
|
||||
subscriptionStats: {
|
||||
is_expandable: false,
|
||||
getRequestState: 'OK',
|
||||
},
|
||||
};
|
||||
|
||||
store.entities.admin = {
|
||||
@@ -226,10 +212,6 @@ describe('components/overage_users_banner', () => {
|
||||
|
||||
store.entities.cloud = {
|
||||
...store.entities.cloud,
|
||||
subscriptionStats: {
|
||||
is_expandable: false,
|
||||
getRequestState: 'OK',
|
||||
},
|
||||
};
|
||||
|
||||
store.entities.admin = {
|
||||
@@ -259,10 +241,6 @@ describe('components/overage_users_banner', () => {
|
||||
|
||||
store.entities.cloud = {
|
||||
...store.entities.cloud,
|
||||
subscriptionStats: {
|
||||
is_expandable: false,
|
||||
getRequestState: 'OK',
|
||||
},
|
||||
};
|
||||
|
||||
store.entities.preferences.myPreferences = TestHelper.getPreferencesMock(
|
||||
@@ -316,10 +294,6 @@ describe('components/overage_users_banner', () => {
|
||||
|
||||
store.entities.cloud = {
|
||||
...store.entities.cloud,
|
||||
subscriptionStats: {
|
||||
is_expandable: false,
|
||||
getRequestState: 'OK',
|
||||
},
|
||||
};
|
||||
|
||||
store.entities.admin = {
|
||||
@@ -340,10 +314,6 @@ describe('components/overage_users_banner', () => {
|
||||
|
||||
store.entities.cloud = {
|
||||
...store.entities.cloud,
|
||||
subscriptionStats: {
|
||||
is_expandable: false,
|
||||
getRequestState: 'OK',
|
||||
},
|
||||
};
|
||||
|
||||
store.entities.admin = {
|
||||
@@ -367,114 +337,4 @@ describe('components/overage_users_banner', () => {
|
||||
banner: 'global banner',
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the warning banner with expansion seats CTA if the license is expandable', () => {
|
||||
const store = JSON.parse(JSON.stringify(initialState));
|
||||
|
||||
store.entities.cloud = {
|
||||
...store.entities.cloud,
|
||||
subscriptionStats: {
|
||||
...store.entities.cloud.subscriptionStats,
|
||||
is_expandable: true,
|
||||
getRequestState: 'OK',
|
||||
},
|
||||
};
|
||||
|
||||
store.entities.admin = {
|
||||
...store.entities.admin,
|
||||
analytics: {
|
||||
[StatTypes.TOTAL_USERS]: seatsMinimumFor5PercentageState,
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(<OverageUsersBanner/>, store);
|
||||
|
||||
expect(screen.getByText(expandSeatsTextLink)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should track if the admin click expansion seats CTA in a 5% overage state', () => {
|
||||
const store = JSON.parse(JSON.stringify(initialState));
|
||||
|
||||
store.entities.cloud = {
|
||||
...store.entities.cloud,
|
||||
subscriptionStats: {
|
||||
...store.entities.cloud.subscriptionStats,
|
||||
is_expandable: true,
|
||||
getRequestState: 'OK',
|
||||
},
|
||||
};
|
||||
|
||||
store.entities.admin = {
|
||||
...store.entities.admin,
|
||||
analytics: {
|
||||
[StatTypes.TOTAL_USERS]: seatsMinimumFor5PercentageState,
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(<OverageUsersBanner/>, store);
|
||||
|
||||
fireEvent.click(screen.getByText(expandSeatsTextLink));
|
||||
expect(windowSpy).toBeCalledTimes(1);
|
||||
expect(windowSpy).toBeCalledWith(`http://testing/subscribe/expand?licenseId=${licenseId}`, '_blank');
|
||||
expect(trackEvent).toBeCalledTimes(1);
|
||||
expect(trackEvent).toBeCalledWith('insights', 'click_true_up_warning', {
|
||||
cta: 'Self Serve',
|
||||
banner: 'global banner',
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the error banner with expansion seats CTA if the license is be expandable', () => {
|
||||
const store = JSON.parse(JSON.stringify(initialState));
|
||||
|
||||
store.entities.cloud = {
|
||||
...store.entities.cloud,
|
||||
subscriptionStats: {
|
||||
...store.entities.cloud.subscriptionStats,
|
||||
is_expandable: true,
|
||||
getRequestState: 'OK',
|
||||
},
|
||||
};
|
||||
|
||||
store.entities.admin = {
|
||||
...store.entities.admin,
|
||||
analytics: {
|
||||
[StatTypes.TOTAL_USERS]: seatsMinimumFor10PercentageState,
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(<OverageUsersBanner/>, store);
|
||||
|
||||
expect(screen.getByText(expandSeatsTextLink)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should track if the admin click expansion seats CTA in a 10% overage state', () => {
|
||||
const store = JSON.parse(JSON.stringify(initialState));
|
||||
|
||||
store.entities.cloud = {
|
||||
...store.entities.cloud,
|
||||
subscriptionStats: {
|
||||
...store.entities.cloud.subscriptionStats,
|
||||
is_expandable: true,
|
||||
getRequestState: 'OK',
|
||||
},
|
||||
};
|
||||
|
||||
store.entities.admin = {
|
||||
...store.entities.admin,
|
||||
analytics: {
|
||||
[StatTypes.TOTAL_USERS]: seatsMinimumFor10PercentageState,
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(<OverageUsersBanner/>, store);
|
||||
|
||||
fireEvent.click(screen.getByText(expandSeatsTextLink));
|
||||
expect(windowSpy).toBeCalledTimes(1);
|
||||
expect(windowSpy).toBeCalledWith(`http://testing/subscribe/expand?licenseId=${licenseId}`, '_blank');
|
||||
expect(trackEvent).toBeCalledTimes(1);
|
||||
expect(trackEvent).toBeCalledWith('insights', 'click_true_up_error', {
|
||||
cta: 'Self Serve',
|
||||
banner: 'global banner',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,8 +6,6 @@ import React from 'react';
|
||||
import {act} from 'react-dom/test-utils';
|
||||
import {Provider} from 'react-redux';
|
||||
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
|
||||
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
|
||||
import mockStore from 'tests/test_store';
|
||||
|
||||
@@ -66,29 +64,7 @@ describe('components/RenewalLink', () => {
|
||||
},
|
||||
};
|
||||
|
||||
test('should show Renew now when a renewal link is successfully returned', async () => {
|
||||
const getRenewalLinkSpy = jest.spyOn(Client4, 'getRenewalLink');
|
||||
const promise = new Promise<{renewal_link: string}>((resolve) => {
|
||||
resolve({
|
||||
renewal_link: 'https://testrenewallink',
|
||||
});
|
||||
});
|
||||
getRenewalLinkSpy.mockImplementation(() => promise);
|
||||
const store = mockStore(initialState);
|
||||
const wrapper = mountWithIntl(<Provider store={store}><RenewalLink {...props}/></Provider>);
|
||||
|
||||
// wait for the promise to resolve and component to update
|
||||
await actImmediate(wrapper);
|
||||
|
||||
expect(wrapper.find('.btn').text().includes('Renew license now')).toBe(true);
|
||||
});
|
||||
|
||||
test('should show Contact sales when a renewal link is not returned', async () => {
|
||||
const getRenewalLinkSpy = jest.spyOn(Client4, 'getRenewalLink');
|
||||
const promise = new Promise<{renewal_link: string}>((resolve, reject) => {
|
||||
reject(new Error('License cannot be renewed from portal'));
|
||||
});
|
||||
getRenewalLinkSpy.mockImplementation(() => promise);
|
||||
test('should show Contact sales button', async () => {
|
||||
const store = mockStore(initialState);
|
||||
const wrapper = mountWithIntl(<Provider store={store}><RenewalLink {...props}/></Provider>);
|
||||
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import React from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
|
||||
import {trackEvent} from 'actions/telemetry_actions';
|
||||
|
||||
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
|
||||
|
||||
import {
|
||||
ModalIdentifiers,
|
||||
} from 'utils/constants';
|
||||
|
||||
import type {ModalData} from 'types/actions';
|
||||
|
||||
import NoInternetConnection from '../no_internet_connection/no_internet_connection';
|
||||
|
||||
import './renew_link.scss';
|
||||
|
||||
export interface RenewalLinkProps {
|
||||
telemetryInfo?: {success: string; error: string};
|
||||
telemetryInfo?: { success: string; error: string };
|
||||
actions: {
|
||||
openModal: <P>(modalData: ModalData<P>) => void;
|
||||
};
|
||||
@@ -30,70 +20,20 @@ export interface RenewalLinkProps {
|
||||
}
|
||||
|
||||
const RenewalLink = (props: RenewalLinkProps) => {
|
||||
const [renewalLink, setRenewalLink] = useState('');
|
||||
const [manualInterventionRequired, setManualInterventionRequired] = useState(false);
|
||||
|
||||
const [openContactSales] = useOpenSalesLink();
|
||||
|
||||
useEffect(() => {
|
||||
Client4.getRenewalLink().then(({renewal_link: renewalLinkParam}) => {
|
||||
try {
|
||||
if (renewalLinkParam && (/^http[s]?:\/\//).test(renewalLinkParam)) {
|
||||
setRenewalLink(renewalLinkParam);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('No link returned', error); // eslint-disable-line no-console
|
||||
}
|
||||
}).catch(() => {
|
||||
setManualInterventionRequired(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleLinkClick = async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const {status} = await Client4.ping(false);
|
||||
if (status === 'OK' && renewalLink !== '') {
|
||||
if (props.telemetryInfo?.success) {
|
||||
trackEvent('renew_license', props.telemetryInfo.success);
|
||||
}
|
||||
window.open(renewalLink, '_blank');
|
||||
} else if (manualInterventionRequired) {
|
||||
openContactSales();
|
||||
} else {
|
||||
showConnectionErrorModal();
|
||||
}
|
||||
} catch (error) {
|
||||
showConnectionErrorModal();
|
||||
}
|
||||
openContactSales();
|
||||
};
|
||||
|
||||
const showConnectionErrorModal = () => {
|
||||
if (props.telemetryInfo?.error) {
|
||||
trackEvent('renew_license', props.telemetryInfo.error);
|
||||
}
|
||||
props.actions.openModal({
|
||||
modalId: ModalIdentifiers.NO_INTERNET_CONNECTION,
|
||||
dialogType: NoInternetConnection,
|
||||
});
|
||||
};
|
||||
|
||||
let btnText = props.customBtnText ? props.customBtnText : (
|
||||
const btnText = (
|
||||
<FormattedMessage
|
||||
id='announcement_bar.warn.renew_license_now'
|
||||
defaultMessage='Renew license now'
|
||||
id='announcement_bar.warn.renew_license_contact_sales'
|
||||
defaultMessage='Contact sales'
|
||||
/>
|
||||
);
|
||||
|
||||
if (manualInterventionRequired) {
|
||||
btnText = (
|
||||
<FormattedMessage
|
||||
id='announcement_bar.warn.renew_license_contact_sales'
|
||||
defaultMessage='Contact sales'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className='btn btn-primary annnouncementBar__renewLicense'
|
||||
|
||||
@@ -10,13 +10,16 @@ import {useDispatch, useSelector} from 'react-redux';
|
||||
import {isCurrentLicenseCloud} from 'mattermost-redux/selectors/entities/cloud';
|
||||
|
||||
import {retryFailedCloudFetches} from 'actions/cloud';
|
||||
import {retryFailedHostedCustomerFetches} from 'actions/hosted_customer';
|
||||
|
||||
import './cloud_fetch_error.scss';
|
||||
|
||||
export default function CloudFetchError() {
|
||||
const dispatch = useDispatch();
|
||||
const isCloud = useSelector(isCurrentLicenseCloud);
|
||||
if (!isCloud) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (<div className='CloudFetchError '>
|
||||
<div className='CloudFetchError__header '>
|
||||
<FormattedMessage
|
||||
@@ -27,7 +30,7 @@ export default function CloudFetchError() {
|
||||
<button
|
||||
className='btn btn-primary'
|
||||
onClick={() => {
|
||||
dispatch(isCloud ? retryFailedCloudFetches() : retryFailedHostedCustomerFetches());
|
||||
dispatch(retryFailedCloudFetches());
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/cloud_start_trial_btn/cloud_start_trial_btn should match snapshot 1`] = `
|
||||
<ContextProvider
|
||||
value={
|
||||
Object {
|
||||
"store": Object {
|
||||
"clearActions": [Function],
|
||||
"dispatch": [Function],
|
||||
"getActions": [Function],
|
||||
"getState": [Function],
|
||||
"replaceReducer": [Function],
|
||||
"subscribe": [Function],
|
||||
},
|
||||
"subscription": Subscription {
|
||||
"handleChangeWrapper": [Function],
|
||||
"listeners": Object {
|
||||
"notify": [Function],
|
||||
},
|
||||
"onStateChange": [Function],
|
||||
"parentSub": undefined,
|
||||
"store": Object {
|
||||
"clearActions": [Function],
|
||||
"dispatch": [Function],
|
||||
"getActions": [Function],
|
||||
"getState": [Function],
|
||||
"replaceReducer": [Function],
|
||||
"subscribe": [Function],
|
||||
},
|
||||
"unsubscribe": null,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
<CloudStartTrialButton
|
||||
message="Cloud Start trial"
|
||||
onClick={[MockFunction]}
|
||||
telemetryId="test_telemetry_id"
|
||||
/>
|
||||
</ContextProvider>
|
||||
`;
|
||||
@@ -1,39 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/request_business_email_modal/request_business_email_modal should match snapshot 1`] = `
|
||||
<ContextProvider
|
||||
value={
|
||||
Object {
|
||||
"store": Object {
|
||||
"clearActions": [Function],
|
||||
"dispatch": [Function],
|
||||
"getActions": [Function],
|
||||
"getState": [Function],
|
||||
"replaceReducer": [Function],
|
||||
"subscribe": [Function],
|
||||
},
|
||||
"subscription": Subscription {
|
||||
"handleChangeWrapper": [Function],
|
||||
"listeners": Object {
|
||||
"notify": [Function],
|
||||
},
|
||||
"onStateChange": [Function],
|
||||
"parentSub": undefined,
|
||||
"store": Object {
|
||||
"clearActions": [Function],
|
||||
"dispatch": [Function],
|
||||
"getActions": [Function],
|
||||
"getState": [Function],
|
||||
"replaceReducer": [Function],
|
||||
"subscribe": [Function],
|
||||
},
|
||||
"unsubscribe": null,
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
<RequestBusinessEmailModal
|
||||
onExited={[MockFunction]}
|
||||
/>
|
||||
</ContextProvider>
|
||||
`;
|
||||
@@ -1,14 +0,0 @@
|
||||
.CloudStartTrialButton {
|
||||
&:not(.style-link) {
|
||||
width: fit-content;
|
||||
padding: 13px 20px;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
&.style-link {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {ReactWrapper} from 'enzyme';
|
||||
import {shallow} from 'enzyme';
|
||||
import React from 'react';
|
||||
import {act} from 'react-dom/test-utils';
|
||||
import {Provider} from 'react-redux';
|
||||
|
||||
import * as cloudActions from 'actions/cloud';
|
||||
import {trackEvent} from 'actions/telemetry_actions.jsx';
|
||||
|
||||
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
|
||||
import mockStore from 'tests/test_store';
|
||||
import {TELEMETRY_CATEGORIES} from 'utils/constants';
|
||||
|
||||
import CloudStartTrialButton from './cloud_start_trial_btn';
|
||||
|
||||
jest.mock('actions/telemetry_actions.jsx', () => {
|
||||
const original = jest.requireActual('actions/telemetry_actions.jsx');
|
||||
return {
|
||||
...original,
|
||||
trackEvent: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('mattermost-redux/actions/general', () => ({
|
||||
...jest.requireActual('mattermost-redux/actions/general'),
|
||||
getLicenseConfig: () => ({type: 'adsf'}),
|
||||
getClientConfig: () => ({type: 'adsf'}),
|
||||
}));
|
||||
|
||||
jest.mock('mattermost-redux/actions/cloud', () => ({
|
||||
...jest.requireActual('mattermost-redux/actions/cloud'),
|
||||
getCloudSubscription: () => ({type: 'adsf'}),
|
||||
getCloudProducts: () => ({type: 'adsf'}),
|
||||
getCloudLimits: () => ({}),
|
||||
}));
|
||||
|
||||
describe('components/cloud_start_trial_btn/cloud_start_trial_btn', () => {
|
||||
const state = {
|
||||
entities: {
|
||||
admin: {},
|
||||
general: {
|
||||
license: {
|
||||
IsLicensed: 'true',
|
||||
Cloud: 'true',
|
||||
},
|
||||
},
|
||||
cloud: {
|
||||
subscription: {
|
||||
is_free_trial: 'false',
|
||||
trial_end_at: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
views: {
|
||||
modals: {
|
||||
modalState: {
|
||||
learn_more_trial_modal: {
|
||||
open: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const store = mockStore(state);
|
||||
|
||||
const props = {
|
||||
onClick: jest.fn(),
|
||||
message: 'Cloud Start trial',
|
||||
telemetryId: 'test_telemetry_id',
|
||||
};
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallow(
|
||||
<Provider store={store}>
|
||||
<CloudStartTrialButton {...props}/>
|
||||
</Provider>,
|
||||
);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should handle on click and change button text on SUCCESSFUL trial request', async () => {
|
||||
const mockOnClick = jest.fn();
|
||||
const requestTrialFn: () => () => Promise<any> = () => () => Promise.resolve(true);
|
||||
jest.spyOn(cloudActions, 'requestCloudTrial').mockImplementation(requestTrialFn);
|
||||
|
||||
let wrapper: ReactWrapper<any>;
|
||||
|
||||
// Mount the component
|
||||
await act(async () => {
|
||||
wrapper = mountWithIntl(
|
||||
<Provider store={store}>
|
||||
<CloudStartTrialButton
|
||||
{...props}
|
||||
onClick={mockOnClick}
|
||||
email='fakeemail@topreventbusinessemailvalidation'
|
||||
/>
|
||||
</Provider>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
expect(wrapper.find('.CloudStartTrialButton').text().includes('Cloud Start trial')).toBe(true);
|
||||
wrapper.find('.CloudStartTrialButton').simulate('click');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
expect(wrapper.find('.CloudStartTrialButton').text().includes('Loaded!')).toBe(true);
|
||||
});
|
||||
|
||||
expect(mockOnClick).toHaveBeenCalled();
|
||||
|
||||
expect(trackEvent).toHaveBeenCalledWith(TELEMETRY_CATEGORIES.CLOUD_START_TRIAL_BUTTON, 'test_telemetry_id');
|
||||
});
|
||||
|
||||
test('should handle on click and change button text on FAILED trial request', async () => {
|
||||
const mockOnClick = jest.fn();
|
||||
const requestTrialFn: () => () => Promise<any> = () => () => Promise.resolve(true);
|
||||
jest.spyOn(cloudActions, 'requestCloudTrial').mockImplementation(requestTrialFn);
|
||||
|
||||
let wrapper: ReactWrapper<any>;
|
||||
|
||||
// Mount the component
|
||||
await act(async () => {
|
||||
wrapper = mountWithIntl(
|
||||
<Provider store={store}>
|
||||
<CloudStartTrialButton
|
||||
{...props}
|
||||
onClick={mockOnClick}
|
||||
/>
|
||||
</Provider>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
expect(wrapper.find('.CloudStartTrialButton').text().includes('Cloud Start trial')).toBe(true);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find('.CloudStartTrialButton').simulate('click');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
expect(wrapper.find('.CloudStartTrialButton').text().includes('Failed')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,198 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import type {ReactNode} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {useDispatch} from 'react-redux';
|
||||
|
||||
import {requestCloudTrial, validateWorkspaceBusinessEmail, getCloudLimits} from 'actions/cloud';
|
||||
import {trackEvent} from 'actions/telemetry_actions';
|
||||
import {openModal, closeModal} from 'actions/views/modals';
|
||||
|
||||
import useGetSubscription from 'components/common/hooks/useGetSubscription';
|
||||
import ExternalLink from 'components/external_link';
|
||||
import TrialBenefitsModal from 'components/trial_benefits_modal/trial_benefits_modal';
|
||||
|
||||
import {ModalIdentifiers, TELEMETRY_CATEGORIES, LicenseLinks} from 'utils/constants';
|
||||
|
||||
import RequestBusinessEmailModal from './request_business_email_modal';
|
||||
|
||||
import './cloud_start_trial_btn.scss';
|
||||
|
||||
export type CloudStartTrialBtnProps = {
|
||||
message: string;
|
||||
telemetryId: string;
|
||||
onClick?: () => void;
|
||||
extraClass?: string;
|
||||
afterTrialRequest?: () => void;
|
||||
email?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
enum TrialLoadStatus {
|
||||
NotStarted = 'NOT_STARTED',
|
||||
Started = 'STARTED',
|
||||
Success = 'SUCCESS',
|
||||
Failed = 'FAILED',
|
||||
Embargoed = 'EMBARGOED',
|
||||
}
|
||||
|
||||
const TIME_UNTIL_CACHE_PURGE_GUESS = 5000;
|
||||
|
||||
const CloudStartTrialButton = ({
|
||||
message,
|
||||
telemetryId,
|
||||
extraClass,
|
||||
onClick,
|
||||
afterTrialRequest,
|
||||
email,
|
||||
disabled = false,
|
||||
}: CloudStartTrialBtnProps) => {
|
||||
const {formatMessage} = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const subscription = useGetSubscription();
|
||||
const [openBusinessEmailModal, setOpenBusinessEmailModal] = useState(false);
|
||||
const [status, setLoadStatus] = useState(TrialLoadStatus.NotStarted);
|
||||
|
||||
const validateBusinessEmailOnLoad = async () => {
|
||||
const isValidBusinessEmail = await validateWorkspaceBusinessEmail()();
|
||||
if (!isValidBusinessEmail) {
|
||||
setOpenBusinessEmailModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
validateBusinessEmailOnLoad();
|
||||
}, []);
|
||||
|
||||
const requestStartTrial = async (): Promise<TrialLoadStatus> => {
|
||||
setLoadStatus(TrialLoadStatus.Started);
|
||||
|
||||
// email is set ONLY from the instance of this component created in the requestBusinessEmail modal.
|
||||
// So the flow is the following: If the email of the admin and the
|
||||
// email of the CWS customer are not valid, the requestBusinessModal is shown and that component will
|
||||
// create this StartCloudTrialBtn passing the email as Truthy, so the requetTrial flow continues normally
|
||||
if (openBusinessEmailModal && !email) {
|
||||
trackEvent(
|
||||
TELEMETRY_CATEGORIES.CLOUD_START_TRIAL_BUTTON,
|
||||
'trial_request_attempt_with_no_valid_business_email',
|
||||
);
|
||||
await dispatch(closeModal(ModalIdentifiers.LEARN_MORE_TRIAL_MODAL));
|
||||
openRequestBusinessEmailModal();
|
||||
setLoadStatus(TrialLoadStatus.Failed);
|
||||
return TrialLoadStatus.Failed;
|
||||
}
|
||||
|
||||
const subscriptionUpdated = await dispatch(requestCloudTrial('start_cloud_trial_btn', subscription?.id as string, (email || '')));
|
||||
if (!subscriptionUpdated) {
|
||||
setLoadStatus(TrialLoadStatus.Failed);
|
||||
return TrialLoadStatus.Failed;
|
||||
}
|
||||
|
||||
function ensureUpdatedData() {
|
||||
// Depending on timing of pods rolling, the webhook may still not get sent.
|
||||
// Re-request limits as a just-in-case, but only well after any
|
||||
// pods still alive should have either purged cache,
|
||||
// updated limits, or be brand new pods that won't be holding onto stale limits
|
||||
// We don't need to re-request subscription: the updated value is sent in the
|
||||
// request cloud trial response.
|
||||
// We don't need to request license: its update process is independent
|
||||
// from subscription/limit changes and always happens after pods roll.
|
||||
dispatch(getCloudLimits());
|
||||
}
|
||||
|
||||
setTimeout(ensureUpdatedData, TIME_UNTIL_CACHE_PURGE_GUESS);
|
||||
if (afterTrialRequest) {
|
||||
afterTrialRequest();
|
||||
}
|
||||
setLoadStatus(TrialLoadStatus.Success);
|
||||
return TrialLoadStatus.Success;
|
||||
};
|
||||
|
||||
const openTrialBenefitsModal = async (status: TrialLoadStatus) => {
|
||||
// Only open the benefits modal if the trial request succeeded
|
||||
if (status !== TrialLoadStatus.Success) {
|
||||
return;
|
||||
}
|
||||
await dispatch(openModal({
|
||||
modalId: ModalIdentifiers.TRIAL_BENEFITS_MODAL,
|
||||
dialogType: TrialBenefitsModal,
|
||||
dialogProps: {trialJustStarted: true},
|
||||
}));
|
||||
};
|
||||
|
||||
const openRequestBusinessEmailModal = () => {
|
||||
dispatch(openModal({
|
||||
modalId: ModalIdentifiers.REQUEST_BUSINESS_EMAIL_MODAL,
|
||||
dialogType: RequestBusinessEmailModal,
|
||||
}));
|
||||
};
|
||||
|
||||
const btnText = (status: TrialLoadStatus) => {
|
||||
switch (status) {
|
||||
case TrialLoadStatus.Started:
|
||||
return formatMessage({id: 'start_cloud_trial.modal.gettingTrial', defaultMessage: 'Getting Trial...'});
|
||||
case TrialLoadStatus.Success:
|
||||
return formatMessage({id: 'start_cloud_trial.modal.loaded', defaultMessage: 'Loaded!'});
|
||||
case TrialLoadStatus.Failed:
|
||||
return formatMessage({id: 'start_cloud_trial.modal.failed', defaultMessage: 'Failed'});
|
||||
case TrialLoadStatus.Embargoed:
|
||||
return formatMessage<ReactNode>(
|
||||
{
|
||||
id: 'admin.license.trial-request.embargoed',
|
||||
defaultMessage: 'We were unable to process the request due to limitations for embargoed countries. <link>Learn more in our documentation</link>, or reach out to legal@mattermost.com for questions around export limitations.',
|
||||
},
|
||||
{
|
||||
link: (text: string) => (
|
||||
<ExternalLink
|
||||
location='trial_banner'
|
||||
href={LicenseLinks.EMBARGOED_COUNTRIES}
|
||||
>
|
||||
{text}
|
||||
</ExternalLink>
|
||||
),
|
||||
},
|
||||
);
|
||||
default:
|
||||
return message;
|
||||
}
|
||||
};
|
||||
const startCloudTrial = async () => {
|
||||
if (status !== TrialLoadStatus.NotStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedStatus = await requestStartTrial();
|
||||
|
||||
if (updatedStatus !== TrialLoadStatus.Success) {
|
||||
return;
|
||||
}
|
||||
|
||||
trackEvent(
|
||||
TELEMETRY_CATEGORIES.CLOUD_START_TRIAL_BUTTON,
|
||||
telemetryId,
|
||||
);
|
||||
|
||||
// on click will execute whatever action is sent from the invoking place, if nothing is sent, open the trial benefits modal
|
||||
if (onClick) {
|
||||
onClick();
|
||||
return;
|
||||
}
|
||||
|
||||
await openTrialBenefitsModal(updatedStatus);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
id='start_cloud_trial_btn'
|
||||
className={`CloudStartTrialButton ${extraClass}`}
|
||||
onClick={startCloudTrial}
|
||||
disabled={disabled || status === TrialLoadStatus.Failed}
|
||||
>
|
||||
{btnText(status)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default CloudStartTrialButton;
|
||||
@@ -1,115 +0,0 @@
|
||||
@import 'utils/variables';
|
||||
@import 'utils/mixins';
|
||||
|
||||
.RequestBusinessEmailModal {
|
||||
height: 320px;
|
||||
|
||||
&.modal-dialog {
|
||||
margin-top: calc(50vh - 350px) !important;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: 0 !important;
|
||||
border-color: rgba(var(--center-channel-color-rgb), 0.16);
|
||||
border-radius: 8px;
|
||||
background: var(--center-channel-bg);
|
||||
color: var(--center-channel-color);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
.close {
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus,
|
||||
&:active:focus {
|
||||
background-color: rgba(var(--center-channel-color-rgb), 0.08);
|
||||
color: rgba(var(--center-channel-color-rgb), 0.8);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
top: 6px;
|
||||
right: 4px;
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
border-radius: 4px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.75) !important;
|
||||
font-family:
|
||||
'Open Sans',
|
||||
sans-serif;
|
||||
font-size: 32px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
height: 38px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: var(--center-channel-bg) !important;
|
||||
color: var(--center-channel-color);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: calc(100% - 38px);
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
|
||||
.GenericModal__body {
|
||||
height: 100%;
|
||||
padding: 0 24px 24px 24px;
|
||||
|
||||
.container-footer {
|
||||
bottom: 0;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
.request-business-email-input {
|
||||
height: 34px !important;
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.start-trial-email-title {
|
||||
margin-bottom: 22px;
|
||||
color: var(--center-channel-color);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.start-trial-email-description {
|
||||
margin-bottom: 16px;
|
||||
color: var(--center-channel-color);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.start-trial-email-disclaimer {
|
||||
margin-top: 56px;
|
||||
}
|
||||
|
||||
.start-trial-button {
|
||||
display: flex;
|
||||
|
||||
button {
|
||||
@include primary-button;
|
||||
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-centered {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 12px 24px 24px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {shallow} from 'enzyme';
|
||||
import React from 'react';
|
||||
import {act} from 'react-dom/test-utils';
|
||||
import {Provider} from 'react-redux';
|
||||
|
||||
import {GenericModal} from '@mattermost/components';
|
||||
|
||||
import * as cloudActions from 'actions/cloud';
|
||||
|
||||
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
|
||||
import mockStore from 'tests/test_store';
|
||||
|
||||
import RequestBusinessEmailModal from './request_business_email_modal';
|
||||
|
||||
jest.useFakeTimers();
|
||||
jest.mock('lodash/debounce', () => jest.fn((fn) => fn));
|
||||
|
||||
describe('components/request_business_email_modal/request_business_email_modal', () => {
|
||||
const state = {
|
||||
entities: {
|
||||
users: {
|
||||
currentUserId: 'current_user_id',
|
||||
},
|
||||
admin: {},
|
||||
general: {
|
||||
license: {
|
||||
IsLicensed: 'true',
|
||||
Cloud: 'true',
|
||||
},
|
||||
config: {},
|
||||
},
|
||||
cloud: {
|
||||
subscription: {id: 'subscriptionID'},
|
||||
},
|
||||
},
|
||||
views: {
|
||||
modals: {
|
||||
modalState: {
|
||||
request_business_email_modal: {
|
||||
open: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const props = {
|
||||
onExited: jest.fn(),
|
||||
};
|
||||
|
||||
const store = mockStore(state);
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallow(
|
||||
<Provider store={store}>
|
||||
<RequestBusinessEmailModal {...props}/>
|
||||
</Provider>,
|
||||
);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should show the Start Cloud Trial Button', async () => {
|
||||
const wrapper = mountWithIntl(
|
||||
<Provider store={store}>
|
||||
<RequestBusinessEmailModal {...props}/>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
const startTrialBtn = wrapper.find('CloudStartTrialButton');
|
||||
expect(startTrialBtn).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('should call on close', async () => {
|
||||
const mockOnClose = jest.fn();
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<Provider store={store}>
|
||||
<RequestBusinessEmailModal
|
||||
{...props}
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find(GenericModal).props().onExited();
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('should call on exited', async () => {
|
||||
const mockOnExited = jest.fn();
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<Provider store={store}>
|
||||
<RequestBusinessEmailModal
|
||||
{...props}
|
||||
onExited={mockOnExited}
|
||||
/>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
wrapper.find(GenericModal).props().onExited();
|
||||
expect(mockOnExited).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('should show the Input to enter the valid Business Email', async () => {
|
||||
const wrapper = mountWithIntl(
|
||||
<Provider store={store}>
|
||||
<RequestBusinessEmailModal {...props}/>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
expect(wrapper.find('InputBusinessEmail')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('should start with Start Cloud Trial Button disabled', async () => {
|
||||
const wrapper = mountWithIntl(
|
||||
<Provider store={store}>
|
||||
<RequestBusinessEmailModal {...props}/>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
const startTrialBtn = wrapper.find('CloudStartTrialButton');
|
||||
expect(startTrialBtn.props().disabled).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('should ENABLE the trial button if email is VALID', async () => {
|
||||
// mock validation response to TRUE meaning the email is a valid email
|
||||
const validateBusinessEmail = () => () => Promise.resolve(true);
|
||||
jest.spyOn(cloudActions, 'validateBusinessEmail').mockImplementation(validateBusinessEmail);
|
||||
|
||||
const event = {
|
||||
target: {value: 'valid-email@domain.com'},
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<Provider store={store}>
|
||||
<RequestBusinessEmailModal {...props}/>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
const inputBusinessEmail = wrapper.find('InputBusinessEmail');
|
||||
const input = inputBusinessEmail.find('input');
|
||||
input.find('input').at(0).simulate('change', event);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
wrapper.update();
|
||||
const startTrialBtn = wrapper.find('CloudStartTrialButton');
|
||||
expect(startTrialBtn.props().disabled).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('should show the success custom message if the email is valid', async () => {
|
||||
// mock validation response to TRUE meaning the email is a valid email
|
||||
const validateBusinessEmail = () => () => Promise.resolve(true);
|
||||
|
||||
jest.spyOn(cloudActions, 'validateBusinessEmail').mockImplementation(validateBusinessEmail);
|
||||
|
||||
const event = {
|
||||
target: {value: 'valid-email@domain.com'},
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<Provider store={store}>
|
||||
<RequestBusinessEmailModal {...props}/>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
const inputBusinessEmail = wrapper.find('InputBusinessEmail');
|
||||
const input = inputBusinessEmail.find('input');
|
||||
input.find('input').at(0).simulate('change', event);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
wrapper.update();
|
||||
const customMessageElement = wrapper.find('.Input___customMessage.Input___success');
|
||||
expect(customMessageElement.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('should DISABLE the trial button if email is INVALID', async () => {
|
||||
// mock validation response to FALSE meaning the email is an invalid email
|
||||
const validateBusinessEmail = () => () => Promise.resolve(false);
|
||||
jest.spyOn(cloudActions, 'validateBusinessEmail').mockImplementation(validateBusinessEmail);
|
||||
|
||||
const event = {
|
||||
target: {value: 'INvalid-email@domain.com'},
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<Provider store={store}>
|
||||
<RequestBusinessEmailModal {...props}/>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
const inputBusinessEmail = wrapper.find('InputBusinessEmail');
|
||||
const input = inputBusinessEmail.find('input');
|
||||
input.find('input').at(0).simulate('change', event);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
wrapper.update();
|
||||
const startTrialBtn = wrapper.find('CloudStartTrialButton');
|
||||
expect(startTrialBtn.props().disabled).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('should show the error custom message if the email is invalid', async () => {
|
||||
// mock validation response to FALSE meaning the email is an invalid email
|
||||
const validateBusinessEmail = () => () => Promise.resolve(false);
|
||||
jest.spyOn(cloudActions, 'validateBusinessEmail').mockImplementation(validateBusinessEmail);
|
||||
|
||||
const event = {
|
||||
target: {value: 'INvalid-email@domain.com'},
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<Provider store={store}>
|
||||
<RequestBusinessEmailModal {...props}/>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
const inputBusinessEmail = wrapper.find('InputBusinessEmail');
|
||||
const input = inputBusinessEmail.find('input');
|
||||
input.find('input').at(0).simulate('change', event);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
wrapper.update();
|
||||
const customMessageElement = wrapper.find('.Input___customMessage.Input___error');
|
||||
expect(customMessageElement.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,169 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import debounce from 'lodash/debounce';
|
||||
import React, {useCallback, useEffect, useState} from 'react';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
import {useDispatch} from 'react-redux';
|
||||
|
||||
import {GenericModal} from '@mattermost/components';
|
||||
|
||||
import {isEmail} from 'mattermost-redux/utils/helpers';
|
||||
|
||||
import {validateBusinessEmail} from 'actions/cloud';
|
||||
import {trackEvent} from 'actions/telemetry_actions';
|
||||
import {closeModal} from 'actions/views/modals';
|
||||
|
||||
import ExternalLink from 'components/external_link';
|
||||
import type {CustomMessageInputType} from 'components/widgets/inputs/input/input';
|
||||
|
||||
import {ItemStatus, TELEMETRY_CATEGORIES, ModalIdentifiers, LicenseLinks, AboutLinks} from 'utils/constants';
|
||||
|
||||
import StartCloudTrialBtn from './cloud_start_trial_btn';
|
||||
import InputBusinessEmail from './input_business_email';
|
||||
|
||||
import './request_business_email_modal.scss';
|
||||
|
||||
type Props = {
|
||||
onClose?: () => void;
|
||||
onExited: () => void;
|
||||
}
|
||||
|
||||
const RequestBusinessEmailModal = (
|
||||
{
|
||||
onClose,
|
||||
onExited,
|
||||
}: Props): JSX.Element | null => {
|
||||
const {formatMessage} = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const [email, setEmail] = useState<string>('');
|
||||
const [customInputLabel, setCustomInputLabel] = useState<CustomMessageInputType>(null);
|
||||
const [trialBtnDisabled, setTrialBtnDisabled] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
trackEvent(
|
||||
TELEMETRY_CATEGORIES.REQUEST_BUSINESS_EMAIL,
|
||||
'request_business_email',
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleOnClose = useCallback(() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
|
||||
onExited();
|
||||
}, [onClose, onExited]);
|
||||
|
||||
const handleEmailValues = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const email = e.target.value;
|
||||
setEmail(email.trim().toLowerCase());
|
||||
|
||||
validateEmail(email);
|
||||
}, []);
|
||||
|
||||
const validateEmail = useCallback(debounce(async (email: string) => {
|
||||
// no value set, no validation and clean the custom input label
|
||||
if (!email) {
|
||||
setTrialBtnDisabled(true);
|
||||
setCustomInputLabel(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// function isEmail aready handle empty / null value
|
||||
if (!isEmail(email)) {
|
||||
const errMsg = formatMessage({id: 'request_business_email_modal.invalidEmail', defaultMessage: 'This doesn\'t look like a valid email'});
|
||||
setCustomInputLabel({type: ItemStatus.WARNING, value: errMsg});
|
||||
setTrialBtnDisabled(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// go and validate the email against the validateBusinessEmail endpoint
|
||||
const isValidBusinessEmail = await validateBusinessEmail(email)();
|
||||
if (!isValidBusinessEmail) {
|
||||
const errMsg = formatMessage({id: 'request_business_email_modal.not_business_email', defaultMessage: 'This doesn\'t look like a business email'});
|
||||
setCustomInputLabel({type: ItemStatus.ERROR, value: errMsg});
|
||||
setTrialBtnDisabled(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// if it is a valid business email, proceed, enable the start trial button and notify the user about the email is valid
|
||||
const okMsg = formatMessage({id: 'request_business_email_modal.valid_business_email', defaultMessage: 'This is a valid email'});
|
||||
setCustomInputLabel({type: ItemStatus.SUCCESS, value: okMsg});
|
||||
setTrialBtnDisabled(false);
|
||||
}, 250), []);
|
||||
|
||||
// this function will be executed after successfull trial request, closing this request business email modal
|
||||
const closeMeAfterSuccessTrialReq = async () => {
|
||||
await dispatch(closeModal(ModalIdentifiers.REQUEST_BUSINESS_EMAIL_MODAL));
|
||||
};
|
||||
|
||||
return (
|
||||
<GenericModal
|
||||
className='RequestBusinessEmailModal'
|
||||
compassDesign={true}
|
||||
id='RequestBusinessEmailModal'
|
||||
onExited={handleOnClose}
|
||||
>
|
||||
<div className='start-trial-email-title'>
|
||||
<FormattedMessage
|
||||
id='start_cloud_trial.modal.enter_trial_email.title'
|
||||
defaultMessage='Enter an email to start your trial'
|
||||
/>
|
||||
</div>
|
||||
<div className='start-trial-email-description'>
|
||||
<FormattedMessage
|
||||
id='start_cloud_trial.modal.enter_trial_email.description'
|
||||
defaultMessage='Start a trial and enter a business email to get started. '
|
||||
/>
|
||||
</div>
|
||||
<div className='start-trial-email-input'>
|
||||
<InputBusinessEmail
|
||||
email={email}
|
||||
handleEmailValues={handleEmailValues}
|
||||
customInputLabel={customInputLabel}
|
||||
/>
|
||||
</div>
|
||||
<div className='start-trial-email-disclaimer'>
|
||||
<FormattedMessage
|
||||
id='request_business_email.start_trial.modal.disclaimer'
|
||||
defaultMessage='By selecting <highlight>“Start trial”</highlight>, I agree to the <linkEvaluation>Mattermost Software and Services License Agreement</linkEvaluation>, <linkPrivacy>privacy policy</linkPrivacy> and receiving product emails.'
|
||||
values={{
|
||||
highlight: (msg: React.ReactNode) => (
|
||||
<strong>
|
||||
{msg}
|
||||
</strong>
|
||||
),
|
||||
linkEvaluation: (msg: React.ReactNode) => (
|
||||
<ExternalLink
|
||||
href={LicenseLinks.SOFTWARE_SERVICES_LICENSE_AGREEMENT}
|
||||
location='request_business_email_modal'
|
||||
>
|
||||
{msg}
|
||||
</ExternalLink>
|
||||
),
|
||||
linkPrivacy: (msg: React.ReactNode) => (
|
||||
<ExternalLink
|
||||
href={AboutLinks.PRIVACY_POLICY}
|
||||
location='request_business_email_modal'
|
||||
>
|
||||
{msg}
|
||||
</ExternalLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='start-trial-button'>
|
||||
<StartCloudTrialBtn
|
||||
message={formatMessage({id: 'cloud.startTrial.modal.btn', defaultMessage: 'Start trial'})}
|
||||
telemetryId='request_business_email_modal'
|
||||
disabled={trialBtnDisabled}
|
||||
email={email}
|
||||
afterTrialRequest={closeMeAfterSuccessTrialReq}
|
||||
/>
|
||||
</div>
|
||||
</GenericModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default RequestBusinessEmailModal;
|
||||
@@ -1,46 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useSelector} from 'react-redux';
|
||||
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
import {getSubscriptionProduct} from 'mattermost-redux/selectors/entities/cloud';
|
||||
import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
|
||||
|
||||
import {BillingSchemes, SelfHostedProducts} from 'utils/constants';
|
||||
import {findSelfHostedProductBySku} from 'utils/hosted_customer';
|
||||
import {isCloudLicense} from 'utils/license_utils';
|
||||
|
||||
import useGetSelfHostedProducts from './useGetSelfHostedProducts';
|
||||
|
||||
export default function useCanSelfHostedExpand() {
|
||||
const [expansionAvailable, setExpansionAvailable] = useState(false);
|
||||
const config = useSelector(getConfig);
|
||||
const isEnterpriseReady = config.BuildEnterpriseReady === 'true';
|
||||
const isSalesServeOnly = useSelector(getSubscriptionProduct)?.billing_scheme === BillingSchemes.SALES_SERVE;
|
||||
const license = useSelector(getLicense);
|
||||
const isCloud = isCloudLicense(license);
|
||||
const [products] = useGetSelfHostedProducts();
|
||||
const currentProduct = findSelfHostedProductBySku(products, license.SkuShortName);
|
||||
const isAdmin = useSelector(isCurrentUserSystemAdmin);
|
||||
|
||||
// Self Hosted Products never contains a product for starter, additional check is done out of caution.
|
||||
const isSelfHostedStarter = currentProduct === null || currentProduct?.sku === SelfHostedProducts.STARTER;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnterpriseReady || !isAdmin) {
|
||||
return;
|
||||
}
|
||||
Client4.getLicenseSelfServeStatus().
|
||||
then((res) => {
|
||||
setExpansionAvailable(res.is_expandable ?? false);
|
||||
}).
|
||||
catch(() => {
|
||||
setExpansionAvailable(false);
|
||||
});
|
||||
}, [isEnterpriseReady, isAdmin]);
|
||||
|
||||
return !isCloud && !isSelfHostedStarter && !isSalesServeOnly && expansionAvailable;
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import {useSelector} from 'react-redux';
|
||||
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
import {getConfig} from 'mattermost-redux/selectors/entities/general';
|
||||
|
||||
import useLoadStripe from './useLoadStripe';
|
||||
|
||||
interface CWSSignupAvailability {
|
||||
cwsContacted: boolean;
|
||||
cwsServiceOn: boolean;
|
||||
screeningInProgress: boolean;
|
||||
}
|
||||
|
||||
const cwsAvailable: CWSSignupAvailability = {
|
||||
cwsContacted: true,
|
||||
cwsServiceOn: true,
|
||||
screeningInProgress: false,
|
||||
};
|
||||
const cwsAvailableEmptyState: CWSSignupAvailability = {
|
||||
cwsContacted: false,
|
||||
cwsServiceOn: false,
|
||||
screeningInProgress: false,
|
||||
};
|
||||
|
||||
type SignupAvailability = CWSSignupAvailability & {
|
||||
stripeAvailable: boolean;
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
export default function useCanSelfHostedSignup(): SignupAvailability {
|
||||
const [cwsAvailability, setCwsAvailability] = useState(cwsAvailableEmptyState);
|
||||
const config = useSelector(getConfig);
|
||||
const isEnterpriseReady = config.BuildEnterpriseReady === 'true';
|
||||
const stripeAvailable = Boolean(useLoadStripe().current);
|
||||
useEffect(() => {
|
||||
if (!isEnterpriseReady) {
|
||||
return;
|
||||
}
|
||||
Client4.getAvailabilitySelfHostedSignup().
|
||||
then(() => {
|
||||
setCwsAvailability(cwsAvailable);
|
||||
}).
|
||||
catch((err) => {
|
||||
let errorValue = {...cwsAvailableEmptyState};
|
||||
switch (err.status_code) {
|
||||
case 503: {
|
||||
errorValue = {
|
||||
cwsServiceOn: false,
|
||||
cwsContacted: true,
|
||||
screeningInProgress: false,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 425: {
|
||||
errorValue = {
|
||||
cwsServiceOn: true,
|
||||
cwsContacted: true,
|
||||
screeningInProgress: true,
|
||||
};
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
errorValue = {...cwsAvailableEmptyState};
|
||||
break;
|
||||
}
|
||||
}
|
||||
setCwsAvailability(errorValue);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
...cwsAvailability,
|
||||
stripeAvailable,
|
||||
ok: stripeAvailable && cwsAvailability.cwsContacted && cwsAvailability.cwsServiceOn && !cwsAvailability.screeningInProgress,
|
||||
};
|
||||
}, [stripeAvailable, cwsAvailability]);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import useGetSubscription from './useGetSubscription';
|
||||
|
||||
export const useDelinquencySubscription = () => {
|
||||
const subscription = useGetSubscription();
|
||||
|
||||
const isDelinquencySubscription = (): boolean => {
|
||||
if (!subscription) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!subscription.delinquent_since) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const isDelinquencySubscriptionHigherThan90Days = (): boolean => {
|
||||
if (!isDelinquencySubscription()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!subscription) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const delinquencyDate = new Date((subscription.delinquent_since || 0) * 1000);
|
||||
|
||||
const oneDay = 24 * 60 * 60 * 1000; // hours*minutes*seconds*milliseconds
|
||||
const today = new Date();
|
||||
const diffDays = Math.round(
|
||||
Math.abs((today.valueOf() - delinquencyDate.valueOf()) / oneDay),
|
||||
);
|
||||
|
||||
return diffDays > 90;
|
||||
};
|
||||
|
||||
return {isDelinquencySubscription, isDelinquencySubscriptionHigherThan90Days, subscription};
|
||||
};
|
||||
@@ -1,56 +1,28 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useEffect, useMemo} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
import type {LicenseSelfServeStatusReducer} from '@mattermost/types/cloud';
|
||||
|
||||
import {getLicenseSelfServeStatus} from 'mattermost-redux/actions/cloud';
|
||||
import {useSelector} from 'react-redux';
|
||||
|
||||
import {trackEvent} from 'actions/telemetry_actions.jsx';
|
||||
import {getExpandSeatsLink} from 'selectors/cloud';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
type UseExpandOverageUsersCheckArgs = {
|
||||
isWarningState: boolean;
|
||||
shouldRequest: boolean;
|
||||
licenseId?: string;
|
||||
banner: 'global banner' | 'invite modal';
|
||||
canSelfHostedExpand: boolean;
|
||||
}
|
||||
|
||||
export const useExpandOverageUsersCheck = ({
|
||||
shouldRequest,
|
||||
isWarningState,
|
||||
licenseId,
|
||||
banner,
|
||||
canSelfHostedExpand,
|
||||
}: UseExpandOverageUsersCheckArgs) => {
|
||||
const {formatMessage} = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const {getRequestState, is_expandable: isExpandable}: LicenseSelfServeStatusReducer = useSelector((state: GlobalState) => state.entities.cloud.subscriptionStats || {is_expandable: false, getRequestState: 'IDLE'});
|
||||
const expandableLink = useSelector(getExpandSeatsLink);
|
||||
|
||||
const cta = useMemo(() => {
|
||||
if (isExpandable && !canSelfHostedExpand) {
|
||||
return formatMessage({
|
||||
id: 'licensingPage.overageUsersBanner.ctaExpandSeats',
|
||||
defaultMessage: 'Purchase additional seats',
|
||||
});
|
||||
} else if (isExpandable && canSelfHostedExpand) {
|
||||
return formatMessage({
|
||||
id: 'licensingPage.overageUsersBanner.ctaUpdateSeats',
|
||||
defaultMessage: 'Update seat count',
|
||||
});
|
||||
}
|
||||
return formatMessage({
|
||||
id: 'licensingPage.overageUsersBanner.cta',
|
||||
defaultMessage: 'Contact Sales',
|
||||
});
|
||||
}, [isExpandable]);
|
||||
const cta = formatMessage({
|
||||
id: 'licensingPage.overageUsersBanner.cta',
|
||||
defaultMessage: 'Contact Sales',
|
||||
});
|
||||
|
||||
const trackEventFn = (cta: 'Contact Sales' | 'Self Serve') => {
|
||||
trackEvent('insights', isWarningState ? 'click_true_up_warning' : 'click_true_up_error', {
|
||||
@@ -59,17 +31,9 @@ export const useExpandOverageUsersCheck = ({
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldRequest && licenseId && getRequestState === 'IDLE') {
|
||||
dispatch(getLicenseSelfServeStatus());
|
||||
}
|
||||
}, [dispatch, getRequestState, licenseId, shouldRequest]);
|
||||
|
||||
return {
|
||||
cta,
|
||||
expandableLink,
|
||||
trackEventFn,
|
||||
getRequestState,
|
||||
isExpandable,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Stripe} from '@stripe/stripe-js';
|
||||
import {loadStripe} from '@stripe/stripe-js/pure'; // https://github.com/stripe/stripe-js#importing-loadstripe-without-side-effects
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {useSelector} from 'react-redux';
|
||||
|
||||
import {getStripePublicKey} from 'components/payment_form/stripe';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
// reloadHint
|
||||
export default function useLoadStripe(reloadHint?: number) {
|
||||
const stripeRef = useRef<Stripe | null>(null);
|
||||
const [, setDone] = useState(false);
|
||||
const stripePublicKey = useSelector((state: GlobalState) => getStripePublicKey(state));
|
||||
|
||||
useEffect(() => {
|
||||
if (stripeRef.current) {
|
||||
return;
|
||||
}
|
||||
loadStripe(stripePublicKey).then((stripe: Stripe | null) => {
|
||||
stripeRef.current = stripe;
|
||||
|
||||
// deliberately cause a rerender so that the input can render.
|
||||
// otherwise, the input does not show up.
|
||||
setDone(true);
|
||||
});
|
||||
}, [reloadHint]);
|
||||
return stripeRef;
|
||||
}
|
||||
|
||||
@@ -110,32 +110,4 @@ describe('components/global/product_switcher_menu', () => {
|
||||
expect(wrapper.find('.button-plans').length).toEqual(1);
|
||||
expect(wrapper.find('StartTrialBtn').length).toEqual(1);
|
||||
});
|
||||
|
||||
test('should show with system admin pre trial for cloud', () => {
|
||||
mockState.entities.users.profiles.user1.roles = 'system_admin';
|
||||
mockState.entities.general.license = {
|
||||
Cloud: 'true',
|
||||
};
|
||||
|
||||
const wrapper = shallow(<FeatureRestrictedModal {...defaultProps}/>);
|
||||
|
||||
expect(wrapper.find('.FeatureRestrictedModal__description').text()).toEqual(defaultProps.messageAdminPreTrial);
|
||||
expect(wrapper.find('.FeatureRestrictedModal__terms').length).toEqual(1);
|
||||
expect(wrapper.find('.FeatureRestrictedModal__buttons').hasClass('single')).toEqual(false);
|
||||
expect(wrapper.find('.button-plans').length).toEqual(1);
|
||||
expect(wrapper.find('CloudStartTrialButton').length).toEqual(1);
|
||||
});
|
||||
|
||||
test('should match snapshot with system admin post trial', () => {
|
||||
mockState.entities.users.profiles.user1.roles = 'system_admin';
|
||||
mockState.entities.cloud.subscription.is_free_trial = 'false';
|
||||
mockState.entities.cloud.subscription.trial_end_at = 1;
|
||||
|
||||
const wrapper = shallow(<FeatureRestrictedModal {...defaultProps}/>);
|
||||
|
||||
expect(wrapper.find('.FeatureRestrictedModal__description').text()).toEqual(defaultProps.messageAdminPostTrial);
|
||||
expect(wrapper.find('.FeatureRestrictedModal__terms').length).toEqual(0);
|
||||
expect(wrapper.find('.button-plans').length).toEqual(1);
|
||||
expect(wrapper.find('CloudStartTrialButton').length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,15 +9,12 @@ import {useSelector, useDispatch} from 'react-redux';
|
||||
import {GenericModal} from '@mattermost/components';
|
||||
|
||||
import {getPrevTrialLicense} from 'mattermost-redux/actions/admin';
|
||||
import {checkHadPriorTrial} from 'mattermost-redux/selectors/entities/cloud';
|
||||
import {getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
import {deprecateCloudFree} from 'mattermost-redux/selectors/entities/preferences';
|
||||
import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
|
||||
|
||||
import {closeModal} from 'actions/views/modals';
|
||||
import {isModalOpen} from 'selectors/views/modals';
|
||||
|
||||
import CloudStartTrialButton from 'components/cloud_start_trial/cloud_start_trial_btn';
|
||||
import {NotifyStatus} from 'components/common/hooks/useGetNotifyAdmin';
|
||||
import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal';
|
||||
import ExternalLink from 'components/external_link';
|
||||
@@ -38,7 +35,7 @@ type FeatureRestrictedModalProps = {
|
||||
messageAdminPostTrial?: string;
|
||||
titleEndUser?: string;
|
||||
messageEndUser?: string;
|
||||
customSecondaryButton?: {msg: string; action: () => void};
|
||||
customSecondaryButton?: { msg: string; action: () => void };
|
||||
feature?: string;
|
||||
minimumPlanRequiredForFeature?: string;
|
||||
}
|
||||
@@ -61,12 +58,10 @@ const FeatureRestrictedModal = ({
|
||||
dispatch(getPrevTrialLicense());
|
||||
}, []);
|
||||
|
||||
const cloudFreeDeprecated = useSelector(deprecateCloudFree);
|
||||
const hasCloudPriorTrial = useSelector(checkHadPriorTrial);
|
||||
const prevTrialLicense = useSelector((state: GlobalState) => state.entities.admin.prevTrialLicense);
|
||||
const hasSelfHostedPriorTrial = prevTrialLicense.IsLicensed === 'true';
|
||||
|
||||
const hasPriorTrial = hasCloudPriorTrial || hasSelfHostedPriorTrial;
|
||||
const hasPriorTrial = hasSelfHostedPriorTrial;
|
||||
const isSystemAdmin = useSelector(isCurrentUserSystemAdmin);
|
||||
const show = useSelector((state: GlobalState) => isModalOpen(state, ModalIdentifiers.FEATURE_RESTRICTED_MODAL));
|
||||
const license = useSelector(getLicense);
|
||||
@@ -103,7 +98,7 @@ const FeatureRestrictedModal = ({
|
||||
|
||||
const getTitle = () => {
|
||||
if (isSystemAdmin) {
|
||||
return (hasPriorTrial || cloudFreeDeprecated) ? titleAdminPostTrial : titleAdminPreTrial;
|
||||
return (hasPriorTrial) ? titleAdminPostTrial : titleAdminPreTrial;
|
||||
}
|
||||
|
||||
return titleEndUser;
|
||||
@@ -111,13 +106,13 @@ const FeatureRestrictedModal = ({
|
||||
|
||||
const getMessage = () => {
|
||||
if (isSystemAdmin) {
|
||||
return (hasPriorTrial || cloudFreeDeprecated) ? messageAdminPostTrial : messageAdminPreTrial;
|
||||
return (hasPriorTrial) ? messageAdminPostTrial : messageAdminPreTrial;
|
||||
}
|
||||
|
||||
return messageEndUser;
|
||||
};
|
||||
|
||||
const showStartTrial = isSystemAdmin && !hasPriorTrial && !cloudFreeDeprecated;
|
||||
const showStartTrial = isSystemAdmin && !hasPriorTrial && !isCloud;
|
||||
|
||||
// define what is the secondary button text and action, by default will be the View Plan button
|
||||
let secondaryBtnMsg = formatMessage({id: 'feature_restricted_modal.button.plans', defaultMessage: 'View plans'});
|
||||
@@ -130,26 +125,15 @@ const FeatureRestrictedModal = ({
|
||||
secondaryBtnAction = customSecondaryButton.action;
|
||||
}
|
||||
|
||||
let trialBtn;
|
||||
if (isCloud) {
|
||||
trialBtn = (
|
||||
<CloudStartTrialButton
|
||||
extraClass='button-trial'
|
||||
message={formatMessage({id: 'trial_btn.free.tryFreeFor30Days', defaultMessage: 'Start trial'})}
|
||||
telemetryId={'start_cloud_trial_after_team_creation_restricted'}
|
||||
onClick={dismissAction}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
trialBtn = (
|
||||
<StartTrialBtn
|
||||
message={formatMessage({id: 'trial_btn.free.tryFreeFor30Days', defaultMessage: 'Start trial'})}
|
||||
onClick={dismissAction}
|
||||
telemetryId='start_self_hosted_trial_after_team_creation_restricted'
|
||||
btnClass='btn btn-primary'
|
||||
renderAsButton={true}
|
||||
/>);
|
||||
}
|
||||
const trialBtn = (
|
||||
<StartTrialBtn
|
||||
message={formatMessage({id: 'trial_btn.free.tryFreeFor30Days', defaultMessage: 'Start trial'})}
|
||||
onClick={dismissAction}
|
||||
telemetryId='start_self_hosted_trial_after_team_creation_restricted'
|
||||
btnClass='btn btn-primary'
|
||||
renderAsButton={true}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<GenericModal
|
||||
|
||||
@@ -8,21 +8,18 @@ import {useDispatch, useSelector} from 'react-redux';
|
||||
import type {PreferenceType} from '@mattermost/types/preferences';
|
||||
|
||||
import {savePreferences} from 'mattermost-redux/actions/preferences';
|
||||
import {getConfig} from 'mattermost-redux/selectors/entities/admin';
|
||||
import {isCurrentLicenseCloud} from 'mattermost-redux/selectors/entities/cloud';
|
||||
import {getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
import {makeGetCategory} from 'mattermost-redux/selectors/entities/preferences';
|
||||
import {getCurrentUser, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
|
||||
|
||||
import AlertBanner from 'components/alert_banner';
|
||||
import useCanSelfHostedExpand from 'components/common/hooks/useCanSelfHostedExpand';
|
||||
import {useExpandOverageUsersCheck} from 'components/common/hooks/useExpandOverageUsersCheck';
|
||||
import ExternalLink from 'components/external_link';
|
||||
|
||||
import {LicenseLinks, StatTypes, Preferences, ConsolePages} from 'utils/constants';
|
||||
import {LicenseLinks, StatTypes, Preferences} from 'utils/constants';
|
||||
import {getIsGovSku} from 'utils/license_utils';
|
||||
import {calculateOverageUserActivated} from 'utils/overage_team';
|
||||
import {getSiteURL} from 'utils/url';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
@@ -49,9 +46,6 @@ const OverageUsersBannerNotice = () => {
|
||||
const currentUser = useSelector((state: GlobalState) => getCurrentUser(state));
|
||||
const overagePreferences = useSelector((state: GlobalState) => getPreferencesCategory(state, Preferences.OVERAGE_USERS_BANNER));
|
||||
const activeUsers = ((stats || {})[StatTypes.TOTAL_USERS]) as number || 0;
|
||||
const isSelfHostedPurchaseEnabled = useSelector(getConfig)?.ServiceSettings?.SelfHostedPurchase;
|
||||
const canSelfHostedExpand = useCanSelfHostedExpand() && isSelfHostedPurchaseEnabled;
|
||||
const siteURL = getSiteURL();
|
||||
|
||||
const {
|
||||
isBetween5PercerntAnd10PercentPurchasedSeats,
|
||||
@@ -69,16 +63,10 @@ const OverageUsersBannerNotice = () => {
|
||||
const hasPermission = isAdmin && isOverageState && !isCloud;
|
||||
const {
|
||||
cta,
|
||||
expandableLink,
|
||||
trackEventFn,
|
||||
getRequestState,
|
||||
isExpandable,
|
||||
} = useExpandOverageUsersCheck({
|
||||
shouldRequest: hasPermission && !adminHasDismissed({overagePreferences, preferenceName}),
|
||||
licenseId: license.Id,
|
||||
isWarningState: isBetween5PercerntAnd10PercentPurchasedSeats,
|
||||
banner: 'invite modal',
|
||||
canSelfHostedExpand: canSelfHostedExpand || false,
|
||||
});
|
||||
|
||||
if (!hasPermission || adminHasDismissed({overagePreferences, preferenceName})) {
|
||||
@@ -96,44 +84,21 @@ const OverageUsersBannerNotice = () => {
|
||||
|
||||
let message;
|
||||
|
||||
if (canSelfHostedExpand) {
|
||||
message = (
|
||||
<FormattedMessage
|
||||
id='licensingPage.overageUsersBanner.selfHostedNoticeDescription'
|
||||
defaultMessage={'<a>Purchase additional seats</a> to remain compliant.'}
|
||||
values={{
|
||||
a: (chunks: React.ReactNode) => {
|
||||
return (
|
||||
<ExternalLink
|
||||
className='overage_users_banner__button'
|
||||
href={`${siteURL}/${ConsolePages.LICENSE}?action=show_expansion_modal`}
|
||||
>
|
||||
{chunks}
|
||||
</ExternalLink>
|
||||
);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (!isGovSku) {
|
||||
if (!isGovSku) {
|
||||
message = (
|
||||
<FormattedMessage
|
||||
id='licensingPage.overageUsersBanner.noticeDescription'
|
||||
defaultMessage='Notify your Customer Success Manager on your next true-up check. <a></a>'
|
||||
values={{
|
||||
a: () => {
|
||||
if (getRequestState === 'IDLE' || getRequestState === 'LOADING') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
trackEventFn(isExpandable ? 'Self Serve' : 'Contact Sales');
|
||||
trackEventFn('Contact Sales');
|
||||
};
|
||||
|
||||
return (
|
||||
<ExternalLink
|
||||
className='overage_users_banner__button'
|
||||
href={isExpandable ? expandableLink(license.Id) : LicenseLinks.CONTACT_SALES}
|
||||
href={LicenseLinks.CONTACT_SALES}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{cta}
|
||||
|
||||
@@ -50,7 +50,6 @@ const text10PercentageState = `Your workspace user count has exceeded your paid
|
||||
const notifyText = 'Notify your Customer Success Manager on your next true-up check';
|
||||
|
||||
const contactSalesTextLink = 'Contact Sales';
|
||||
const expandSeatsTextLink = 'Purchase additional seats';
|
||||
|
||||
const licenseId = generateId();
|
||||
|
||||
@@ -90,12 +89,7 @@ describe('components/invitation_modal/overage_users_banner_notice', () => {
|
||||
preferences: {
|
||||
myPreferences: {},
|
||||
},
|
||||
cloud: {
|
||||
subscriptionStats: {
|
||||
is_expandable: false,
|
||||
getRequestState: 'IDLE',
|
||||
},
|
||||
},
|
||||
cloud: {},
|
||||
hostedCustomer: {
|
||||
products: {
|
||||
productsLoaded: true,
|
||||
@@ -226,10 +220,6 @@ describe('components/invitation_modal/overage_users_banner_notice', () => {
|
||||
|
||||
store.entities.cloud = {
|
||||
...store.entities.cloud,
|
||||
subscriptionStats: {
|
||||
is_expandable: false,
|
||||
getRequestState: 'OK',
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(
|
||||
@@ -336,10 +326,6 @@ describe('components/invitation_modal/overage_users_banner_notice', () => {
|
||||
|
||||
store.entities.cloud = {
|
||||
...store.entities.cloud,
|
||||
subscriptionStats: {
|
||||
is_expandable: false,
|
||||
getRequestState: 'OK',
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(
|
||||
@@ -444,70 +430,6 @@ describe('components/invitation_modal/overage_users_banner_notice', () => {
|
||||
}]);
|
||||
});
|
||||
|
||||
it('should track if the admin click expansion seats CTA in a 5% overage state', () => {
|
||||
const store: GlobalState = JSON.parse(JSON.stringify(initialState));
|
||||
|
||||
store.entities.admin = {
|
||||
...store.entities.admin,
|
||||
analytics: {
|
||||
[StatTypes.TOTAL_USERS]: seatsMinimumFor5PercentageState,
|
||||
},
|
||||
};
|
||||
|
||||
store.entities.cloud = {
|
||||
...store.entities.cloud,
|
||||
subscriptionStats: {
|
||||
is_expandable: true,
|
||||
getRequestState: 'OK',
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(
|
||||
<OverageUsersBannerNotice/>,
|
||||
store,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText(expandSeatsTextLink));
|
||||
expect(screen.getByRole('link')).toHaveAttribute('href', `http://testing/subscribe/expand?licenseId=${licenseId}`);
|
||||
expect(trackEvent).toBeCalledTimes(2);
|
||||
expect(trackEvent).toBeCalledWith('insights', 'click_true_up_warning', {
|
||||
cta: 'Self Serve',
|
||||
banner: 'invite modal',
|
||||
});
|
||||
});
|
||||
|
||||
it('should track if the admin click expansion seats CTA in a 10% overage state', () => {
|
||||
const store: GlobalState = JSON.parse(JSON.stringify(initialState));
|
||||
|
||||
store.entities.admin = {
|
||||
...store.entities.admin,
|
||||
analytics: {
|
||||
[StatTypes.TOTAL_USERS]: seatsMinimumFor10PercentageState,
|
||||
},
|
||||
};
|
||||
|
||||
store.entities.cloud = {
|
||||
...store.entities.cloud,
|
||||
subscriptionStats: {
|
||||
is_expandable: true,
|
||||
getRequestState: 'OK',
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(
|
||||
<OverageUsersBannerNotice/>,
|
||||
store,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText(expandSeatsTextLink));
|
||||
expect(screen.getByRole('link')).toHaveAttribute('href', `http://testing/subscribe/expand?licenseId=${licenseId}`);
|
||||
expect(trackEvent).toBeCalledTimes(2);
|
||||
expect(trackEvent).toBeCalledWith('insights', 'click_true_up_error', {
|
||||
cta: 'Self Serve',
|
||||
banner: 'invite modal',
|
||||
});
|
||||
});
|
||||
|
||||
it('gov sku sees overage notice but not a call to do true up', async () => {
|
||||
const store: GlobalState = JSON.parse(JSON.stringify(initialState));
|
||||
|
||||
@@ -520,10 +442,6 @@ describe('components/invitation_modal/overage_users_banner_notice', () => {
|
||||
|
||||
store.entities.cloud = {
|
||||
...store.entities.cloud,
|
||||
subscriptionStats: {
|
||||
is_expandable: false,
|
||||
getRequestState: 'OK',
|
||||
},
|
||||
};
|
||||
store.entities.general.license.IsGovSku = 'true';
|
||||
|
||||
|
||||
@@ -21,11 +21,6 @@ jest.mock('actions/telemetry_actions.jsx', () => {
|
||||
};
|
||||
});
|
||||
|
||||
const CloudStartTrialButton = () => {
|
||||
return (<button>{'Start Cloud Trial'}</button>);
|
||||
};
|
||||
|
||||
jest.mock('components/cloud_start_trial/cloud_start_trial_btn', () => CloudStartTrialButton);
|
||||
describe('components/learn_more_trial_modal/learn_more_trial_modal', () => {
|
||||
// required state to mount using the provider
|
||||
const state = {
|
||||
@@ -50,15 +45,12 @@ describe('components/learn_more_trial_modal/learn_more_trial_modal', () => {
|
||||
general: {
|
||||
license: {
|
||||
IsLicensed: 'false',
|
||||
Cloud: 'true',
|
||||
Cloud: 'false',
|
||||
},
|
||||
config: {
|
||||
DiagnosticsEnabled: 'false',
|
||||
},
|
||||
},
|
||||
cloud: {
|
||||
subscription: {id: 'subscription'},
|
||||
},
|
||||
},
|
||||
views: {
|
||||
modals: {
|
||||
@@ -172,20 +164,6 @@ describe('components/learn_more_trial_modal/learn_more_trial_modal', () => {
|
||||
expect(activeSlideId).toBe('ldap');
|
||||
});
|
||||
|
||||
test('should have the start cloud trial button when is cloud workspace and cloud free is enabled', () => {
|
||||
const wrapper = mountWithIntl(
|
||||
<Provider store={store}>
|
||||
<LearnMoreTrialModal
|
||||
{...props}
|
||||
/>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
const trialButton = wrapper.find('CloudStartTrialButton');
|
||||
|
||||
expect(trialButton).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('should have the self hosted request trial button cloud free is disabled', () => {
|
||||
const nonCloudState = {
|
||||
...state,
|
||||
@@ -210,10 +188,6 @@ describe('components/learn_more_trial_modal/learn_more_trial_modal', () => {
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
// validate the cloud start trial button is not present
|
||||
const trialButton = wrapper.find('CloudStartTrialButton');
|
||||
expect(trialButton).toHaveLength(0);
|
||||
|
||||
// validate the cloud start trial button is not present
|
||||
const selfHostedRequestTrialButton = wrapper.find('StartTrialBtn');
|
||||
expect(selfHostedRequestTrialButton).toHaveLength(1);
|
||||
|
||||
@@ -2,25 +2,21 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {useSelector, useDispatch} from 'react-redux';
|
||||
|
||||
import {GenericModal} from '@mattermost/components';
|
||||
|
||||
import {getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
import {deprecateCloudFree} from 'mattermost-redux/selectors/entities/preferences';
|
||||
|
||||
import {trackEvent} from 'actions/telemetry_actions';
|
||||
import {closeModal} from 'actions/views/modals';
|
||||
|
||||
import SystemRolesSVG from 'components/admin_console/feature_discovery/features/images/system_roles_svg';
|
||||
import CloudStartTrialButton from 'components/cloud_start_trial/cloud_start_trial_btn';
|
||||
import Carousel from 'components/common/carousel/carousel';
|
||||
import {BtnStyle} from 'components/common/carousel/carousel_button';
|
||||
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
|
||||
import GuestAccessSvg from 'components/common/svg_images_components/guest_access_svg';
|
||||
import MonitorImacLikeSVG from 'components/common/svg_images_components/monitor_imaclike_svg';
|
||||
import ExternalLink from 'components/external_link';
|
||||
|
||||
import {ConsolePages, DocLinks, ModalIdentifiers, TELEMETRY_CATEGORIES} from 'utils/constants';
|
||||
|
||||
@@ -44,25 +40,22 @@ const LearnMoreTrialModal = (
|
||||
const [embargoed, setEmbargoed] = useState(false);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [, salesLink] = useOpenSalesLink();
|
||||
|
||||
// Cloud conditions
|
||||
const license = useSelector(getLicense);
|
||||
const cloudFreeDeprecated = useSelector(deprecateCloudFree);
|
||||
const isCloud = license?.Cloud === 'true';
|
||||
|
||||
const handleEmbargoError = useCallback(() => {
|
||||
setEmbargoed(true);
|
||||
}, []);
|
||||
|
||||
let startTrialBtnMsg = formatMessage({id: 'start_trial.modal_btn.start_free_trial', defaultMessage: 'Start free 30-day trial'});
|
||||
const startTrialBtnMsg = formatMessage({id: 'start_trial.modal_btn.start_free_trial', defaultMessage: 'Start free 30-day trial'});
|
||||
|
||||
// close this modal once start trial btn is clicked and trial has started successfully
|
||||
const dismissAction = useCallback(() => {
|
||||
dispatch(closeModal(ModalIdentifiers.LEARN_MORE_TRIAL_MODAL));
|
||||
}, []);
|
||||
|
||||
let startTrialBtn = (
|
||||
const startTrialBtn = (
|
||||
<StartTrialBtn
|
||||
message={startTrialBtnMsg}
|
||||
handleEmbargoError={handleEmbargoError}
|
||||
@@ -71,33 +64,6 @@ const LearnMoreTrialModal = (
|
||||
/>
|
||||
);
|
||||
|
||||
// no need to check if is cloud trial or if it have had prev cloud trial because the button that show this modal takes care of that
|
||||
if (isCloud) {
|
||||
startTrialBtnMsg = formatMessage({id: 'trial_btn.free.tryFreeFor30Days', defaultMessage: 'Start trial'});
|
||||
startTrialBtn = (
|
||||
<CloudStartTrialButton
|
||||
message={startTrialBtnMsg}
|
||||
telemetryId={`start_cloud_trial__learn_more_modal__${launchedBy}`}
|
||||
onClick={dismissAction}
|
||||
extraClass={'btn btn-primary start-cloud-trial-btn'}
|
||||
/>
|
||||
);
|
||||
if (cloudFreeDeprecated) {
|
||||
startTrialBtn = (
|
||||
<ExternalLink
|
||||
location='learn_more_trial_modal'
|
||||
href={salesLink}
|
||||
className='btn btn-primary start-cloud-trial-btn'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='learn_more_trial_modal.contact_sales'
|
||||
defaultMessage='Contact sales'
|
||||
/>
|
||||
</ExternalLink>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const handleOnClose = useCallback(() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
@@ -185,6 +151,11 @@ const LearnMoreTrialModal = (
|
||||
|
||||
const headerText = formatMessage({id: 'learn_more_trial_modal.pretitle', defaultMessage: 'With Enterprise, you can...'});
|
||||
|
||||
if (isCloud) {
|
||||
// Cloud users shouldn't be able to reach this modal, but in case they do, return nothing.
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<GenericModal
|
||||
compassDesign={true}
|
||||
|
||||
@@ -12,12 +12,11 @@ import type {GlobalState} from '@mattermost/types/store';
|
||||
import {getPrevTrialLicense} from 'mattermost-redux/actions/admin';
|
||||
import {getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
|
||||
import CloudStartTrialButton from 'components/cloud_start_trial/cloud_start_trial_btn';
|
||||
import ExternalLink from 'components/external_link';
|
||||
import StartTrialBtn from 'components/learn_more_trial_modal/start_trial_btn';
|
||||
|
||||
import completedImg from 'images/completed.svg';
|
||||
import {AboutLinks, LicenseLinks, LicenseSkus} from 'utils/constants';
|
||||
import {AboutLinks, LicenseLinks} from 'utils/constants';
|
||||
|
||||
const CompletedWrapper = styled.div`
|
||||
display: flex;
|
||||
@@ -141,21 +140,15 @@ const Completed = (props: Props): JSX.Element => {
|
||||
const isCurrentLicensed = license?.IsLicensed;
|
||||
|
||||
// Cloud conditions
|
||||
const subscription = useSelector((state: GlobalState) => state.entities.cloud.subscription);
|
||||
const isCloud = license?.Cloud === 'true';
|
||||
const isFreeTrial = subscription?.is_free_trial === 'true';
|
||||
const hadPrevCloudTrial = subscription?.is_free_trial === 'false' && subscription?.trial_end_at > 0;
|
||||
const isPaidSubscription = isCloud && license?.SkuShortName !== LicenseSkus.Starter && !isFreeTrial;
|
||||
|
||||
// Show this CTA if the instance is currently not licensed and has never had a trial license loaded before
|
||||
// also check that the user is a system admin (this after the onboarding task list is shown to all users)
|
||||
const selfHostedTrialCondition = (isCurrentLicensed === 'false' && isPrevLicensed === 'false') &&
|
||||
(props.isCurrentUserSystemAdmin || props.isFirstAdmin);
|
||||
(props.isCurrentUserSystemAdmin || props.isFirstAdmin);
|
||||
|
||||
// if Cloud, show if not in trial and had never been on trial
|
||||
const cloudTrialCondition = isCloud && !isFreeTrial && !hadPrevCloudTrial && !isPaidSubscription;
|
||||
|
||||
const showStartTrialBtn = selfHostedTrialCondition || cloudTrialCondition;
|
||||
// if Cloud, don't show
|
||||
const showStartTrialBtn = selfHostedTrialCondition && !isCloud;
|
||||
|
||||
const {formatMessage} = useIntl();
|
||||
|
||||
@@ -196,20 +189,11 @@ const Completed = (props: Props): JSX.Element => {
|
||||
defaultMessage='Start your free Enterprise trial now!'
|
||||
/>
|
||||
</span>
|
||||
{isCloud ? (
|
||||
<CloudStartTrialButton
|
||||
message={formatMessage({id: 'trial_btn.free.tryFreeFor30Days', defaultMessage: 'Start trial'})}
|
||||
telemetryId={'start_cloud_trial_after_completing_steps'}
|
||||
extraClass={'btn btn-primary'}
|
||||
afterTrialRequest={dismissAction}
|
||||
/>
|
||||
) : (
|
||||
<StartTrialBtn
|
||||
message={formatMessage({id: 'start_trial.modal_btn.start_free_trial', defaultMessage: 'Start free 30-day trial'})}
|
||||
telemetryId='start_trial_from_onboarding_completed_task'
|
||||
onClick={dismissAction}
|
||||
/>
|
||||
)}
|
||||
<StartTrialBtn
|
||||
message={formatMessage({id: 'start_trial.modal_btn.start_free_trial', defaultMessage: 'Start free 30-day trial'})}
|
||||
telemetryId='start_trial_from_onboarding_completed_task'
|
||||
onClick={dismissAction}
|
||||
/>
|
||||
<button
|
||||
onClick={dismissAction}
|
||||
className={'no-thanks-link style-link'}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
.StripeElement {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 2px !important;
|
||||
padding-left: 12px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--center-channel-bg);
|
||||
background-image: none;
|
||||
box-shadow: none;
|
||||
color: var(--center-channel-color);
|
||||
font-family: 'Open Sans';
|
||||
font-size: 14px;
|
||||
line-height: 23px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.StripeElement--invalid {
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.StripeElement::placeholder {
|
||||
color: rgba(var(--center-channel-color-rgb), 0.75);
|
||||
font-size: 14px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.StripeElement:focus::placeholder {
|
||||
color: 'transparent';
|
||||
}
|
||||
|
||||
.StripeElement:focus {
|
||||
border-color: transparent;
|
||||
box-shadow: 0 0 0 2px var(--button-bg);
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {ElementsConsumer, CardElement} from '@stripe/react-stripe-js';
|
||||
import type {StripeElements, StripeCardElement, StripeCardElementChangeEvent} from '@stripe/stripe-js';
|
||||
import React from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import type {Theme} from 'mattermost-redux/selectors/entities/preferences';
|
||||
|
||||
import {toRgbValues} from 'utils/utils';
|
||||
|
||||
import 'components/widgets/inputs/input/input.scss';
|
||||
|
||||
import './card_input.css';
|
||||
|
||||
type OwnProps = {
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
forwardedRef?: any;
|
||||
theme: Theme;
|
||||
onBlur?: () => void;
|
||||
onFocus?: () => void;
|
||||
className?: string;
|
||||
|
||||
// Stripe doesn't give type exports
|
||||
[propName: string]: any;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
elements: StripeElements | null | undefined;
|
||||
onCardInputChange?: (event: StripeCardElementChangeEvent) => void;
|
||||
} & OwnProps;
|
||||
|
||||
type State = {
|
||||
focused: boolean;
|
||||
error: string;
|
||||
empty: boolean;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
const REQUIRED_FIELD_TEXT = 'This field is required';
|
||||
const VALID_CARD_TEXT = 'Please enter a valid credit card';
|
||||
|
||||
export interface CardInputType extends React.PureComponent {
|
||||
getCard(): StripeCardElement | undefined;
|
||||
}
|
||||
|
||||
class CardInput extends React.PureComponent<Props, State> {
|
||||
public constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
focused: false,
|
||||
error: '',
|
||||
empty: true,
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
|
||||
private onFocus = () => {
|
||||
const {onFocus} = this.props;
|
||||
|
||||
this.setState({focused: true});
|
||||
|
||||
if (onFocus) {
|
||||
onFocus();
|
||||
}
|
||||
};
|
||||
|
||||
private onBlur = () => {
|
||||
const {onBlur} = this.props;
|
||||
|
||||
this.setState({focused: false});
|
||||
this.validateInput();
|
||||
|
||||
if (onBlur) {
|
||||
onBlur();
|
||||
}
|
||||
};
|
||||
|
||||
private onChange = (event: StripeCardElementChangeEvent) => {
|
||||
this.setState({error: '', empty: event.empty, complete: event.complete});
|
||||
if (this.props.onCardInputChange) {
|
||||
this.props.onCardInputChange(event);
|
||||
}
|
||||
};
|
||||
|
||||
private validateInput = () => {
|
||||
const {required} = this.props;
|
||||
const {empty, complete} = this.state;
|
||||
let error = '';
|
||||
|
||||
this.setState({error: ''});
|
||||
if (required && empty) {
|
||||
error = REQUIRED_FIELD_TEXT;
|
||||
} else if (!complete) {
|
||||
error = VALID_CARD_TEXT;
|
||||
}
|
||||
|
||||
this.setState({error});
|
||||
};
|
||||
|
||||
private renderError(error: string) {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let errorMessage;
|
||||
if (error === REQUIRED_FIELD_TEXT) {
|
||||
errorMessage = (
|
||||
<FormattedMessage
|
||||
id='payment.field_required'
|
||||
defaultMessage='This field is required'
|
||||
/>);
|
||||
} else if (error === VALID_CARD_TEXT) {
|
||||
errorMessage = (
|
||||
<FormattedMessage
|
||||
id='payment.invalid_card_number'
|
||||
defaultMessage='Please enter a valid credit card'
|
||||
/>);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='Input___error'>
|
||||
<i className='icon icon-alert-outline'/>
|
||||
{errorMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public getCard(): StripeCardElement | null | undefined {
|
||||
return this.props.elements?.getElement(CardElement);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const {className, error: propError, theme, ...otherProps} = this.props;
|
||||
const CARD_ELEMENT_OPTIONS = {
|
||||
hidePostalCode: true,
|
||||
style: {
|
||||
base: {
|
||||
fontFamily: "'Open Sans', sans-serif",
|
||||
fontSize: '14px',
|
||||
fontSmoothing: 'antialiased',
|
||||
color: theme.centerChannelColor,
|
||||
'::placeholder': {
|
||||
color: `rgba(${toRgbValues(theme.centerChannelColor)}, 0.75)`,
|
||||
},
|
||||
},
|
||||
invalid: {
|
||||
color: theme.errorTextColor,
|
||||
iconColor: theme.errorTextColor,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const {empty, focused, error: stateError} = this.state;
|
||||
let fieldsetClass = className ? `Input_fieldset ${className}` : 'Input_fieldset';
|
||||
let fieldsetErrorClass = className ? `Input_fieldset Input_fieldset___error ${className}` : 'Input_fieldset Input_fieldset___error';
|
||||
const showLegend = Boolean(focused || !empty);
|
||||
|
||||
fieldsetClass = showLegend ? fieldsetClass + ' Input_fieldset___legend' : fieldsetClass;
|
||||
fieldsetErrorClass = showLegend ? fieldsetErrorClass + ' Input_fieldset___legend' : fieldsetErrorClass;
|
||||
|
||||
const error = propError || stateError;
|
||||
|
||||
return (
|
||||
<div className='Input_container'>
|
||||
<fieldset className={error ? fieldsetErrorClass : fieldsetClass}>
|
||||
<legend className={showLegend ? 'Input_legend Input_legend___focus' : 'Input_legend'}>
|
||||
<FormattedMessage
|
||||
id='payment.card_number'
|
||||
defaultMessage='Card Number'
|
||||
/>
|
||||
</legend>
|
||||
<CardElement
|
||||
{...otherProps}
|
||||
options={CARD_ELEMENT_OPTIONS}
|
||||
onBlur={this.onBlur}
|
||||
onFocus={this.onFocus}
|
||||
onChange={this.onChange}
|
||||
/>
|
||||
</fieldset>
|
||||
{this.renderError(error)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const InjectedCardInput = (props: OwnProps) => {
|
||||
return (
|
||||
<ElementsConsumer>
|
||||
{({elements}) => (
|
||||
<CardInput
|
||||
ref={props.forwardedRef}
|
||||
elements={elements}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</ElementsConsumer>
|
||||
);
|
||||
};
|
||||
|
||||
export default InjectedCardInput;
|
||||
@@ -1,186 +0,0 @@
|
||||
@import 'utils/mixins';
|
||||
|
||||
.gatherIntent {
|
||||
margin-bottom: 24px;
|
||||
|
||||
&__title {
|
||||
padding: 0;
|
||||
margin: 0 0 8px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.75);
|
||||
font-size: 16px !important;
|
||||
font-weight: 600 !important;
|
||||
line-height: 24px !important;
|
||||
}
|
||||
|
||||
&__button {
|
||||
height: auto !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
color: var(--button-bg) !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 400 !important;
|
||||
line-height: 20px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.savedFeedback__text {
|
||||
align-self: center;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.75);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.AltPaymentsModal {
|
||||
.modal-content {
|
||||
max-width: 512px;
|
||||
max-height: 465px;
|
||||
border-radius: 12px;
|
||||
background: var(--center-channel-bg);
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
min-width: 532px;
|
||||
margin: auto;
|
||||
transform: translate(-50%, -50%) !important;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding-top: 24px;
|
||||
padding-bottom: 24px;
|
||||
border-radius: 0 0 12px 12px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.modal-header,
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
padding-right: 32px;
|
||||
padding-left: 32px;
|
||||
}
|
||||
|
||||
.AltPaymentsModal__header {
|
||||
align-items: baseline;
|
||||
border: none;
|
||||
|
||||
&.modal-header {
|
||||
background: var(--center-channel-bg);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.75);
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.icon-close {
|
||||
padding: 0;
|
||||
border: none;
|
||||
margin-left: auto;
|
||||
background: var(--center-channel-bg);
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
display: block;
|
||||
overflow: auto;
|
||||
padding: 0 32px;
|
||||
margin-bottom: 24px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.75);
|
||||
}
|
||||
|
||||
&__submitted-icon-container {
|
||||
margin-bottom: 24px;
|
||||
|
||||
> svg {
|
||||
width: 51px;
|
||||
height: 51px;
|
||||
align-self: center;
|
||||
color: rgba(61, 184, 135, 1);
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
text-align: center;
|
||||
|
||||
&__question {
|
||||
margin-bottom: 10px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.75);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__option {
|
||||
margin-top: 0;
|
||||
margin-bottom: 12px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.75);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&__label {
|
||||
padding-left: 12px;
|
||||
cursor: default;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
&__checkbox {
|
||||
height: 28px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
&__error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
&__text {
|
||||
color: var(--error-text);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: inline-flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-grow: 0;
|
||||
align-items: center;
|
||||
filter: invert(54%) sepia(68%) saturate(314%) hue-rotate(309deg) brightness(83%) contrast(117%);
|
||||
}
|
||||
}
|
||||
|
||||
&__textarea {
|
||||
width: 100%;
|
||||
border: solid 1px rgba(63, 67, 80, 0.16);
|
||||
border-radius: 4px;
|
||||
background: rgb(var(--center-channel-bg-rgb));
|
||||
resize: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
|
||||
&--secondary {
|
||||
@include tertiary-button;
|
||||
@include button-medium;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
@include primary-button;
|
||||
@include button-medium;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import * as reactRedux from 'react-redux';
|
||||
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
renderWithContext,
|
||||
screen,
|
||||
} from 'tests/react_testing_utils';
|
||||
import {TestHelper} from 'utils/test_helper';
|
||||
|
||||
import type {GatherIntentProps} from './gather_intent';
|
||||
import {GatherIntent} from './gather_intent';
|
||||
import type {GatherIntentModalProps} from './gather_intent_modal';
|
||||
|
||||
const DummyModal = ({onClose, onSave}: GatherIntentModalProps) => {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
id='closeIcon'
|
||||
className='icon icon-close'
|
||||
aria-label='Close'
|
||||
title='Close'
|
||||
onClick={onClose}
|
||||
/>
|
||||
<p>{'Body'}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
onSave({ach: true, other: false, wire: true});
|
||||
}}
|
||||
type='button'
|
||||
>
|
||||
{'Test'}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
describe('components/gather_intent/gather_intent.tsx', () => {
|
||||
const gatherIntentText = 'gatherIntentText';
|
||||
const useDispatchMock = jest.spyOn(reactRedux, 'useDispatch');
|
||||
|
||||
const initialState = {
|
||||
entities: {
|
||||
cloud: {
|
||||
customer: TestHelper.getCloudCustomerMock(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const baseProps: GatherIntentProps = {
|
||||
modalComponent: DummyModal as any,
|
||||
gatherIntentText,
|
||||
typeGatherIntent: 'monthlySubscription',
|
||||
};
|
||||
|
||||
it('should display modal if the user click on the modal opener', () => {
|
||||
renderWithContext(
|
||||
<GatherIntent {...baseProps}/>,
|
||||
initialState,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText(gatherIntentText));
|
||||
|
||||
expect(screen.getByText('Body')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display the modal opener after close the modal', () => {
|
||||
renderWithContext(
|
||||
<GatherIntent {...baseProps}/>,
|
||||
initialState,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText(gatherIntentText));
|
||||
fireEvent.click(screen.getByLabelText('Close'));
|
||||
|
||||
expect(screen.queryByText('Body')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the submitted modal after save the configuration', async () => {
|
||||
useDispatchMock.mockReturnValue(jest.fn().mockImplementation(() => new Promise((resolve) => {
|
||||
resolve({});
|
||||
})));
|
||||
renderWithContext(
|
||||
<GatherIntent {...baseProps}/>,
|
||||
initialState,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText(gatherIntentText));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('Test'));
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Thanks for sharing feedback!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the submitted modal after save the configuration and reopening the modal', async () => {
|
||||
useDispatchMock.mockReturnValue(jest.fn().mockImplementation(() => new Promise((resolve) => {
|
||||
resolve({});
|
||||
})));
|
||||
renderWithContext(
|
||||
<GatherIntent {...baseProps}/>,
|
||||
initialState,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText(gatherIntentText));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('Test'));
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Done'));
|
||||
fireEvent.click(screen.getByText(gatherIntentText));
|
||||
|
||||
expect(screen.queryByText('Thanks for sharing feedback!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the submitted modal when the user has a feedback recorded', async () => {
|
||||
useDispatchMock.mockReturnValue(jest.fn().mockImplementation(() => new Promise((resolve) => {
|
||||
resolve({});
|
||||
})));
|
||||
const newState = JSON.parse(JSON.stringify(initialState));
|
||||
newState.entities.cloud.customer = {
|
||||
...newState.entities.cloud.customer,
|
||||
monthly_subscription_alt_payment_method: 'Dummy feedback',
|
||||
};
|
||||
|
||||
renderWithContext(
|
||||
<GatherIntent {...baseProps}/>,
|
||||
newState,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText(gatherIntentText));
|
||||
|
||||
expect(screen.queryByText('Thanks for sharing feedback!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше
Ссылка в новой задаче
Block a user