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
{
});
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 = {