diff --git a/NOTICE.txt b/NOTICE.txt index e022a95eda4..043897ab529 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -2606,42 +2606,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ---- - -## Azure/azure-sdk-for-go - -This product contains 'Azure/azure-sdk-for-go' by Microsoft Azure. - -This repository is for active development of the Azure SDK for Go. For consumers of the SDK we recommend visiting our public developer docs at: - -* HOMEPAGE: - * https://docs.microsoft.com/azure/developer/go/ - -* LICENSE: MIT License - -The MIT License (MIT) - -Copyright (c) Microsoft Corporation. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - --- ## Go diff --git a/api/v4/source/system.yaml b/api/v4/source/system.yaml index 5f973d118e1..edcdeb5ff59 100644 --- a/api/v4/source/system.yaml +++ b/api/v4/source/system.yaml @@ -912,6 +912,92 @@ $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" + /api/v4/license/preview: + post: + tags: + - system + summary: Preview license file + description: | + Validate and parse a license file without saving it. This allows administrators + to preview the license details before applying it. + + __Minimum server version__: 10.9 + + ##### Permissions + Must have `manage_license_information` permission. + operationId: PreviewLicenseFile + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + license: + description: The license to be previewed + type: string + format: binary + required: + - license + responses: + "200": + description: License preview successful + content: + application/json: + schema: + type: object + properties: + id: + type: string + description: The unique identifier of the license + issued_at: + type: integer + format: int64 + description: The timestamp when the license was issued + starts_at: + type: integer + format: int64 + description: The timestamp when the license becomes active + expires_at: + type: integer + format: int64 + description: The timestamp when the license expires + sku_name: + type: string + description: The display name of the license SKU (e.g., "Enterprise", "Professional") + sku_short_name: + type: string + description: The short name of the license SKU (e.g., "enterprise", "professional") + is_trial: + type: boolean + description: Whether this is a trial license + is_gov_sku: + type: boolean + description: Whether this is a government SKU license + customer: + type: object + properties: + id: + type: string + name: + type: string + email: + type: string + company: + type: string + features: + type: object + properties: + users: + type: integer + description: The number of users allowed by the license + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "413": + $ref: "#/components/responses/TooLarge" /api/v4/license/client: get: tags: diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/about/license_preview_spec.js b/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/about/license_preview_spec.js new file mode 100644 index 00000000000..f441ef05c98 --- /dev/null +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/about/license_preview_spec.js @@ -0,0 +1,270 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// *************************************************************** +// - [#] indicates a test step (e.g. # Go to a page) +// - [*] indicates an assertion (e.g. * Check the title) +// - Use element ID when selecting an element. Create one if none. +// *************************************************************** + +// Group: @channels @enterprise @not_cloud @system_console + +import * as TIMEOUTS from '@/fixtures/timeouts'; + +// These specs exercise the license upload preview/diff flow added in MM-67113. +// The upload and preview endpoints require real, signed license bytes that are +// not available to the e2e environment, so the network is stubbed via +// cy.intercept and a tiny dummy file is fed into the hidden file input. The +// stubs are crafted to reproduce the two issues reported during QA: +// 1. After uploading a new license, the success screen showed the previous +// tier and the System Console license page did not refresh. +// 2. Re-uploading the currently applied license was not handled gracefully. + +const OLD_LICENSE_ID = 'old-license-id-0000000000000'; + +// Current (already applied) license, as returned by GET /license/client?format=old. +const currentClientLicense = buildClientLicense({ + id: OLD_LICENSE_ID, + skuName: 'Professional', + skuShortName: 'professional', + users: '100', +}); + +// New license returned by the preview/upload endpoints (an upgrade to Enterprise). +const newLicense = buildLicense({ + id: 'new-license-id-0000000000000', + skuName: 'Enterprise', + skuShortName: 'enterprise', + users: 500, +}); + +describe('System console - License preview/diff view', () => { + before(() => { + cy.apiAdminLogin(); + cy.shouldNotRunOnCloudEdition(); + + // * Ensure the server is licensed so the System Console renders the + // enterprise license page (the displayed license is stubbed below). + cy.apiRequireLicense(); + }); + + beforeEach(() => { + cy.apiAdminLogin(); + }); + + it('MM-67113 - Shows the newly uploaded license tier on success and refreshes the page after closing', () => { + // # The displayed license starts as Professional. It is served from a + // mutable holder so we can simulate the server only reporting the new + // license once propagation completes (by the time the modal closes). + const clientLicenseHolder = {body: currentClientLicense}; + cy.intercept('GET', '**/api/v4/license/client?format=old', (req) => { + req.reply({statusCode: 200, body: clientLicenseHolder.body}); + }).as('getClientLicense'); + + // # Preview and upload both return the new Enterprise license + cy.intercept('POST', '**/api/v4/license/preview', {statusCode: 200, body: newLicense}).as('previewLicense'); + cy.intercept('POST', '**/api/v4/license', {statusCode: 200, body: newLicense}).as('uploadLicense'); + + cy.visit('/admin_console/about/license'); + cy.wait('@getClientLicense'); + + // * The page initially shows the current (Professional) license + cy.get('.EnterpriseEditionLeftPanel__Title', {timeout: TIMEOUTS.TEN_SEC}). + should('contain.text', 'Mattermost Professional'); + + // # Select a license file to open the upload modal + selectLicenseFile(); + + // # Wait for the preview request and the diff view to render + cy.wait('@previewLicense'); + cy.get('#UploadLicenseModal').should('be.visible').within(() => { + // * The diff view compares the current and new license tiers + cy.findByText('Review License Changes').should('be.visible'); + cy.get('.diff-current').should('contain.text', 'Professional'); + cy.get('.diff-new').should('contain.text', 'Enterprise'); + + // # Apply the new license + cy.get('#confirm-button').click(); + }); + cy.wait('@uploadLicense'); + + // * The success screen reflects the NEWLY uploaded tier (Enterprise), + // not the previously applied tier (Professional). This is the core of + // the "uploaded Enterprise, got Professional" report. + cy.get('#UploadLicenseModal').should('be.visible').within(() => { + cy.findByText('New license successfully applied').should('be.visible'); + cy.get('.subtitle'). + should('contain.text', 'Enterprise'). + and('not.contain.text', 'Professional'); + }); + + // # By the time the admin closes the modal, the server reports the new + // license. Closing must trigger a refresh that picks this up. + cy.then(() => { + clientLicenseHolder.body = buildClientLicense({ + id: newLicense.id, + skuName: 'Enterprise', + skuShortName: 'enterprise', + users: '500', + }); + }); + cy.get('#UploadLicenseModal').within(() => { + cy.get('#done-button').click(); + }); + cy.get('#UploadLicenseModal').should('not.exist'); + + // * The System Console license page refreshes to show the new license + // without requiring a manual page reload. + cy.get('.EnterpriseEditionLeftPanel__Title', {timeout: TIMEOUTS.TEN_SEC}). + should('contain.text', 'Mattermost Enterprise'); + }); + + it('MM-67113 - Warns when re-uploading the currently applied license and leaves it unchanged', () => { + // # The displayed license stays Professional throughout this flow + cy.intercept('GET', '**/api/v4/license/client?format=old', { + statusCode: 200, + body: currentClientLicense, + }).as('getClientLicense'); + + // # Preview and upload return the SAME license that is currently applied + // (same id), so the diff view should detect an unchanged license. + const sameLicense = buildLicense({ + id: OLD_LICENSE_ID, + skuName: 'Professional', + skuShortName: 'professional', + users: 100, + }); + cy.intercept('POST', '**/api/v4/license/preview', {statusCode: 200, body: sameLicense}).as('previewLicense'); + cy.intercept('POST', '**/api/v4/license', {statusCode: 200, body: sameLicense}).as('uploadLicense'); + + cy.visit('/admin_console/about/license'); + cy.wait('@getClientLicense'); + cy.get('.EnterpriseEditionLeftPanel__Title', {timeout: TIMEOUTS.TEN_SEC}). + should('contain.text', 'Mattermost Professional'); + + // # Select the same license file to open the upload modal + selectLicenseFile(); + cy.wait('@previewLicense'); + + // * The diff view warns that the license is already active + cy.get('#UploadLicenseModal').should('be.visible').within(() => { + cy.findByText('This license is already active').should('be.visible'); + + // # The admin can still apply it; doing so must not break the flow + cy.get('#confirm-button').click(); + }); + cy.wait('@uploadLicense'); + + // * The flow completes successfully and the modal can be closed + cy.get('#UploadLicenseModal').should('be.visible').within(() => { + cy.findByText('New license successfully applied').should('be.visible'); + cy.get('#done-button').click(); + }); + cy.get('#UploadLicenseModal').should('not.exist'); + + // * The applied license is unchanged and the page remains functional + cy.get('.EnterpriseEditionLeftPanel__Title', {timeout: TIMEOUTS.TEN_SEC}). + should('contain.text', 'Mattermost Professional'); + }); +}); + +// Feed a small dummy file into the hidden license file input. The actual bytes +// are irrelevant because the preview/upload endpoints are stubbed. +function selectLicenseFile() { + cy.get('[data-testid="EnterpriseEditionLeftPanel"] input[type="file"]'). + selectFile({ + contents: Cypress.Buffer.from('dummy-license-content'), + fileName: 'test-license.mattermost-license', + }, {force: true}); +} + +// Build a License object matching the shape returned by POST /license/preview +// and POST /license (server model.License serialized to JSON). +function buildLicense({id, skuName, skuShortName, users}) { + const now = Date.now(); + return { + id, + issued_at: now, + starts_at: now, + expires_at: now + (365 * 24 * 60 * 60 * 1000), + customer: { + id: 'customer-id', + name: 'Test Customer', + email: 'test@example.com', + company: 'Test Company', + }, + features: { + users, + ldap: true, + ldap_groups: true, + mfa: true, + google_oauth: true, + office365_oauth: true, + compliance: true, + cluster: true, + metrics: true, + mhpns: true, + saml: true, + elastic_search: true, + announcement: true, + theme_management: true, + email_notification_contents: true, + data_retention: true, + message_export: true, + custom_permissions_schemes: true, + custom_terms_of_service: true, + guest_accounts: true, + guest_accounts_permissions: true, + }, + sku_name: skuName, + sku_short_name: skuShortName, + is_gov_sku: false, + }; +} + +// Build a ClientLicense object (old format) matching GET /license/client?format=old. +// All values are strings, as produced by the server. +function buildClientLicense({id, skuName, skuShortName, users}) { + const now = Date.now(); + return { + IsLicensed: 'true', + IsTrial: 'false', + Id: id, + SkuName: skuName, + SkuShortName: skuShortName, + Users: users, + IssuedAt: String(now), + StartsAt: String(now), + ExpiresAt: String(now + (365 * 24 * 60 * 60 * 1000)), + Name: 'Test Customer', + Email: 'test@example.com', + Company: 'Test Company', + IsGovSku: 'false', + LDAP: 'true', + LDAPGroups: 'true', + MFA: 'true', + SAML: 'true', + Cluster: 'true', + Metrics: 'true', + GoogleOAuth: 'true', + Office365OAuth: 'true', + OpenId: 'true', + Compliance: 'true', + MHPNS: 'true', + Announcement: 'true', + Elasticsearch: 'true', + DataRetention: 'true', + IDLoadedPushNotifications: 'true', + EmailNotificationContents: 'true', + MessageExport: 'true', + CustomPermissionsSchemes: 'true', + GuestAccounts: 'true', + GuestAccountsPermissions: 'true', + CustomTermsOfService: 'true', + LockTeammateNameDisplay: 'true', + Cloud: 'false', + SharedChannels: 'true', + RemoteClusterService: 'true', + OutgoingOAuthConnections: 'true', + }; +} diff --git a/server/channels/api4/license.go b/server/channels/api4/license.go index f9dc659a5ac..75c9630253c 100644 --- a/server/channels/api4/license.go +++ b/server/channels/api4/license.go @@ -24,6 +24,7 @@ func (api *API) InitLicense() { api.BaseRoutes.APIRoot.Handle("/license", api.APISessionRequired(removeLicense)).Methods(http.MethodDelete) api.BaseRoutes.APIRoot.Handle("/license/client", api.APIHandler(getClientLicense)).Methods(http.MethodGet) api.BaseRoutes.APIRoot.Handle("/license/load_metric", api.APISessionRequired(getLicenseLoadMetric)).Methods(http.MethodGet) + api.BaseRoutes.APIRoot.Handle("/license/preview", api.APISessionRequired(previewLicense, handlerParamFileAPI)).Methods(http.MethodPost) } func getClientLicense(c *Context, w http.ResponseWriter, r *http.Request) { @@ -52,52 +53,63 @@ func getClientLicense(c *Context, w http.ResponseWriter, r *http.Request) { } } -func addLicense(c *Context, w http.ResponseWriter, r *http.Request) { - auditRec := c.MakeAuditRecord(model.AuditEventAddLicense, model.AuditStatusFail) - defer c.LogAuditRec(auditRec) - c.LogAudit("attempt") - - if !c.App.SessionHasPermissionToAndNotRestrictedAdmin(*c.AppContext.Session(), model.PermissionManageLicenseInformation) { - c.SetPermissionError(model.PermissionManageLicenseInformation) - return - } - +// parseLicenseFileFromRequest extracts license bytes from a multipart form request. +// Returns the license bytes, the filename, and any error that occurred. +func parseLicenseFileFromRequest(c *Context, r *http.Request) ([]byte, string, *model.AppError) { err := r.ParseMultipartForm(*c.App.Config().FileSettings.MaxFileSize) if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return + return nil, "", model.NewAppError("parseLicenseFileFromRequest", "api.license.parse_license.parse_form.app_error", nil, "", http.StatusBadRequest).Wrap(err) } + defer func() { + if r.MultipartForm != nil { + if err = r.MultipartForm.RemoveAll(); err != nil { + c.Logger.Warn("Failed to remove temporary multipart files", mlog.Err(err)) + } + } + }() - m := r.MultipartForm - - fileArray, ok := m.File["license"] + fileArray, ok := r.MultipartForm.File["license"] if !ok { - c.Err = model.NewAppError("addLicense", "api.license.add_license.no_file.app_error", nil, "", http.StatusBadRequest) - return + return nil, "", model.NewAppError("parseLicenseFileFromRequest", "api.license.add_license.no_file.app_error", nil, "", http.StatusBadRequest) } if len(fileArray) <= 0 { - c.Err = model.NewAppError("addLicense", "api.license.add_license.array.app_error", nil, "", http.StatusBadRequest) - return + return nil, "", model.NewAppError("parseLicenseFileFromRequest", "api.license.add_license.array.app_error", nil, "", http.StatusBadRequest) } fileData := fileArray[0] - model.AddEventParameterToAuditRec(auditRec, "filename", fileData.Filename) file, err := fileData.Open() if err != nil { - c.Err = model.NewAppError("addLicense", "api.license.add_license.open.app_error", nil, "", http.StatusBadRequest).Wrap(err) - return + return nil, "", model.NewAppError("parseLicenseFileFromRequest", "api.license.add_license.open.app_error", nil, "", http.StatusBadRequest).Wrap(err) } defer file.Close() buf := bytes.NewBuffer(nil) if _, err := io.Copy(buf, file); err != nil { - c.Err = model.NewAppError("addLicense", "api.license.add_license.copy.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return nil, "", model.NewAppError("parseLicenseFileFromRequest", "api.license.add_license.copy.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + return buf.Bytes(), fileData.Filename, nil +} + +func addLicense(c *Context, w http.ResponseWriter, r *http.Request) { + auditRec := c.MakeAuditRecord(model.AuditEventAddLicense, model.AuditStatusFail) + defer c.LogAuditRec(auditRec) + c.LogAudit("attempt") + + if !c.App.SessionHasPermissionToAndNotRestrictedAdmin(*c.AppContext.Session(), model.PermissionManageLicenseInformation) { + c.SetPermissionError(model.PermissionManageLicenseInformation) return } - licenseBytes := buf.Bytes() + licenseBytes, filename, appErr := parseLicenseFileFromRequest(c, r) + if appErr != nil { + c.Err = appErr + return + } + model.AddEventParameterToAuditRec(auditRec, "filename", filename) + license, appErr := utils.LicenseValidator.LicenseFromBytes(licenseBytes) if appErr != nil { c.Err = appErr @@ -154,6 +166,30 @@ func addLicense(c *Context, w http.ResponseWriter, r *http.Request) { } } +func previewLicense(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionToAndNotRestrictedAdmin(*c.AppContext.Session(), model.PermissionManageLicenseInformation) { + c.SetPermissionError(model.PermissionManageLicenseInformation) + return + } + + licenseBytes, _, appErr := parseLicenseFileFromRequest(c, r) + if appErr != nil { + c.Err = appErr + return + } + + license, appErr := utils.LicenseValidator.LicenseFromBytes(licenseBytes) + if appErr != nil { + c.Err = appErr + return + } + + // Return the parsed license without saving it + if err := json.NewEncoder(w).Encode(license); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + func removeLicense(c *Context, w http.ResponseWriter, r *http.Request) { auditRec := c.MakeAuditRecord(model.AuditEventRemoveLicense, model.AuditStatusFail) defer c.LogAuditRec(auditRec) diff --git a/server/channels/api4/license_local.go b/server/channels/api4/license_local.go index b2a47e7fb5f..08bd74dbcc7 100644 --- a/server/channels/api4/license_local.go +++ b/server/channels/api4/license_local.go @@ -30,9 +30,15 @@ func localAddLicense(c *Context, w http.ResponseWriter, r *http.Request) { return } - m := r.MultipartForm + defer func() { + if r.MultipartForm != nil { + if err = r.MultipartForm.RemoveAll(); err != nil { + c.Logger.Warn("Failed to remove temporary multipart files", mlog.Err(err)) + } + } + }() - fileArray, ok := m.File["license"] + fileArray, ok := r.MultipartForm.File["license"] if !ok { c.Err = model.NewAppError("addLicense", "api.license.add_license.no_file.app_error", nil, "", http.StatusBadRequest) return diff --git a/server/channels/api4/license_test.go b/server/channels/api4/license_test.go index dfed4bd3b00..61ab4407b36 100644 --- a/server/channels/api4/license_test.go +++ b/server/channels/api4/license_test.go @@ -191,6 +191,127 @@ func TestUploadLicenseFile(t *testing.T) { }) } +func TestPreviewLicenseFile(t *testing.T) { + th := Setup(t) + client := th.Client + + t.Run("as system user", func(t *testing.T) { + _, resp, err := client.PreviewLicenseFile(context.Background(), []byte{}) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + }) + + t.Run("as system admin with empty file", func(t *testing.T) { + _, resp, err := th.SystemAdminClient.PreviewLicenseFile(context.Background(), []byte{}) + require.Error(t, err) + CheckBadRequestStatus(t, resp) + }) + + t.Run("as restricted system admin user", func(t *testing.T) { + originalRestrictSystemAdmin := *th.App.Config().ExperimentalSettings.RestrictSystemAdmin + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ExperimentalSettings.RestrictSystemAdmin = true }) + t.Cleanup(func() { + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ExperimentalSettings.RestrictSystemAdmin = originalRestrictSystemAdmin + }) + }) + + _, resp, err := th.SystemAdminClient.PreviewLicenseFile(context.Background(), []byte{}) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + }) + + t.Run("preview valid license", func(t *testing.T) { + mockLicenseValidator := mocks2.LicenseValidatorIface{} + defer testutils.ResetLicenseValidator() + + userCount := 100 + mills := model.GetMillis() + + license := model.License{ + Id: model.NewId(), + Features: &model.Features{ + Users: &userCount, + }, + Customer: &model.Customer{ + Name: "Test Customer", + Company: "Test Company", + }, + SkuName: "Enterprise", + SkuShortName: "enterprise", + StartsAt: mills, + ExpiresAt: mills + (365 * 24 * time.Hour).Milliseconds(), + } + + mockLicenseValidator.On("LicenseFromBytes", mock.Anything).Return(&license, nil).Once() + utils.LicenseValidator = &mockLicenseValidator + + previewedLicense, resp, err := th.SystemAdminClient.PreviewLicenseFile(context.Background(), []byte("test-license-data")) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.NotNil(t, previewedLicense) + require.Equal(t, license.Id, previewedLicense.Id) + require.Equal(t, "Test Customer", previewedLicense.Customer.Name) + require.Equal(t, "Test Company", previewedLicense.Customer.Company) + require.Equal(t, "Enterprise", previewedLicense.SkuName) + require.Equal(t, "enterprise", previewedLicense.SkuShortName) + require.Equal(t, userCount, *previewedLicense.Features.Users) + }) + + t.Run("preview invalid license", func(t *testing.T) { + mockLicenseValidator := mocks2.LicenseValidatorIface{} + defer testutils.ResetLicenseValidator() + + mockLicenseValidator.On("LicenseFromBytes", mock.Anything).Return(nil, model.NewAppError("LicenseFromBytes", "model.license.is_valid.app_error", nil, "", http.StatusBadRequest)).Once() + utils.LicenseValidator = &mockLicenseValidator + + _, resp, err := th.SystemAdminClient.PreviewLicenseFile(context.Background(), []byte("invalid-license-data")) + require.Error(t, err) + CheckBadRequestStatus(t, resp) + }) + + t.Run("preview does not save license", func(t *testing.T) { + mockLicenseValidator := mocks2.LicenseValidatorIface{} + defer testutils.ResetLicenseValidator() + + userCount := 50 + mills := model.GetMillis() + + license := model.License{ + Id: model.NewId(), + Features: &model.Features{ + Users: &userCount, + }, + Customer: &model.Customer{ + Name: "Preview Only", + }, + SkuName: "Professional", + SkuShortName: "professional", + StartsAt: mills, + ExpiresAt: mills + (365 * 24 * time.Hour).Milliseconds(), + } + + mockLicenseValidator.On("LicenseFromBytes", mock.Anything).Return(&license, nil).Once() + utils.LicenseValidator = &mockLicenseValidator + + // Get current license before preview + currentLicense := th.App.Srv().License() + + // Preview the license + _, resp, err := th.SystemAdminClient.PreviewLicenseFile(context.Background(), []byte("test-license-data")) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Verify the license was not saved + licenseAfterPreview := th.App.Srv().License() + if currentLicense == nil { + require.Nil(t, licenseAfterPreview) + } else { + require.Equal(t, currentLicense.Id, licenseAfterPreview.Id) + } + }) +} + func TestRemoveLicenseFile(t *testing.T) { mainHelper.Parallel(t) th := Setup(t) diff --git a/server/channels/app/platform/license.go b/server/channels/app/platform/license.go index 71d0bd218b6..6469e65b453 100644 --- a/server/channels/app/platform/license.go +++ b/server/channels/app/platform/license.go @@ -81,9 +81,11 @@ func (ps *PlatformService) LoadLicense() { } licenseId := "" - props, nErr := ps.Store.System().Get() + // Read from master: SaveLicense writes the active license ID then calls + // LoadLicense, so a replica read can return a stale ID and revert the license. + activeLicense, nErr := ps.Store.System().GetByNameWithContext(sqlstore.RequestContextWithMaster(c), model.SystemActiveLicenseId) if nErr == nil { - licenseId = props[model.SystemActiveLicenseId] + licenseId = activeLicense.Value } if !model.IsValidId(licenseId) { diff --git a/server/i18n/en.json b/server/i18n/en.json index 9f99e34e7f8..101924f3d9f 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -2664,6 +2664,10 @@ "id": "api.license.load_metric.app_error", "translation": "Failed to compute monthly active users." }, + { + "id": "api.license.parse_license.parse_form.app_error", + "translation": "Could not parse the license upload form." + }, { "id": "api.license.remove_expired_license.failed.error", "translation": "Failed to send the disable license email successfully." diff --git a/server/public/model/client4.go b/server/public/model/client4.go index 51c42e9e90e..38371edda01 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -4579,6 +4579,33 @@ func (c *Client4) UploadLicenseFile(ctx context.Context, data []byte) (*Response return BuildResponse(r), nil } +// PreviewLicenseFile will validate and parse a license file without saving it. +// This allows users to preview the license details before applying it. +func (c *Client4) PreviewLicenseFile(ctx context.Context, data []byte) (*License, *Response, error) { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + part, err := writer.CreateFormFile("license", "test-license.mattermost-license") + if err != nil { + return nil, nil, fmt.Errorf("failed to create form file for license preview: %w", err) + } + + if _, err = io.Copy(part, bytes.NewBuffer(data)); err != nil { + return nil, nil, fmt.Errorf("failed to copy license data to form file: %w", err) + } + + if err = writer.Close(); err != nil { + return nil, nil, fmt.Errorf("failed to close multipart writer for license preview: %w", err) + } + + r, err := c.doAPIRequestReaderRoute(ctx, http.MethodPost, c.licenseRoute().Join("preview"), writer.FormDataContentType(), body, nil) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + return DecodeJSONFromResponse[*License](r) +} + // RemoveLicenseFile will remove the server license it exists. Note that this will // disable all enterprise features. func (c *Client4) RemoveLicenseFile(ctx context.Context) (*Response, error) { diff --git a/webapp/channels/src/components/admin_console/license_settings/__snapshots__/license_settings.test.tsx.snap b/webapp/channels/src/components/admin_console/license_settings/__snapshots__/license_settings.test.tsx.snap index 7aedc4b13d7..2b6600c59e8 100644 --- a/webapp/channels/src/components/admin_console/license_settings/__snapshots__/license_settings.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/license_settings/__snapshots__/license_settings.test.tsx.snap @@ -1137,7 +1137,7 @@ exports[`components/admin_console/license_settings/LicenseSettings should match - 2/4/2018 + February 04, 2018
- 5/6/2021 + May 06, 2021 - 2/4/2018 + February 04, 2018 3:24 AM @@ -1799,7 +1799,7 @@ exports[`components/admin_console/license_settings/LicenseSettings should match - 2/4/2018 + February 04, 2018
- 5/6/2021 + May 06, 2021 - 2/4/2018 + February 04, 2018 3:24 AM @@ -2447,7 +2447,7 @@ exports[`components/admin_console/license_settings/LicenseSettings should match - 2/4/2018 + February 04, 2018
- 5/6/2021 + May 06, 2021 - 2/4/2018 + February 04, 2018 3:24 AM @@ -3111,7 +3111,7 @@ exports[`components/admin_console/license_settings/LicenseSettings should match - 2/4/2018 + February 04, 2018
- 5/6/2021 + May 06, 2021 - 2/4/2018 + February 04, 2018 3:24 AM @@ -3759,7 +3759,7 @@ exports[`components/admin_console/license_settings/LicenseSettings should match - 2/4/2018 + February 04, 2018
- 5/6/2021 + May 06, 2021 - 2/4/2018 + February 04, 2018 3:24 AM @@ -4408,7 +4408,7 @@ exports[`components/admin_console/license_settings/LicenseSettings should match - 2/4/2018 + February 04, 2018
- 5/6/2021 + May 06, 2021 - 2/4/2018 + February 04, 2018 3:24 AM @@ -5054,7 +5054,7 @@ exports[`components/admin_console/license_settings/LicenseSettings should match - 4/6/2021 + April 06, 2021
- 5/6/2021 + May 06, 2021 - 4/6/2021 + April 06, 2021 1:10 PM @@ -8122,7 +8122,7 @@ exports[`components/admin_console/license_settings/LicenseSettings should match - 2/4/2018 + February 04, 2018
- 5/14/2021 + May 14, 2021 - 2/4/2018 + February 04, 2018 3:24 AM @@ -8770,7 +8770,7 @@ exports[`components/admin_console/license_settings/LicenseSettings should match - 2/4/2018 + February 04, 2018
- 5/14/2021 + May 14, 2021 - 2/4/2018 + February 04, 2018 3:24 AM diff --git a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx index 40aa7eefacb..8002887f7eb 100644 --- a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx +++ b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx @@ -23,6 +23,7 @@ import ExternalLink from 'components/external_link'; import Tag from 'components/widgets/tag/tag'; import {FileTypes, LicenseLinks, LicenseSkus} from 'utils/constants'; +import {getMonthLong} from 'utils/i18n'; import {calculateOverageUserActivated} from 'utils/overage_team'; import {getSkuDisplayName} from 'utils/subscription'; import {getRemainingDaysFromFutureTimestamp, toTitleCase} from 'utils/utils'; @@ -64,7 +65,7 @@ const EnterpriseEditionLeftPanel = ({ statsActiveUsers, isLicenseSetByEnvVar, }: EnterpriseEditionProps) => { - const {formatMessage} = useIntl(); + const {formatMessage, locale} = useIntl(); const [unsanitizedLicense, setUnsanitizedLicense] = useState(license); const {openPricingModal, isAirGapped} = useOpenPricingModal(); const [openContactSales] = useOpenSalesLink(); @@ -245,6 +246,7 @@ const EnterpriseEditionLeftPanel = ({ expirationDays, isLicenseSetByEnvVar, enableMattermostEntry, + locale, singleChannelGuestCount, singleChannelGuestLimit, ) @@ -379,6 +381,7 @@ const renderLicenseContent = ( expirationDays: number, isLicenseSetByEnvVar: boolean, enableMattermostEntry: string | undefined, + locale: string, singleChannelGuestCount: number, singleChannelGuestLimit: number, ) => { @@ -389,14 +392,36 @@ const renderLicenseContent = ( const users = ; const activeUsers = ; const singleChannelGuestsValue = ; - const startsAt = ; - const expiresAt = ; + const startsDate = new Date(parseInt(license.StartsAt, 10)); + const startsAt = ( + + ); + const expiresDate = new Date(parseInt(license.ExpiresAt, 10)); + const expiresAt = ( + + ); + const issuedDate = new Date(parseInt(license.IssuedAt, 10)); const issued = ( <> - + {' '} - + ); diff --git a/webapp/channels/src/components/admin_console/license_settings/license_settings.tsx b/webapp/channels/src/components/admin_console/license_settings/license_settings.tsx index 836e770d90b..f283bf514f6 100644 --- a/webapp/channels/src/components/admin_console/license_settings/license_settings.tsx +++ b/webapp/channels/src/components/admin_console/license_settings/license_settings.tsx @@ -167,6 +167,9 @@ export default class LicenseSettings extends React.PureComponent { const element = this.fileInputRef.current; if (element?.files?.length) { this.setState({fileSelected: true, file: element.files[0]}); + + // Reset the input value so re-selecting the same file re-fires onChange. + element.value = ''; } }; diff --git a/webapp/channels/src/components/admin_console/license_settings/modals/license_diff_view.scss b/webapp/channels/src/components/admin_console/license_settings/modals/license_diff_view.scss new file mode 100644 index 00000000000..6281c5fbae7 --- /dev/null +++ b/webapp/channels/src/components/admin_console/license_settings/modals/license_diff_view.scss @@ -0,0 +1,62 @@ +.license-diff-view { + overflow: hidden; + width: 100%; + border: var(--border-default); + border-radius: 4px; + margin: 16px 0; + + .diff-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; + + th, td { + padding: 8px 12px; + border-bottom: var(--border-light); + text-align: left; + } + + th { + border-bottom: var(--border-default); + background-color: rgba(var(--center-channel-color-rgb), 0.04); + } + + tr:last-child td { + border-bottom: none; + } + + .diff-row { + .diff-label, + .diff-current { + color: rgba(var(--center-channel-color-rgb), 0.72); + } + + &-plan { + .diff-current, + .diff-new { + font-weight: 600; + } + } + } + + // Info-only table (for entry license - no comparison) + &.info-only { + .info-row { + .info-label { + color: rgba(var(--center-channel-color-rgb), 0.72); + } + + .info-value { + color: var(--center-channel-color); + font-weight: 500; + } + + &-plan { + .info-value { + font-weight: 600; + } + } + } + } + } +} diff --git a/webapp/channels/src/components/admin_console/license_settings/modals/license_diff_view.test.tsx b/webapp/channels/src/components/admin_console/license_settings/modals/license_diff_view.test.tsx new file mode 100644 index 00000000000..632c230afc6 --- /dev/null +++ b/webapp/channels/src/components/admin_console/license_settings/modals/license_diff_view.test.tsx @@ -0,0 +1,457 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import type {ClientLicense, License} from '@mattermost/types/config'; + +import {renderWithContext, screen} from 'tests/react_testing_utils'; + +import LicenseDiffView from './license_diff_view'; + +describe('components/admin_console/license_settings/modals/license_diff_view', () => { + const initialState = { + entities: { + users: { + currentUserId: '', + }, + }, + }; + + const baseCurrentLicense: ClientLicense = { + IsLicensed: 'true', + SkuName: 'Professional', + SkuShortName: 'professional', + StartsAt: '1704067200000', // 2024-01-01 + ExpiresAt: '1735689600000', // 2025-01-01 + Users: '100', + Name: 'Test User', + Company: 'Test Company', + }; + + const baseNewLicense: License = { + id: 'license_id', + issued_at: 1704067200000, + starts_at: 1735689600000, // 2025-01-01 + expires_at: 1767225600000, // 2026-01-01 + sku_name: 'Enterprise', + sku_short_name: 'enterprise', + customer: { + id: 'customer_id', + name: 'New Test User', + email: 'test@example.com', + company: 'New Test Company', + }, + features: { + users: 200, + }, + }; + + const locale = 'en'; + + test('should render comparison view when current license exists', () => { + renderWithContext( + , + initialState, + ); + + // Should show labels + expect(screen.getByText('Plan')).toBeInTheDocument(); + expect(screen.getByText('Start Date')).toBeInTheDocument(); + expect(screen.getByText('End Date')).toBeInTheDocument(); + expect(screen.getByText('Users')).toBeInTheDocument(); + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Company')).toBeInTheDocument(); + + // Should show current values + expect(screen.getByText('Professional')).toBeInTheDocument(); + expect(screen.getByText('100')).toBeInTheDocument(); + expect(screen.getByText('Test User')).toBeInTheDocument(); + expect(screen.getByText('Test Company')).toBeInTheDocument(); + + // Should show new values + expect(screen.getByText('Enterprise')).toBeInTheDocument(); + expect(screen.getByText('200')).toBeInTheDocument(); + expect(screen.getByText('New Test User')).toBeInTheDocument(); + expect(screen.getByText('New Test Company')).toBeInTheDocument(); + }); + + test('should render info-only view when current license is entry', () => { + const entryLicense: ClientLicense = { + ...baseCurrentLicense, + SkuShortName: 'entry', + SkuName: 'Entry', + }; + + renderWithContext( + , + initialState, + ); + + // Should have info-only table class + const table = screen.getByRole('table'); + expect(table).toHaveClass('info-only'); + + // Should show new license info only + expect(screen.getByText('Enterprise')).toBeInTheDocument(); + expect(screen.getByText('200')).toBeInTheDocument(); + expect(screen.getByText('New Test User')).toBeInTheDocument(); + expect(screen.getByText('New Test Company')).toBeInTheDocument(); + + // Should NOT show current license values + expect(screen.queryByText('Professional')).not.toBeInTheDocument(); + expect(screen.queryByText('100')).not.toBeInTheDocument(); + + // Should show End Date label instead of Expiration Date + expect(screen.getByText('End Date')).toBeInTheDocument(); + expect(screen.queryByText('Expiration Date')).not.toBeInTheDocument(); + }); + + test('should show warning banner for entry to professional transition', () => { + const entryLicense: ClientLicense = { + ...baseCurrentLicense, + SkuShortName: 'entry', + SkuName: 'Entry', + }; + + const professionalLicense: License = { + ...baseNewLicense, + sku_name: 'Professional', + sku_short_name: 'professional', + }; + + const {container} = renderWithContext( + , + initialState, + ); + + expect(screen.getByText('This license changes your available features')).toBeInTheDocument(); + expect(container.querySelector('.AlertBanner.warning')).toBeInTheDocument(); + expect(screen.getByText('View plan differences')).toBeInTheDocument(); + }); + + test('should show info banner for entry to enterprise transition', () => { + const entryLicense: ClientLicense = { + ...baseCurrentLicense, + SkuShortName: 'entry', + SkuName: 'Entry', + }; + + const {container} = renderWithContext( + , + initialState, + ); + + expect(screen.getByText('This license adds Enterprise capabilities, with feature changes')).toBeInTheDocument(); + expect(container.querySelector('.AlertBanner.info')).toBeInTheDocument(); + expect(screen.getByText('View plan differences')).toBeInTheDocument(); + }); + + test('should show success banner for entry to enterprise advanced transition', () => { + const entryLicense: ClientLicense = { + ...baseCurrentLicense, + SkuShortName: 'entry', + SkuName: 'Entry', + }; + + const advancedLicense: License = { + ...baseNewLicense, + sku_name: 'Enterprise Advanced', + sku_short_name: 'advanced', + }; + + const {container} = renderWithContext( + , + initialState, + ); + + expect(screen.getByText('This license adds Enterprise Advanced capabilities')).toBeInTheDocument(); + expect(container.querySelector('.AlertBanner.success')).toBeInTheDocument(); + expect(screen.queryByText('View plan differences')).not.toBeInTheDocument(); + }); + + test('should not show banner when no current license', () => { + const emptyLicense: ClientLicense = {}; + + const {container} = renderWithContext( + , + initialState, + ); + + expect(container.querySelector('.AlertBanner')).not.toBeInTheDocument(); + }); + + // Upgrade banners (comparison diff view) + test('should show success banner for professional to enterprise upgrade', () => { + const {container} = renderWithContext( + , + initialState, + ); + + expect(screen.getByText('This license adds Enterprise capabilities')).toBeInTheDocument(); + expect(container.querySelector('.AlertBanner.success')).toBeInTheDocument(); + expect(screen.queryByText('View plan differences')).not.toBeInTheDocument(); + }); + + test('should show success banner for professional to enterprise advanced upgrade', () => { + const advancedLicense: License = { + ...baseNewLicense, + sku_name: 'Enterprise Advanced', + sku_short_name: 'advanced', + }; + + const {container} = renderWithContext( + , + initialState, + ); + + expect(screen.getByText('This license adds Enterprise Advanced capabilities')).toBeInTheDocument(); + expect(container.querySelector('.AlertBanner.success')).toBeInTheDocument(); + expect(screen.queryByText('View plan differences')).not.toBeInTheDocument(); + }); + + test('should show success banner for enterprise to enterprise advanced upgrade', () => { + const enterpriseLicense: ClientLicense = { + ...baseCurrentLicense, + SkuName: 'Enterprise', + SkuShortName: 'enterprise', + }; + + const advancedLicense: License = { + ...baseNewLicense, + sku_name: 'Enterprise Advanced', + sku_short_name: 'advanced', + }; + + const {container} = renderWithContext( + , + initialState, + ); + + expect(screen.getByText('This license adds Enterprise Advanced capabilities')).toBeInTheDocument(); + expect(container.querySelector('.AlertBanner.success')).toBeInTheDocument(); + }); + + // Downgrade banners (comparison diff view) + test('should show danger banner for enterprise to professional downgrade', () => { + const enterpriseLicense: ClientLicense = { + ...baseCurrentLicense, + SkuName: 'Enterprise', + SkuShortName: 'enterprise', + }; + + const professionalLicense: License = { + ...baseNewLicense, + sku_name: 'Professional', + sku_short_name: 'professional', + }; + + const {container} = renderWithContext( + , + initialState, + ); + + expect(screen.getByText('This license downgrades your plan')).toBeInTheDocument(); + expect(container.querySelector('.AlertBanner.danger')).toBeInTheDocument(); + expect(screen.getByText('View plan differences')).toBeInTheDocument(); + }); + + test('should show danger banner for enterprise advanced to enterprise downgrade', () => { + const advancedLicense: ClientLicense = { + ...baseCurrentLicense, + SkuName: 'Enterprise Advanced', + SkuShortName: 'advanced', + }; + + const enterpriseNewLicense: License = { + ...baseNewLicense, + sku_name: 'Enterprise', + sku_short_name: 'enterprise', + }; + + const {container} = renderWithContext( + , + initialState, + ); + + expect(screen.getByText('This license downgrades your plan')).toBeInTheDocument(); + expect(container.querySelector('.AlertBanner.danger')).toBeInTheDocument(); + expect(screen.getByText('View plan differences')).toBeInTheDocument(); + }); + + test('should show danger banner for enterprise advanced to professional downgrade', () => { + const advancedLicense: ClientLicense = { + ...baseCurrentLicense, + SkuName: 'Enterprise Advanced', + SkuShortName: 'advanced', + }; + + const professionalLicense: License = { + ...baseNewLicense, + sku_name: 'Professional', + sku_short_name: 'professional', + }; + + const {container} = renderWithContext( + , + initialState, + ); + + expect(screen.getByText('This license downgrades your plan')).toBeInTheDocument(); + expect(container.querySelector('.AlertBanner.danger')).toBeInTheDocument(); + expect(screen.getByText('View plan differences')).toBeInTheDocument(); + }); + + test('should show info banner when re-applying the same license', () => { + const sameCurrentLicense: ClientLicense = { + ...baseCurrentLicense, + Id: 'license_id', + }; + + const {container} = renderWithContext( + , + initialState, + ); + + expect(screen.getByText('This license is already active')).toBeInTheDocument(); + expect(container.querySelector('.AlertBanner.info')).toBeInTheDocument(); + }); + + test('should not show banner when same SKU', () => { + const sameLicense: License = { + ...baseNewLicense, + sku_name: 'Professional', + sku_short_name: 'professional', + }; + + const {container} = renderWithContext( + , + initialState, + ); + + expect(container.querySelector('.AlertBanner')).not.toBeInTheDocument(); + }); + + test('should highlight changed values', () => { + const {container} = renderWithContext( + , + initialState, + ); + + // Find rows with 'changed' class (values that differ) + const changedRows = container.querySelectorAll('.diff-row.changed'); + expect(changedRows.length).toBeGreaterThan(0); + }); + + test('should not highlight unchanged values', () => { + const sameLicense: License = { + ...baseNewLicense, + sku_name: 'Professional', + sku_short_name: 'professional', + customer: { + ...baseNewLicense.customer!, + name: 'Test User', + company: 'Test Company', + }, + features: { + users: 100, + }, + }; + + const {container} = renderWithContext( + , + initialState, + ); + + // Find the Plan row (SKU) - should not be changed + const rows = container.querySelectorAll('.diff-row'); + const planRow = Array.from(rows).find((row) => + row.querySelector('.diff-label')?.textContent === 'Plan', + ); + expect(planRow).not.toHaveClass('changed'); + }); + + test('should display dash for missing values', () => { + const licenseWithMissingData: License = { + ...baseNewLicense, + customer: undefined, + }; + + renderWithContext( + , + initialState, + ); + + // Should show '-' for missing customer name and company + const dashes = screen.getAllByText('-'); + expect(dashes.length).toBeGreaterThan(0); + }); +}); diff --git a/webapp/channels/src/components/admin_console/license_settings/modals/license_diff_view.tsx b/webapp/channels/src/components/admin_console/license_settings/modals/license_diff_view.tsx new file mode 100644 index 00000000000..da68d289ea4 --- /dev/null +++ b/webapp/channels/src/components/admin_console/license_settings/modals/license_diff_view.tsx @@ -0,0 +1,506 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {defineMessage, FormattedDate, FormattedMessage, useIntl} from 'react-intl'; +import type {MessageDescriptor} from 'react-intl'; + +import type {ClientLicense, License} from '@mattermost/types/config'; + +import AlertBanner from 'components/alert_banner'; +import type {ModeType} from 'components/alert_banner'; + +import {CloudLinks, LicenseSkus, getLicenseTier} from 'utils/constants'; +import {getMonthLong} from 'utils/i18n'; + +import './license_diff_view.scss'; + +type Props = { + currentLicense: ClientLicense; + newLicense: License; + locale: string; +}; + +type DiffRowProps = { + label: React.ReactNode; + currentValue: React.ReactNode; + newValue: React.ReactNode; + changed: boolean; + className?: string; +}; + +type InfoRowProps = { + label: React.ReactNode; + value: React.ReactNode; + className?: string; +}; + +const DiffRow = ({label, currentValue, newValue, changed, className}: DiffRowProps) => ( + + {label} + {currentValue} + {newValue} + +); + +const InfoRow = ({label, value, className}: InfoRowProps) => ( + + {label} + {value} + +); + +const formatDate = (timestamp: number | string, locale: string) => { + const ts = typeof timestamp === 'string' ? parseInt(timestamp, 10) : timestamp; + if (!ts || isNaN(ts)) { + return '-'; + } + return ( + + ); +}; + +type BannerConfig = { + mode: ModeType; + title: MessageDescriptor; + description: MessageDescriptor; + showPlanDiffLink: boolean; +}; + +// Normalize legacy Enterprise Edition SKU short names to their modern equivalents: +// E10 → Professional, E20 → Enterprise. +const normalizeSku = (skuShortName: string | undefined): string | undefined => { + const lower = skuShortName?.toLowerCase(); + switch (lower) { + case LicenseSkus.E10.toLowerCase(): + return LicenseSkus.Professional; + case LicenseSkus.E20.toLowerCase(): + return LicenseSkus.Enterprise; + default: + return lower; + } +}; + +// getPlanLevel produces a monotone ordering used to detect upgrades vs downgrades. +// This intentionally diverges from utils/constants#getLicenseTier for Entry: the +// server treats Entry as a top-tier capability gate (tier 30, same as EnterpriseAdvanced), +// but for "is this an upgrade or a downgrade" UX we treat Entry as the lowest tier +// so transitions from Entry are framed as upgrades. +const getPlanLevel = (skuShortName: string | undefined): number => { + const sku = normalizeSku(skuShortName); + if (!sku) { + return -1; + } + if (sku === LicenseSkus.Entry) { + return 0; + } + const tier = getLicenseTier(sku); + return tier === 0 ? -1 : tier; +}; + +// Entry transitions (info-only view) +const getEntryTransitionBanner = (newSkuShortName: string | undefined): BannerConfig | null => { + const sku = normalizeSku(newSkuShortName); + + switch (sku) { + case LicenseSkus.Professional: + return { + mode: 'warning', + title: defineMessage({ + id: 'admin.license.diff.banner.entry_to_professional.title', + defaultMessage: 'This license changes your available features', + }), + description: defineMessage({ + id: 'admin.license.diff.banner.entry_to_professional.description', + defaultMessage: 'Mattermost Professional adds paid-tier capabilities such as unlimited message history. Some features in Mattermost Entry are not included in Professional (see plan differences).', + }), + showPlanDiffLink: true, + }; + case LicenseSkus.Enterprise: + return { + mode: 'info', + title: defineMessage({ + id: 'admin.license.diff.banner.entry_to_enterprise.title', + defaultMessage: 'This license adds Enterprise capabilities, with feature changes', + }), + description: defineMessage({ + id: 'admin.license.diff.banner.entry_to_enterprise.description', + defaultMessage: 'Mattermost Enterprise includes unlimited message history and adds enterprise-grade scale, compliance, and administration capabilities. Some features in Mattermost Entry are not included in Enterprise.', + }), + showPlanDiffLink: true, + }; + case LicenseSkus.EnterpriseAdvanced: + return { + mode: 'success', + title: defineMessage({ + id: 'admin.license.diff.banner.entry_to_advanced.title', + defaultMessage: 'This license adds Enterprise Advanced capabilities', + }), + description: defineMessage({ + id: 'admin.license.diff.banner.entry_to_advanced.description', + defaultMessage: 'Mattermost Enterprise Advanced includes all Enterprise features, unlocks unlimited message history, and adds exclusive capabilities like Zero Trust security, sensitive information controls, and mobile security hardening for mission-critical operations', + }), + showPlanDiffLink: false, + }; + default: + return null; + } +}; + +// Upgrade banners (comparison diff view). Mirrors the downgrade matrix so that +// each (currentSku → newSku) upgrade pair gets accurate copy. Entry source is +// handled separately via getEntryTransitionBanner. +const getUpgradeBanner = (currentSkuShortName: string | undefined, newSkuShortName: string | undefined): BannerConfig | null => { + const currentSku = normalizeSku(currentSkuShortName); + const newSku = normalizeSku(newSkuShortName); + + if (currentSku === LicenseSkus.Professional && newSku === LicenseSkus.Enterprise) { + return { + mode: 'success', + title: defineMessage({ + id: 'admin.license.diff.banner.upgrade_professional_to_enterprise.title', + defaultMessage: 'This license adds Enterprise capabilities', + }), + description: defineMessage({ + id: 'admin.license.diff.banner.upgrade_professional_to_enterprise.description', + defaultMessage: 'Mattermost Enterprise includes all features available in Mattermost Professional, plus enterprise scale and high availability, advanced compliance and administration features, and enterprise support options.', + }), + showPlanDiffLink: false, + }; + } + + if (currentSku === LicenseSkus.Professional && newSku === LicenseSkus.EnterpriseAdvanced) { + return { + mode: 'success', + title: defineMessage({ + id: 'admin.license.diff.banner.upgrade_professional_to_advanced.title', + defaultMessage: 'This license adds Enterprise Advanced capabilities', + }), + description: defineMessage({ + id: 'admin.license.diff.banner.upgrade_professional_to_advanced.description', + defaultMessage: 'Mattermost Enterprise Advanced includes all Professional and Enterprise features — enterprise scale, advanced compliance and administration — plus Zero Trust security, sensitive information controls, and mobile security hardening for mission-critical operations.', + }), + showPlanDiffLink: false, + }; + } + + if (currentSku === LicenseSkus.Enterprise && newSku === LicenseSkus.EnterpriseAdvanced) { + return { + mode: 'success', + title: defineMessage({ + id: 'admin.license.diff.banner.upgrade_enterprise_to_advanced.title', + defaultMessage: 'This license adds Enterprise Advanced capabilities', + }), + description: defineMessage({ + id: 'admin.license.diff.banner.upgrade_enterprise_to_advanced.description', + defaultMessage: 'Mattermost Enterprise Advanced includes all Enterprise features, plus Zero Trust security, sensitive information controls, and mobile security hardening for mission-critical operations.', + }), + showPlanDiffLink: false, + }; + } + + return null; +}; + +// Downgrade banners (comparison diff view) +const getDowngradeBanner = (currentSkuShortName: string | undefined, newSkuShortName: string | undefined): BannerConfig | null => { + const currentSku = normalizeSku(currentSkuShortName); + const newSku = normalizeSku(newSkuShortName); + + if (currentSku === LicenseSkus.Enterprise && newSku === LicenseSkus.Professional) { + return { + mode: 'danger', + title: defineMessage({ + id: 'admin.license.diff.banner.downgrade.title', + defaultMessage: 'This license downgrades your plan', + }), + description: defineMessage({ + id: 'admin.license.diff.banner.downgrade_enterprise_to_professional.description', + defaultMessage: 'You will lose access to Enterprise features including enterprise scale and high availability, advanced compliance and administration features, and enterprise support options.', + }), + showPlanDiffLink: true, + }; + } + + if (currentSku === LicenseSkus.EnterpriseAdvanced && newSku === LicenseSkus.Enterprise) { + return { + mode: 'danger', + title: defineMessage({ + id: 'admin.license.diff.banner.downgrade.title', + defaultMessage: 'This license downgrades your plan', + }), + description: defineMessage({ + id: 'admin.license.diff.banner.downgrade_advanced_to_enterprise.description', + defaultMessage: 'You will lose access to Mattermost Enterprise Advanced features, including Zero Trust security, sensitive information controls, and mobile security hardening.', + }), + showPlanDiffLink: true, + }; + } + + if (currentSku === LicenseSkus.EnterpriseAdvanced && newSku === LicenseSkus.Professional) { + return { + mode: 'danger', + title: defineMessage({ + id: 'admin.license.diff.banner.downgrade.title', + defaultMessage: 'This license downgrades your plan', + }), + description: defineMessage({ + id: 'admin.license.diff.banner.downgrade_advanced_to_professional.description', + defaultMessage: 'You will lose access to Enterprise features for high availability administration, as well as Enterprise Advanced features including Zero Trust security, sensitive information controls, and advanced mobile security controls for mission-critical operations.', + }), + showPlanDiffLink: true, + }; + } + + return null; +}; + +const getSameLicenseBanner = (): BannerConfig => ({ + mode: 'info', + title: defineMessage({ + id: 'admin.license.diff.banner.same_license.title', + defaultMessage: 'This license is already active', + }), + description: defineMessage({ + id: 'admin.license.diff.banner.same_license.description', + defaultMessage: 'The selected license matches the license currently applied to this workspace. Applying it again will not change any features or limits.', + }), + showPlanDiffLink: false, +}); + +// Get the appropriate banner for any license transition +const getTransitionBanner = (currentSkuShortName: string | undefined, newSkuShortName: string | undefined): BannerConfig | null => { + const currentLevel = getPlanLevel(currentSkuShortName); + const newLevel = getPlanLevel(newSkuShortName); + + if (currentLevel < 0 || newLevel < 0 || currentLevel === newLevel) { + return null; + } + + if (newLevel > currentLevel) { + return getUpgradeBanner(currentSkuShortName, newSkuShortName); + } + + return getDowngradeBanner(currentSkuShortName, newSkuShortName); +}; + +const LicenseDiffView = ({currentLicense, newLicense, locale}: Props) => { + const intl = useIntl(); + const hasCurrentLicense = currentLicense && Object.keys(currentLicense).length > 0 && currentLicense.IsLicensed === 'true'; + const isEntryLicense = hasCurrentLicense && normalizeSku(currentLicense.SkuShortName) === LicenseSkus.Entry; + const isSameLicense = hasCurrentLicense && Boolean(newLicense.id) && currentLicense.Id === newLicense.id; + + const renderBanner = (config: BannerConfig | null) => { + if (!config) { + return null; + } + const actionButtonLeft = config.showPlanDiffLink ? ( + + ) : undefined; + return ( + + ); + }; + + // If current license is "entry", show only new license info (no comparison) + if (isEntryLicense || !hasCurrentLicense) { + const bannerConfig = isEntryLicense ? getEntryTransitionBanner(newLicense.sku_short_name) : null; + + return ( + <> + {renderBanner(bannerConfig)} +
+ + + + } + value={newLicense.sku_name || '-'} + className='info-row-plan' + /> + + } + value={formatDate(newLicense.starts_at, locale)} + /> + + } + value={formatDate(newLicense.expires_at, locale)} + /> + + } + value={newLicense.features?.users ?? '-'} + /> + + } + value={newLicense.customer?.name || '-'} + /> + + } + value={newLicense.customer?.company || '-'} + /> + +
+
+ + ); + } + + // isChanged normalizes both sides to strings before comparing because + // currentVal comes from ClientLicense (always string-typed, e.g. "1517714643650") + // while newVal comes from License (may be number/boolean, e.g. 1517714643650). + // We use `currentVal ?? ''` and `String(newVal ?? '')` so the comparison is + // stable across these differing source types. + const isChanged = (currentVal: string | undefined, newVal: string | number | boolean | undefined): boolean => { + const currentStr = currentVal ?? ''; + const newStr = String(newVal ?? ''); + return currentStr !== newStr; + }; + + const bannerConfig = isSameLicense ? getSameLicenseBanner() : getTransitionBanner(currentLicense.SkuShortName, newLicense.sku_short_name); + + return ( + <> + {renderBanner(bannerConfig)} +
+ + + + + + + + + + + } + currentValue={currentLicense.SkuName || '-'} + newValue={newLicense.sku_name || '-'} + changed={isChanged(currentLicense.SkuName, newLicense.sku_name)} + className='diff-row-plan' + /> + + } + currentValue={formatDate(currentLicense.StartsAt, locale)} + newValue={formatDate(newLicense.starts_at, locale)} + changed={isChanged(currentLicense.StartsAt, newLicense.starts_at)} + /> + + } + currentValue={formatDate(currentLicense.ExpiresAt, locale)} + newValue={formatDate(newLicense.expires_at, locale)} + changed={isChanged(currentLicense.ExpiresAt, newLicense.expires_at)} + /> + + } + currentValue={currentLicense.Users || '-'} + newValue={newLicense.features?.users ?? '-'} + changed={isChanged(currentLicense.Users, newLicense.features?.users)} + /> + + } + currentValue={currentLicense.Name || '-'} + newValue={newLicense.customer?.name || '-'} + changed={isChanged(currentLicense.Name, newLicense.customer?.name)} + /> + + } + currentValue={currentLicense.Company || '-'} + newValue={newLicense.customer?.company || '-'} + changed={isChanged(currentLicense.Company, newLicense.customer?.company)} + /> + +
+ + + +
+
+ + ); +}; + +export default LicenseDiffView; diff --git a/webapp/channels/src/components/admin_console/license_settings/modals/upload_license_modal.scss b/webapp/channels/src/components/admin_console/license_settings/modals/upload_license_modal.scss index 96ab3e5dc3f..9e84166f266 100644 --- a/webapp/channels/src/components/admin_console/license_settings/modals/upload_license_modal.scss +++ b/webapp/channels/src/components/admin_console/license_settings/modals/upload_license_modal.scss @@ -1,5 +1,12 @@ .UploadLicenseModal { + .modal-dialog { + width: 720px; + max-width: 90vw; + } + .modal-header { + min-height: 48px; + padding-bottom: 0; border-bottom: none !important; background: none !important; @@ -12,7 +19,7 @@ .modal-body { padding-top: 0 !important; - padding-bottom: 24px !important; + padding-bottom: 40px !important; } .content-body { @@ -31,7 +38,7 @@ } .title { - margin: 10px 0; + margin: 0 0 10px 0; color: #3f4350; font-family: Metropolis; font-size: 22px; @@ -41,6 +48,7 @@ .subtitle { width: 90%; + margin-bottom: 16px; color: #3f4350; font-size: 14px; font-style: normal; @@ -49,66 +57,6 @@ text-align: center; } - .file-upload { - width: 90%; - padding: 12px; - border: 1px solid rgba(63, 67, 80, 0.32); - border-radius: 4px; - margin-top: 15px; - - &__titleSection { - color: rgba(63, 67, 80, 0.75); - font-size: 12px; - font-weight: bold; - line-height: 16px; - } - - &__inputSection { - display: flex; - justify-content: space-between; - margin-top: 10px; - - .file-name-section { - display: flex; - - svg { - margin-right: 5px; - } - - .file-size { - margin-left: 20px; - } - } - - .file__upload { - position: relative; - display: flex; - align-self: flex-end; - margin-left: auto; - - a { - color: #1c58d9; - font-weight: 600; - } - - input { - position: absolute; - z-index: 5; - top: 0; - left: 0; - width: 100%; - height: 100%; - padding-left: 100%; - cursor: pointer; - - &[disabled] { - cursor: not-allowed; - } - } - } - } - } - .serverError { display: flex; width: 90%; @@ -144,6 +92,14 @@ font-weight: 600; line-height: 18px; } + + &.preview-footer { + flex-direction: row; + + button { + margin-top: 0 !important; + } + } } .modal-footer { diff --git a/webapp/channels/src/components/admin_console/license_settings/modals/upload_license_modal.test.tsx b/webapp/channels/src/components/admin_console/license_settings/modals/upload_license_modal.test.tsx index e41483cbeed..8a0601603bc 100644 --- a/webapp/channels/src/components/admin_console/license_settings/modals/upload_license_modal.test.tsx +++ b/webapp/channels/src/components/admin_console/license_settings/modals/upload_license_modal.test.tsx @@ -18,6 +18,15 @@ import UploadLicenseModal from './upload_license_modal'; jest.mock('selectors/i18n'); jest.mock('mattermost-redux/actions/admin', () => ({ uploadLicense: jest.fn(() => () => Promise.resolve({data: true})), + previewLicense: jest.fn(() => () => Promise.resolve({data: { + id: 'preview-id', + issued_at: 1517714643650, + starts_at: 1517714643650, + expires_at: 1620335443650, + sku_name: 'Enterprise', + sku_short_name: 'Enterprise', + features: {}, + }})), })); jest.mock('mattermost-redux/actions/general', () => ({ ...jest.requireActual('mattermost-redux/actions/general'), @@ -27,6 +36,10 @@ jest.mock('mattermost-redux/actions/general', () => ({ describe('components/admin_console/license_settings/modals/upload_license_modal', () => { (i18Selectors.getCurrentLocale as jest.Mock).mockReturnValue(General.DEFAULT_LOCALE); + afterEach(() => { + jest.restoreAllMocks(); + }); + // required state to mount using the provider const license = { IsLicensed: 'true', @@ -46,6 +59,9 @@ describe('components/admin_console/license_settings/modals/upload_license_modal' IsLicensed: 'false', }, }, + users: { + currentUserId: '', + }, }, views: { modals: { @@ -65,15 +81,18 @@ describe('components/admin_console/license_settings/modals/upload_license_modal' fileObjFromProps: {name: 'Test license file'} as File, }; - test('should match snapshot when is not licensed', () => { - const {container} = renderWithContext( - , - state, - ); - expect(container).toMatchSnapshot(); + test('should match snapshot when is not licensed', async () => { + let container: HTMLElement; + await act(async () => { + ({container} = renderWithContext( + , + state, + )); + }); + expect(container!).toMatchSnapshot(); }); - test('should match snapshot when is licensed', () => { + test('should match snapshot when is licensed', async () => { const localState: DeepPartial = { ...state, entities: { @@ -82,59 +101,56 @@ describe('components/admin_console/license_settings/modals/upload_license_modal' }, }, }; - const {container} = renderWithContext( - , - localState, - ); - expect(container).toMatchSnapshot(); + let container: HTMLElement; + await act(async () => { + ({container} = renderWithContext( + , + localState, + )); + }); + expect(container!).toMatchSnapshot(); }); - test('should display upload btn Disabled on initial load and no file selected', () => { + test('should not show apply button when no file is selected', () => { const newProps = {...props, fileObjFromProps: null}; renderWithContext( , state, ); - const uploadButton = screen.getByRole('button', {name: 'Upload'}); - expect(uploadButton).toBeDisabled(); + expect(screen.queryByRole('button', {name: 'Apply License'})).not.toBeInTheDocument(); }); - test('should display upload btn Enabled when file is loaded', () => { - const realUseState = React.useState; - const initialStateForFileObj = {name: 'testing.mattermost-license', size: 10240000} as File; - - jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(initialStateForFileObj as any)); - renderWithContext( - , - state, - ); - const uploadButton = screen.getByRole('button', {name: 'Upload'}); - expect(uploadButton).not.toBeDisabled(); + test('should show apply button enabled after preview when file is loaded', async () => { + await act(async () => { + renderWithContext( + , + state, + ); + }); + const applyButton = screen.getByRole('button', {name: 'Apply License'}); + expect(applyButton).not.toBeDisabled(); }); - test('should display no file selected text when no file is loaded', () => { + test('should show loading state when no file is loaded', () => { const newProps = {...props, fileObjFromProps: null}; renderWithContext( , state, ); - expect(screen.getByText('No file selected')).toBeInTheDocument(); + expect(screen.getByText('Validating License')).toBeInTheDocument(); }); - test('should display the file name when is selected', () => { - const realUseState = React.useState; - const initialStateForFileObj = {name: 'testing.mattermost-license', size: (5 * 1024)} as File; - - jest.spyOn(React, 'useState').mockImplementationOnce(() => realUseState(initialStateForFileObj as any)); - renderWithContext( - , - state, - ); - expect(screen.getByText('testing.mattermost-license')).toBeInTheDocument(); - expect(screen.getByText('5KB')).toBeInTheDocument(); + test('should show preview step after file is loaded', async () => { + await act(async () => { + renderWithContext( + , + state, + ); + }); + expect(screen.getByText('Review License Changes')).toBeInTheDocument(); }); - test('should show success image when open and there is a license (successful license upload)', async () => { + test('should show success state when apply license is clicked', async () => { const localState: DeepPartial = { ...state, entities: { @@ -144,16 +160,18 @@ describe('components/admin_console/license_settings/modals/upload_license_modal' }, }; - renderWithContext( - , - localState, - ); + await act(async () => { + renderWithContext( + , + localState, + ); + }); await act(async () => { - fireEvent.click(screen.getByRole('button', {name: 'Upload'})); + fireEvent.click(screen.getByRole('button', {name: 'Apply License'})); }); - expect(screen.getByText('Successful Upgrade!')).toBeInTheDocument(); + expect(screen.getByText('New license successfully applied')).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Done'})).toBeInTheDocument(); }); @@ -167,18 +185,38 @@ describe('components/admin_console/license_settings/modals/upload_license_modal' }, }; - renderWithContext( - , - localState, - ); + await act(async () => { + renderWithContext( + , + localState, + ); + }); await act(async () => { - fireEvent.click(screen.getByRole('button', {name: 'Upload'})); + fireEvent.click(screen.getByRole('button', {name: 'Apply License'})); }); expect(screen.getByText(/123,456,789/)).toBeInTheDocument(); }); + test('should show error state when license validation fails', async () => { + const previewLicenseMock = jest.requireMock('mattermost-redux/actions/admin').previewLicense; + previewLicenseMock.mockImplementationOnce(() => () => Promise.resolve({error: {message: 'Invalid license file.'}})); + + await act(async () => { + renderWithContext( + , + state, + ); + }); + + expect(screen.getByText('License validation failed')).toBeInTheDocument(); + expect(screen.getByText('Invalid license file.')).toBeInTheDocument(); + expect(document.getElementById('close-button')).toBeInTheDocument(); + expect(screen.queryByText('Please wait while we validate your license file...')).not.toBeInTheDocument(); + expect(screen.queryByText('Validating License')).not.toBeInTheDocument(); + }); + test('should hide the upload modal', () => { const localState: DeepPartial = { ...state, @@ -193,6 +231,6 @@ describe('components/admin_console/license_settings/modals/upload_license_modal' localState, ); - expect(screen.queryByText('Upload a License Key')).not.toBeInTheDocument(); + expect(screen.queryByText('Validating License')).not.toBeInTheDocument(); }); }); diff --git a/webapp/channels/src/components/admin_console/license_settings/modals/upload_license_modal.tsx b/webapp/channels/src/components/admin_console/license_settings/modals/upload_license_modal.tsx index 5348da4ce8d..1788386c568 100644 --- a/webapp/channels/src/components/admin_console/license_settings/modals/upload_license_modal.tsx +++ b/webapp/channels/src/components/admin_console/license_settings/modals/upload_license_modal.tsx @@ -1,16 +1,15 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import marked from 'marked'; -import React, {useRef} from 'react'; -import {defineMessage, FormattedDate, FormattedMessage} from 'react-intl'; +import React, {useEffect, useCallback} from 'react'; +import {defineMessage, FormattedDate, FormattedMessage, useIntl} from 'react-intl'; import {useSelector, useDispatch} from 'react-redux'; import {GenericModal} from '@mattermost/components'; import {Button} from '@mattermost/shared/components/button'; -import type {ClientLicense} from '@mattermost/types/config'; +import type {ClientLicense, License} from '@mattermost/types/config'; -import {uploadLicense} from 'mattermost-redux/actions/admin'; +import {previewLicense, uploadLicense} from 'mattermost-redux/actions/admin'; import {getLicenseConfig} from 'mattermost-redux/actions/general'; import {getLicense} from 'mattermost-redux/selectors/entities/general'; @@ -18,18 +17,18 @@ import {closeModal} from 'actions/views/modals'; import {getCurrentLocale} from 'selectors/i18n'; import {isModalOpen} from 'selectors/views/modals'; -import FileSvg from 'components/common/svg_images_components/file_svg'; +import AlertBanner from 'components/alert_banner'; import SuccessSvg from 'components/common/svg_images_components/success_svg'; -import UploadLicenseSvg from 'components/common/svg_images_components/upload_license_svg'; import LoadingWrapper from 'components/widgets/loading/loading_wrapper'; -import {FileTypes, ModalIdentifiers} from 'utils/constants'; +import {ModalIdentifiers} from 'utils/constants'; import {getMonthLong} from 'utils/i18n'; import {getSkuDisplayName} from 'utils/subscription'; -import {fileSizeToString} from 'utils/utils'; import type {GlobalState} from 'types/store'; +import LicenseDiffView from './license_diff_view'; + import './upload_license_modal.scss'; type Props = { @@ -37,192 +36,248 @@ type Props = { fileObjFromProps: File | null; } +type ModalStep = 'loading' | 'preview' | 'success'; + const UploadLicenseModal = (props: Props): JSX.Element | null => { const dispatch = useDispatch(); + const intl = useIntl(); - const [fileObj, setFileObj] = React.useState(props.fileObjFromProps); - const [isUploading, setIsUploading] = React.useState(false); + const [fileObj] = React.useState(props.fileObjFromProps); + const [isLoading, setIsLoading] = React.useState(false); const [serverError, setServerError] = React.useState(null); - const [uploadSuccessful, setUploadSuccessful] = React.useState(false); - const fileInputRef = useRef(null); + const [step, setStep] = React.useState('loading'); + const [previewedLicense, setPreviewedLicense] = React.useState(null); const currentLicense: ClientLicense = useSelector(getLicense); const locale = useSelector(getCurrentLocale); + const show = useSelector((state: GlobalState) => isModalOpen(state, ModalIdentifiers.UPLOAD_LICENSE)); - const handleChange = () => { - const element = fileInputRef.current; - if (element === null || element.files === null || element.files.length === 0 || element.files[0].size === 0) { + const {onExited} = props; + const handleOnClose = useCallback(() => { + if (isLoading) { return; } - setFileObj(element.files[0]); - setServerError(null); - }; - const handleSubmit = async (e: React.MouseEvent) => { - e.preventDefault(); - if (fileObj === null) { - return; + // After a successful upload, re-fetch the client license one more time + // before closing. The post-upload getLicenseConfig() inside + // handleConfirmUpload occasionally lost the race against server-side + // license propagation on fresh instances, leaving the parent License + // Settings view stale. A second fetch on close picks up the new value. + if (step === 'success') { + dispatch(getLicenseConfig()); } + if (onExited) { + onExited(); + } + dispatch(closeModal(ModalIdentifiers.UPLOAD_LICENSE)); + }, [isLoading, onExited, dispatch, step]); - setIsUploading(true); - const {error} = await dispatch(uploadLicense(fileObj)); + // Automatically preview the license when the modal opens with a file + useEffect(() => { + const doPreview = async () => { + if (!fileObj || !show) { + setIsLoading(false); + return; + } - if (error) { - setFileObj(null); - setServerError(error.message); - setIsUploading(false); - return; - } + setIsLoading(true); + setServerError(null); - await dispatch(getLicenseConfig()); - setFileObj(null); - setServerError(null); - setIsUploading(false); - setUploadSuccessful(true); - }; + try { + const {data, error} = await dispatch(previewLicense(fileObj)); + + if (error || !data) { + setServerError(error?.message ?? intl.formatMessage({ + id: 'admin.license.preview_error', + defaultMessage: 'Failed to preview license', + })); + setIsLoading(false); + return; + } + + setPreviewedLicense(data); + setIsLoading(false); + setStep('preview'); + } catch (err) { + setServerError((err as Error)?.message ?? intl.formatMessage({ + id: 'admin.license.preview_error', + defaultMessage: 'Failed to preview license', + })); + setIsLoading(false); + } + }; + + doPreview(); + }, [fileObj, show, dispatch, intl]); - const show = useSelector((state: GlobalState) => isModalOpen(state, ModalIdentifiers.UPLOAD_LICENSE)); if (!show) { return null; } - const handleOnClose = () => { - if (isUploading) { + const handleConfirmUpload = async (e: React.MouseEvent) => { + e.preventDefault(); + if (fileObj === null || isLoading) { return; } - if (props.onExited) { - props.onExited(); - } - dispatch(closeModal(ModalIdentifiers.UPLOAD_LICENSE)); - }; - const handleRemoveFile = () => { - setFileObj(null); - }; + setIsLoading(true); + try { + const {error} = await dispatch(uploadLicense(fileObj)); + + if (error) { + setServerError(error.message); + setIsLoading(false); + return; + } - const displayFileName = (fileName: string) => { - const extLen = FileTypes.LICENSE_EXTENSION.length; - let fileNameWithoutExt = fileName.split(FileTypes.LICENSE_EXTENSION)[0]; - fileNameWithoutExt = fileNameWithoutExt.length < (40 - extLen) ? fileNameWithoutExt : `${fileNameWithoutExt.substr(0, (37 - extLen))}...`; - return `${fileNameWithoutExt}${FileTypes.LICENSE_EXTENSION}`; + await dispatch(getLicenseConfig()); + setServerError(null); + setIsLoading(false); + setStep('success'); + } catch (err) { + setServerError((err as Error)?.message ?? intl.formatMessage({ + id: 'admin.license.apply_error', + defaultMessage: 'Failed to apply license', + })); + setIsLoading(false); + } }; - let uploadLicenseContent = ( - <> -
-
- -
-
- +
+
+ +
+
-
- +
+
+ +
-
-
+ + ) : ( + <> +
+
-
-
- {fileObj?.name && fileObj?.size ? ( - <> - - - {displayFileName(fileObj.name)} - - - {fileSizeToString(fileObj.size)} - - - ) : ( - - )} -
-
- {fileObj?.name ? ( - - - - ) : ( - <> - - - - - - )} -
+
+
- {serverError &&
- - +
+ + + +
+
+ + ); + } else if (step === 'preview' && previewedLicense) { + uploadLicenseContent = ( + <> +
+
+ +
+
+ +
+ -
} -
-
-
+ {serverError && ( +
+ + + {serverError} + +
+ )} +
+
+
-
- - ); + + ); + } else { + // Success step. Source display fields from previewedLicense (the + // license that was just applied) rather than currentLicense (a redux + // value that depends on a post-upload refetch we can't fully trust to + // have propagated). previewedLicense is the same license bytes the + // server just applied, so it always matches what's now active. + const appliedStartsAt = previewedLicense?.starts_at ?? parseInt(currentLicense.StartsAt, 10); + const appliedExpiresAt = previewedLicense?.expires_at ?? parseInt(currentLicense.ExpiresAt, 10); + const appliedUsers = previewedLicense?.features?.users ?? Number(currentLicense.Users); + const appliedSkuShortName = previewedLicense?.sku_short_name ?? currentLicense.SkuShortName; + const appliedIsGovSku = previewedLicense?.is_gov_sku ?? (currentLicense.IsGovSku === 'true'); - if (uploadSuccessful) { const startsAt = ( { ); const expiresAt = ( ); - const licensedUsersNum = currentLicense.Users; - const skuName = getSkuDisplayName(currentLicense.SkuShortName, currentLicense.IsGovSku === 'true'); + const licensedUsersNum = appliedUsers; + const skuName = getSkuDisplayName(appliedSkuShortName, appliedIsGovSku); uploadLicenseContent = ( <>
@@ -251,13 +306,13 @@ const UploadLicenseModal = (props: Props): JSX.Element | null => {
{ }); test('should toggle boolean settings', async () => { + // Use a simplified schema without username/jobstable fields to avoid async complications + const simpleSchema = { + id: 'Config', + name: 'config', + name_default: 'Configuration', + settings: [ + { + key: 'FirstSettings.settingb', + label: 'label-b', + label_default: 'Setting Two', + type: 'bool', + default: true, + }, + ], + } as unknown as AdminDefinitionSubSectionSchema; + const {container} = renderWithContext( , ); diff --git a/webapp/channels/src/components/new_channel_modal/new_channel_modal.tsx b/webapp/channels/src/components/new_channel_modal/new_channel_modal.tsx index 8e0a62c924e..c1ee71a66f0 100644 --- a/webapp/channels/src/components/new_channel_modal/new_channel_modal.tsx +++ b/webapp/channels/src/components/new_channel_modal/new_channel_modal.tsx @@ -176,8 +176,10 @@ const NewChannelModal = () => { default_category_name: defaultCategoryName, managed_category_name: managedCategoryName, ...(classificationEnabled && selectedClassificationId && bannerText ? { + + // Leave banner_info disabled: the classification banner renders + // off the property value, not banner_info.enabled. banner_info: { - enabled: true, text: bannerText, background_color: selectedClassificationLevel?.color || '', }, diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index c2e087f8f42..934167970e7 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -1799,12 +1799,39 @@ "admin.ldap.usernameAttrDescHover": "This may be the same as the Login ID Attribute.", "admin.ldap.usernameAttrEx": "E.g.: \"sAMAccountName\"", "admin.ldap.usernameAttrTitle": "Username Attribute:", - "admin.license.choose": "Choose File", + "admin.license.apply_error": "Failed to apply license", "admin.license.confirm-license-removal.cancel": "Cancel", "admin.license.confirm-license-removal.confirm": "Confirm", "admin.license.confirm-license-removal.subtitle": "Removing the license will downgrade your server from {currentSKU} to Free. You may lose information. ", "admin.license.confirm-license-removal.title": "Are you sure?", "admin.license.contactSales": "Questions? Contact sales", + "admin.license.diff.banner.downgrade_advanced_to_enterprise.description": "You will lose access to Mattermost Enterprise Advanced features, including Zero Trust security, sensitive information controls, and mobile security hardening.", + "admin.license.diff.banner.downgrade_advanced_to_professional.description": "You will lose access to Enterprise features for high availability administration, as well as Enterprise Advanced features including Zero Trust security, sensitive information controls, and advanced mobile security controls for mission-critical operations.", + "admin.license.diff.banner.downgrade_enterprise_to_professional.description": "You will lose access to Enterprise features including enterprise scale and high availability, advanced compliance and administration features, and enterprise support options.", + "admin.license.diff.banner.downgrade.title": "This license downgrades your plan", + "admin.license.diff.banner.entry_to_advanced.description": "Mattermost Enterprise Advanced includes all Enterprise features, unlocks unlimited message history, and adds exclusive capabilities like Zero Trust security, sensitive information controls, and mobile security hardening for mission-critical operations", + "admin.license.diff.banner.entry_to_advanced.title": "This license adds Enterprise Advanced capabilities", + "admin.license.diff.banner.entry_to_enterprise.description": "Mattermost Enterprise includes unlimited message history and adds enterprise-grade scale, compliance, and administration capabilities. Some features in Mattermost Entry are not included in Enterprise.", + "admin.license.diff.banner.entry_to_enterprise.title": "This license adds Enterprise capabilities, with feature changes", + "admin.license.diff.banner.entry_to_professional.description": "Mattermost Professional adds paid-tier capabilities such as unlimited message history. Some features in Mattermost Entry are not included in Professional (see plan differences).", + "admin.license.diff.banner.entry_to_professional.title": "This license changes your available features", + "admin.license.diff.banner.same_license.description": "The selected license matches the license currently applied to this workspace. Applying it again will not change any features or limits.", + "admin.license.diff.banner.same_license.title": "This license is already active", + "admin.license.diff.banner.upgrade_enterprise_to_advanced.description": "Mattermost Enterprise Advanced includes all Enterprise features, plus Zero Trust security, sensitive information controls, and mobile security hardening for mission-critical operations.", + "admin.license.diff.banner.upgrade_enterprise_to_advanced.title": "This license adds Enterprise Advanced capabilities", + "admin.license.diff.banner.upgrade_professional_to_advanced.description": "Mattermost Enterprise Advanced includes all Professional and Enterprise features — enterprise scale, advanced compliance and administration — plus Zero Trust security, sensitive information controls, and mobile security hardening for mission-critical operations.", + "admin.license.diff.banner.upgrade_professional_to_advanced.title": "This license adds Enterprise Advanced capabilities", + "admin.license.diff.banner.upgrade_professional_to_enterprise.description": "Mattermost Enterprise includes all features available in Mattermost Professional, plus enterprise scale and high availability, advanced compliance and administration features, and enterprise support options.", + "admin.license.diff.banner.upgrade_professional_to_enterprise.title": "This license adds Enterprise capabilities", + "admin.license.diff.banner.viewPlanDifferences": "View plan differences", + "admin.license.diff.company": "Company", + "admin.license.diff.currentLicense": "Current License", + "admin.license.diff.endDate": "End Date", + "admin.license.diff.name": "Name", + "admin.license.diff.newLicense": "New License", + "admin.license.diff.sku": "Plan", + "admin.license.diff.startsAt": "Start Date", + "admin.license.diff.users": "Users", "admin.license.enterprise.license_required_upgrade": "A license is required to unlock enterprise features", "admin.license.enterprise.restarting": "Restarting", "admin.license.enterprise.upgrade": "Upgrade to Enterprise Edition", @@ -1836,13 +1863,15 @@ "admin.license.keyAddNew": "Add a new license", "admin.license.keyRemove": "Remove license and downgrade to Mattermost Free", "admin.license.keyRemoveEntry": "Remove license and downgrade to Mattermost Entry", + "admin.license.modal.apply": "Apply License", + "admin.license.modal.applying": "Applying", + "admin.license.modal.cancel": "Cancel", + "admin.license.modal.close": "Close", "admin.license.modal.done": "Done", - "admin.license.modal.upload": "Upload", - "admin.license.modal.uploading": "Uploading", - "admin.license.no-file-selected": "No file selected", + "admin.license.modal.validating": "Validating", + "admin.license.preview_error": "Failed to preview license", "admin.license.purchaseEnterprisePlanSubtitle": "Continue your access to Enterprise Advanced features by purchasing a license.", "admin.license.purchaseEnterprisePlanTitle": "Purchase Enterprise Advanced", - "admin.license.remove": "Remove", "admin.license.removing": "Removing License...", "admin.license.renewalCard.description.contact_sales": "Renew your {licenseSku} license by contacting sales to avoid any disruption.", "admin.license.renewalCard.licenseExpired": "License expired on {date, date, long}.", @@ -1877,11 +1906,13 @@ "admin.license.upgradeTitle": "Purchase one of our plans to unlock more features", "admin.license.upgradeToEnterprise": "Upgrade to Enterprise", "admin.license.upgradeToEnterpriseAdvanced": "Upgrade to Enterprise Advanced", - "admin.license.upload-modal.file": "File", - "admin.license.upload-modal.subtitle": "Upload a license key for Mattermost Enterprise Edition to upgrade this server. ", - "admin.license.upload-modal.successfulUpgrade": "Successful Upgrade!", - "admin.license.upload-modal.successfulUpgradeText": "You have upgraded to the {skuName} plan for {licensedUsersNum, number} seats. This is effective from {startsAt} until {expiresAt}. ", - "admin.license.upload-modal.title": "Upload a License Key", + "admin.license.upload-modal.error.title": "License validation failed", + "admin.license.upload-modal.loading.subtitle": "Please wait while we validate your license file...", + "admin.license.upload-modal.loading.title": "Validating License", + "admin.license.upload-modal.preview.subtitle": "Please review the changes before applying the new license.", + "admin.license.upload-modal.preview.title": "Review License Changes", + "admin.license.upload-modal.successfulUpgrade": "New license successfully applied", + "admin.license.upload-modal.successfulUpgradeText": "You are now on the {skuName} plan for {licensedUsersNum, number} seats. This is effective from {startsAt} until {expiresAt}.", "admin.license.uploadFile": "Upload File", "admin.license.uploadLicense": "Upload license", "admin.license.uploadLicenseToUnlock": "Upload your license here to unlock licensed features", diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/admin.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/admin.ts index 8e0a2468869..7995eb09b3d 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/admin.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/admin.ts @@ -441,6 +441,15 @@ export function uploadLicense(fileData: File) { }); } +export function previewLicense(fileData: File) { + return bindClientFunc({ + clientFunc: Client4.previewLicense, + params: [ + fileData, + ], + }); +} + export function removeLicense(): ActionFuncAsync { return async (dispatch, getState) => { try { diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 02c23ec1c1d..8512d2b0434 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -3928,6 +3928,21 @@ export default class Client4 { ); }; + previewLicense = (fileData: File) => { + const formData = new FormData(); + formData.append('license', fileData); + + const request: any = { + method: 'post', + body: formData, + }; + + return this.doFetch( + `${this.getBaseRoute()}/license/preview`, + request, + ); + }; + requestTrialLicense = (body: RequestLicenseBody) => { return this.doFetchWithResponse( `${this.getBaseRoute()}/trial-license`, diff --git a/webapp/platform/types/src/config.ts b/webapp/platform/types/src/config.ts index 15c8f745d88..d976a84c25c 100644 --- a/webapp/platform/types/src/config.ts +++ b/webapp/platform/types/src/config.ts @@ -255,11 +255,12 @@ export type License = { id: string; issued_at: number; starts_at: number; - expires_at: string; - customer: LicenseCustomer; + expires_at: number; + customer?: LicenseCustomer; features: LicenseFeatures; sku_name: string; - short_sku_name: string; + sku_short_name: string; + is_gov_sku?: boolean; }; export type LicenseCustomer = {