From 548183d748ada9af25eb8d7a4cced1064cec4532 Mon Sep 17 00:00:00 2001 From: Carlos Garcia Date: Mon, 18 May 2026 22:23:58 +0200 Subject: [PATCH 1/2] Mm 68282 admin ephemeral mode (#36194) * adds feature flag to enable mattermost ephemeral mode * add ephemeral mode config settings to system console When feature flag is set to true a new section for Mobile Ephemeral Mode settings shows under the Mobile Security section in case a valid Enterprise Advanced License is active. * adds Mobile Ephemeral Mode settings playwright tests * improve descriptions for settings * improves error messages and hints * move validation to common helper and add new tests * reverts package-lock.json changes * proper struct alignment * proper message sorting in json file * use generic doc url for MEM section while docs are not ready * Proper formatting for playwright tests * fixes test --- .../lib/src/server/default_config.ts | 7 + .../playwright/lib/src/ui/components/index.ts | 10 +- .../system_console/base_components.ts | 34 +++ .../sections/environment/mobile_security.ts | 53 +++- .../system_console/mobile_security.spec.ts | 228 ++++++++++++++++++ server/config/client.go | 14 ++ server/config/client_test.go | 134 ++++++++++ server/i18n/en.json | 12 + server/public/model/config.go | 61 +++++ server/public/model/config_test.go | 115 +++++++++ server/public/model/feature_flags.go | 5 + .../admin_console/admin_definition.tsx | 70 ++++++ .../admin_definition_helpers.test.tsx | 21 +- .../admin_definition_helpers.tsx | 1 + ..._definition_mobile_ephemeral_mode.test.tsx | 129 ++++++++++ webapp/channels/src/i18n/en.json | 19 ++ .../src/utils/admin_console_index.test.tsx | 2 +- webapp/platform/types/src/config.ts | 8 + 18 files changed, 919 insertions(+), 4 deletions(-) create mode 100644 webapp/channels/src/components/admin_console/admin_definition_mobile_ephemeral_mode.test.tsx diff --git a/e2e-tests/playwright/lib/src/server/default_config.ts b/e2e-tests/playwright/lib/src/server/default_config.ts index 3c8abca6f6a..b4448dd89b9 100644 --- a/e2e-tests/playwright/lib/src/server/default_config.ts +++ b/e2e-tests/playwright/lib/src/server/default_config.ts @@ -790,6 +790,7 @@ const defaultServerConfig: AdminConfig = { IntegratedBoards: false, CJKSearch: false, ManagedChannelCategories: false, + MobileEphemeralMode: true, }, ImportSettings: { Directory: './import', @@ -868,4 +869,10 @@ const defaultServerConfig: AdminConfig = { LLMServiceID: '', }, }, + MobileEphemeralModeSettings: { + Enable: false, + DisconnectionTimeoutSeconds: 60, + OfflinePersistenceTimerHours: 24, + AutoCacheCleanupDays: 7, + }, }; diff --git a/e2e-tests/playwright/lib/src/ui/components/index.ts b/e2e-tests/playwright/lib/src/ui/components/index.ts index babea003a09..1d7e83a3488 100644 --- a/e2e-tests/playwright/lib/src/ui/components/index.ts +++ b/e2e-tests/playwright/lib/src/ui/components/index.ts @@ -54,7 +54,13 @@ import BurnOnReadTimerChip from './channels/burn_on_read_timer_chip'; import BurnOnReadConcealedPlaceholder from './channels/burn_on_read_concealed_placeholder'; import BurnOnReadConfirmationModal from './channels/burn_on_read_confirmation_modal'; // System Console Components -import {AdminSectionPanel, DropdownSetting, RadioSetting, TextInputSetting} from './system_console/base_components'; +import { + AdminSectionPanel, + DropdownSetting, + NumberInputSetting, + RadioSetting, + TextInputSetting, +} from './system_console/base_components'; import DelegatedGranularAdministration from './system_console/sections/user_management/delegated_granular_administration'; import UserDetail from './system_console/sections/user_management/user_detail'; import EditionAndLicense from './system_console/sections/about/edition_and_license'; @@ -132,6 +138,7 @@ const components = { EditionAndLicense, MobileSecurity, Notifications, + NumberInputSetting, RadioSetting, UsersAndTeams, SystemConsoleFeatureDiscovery, @@ -210,6 +217,7 @@ export { EditionAndLicense, MobileSecurity, Notifications, + NumberInputSetting, RadioSetting, UsersAndTeams, SystemConsoleFeatureDiscovery, diff --git a/e2e-tests/playwright/lib/src/ui/components/system_console/base_components.ts b/e2e-tests/playwright/lib/src/ui/components/system_console/base_components.ts index 2cd2c04cc3b..48ed352a8b3 100644 --- a/e2e-tests/playwright/lib/src/ui/components/system_console/base_components.ts +++ b/e2e-tests/playwright/lib/src/ui/components/system_console/base_components.ts @@ -94,6 +94,40 @@ export class TextInputSetting { } } +/** + * Number Input Setting - represents a number input field + * Uses getByRole('spinbutton') since has ARIA role spinbutton + */ +export class NumberInputSetting { + readonly container: Locator; + readonly label: Locator; + readonly input: Locator; + readonly helpText: Locator; + + constructor(container: Locator, labelText: string) { + this.container = container; + this.label = container.getByText(labelText); + this.input = container.getByRole('spinbutton'); + this.helpText = container.locator('.help-text'); + } + + async fill(value: string) { + await this.input.fill(value); + } + + async getValue(): Promise { + return (await this.input.inputValue()) ?? ''; + } + + async clear() { + await this.input.clear(); + } + + async toBeVisible() { + await expect(this.container).toBeVisible(); + } +} + /** * Dropdown Setting - represents a select dropdown */ diff --git a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/environment/mobile_security.ts b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/environment/mobile_security.ts index f171dc11cc8..0eb97a64ec2 100644 --- a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/environment/mobile_security.ts +++ b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/environment/mobile_security.ts @@ -3,7 +3,13 @@ import {Locator, expect} from '@playwright/test'; -import {RadioSetting, TextInputSetting, DropdownSetting, AdminSectionPanel} from '../../base_components'; +import { + RadioSetting, + TextInputSetting, + NumberInputSetting, + DropdownSetting, + AdminSectionPanel, +} from '../../base_components'; /** * System Console -> Environment -> Mobile Security @@ -17,6 +23,7 @@ export default class MobileSecurity { // Panels readonly generalMobileSecurity: GeneralMobileSecurityPanel; readonly microsoftIntune: MicrosoftIntunePanel; + readonly mobileEphemeralMode: MobileEphemeralModePanel; // Save section readonly saveButton: Locator; @@ -33,6 +40,9 @@ export default class MobileSecurity { this.microsoftIntune = new MicrosoftIntunePanel( container.locator('.AdminSectionPanel').filter({hasText: 'Microsoft Intune'}), ); + this.mobileEphemeralMode = new MobileEphemeralModePanel( + container.locator('.AdminSectionPanel').filter({hasText: 'Mobile Ephemeral Mode'}), + ); this.saveButton = container.getByRole('button', {name: 'Save'}); this.errorMessage = container.locator('.error-message'); @@ -77,6 +87,20 @@ export default class MobileSecurity { get clientId() { return this.microsoftIntune.clientId; } + + // Convenience shortcuts for Mobile Ephemeral Mode settings + get enableMobileEphemeralMode() { + return this.mobileEphemeralMode.enableMobileEphemeralMode; + } + get disconnectionTimeout() { + return this.mobileEphemeralMode.disconnectionTimeout; + } + get offlinePersistenceTimer() { + return this.mobileEphemeralMode.offlinePersistenceTimer; + } + get autoCacheCleanup() { + return this.mobileEphemeralMode.autoCacheCleanup; + } } class GeneralMobileSecurityPanel extends AdminSectionPanel { @@ -105,6 +129,33 @@ class GeneralMobileSecurityPanel extends AdminSectionPanel { } } +class MobileEphemeralModePanel extends AdminSectionPanel { + readonly enableMobileEphemeralMode: RadioSetting; + readonly disconnectionTimeout: NumberInputSetting; + readonly offlinePersistenceTimer: NumberInputSetting; + readonly autoCacheCleanup: NumberInputSetting; + + constructor(container: Locator) { + super(container, 'Mobile Ephemeral Mode'); + + this.enableMobileEphemeralMode = new RadioSetting( + this.body.getByRole('group', {name: /Enable Mobile Ephemeral Mode/}), + ); + this.disconnectionTimeout = new NumberInputSetting( + this.body.locator('.form-group').filter({hasText: 'Disconnection Timeout (seconds):'}), + 'Disconnection Timeout (seconds):', + ); + this.offlinePersistenceTimer = new NumberInputSetting( + this.body.locator('.form-group').filter({hasText: 'Offline Persistence Timer (hours):'}), + 'Offline Persistence Timer (hours):', + ); + this.autoCacheCleanup = new NumberInputSetting( + this.body.locator('.form-group').filter({hasText: 'Auto Cache Cleanup (days):'}), + 'Auto Cache Cleanup (days):', + ); + } +} + class MicrosoftIntunePanel extends AdminSectionPanel { readonly enableIntuneMAM: RadioSetting; readonly authProvider: DropdownSetting; diff --git a/e2e-tests/playwright/specs/functional/system_console/mobile_security.spec.ts b/e2e-tests/playwright/specs/functional/system_console/mobile_security.spec.ts index 44053d653bb..e4f64c21a36 100644 --- a/e2e-tests/playwright/specs/functional/system_console/mobile_security.spec.ts +++ b/e2e-tests/playwright/specs/functional/system_console/mobile_security.spec.ts @@ -507,3 +507,231 @@ test('should disable Intune inputs when toggle is off', async ({pw}) => { expect(await systemConsolePage.mobileSecurity.tenantId.input.isDisabled()).toBe(false); expect(await systemConsolePage.mobileSecurity.clientId.input.isDisabled()).toBe(false); }); + +/** + * @objective Verify timer settings are disabled when Mobile Ephemeral Mode is not enabled, and become editable when enabled + */ +test( + 'should disable Mobile Ephemeral Mode sub-settings when toggle is off and enable them when toggle is on', + {tag: '@mobile_ephemeral_mode'}, + async ({pw}) => { + const {adminUser, adminClient} = await pw.initSetup(); + + const license = await adminClient.getClientLicenseOld(); + + test.skip( + license.SkuShortName !== 'advanced', + 'Skipping test - server does not have enterprise advanced license', + ); + + const config = await adminClient.getConfig(); + test.skip( + config.FeatureFlags.MobileEphemeralMode !== true && config.FeatureFlags.MobileEphemeralMode !== 'true', + 'Skipping test - MobileEphemeralMode feature flag is not enabled on the server', + ); + + if (!adminUser) { + throw new Error('Failed to create admin user'); + } + + // # Log in as admin + const {systemConsolePage} = await pw.testBrowser.login(adminUser); + + // # Visit system console + await systemConsolePage.goto(); + await systemConsolePage.toBeVisible(); + + // # Go to Mobile Security section + await systemConsolePage.sidebar.mobileSecurity.click(); + await systemConsolePage.mobileSecurity.toBeVisible(); + + // * Verify Mobile Ephemeral Mode toggle is off by default + await systemConsolePage.mobileSecurity.enableMobileEphemeralMode.toBeFalse(); + + // * Verify all sub-settings are disabled + expect(await systemConsolePage.mobileSecurity.disconnectionTimeout.input.isDisabled()).toBe(true); + expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.input.isDisabled()).toBe(true); + expect(await systemConsolePage.mobileSecurity.autoCacheCleanup.input.isDisabled()).toBe(true); + + // # Enable Mobile Ephemeral Mode toggle + await systemConsolePage.mobileSecurity.enableMobileEphemeralMode.selectTrue(); + + // * Verify all sub-settings are now enabled + expect(await systemConsolePage.mobileSecurity.disconnectionTimeout.input.isDisabled()).toBe(false); + expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.input.isDisabled()).toBe(false); + expect(await systemConsolePage.mobileSecurity.autoCacheCleanup.input.isDisabled()).toBe(false); + }, +); + +/** + * @objective Verify all Mobile Ephemeral Mode settings persist after save and navigation + */ +test( + 'should save and persist all Mobile Ephemeral Mode settings after navigation', + {tag: '@mobile_ephemeral_mode'}, + async ({pw}) => { + const {adminUser, adminClient} = await pw.initSetup(); + + const license = await adminClient.getClientLicenseOld(); + + test.skip( + license.SkuShortName !== 'advanced', + 'Skipping test - server does not have enterprise advanced license', + ); + + const config = await adminClient.getConfig(); + test.skip( + config.FeatureFlags.MobileEphemeralMode !== true && config.FeatureFlags.MobileEphemeralMode !== 'true', + 'Skipping test - MobileEphemeralMode feature flag is not enabled on the server', + ); + + if (!adminUser) { + throw new Error('Failed to create admin user'); + } + + // # Enable Mobile Ephemeral Mode setting via config API + config.MobileEphemeralModeSettings.Enable = true; + await adminClient.updateConfig(config); + + // # Log in as admin + const {systemConsolePage} = await pw.testBrowser.login(adminUser); + + // # Visit system console + await systemConsolePage.goto(); + await systemConsolePage.toBeVisible(); + + // # Go to Mobile Security section + await systemConsolePage.sidebar.mobileSecurity.click(); + await systemConsolePage.mobileSecurity.toBeVisible(); + + // # Set custom values + await systemConsolePage.mobileSecurity.disconnectionTimeout.fill('120'); + await systemConsolePage.mobileSecurity.offlinePersistenceTimer.fill('48'); + await systemConsolePage.mobileSecurity.autoCacheCleanup.fill('14'); + + // # Save settings + await systemConsolePage.mobileSecurity.save(); + await pw.waitUntil(async () => (await systemConsolePage.mobileSecurity.saveButton.textContent()) === 'Save'); + + // # Navigate away and back + await systemConsolePage.sidebar.users.click(); + await systemConsolePage.users.toBeVisible(); + await systemConsolePage.sidebar.mobileSecurity.click(); + await systemConsolePage.mobileSecurity.toBeVisible(); + + // * Verify Mobile Ephemeral Mode is still enabled + await systemConsolePage.mobileSecurity.enableMobileEphemeralMode.toBeTrue(); + + // * Verify all values persisted correctly + expect(await systemConsolePage.mobileSecurity.disconnectionTimeout.getValue()).toBe('120'); + expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.getValue()).toBe('48'); + expect(await systemConsolePage.mobileSecurity.autoCacheCleanup.getValue()).toBe('14'); + }, +); + +/** + * @objective Verify offline persistence timer is disabled when auto cache cleanup is set to 0 (zero-persistence mode) + */ +test( + 'should disable offline persistence timer when auto cache cleanup is set to zero', + {tag: '@mobile_ephemeral_mode'}, + async ({pw}) => { + const {adminUser, adminClient} = await pw.initSetup(); + + const license = await adminClient.getClientLicenseOld(); + + test.skip( + license.SkuShortName !== 'advanced', + 'Skipping test - server does not have enterprise advanced license', + ); + + const config = await adminClient.getConfig(); + test.skip( + config.FeatureFlags.MobileEphemeralMode !== true && config.FeatureFlags.MobileEphemeralMode !== 'true', + 'Skipping test - MobileEphemeralMode feature flag is not enabled on the server', + ); + + if (!adminUser) { + throw new Error('Failed to create admin user'); + } + + // # Enable Mobile Ephemeral Mode setting via config API + config.MobileEphemeralModeSettings.Enable = true; + await adminClient.updateConfig(config); + + // # Log in as admin + const {systemConsolePage} = await pw.testBrowser.login(adminUser); + + // # Visit system console + await systemConsolePage.goto(); + await systemConsolePage.toBeVisible(); + + // # Go to Mobile Security section + await systemConsolePage.sidebar.mobileSecurity.click(); + await systemConsolePage.mobileSecurity.toBeVisible(); + + // * Verify offline persistence timer is enabled + expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.input.isDisabled()).toBe(false); + + // # Set auto cache cleanup to 0 + await systemConsolePage.mobileSecurity.autoCacheCleanup.clear(); + await systemConsolePage.mobileSecurity.autoCacheCleanup.fill('0'); + + // * Verify offline persistence timer is now disabled + expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.input.isDisabled()).toBe(true); + + // # Set auto cache cleanup back to 7 + await systemConsolePage.mobileSecurity.autoCacheCleanup.clear(); + await systemConsolePage.mobileSecurity.autoCacheCleanup.fill('7'); + + // * Verify offline persistence timer is enabled again + expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.input.isDisabled()).toBe(false); + }, +); + +/** + * @objective Verify Mobile Ephemeral Mode settings show correct defaults on first enable + */ +test( + 'should show correct default values when Mobile Ephemeral Mode is first enabled', + {tag: '@mobile_ephemeral_mode'}, + async ({pw}) => { + const {adminUser, adminClient} = await pw.initSetup(); + + const license = await adminClient.getClientLicenseOld(); + + test.skip( + license.SkuShortName !== 'advanced', + 'Skipping test - server does not have enterprise advanced license', + ); + + const config = await adminClient.getConfig(); + test.skip( + config.FeatureFlags.MobileEphemeralMode !== true && config.FeatureFlags.MobileEphemeralMode !== 'true', + 'Skipping test - MobileEphemeralMode feature flag is not enabled on the server', + ); + + if (!adminUser) { + throw new Error('Failed to create admin user'); + } + + // # Log in as admin + const {systemConsolePage} = await pw.testBrowser.login(adminUser); + + // # Visit system console + await systemConsolePage.goto(); + await systemConsolePage.toBeVisible(); + + // # Go to Mobile Security section + await systemConsolePage.sidebar.mobileSecurity.click(); + await systemConsolePage.mobileSecurity.toBeVisible(); + + // # Enable Mobile Ephemeral Mode + await systemConsolePage.mobileSecurity.enableMobileEphemeralMode.selectTrue(); + + // * Verify default values + expect(await systemConsolePage.mobileSecurity.disconnectionTimeout.getValue()).toBe('60'); + expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.getValue()).toBe('24'); + expect(await systemConsolePage.mobileSecurity.autoCacheCleanup.getValue()).toBe('7'); + }, +); diff --git a/server/config/client.go b/server/config/client.go index d56c9d7e70e..5b255f1af97 100644 --- a/server/config/client.go +++ b/server/config/client.go @@ -255,6 +255,20 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li props["AutoTranslationLanguages"] = "" } props["RestrictDMAndGMAutotranslation"] = strconv.FormatBool(*c.AutoTranslationSettings.RestrictDMAndGM) + + if c.FeatureFlags.MobileEphemeralMode { + ephemeralEnabled := c.MobileEphemeralModeSettings.Enable != nil && *c.MobileEphemeralModeSettings.Enable + props["MobileEphemeralModeEnabled"] = strconv.FormatBool(ephemeralEnabled) + if c.MobileEphemeralModeSettings.DisconnectionTimeoutSeconds != nil { + props["MobileEphemeralModeDisconnectionTimeoutSeconds"] = strconv.Itoa(*c.MobileEphemeralModeSettings.DisconnectionTimeoutSeconds) + } + if c.MobileEphemeralModeSettings.OfflinePersistenceTimerHours != nil { + props["MobileEphemeralModeOfflinePersistenceTimerHours"] = strconv.Itoa(*c.MobileEphemeralModeSettings.OfflinePersistenceTimerHours) + } + if c.MobileEphemeralModeSettings.AutoCacheCleanupDays != nil { + props["MobileEphemeralModeAutoCacheCleanupDays"] = strconv.Itoa(*c.MobileEphemeralModeSettings.AutoCacheCleanupDays) + } + } } } diff --git a/server/config/client_test.go b/server/config/client_test.go index 159d80a8a03..53a9511fd86 100644 --- a/server/config/client_test.go +++ b/server/config/client_test.go @@ -20,6 +20,7 @@ func TestGetClientConfig(t *testing.T) { telemetryID string license *model.License expectedFields map[string]string + absentFields []string }{ { "unlicensed", @@ -48,6 +49,7 @@ func TestGetClientConfig(t *testing.T) { "WebsocketPort": "80", "WebsocketSecurePort": "443", }, + nil, }, { "licensed, but not for theme management", @@ -71,6 +73,7 @@ func TestGetClientConfig(t *testing.T) { "EmailNotificationContentsType": "full", "AllowCustomThemes": "true", }, + nil, }, { "licensed for theme management", @@ -93,6 +96,7 @@ func TestGetClientConfig(t *testing.T) { "EmailNotificationContentsType": "full", "AllowCustomThemes": "false", }, + nil, }, { "licensed for enforcement", @@ -110,6 +114,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "EnforceMultifactorAuthentication": "true", }, + nil, }, { "default marketplace", @@ -123,6 +128,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "IsDefaultMarketplace": "true", }, + nil, }, { "non-default marketplace", @@ -136,6 +142,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "IsDefaultMarketplace": "false", }, + nil, }, { "enable ShowFullName prop", @@ -149,6 +156,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "ShowFullName": "true", }, + nil, }, { "enable UseAnonymousURLs prop", @@ -162,6 +170,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "UseAnonymousURLs": "true", }, + nil, }, { "Custom groups professional license", @@ -174,6 +183,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "EnableCustomGroups": "true", }, + nil, }, { "Custom groups enterprise license", @@ -186,6 +196,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "EnableCustomGroups": "true", }, + nil, }, { "Custom groups other license", @@ -198,6 +209,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "EnableCustomGroups": "false", }, + nil, }, { "Shared channels other license", @@ -216,6 +228,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "ExperimentalSharedChannels": "false", }, + nil, }, { "licensed for shared channels", @@ -234,6 +247,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "ExperimentalSharedChannels": "true", }, + nil, }, { "Shared channels professional license", @@ -252,6 +266,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "ExperimentalSharedChannels": "true", }, + nil, }, { "disable EnableUserStatuses", @@ -265,6 +280,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "EnableUserStatuses": "false", }, + nil, }, { "Shared channels enterprise license", @@ -283,6 +299,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "ExperimentalSharedChannels": "true", }, + nil, }, { "Disable App Bar", @@ -296,6 +313,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "DisableAppBar": "true", }, + nil, }, { "default EnableJoinLeaveMessage", @@ -305,6 +323,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "EnableJoinLeaveMessageByDefault": "true", }, + nil, }, { "disable EnableJoinLeaveMessage", @@ -318,6 +337,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "EnableJoinLeaveMessageByDefault": "false", }, + nil, }, { "test key for GiphySdkKey", @@ -331,6 +351,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "GiphySdkKey": model.ServiceSettingsDefaultGiphySdkKeyTest, }, + nil, }, { "report a problem values", @@ -350,6 +371,7 @@ func TestGetClientConfig(t *testing.T) { "ReportAProblemMail": "mail", "AllowDownloadLogs": "true", }, + nil, }, { "access control settings enabled", @@ -365,6 +387,7 @@ func TestGetClientConfig(t *testing.T) { "EnableAttributeBasedAccessControl": "true", "EnableUserManagedAttributes": "true", }, + nil, }, { "access control settings disabled", @@ -380,6 +403,7 @@ func TestGetClientConfig(t *testing.T) { "EnableAttributeBasedAccessControl": "false", "EnableUserManagedAttributes": "false", }, + nil, }, { "access control settings default", @@ -390,6 +414,7 @@ func TestGetClientConfig(t *testing.T) { "EnableAttributeBasedAccessControl": "false", "EnableUserManagedAttributes": "false", }, + nil, }, { "burn on read enabled", @@ -405,6 +430,7 @@ func TestGetClientConfig(t *testing.T) { "EnableBurnOnRead": "true", "BurnOnReadDurationSeconds": "1800", }, + nil, }, { "burn on read disabled", @@ -420,6 +446,7 @@ func TestGetClientConfig(t *testing.T) { "EnableBurnOnRead": "false", "BurnOnReadDurationSeconds": "600", }, + nil, }, { "burn on read default", @@ -430,6 +457,7 @@ func TestGetClientConfig(t *testing.T) { "EnableBurnOnRead": "true", "BurnOnReadDurationSeconds": "600", // 10 minutes in seconds }, + nil, }, { "mobile watermark uses experimental settings", @@ -446,6 +474,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "ExperimentalEnableWatermark": "true", }, + nil, }, { "Intune MAM enabled with Enterprise Advanced license and Office365 AuthService", @@ -466,6 +495,7 @@ func TestGetClientConfig(t *testing.T) { "IntuneMAMEnabled": "true", "IntuneScope": "api://87654321-4321-4321-4321-210987654321/login.mattermost", }, + nil, }, { "Intune MAM disabled when not enabled", @@ -485,6 +515,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "IntuneMAMEnabled": "false", }, + nil, }, { "Intune MAM disabled when TenantId is missing", @@ -504,6 +535,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "IntuneMAMEnabled": "false", }, + nil, }, { "Intune MAM disabled when ClientId is missing", @@ -523,6 +555,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "IntuneMAMEnabled": "false", }, + nil, }, { "Intune MAM not exposed with lower license tier", @@ -540,6 +573,7 @@ func TestGetClientConfig(t *testing.T) { SkuShortName: model.LicenseShortSkuProfessional, }, map[string]string{}, + []string{"IntuneMAMEnabled", "IntuneScope"}, }, { "Intune MAM not exposed without license", @@ -554,6 +588,7 @@ func TestGetClientConfig(t *testing.T) { "", nil, map[string]string{}, + []string{"IntuneMAMEnabled", "IntuneScope"}, }, { "Intune MAM enabled with Enterprise Advanced license and SAML AuthService", @@ -578,6 +613,7 @@ func TestGetClientConfig(t *testing.T) { "IntuneScope": "api://87654321-4321-4321-4321-210987654321/login.mattermost", "IntuneAuthService": "saml", }, + nil, }, { "Intune MAM disabled when AuthService is missing", @@ -597,6 +633,100 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "IntuneMAMEnabled": "false", }, + nil, + }, + { + "Mobile Ephemeral Mode enabled with custom values", + &model.Config{ + FeatureFlags: &model.FeatureFlags{MobileEphemeralMode: true}, + MobileEphemeralModeSettings: model.MobileEphemeralModeSettings{ + Enable: model.NewPointer(true), + DisconnectionTimeoutSeconds: model.NewPointer(120), + OfflinePersistenceTimerHours: model.NewPointer(48), + AutoCacheCleanupDays: model.NewPointer(14), + }, + }, + "", + &model.License{ + Features: &model.Features{}, + SkuShortName: model.LicenseShortSkuEnterpriseAdvanced, + }, + map[string]string{ + "MobileEphemeralModeEnabled": "true", + "MobileEphemeralModeDisconnectionTimeoutSeconds": "120", + "MobileEphemeralModeOfflinePersistenceTimerHours": "48", + "MobileEphemeralModeAutoCacheCleanupDays": "14", + }, + nil, + }, + { + "Mobile Ephemeral Mode disabled still exposes parameters", + &model.Config{ + FeatureFlags: &model.FeatureFlags{MobileEphemeralMode: true}, + MobileEphemeralModeSettings: model.MobileEphemeralModeSettings{ + Enable: model.NewPointer(false), + DisconnectionTimeoutSeconds: model.NewPointer(60), + OfflinePersistenceTimerHours: model.NewPointer(24), + AutoCacheCleanupDays: model.NewPointer(7), + }, + }, + "", + &model.License{ + Features: &model.Features{}, + SkuShortName: model.LicenseShortSkuEnterpriseAdvanced, + }, + map[string]string{ + "MobileEphemeralModeEnabled": "false", + "MobileEphemeralModeDisconnectionTimeoutSeconds": "60", + "MobileEphemeralModeOfflinePersistenceTimerHours": "24", + "MobileEphemeralModeAutoCacheCleanupDays": "7", + }, + nil, + }, + { + "Mobile Ephemeral Mode not exposed when feature flag is off", + &model.Config{ + FeatureFlags: &model.FeatureFlags{MobileEphemeralMode: false}, + MobileEphemeralModeSettings: model.MobileEphemeralModeSettings{ + Enable: model.NewPointer(true), + }, + }, + "", + &model.License{ + Features: &model.Features{}, + SkuShortName: model.LicenseShortSkuEnterpriseAdvanced, + }, + map[string]string{}, + []string{"MobileEphemeralModeEnabled", "MobileEphemeralModeDisconnectionTimeoutSeconds", "MobileEphemeralModeOfflinePersistenceTimerHours", "MobileEphemeralModeAutoCacheCleanupDays"}, + }, + { + "Mobile Ephemeral Mode not exposed without license", + &model.Config{ + FeatureFlags: &model.FeatureFlags{MobileEphemeralMode: true}, + MobileEphemeralModeSettings: model.MobileEphemeralModeSettings{ + Enable: model.NewPointer(true), + }, + }, + "", + nil, + map[string]string{}, + []string{"MobileEphemeralModeEnabled", "MobileEphemeralModeDisconnectionTimeoutSeconds", "MobileEphemeralModeOfflinePersistenceTimerHours", "MobileEphemeralModeAutoCacheCleanupDays"}, + }, + { + "Mobile Ephemeral Mode not exposed with lower license tier", + &model.Config{ + FeatureFlags: &model.FeatureFlags{MobileEphemeralMode: true}, + MobileEphemeralModeSettings: model.MobileEphemeralModeSettings{ + Enable: model.NewPointer(true), + }, + }, + "", + &model.License{ + Features: &model.Features{}, + SkuShortName: model.LicenseShortSkuProfessional, + }, + map[string]string{}, + []string{"MobileEphemeralModeEnabled", "MobileEphemeralModeDisconnectionTimeoutSeconds", "MobileEphemeralModeOfflinePersistenceTimerHours", "MobileEphemeralModeAutoCacheCleanupDays"}, }, } @@ -616,6 +746,10 @@ func TestGetClientConfig(t *testing.T) { assert.Equal(t, expectedValue, actualValue) } } + for _, absentField := range testCase.absentFields { + _, ok := configMap[absentField] + assert.False(t, ok, fmt.Sprintf("config should not contain %v", absentField)) + } }) } } diff --git a/server/i18n/en.json b/server/i18n/en.json index 01bb249870f..1f58855805c 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -11542,6 +11542,18 @@ "id": "model.config.is_valid.minimum_desktop_app_version.app_error", "translation": "Invalid version number. Must be a valid semantic version (e.g. 5.0.0)." }, + { + "id": "model.config.is_valid.mobile_ephemeral_mode.auto_cache_cleanup.app_error", + "translation": "Invalid Auto Cache Cleanup value. Must be between {{.Min}} and {{.Max}} days." + }, + { + "id": "model.config.is_valid.mobile_ephemeral_mode.disconnection_timeout.app_error", + "translation": "Invalid Disconnection Timeout value. Must be between {{.Min}} and {{.Max}} seconds." + }, + { + "id": "model.config.is_valid.mobile_ephemeral_mode.offline_persistence.app_error", + "translation": "Invalid Offline Persistence Timer value. Must be between {{.Min}} and {{.Max}} hours." + }, { "id": "model.config.is_valid.move_thread.domain_invalid.app_error", "translation": "Invalid domain for move thread settings" diff --git a/server/public/model/config.go b/server/public/model/config.go index e04fe346529..f001937219e 100644 --- a/server/public/model/config.go +++ b/server/public/model/config.go @@ -3442,6 +3442,61 @@ func (s *DataRetentionSettings) GetFileRetentionHours() int { return DataRetentionSettingsDefaultFileRetentionDays * 24 } +const ( + MobileEphemeralModeDefaultDisconnectionTimeoutSeconds = 60 + MobileEphemeralModeDefaultOfflinePersistenceTimerHours = 24 + MobileEphemeralModeDefaultAutoCacheCleanupDays = 7 + + MobileEphemeralModeMaxDisconnectionTimeoutSeconds = 600 + MobileEphemeralModeMaxOfflinePersistenceTimerHours = 72 + MobileEphemeralModeMaxAutoCacheCleanupDays = 60 +) + +type MobileEphemeralModeSettings struct { + Enable *bool `access:"environment_mobile_security"` + DisconnectionTimeoutSeconds *int `access:"environment_mobile_security"` + OfflinePersistenceTimerHours *int `access:"environment_mobile_security"` + AutoCacheCleanupDays *int `access:"environment_mobile_security"` +} + +func (s *MobileEphemeralModeSettings) SetDefaults() { + if s.Enable == nil { + s.Enable = NewPointer(false) + } + if s.DisconnectionTimeoutSeconds == nil { + s.DisconnectionTimeoutSeconds = NewPointer(MobileEphemeralModeDefaultDisconnectionTimeoutSeconds) + } + if s.OfflinePersistenceTimerHours == nil { + s.OfflinePersistenceTimerHours = NewPointer(MobileEphemeralModeDefaultOfflinePersistenceTimerHours) + } + if s.AutoCacheCleanupDays == nil { + s.AutoCacheCleanupDays = NewPointer(MobileEphemeralModeDefaultAutoCacheCleanupDays) + } +} + +func (s *MobileEphemeralModeSettings) isValid() *AppError { + if s.Enable == nil || !*s.Enable { + return nil + } + + if s.DisconnectionTimeoutSeconds == nil || *s.DisconnectionTimeoutSeconds < 0 || *s.DisconnectionTimeoutSeconds > MobileEphemeralModeMaxDisconnectionTimeoutSeconds { + return NewAppError("Config.IsValid", "model.config.is_valid.mobile_ephemeral_mode.disconnection_timeout.app_error", + map[string]any{"Min": 0, "Max": MobileEphemeralModeMaxDisconnectionTimeoutSeconds}, "", http.StatusBadRequest) + } + + if s.OfflinePersistenceTimerHours == nil || *s.OfflinePersistenceTimerHours < 0 || *s.OfflinePersistenceTimerHours > MobileEphemeralModeMaxOfflinePersistenceTimerHours { + return NewAppError("Config.IsValid", "model.config.is_valid.mobile_ephemeral_mode.offline_persistence.app_error", + map[string]any{"Min": 0, "Max": MobileEphemeralModeMaxOfflinePersistenceTimerHours}, "", http.StatusBadRequest) + } + + if s.AutoCacheCleanupDays == nil || *s.AutoCacheCleanupDays < 0 || *s.AutoCacheCleanupDays > MobileEphemeralModeMaxAutoCacheCleanupDays { + return NewAppError("Config.IsValid", "model.config.is_valid.mobile_ephemeral_mode.auto_cache_cleanup.app_error", + map[string]any{"Min": 0, "Max": MobileEphemeralModeMaxAutoCacheCleanupDays}, "", http.StatusBadRequest) + } + + return nil +} + type JobSettings struct { RunJobs *bool `access:"write_restrictable,cloud_restrictable"` // telemetry: none RunScheduler *bool `access:"write_restrictable,cloud_restrictable"` // telemetry: none @@ -4079,6 +4134,7 @@ type Config struct { AnalyticsSettings AnalyticsSettings ElasticsearchSettings ElasticsearchSettings DataRetentionSettings DataRetentionSettings + MobileEphemeralModeSettings MobileEphemeralModeSettings MessageExportSettings MessageExportSettings JobSettings JobSettings PluginSettings PluginSettings @@ -4194,6 +4250,7 @@ func (o *Config) SetDefaults() { o.NativeAppSettings.SetDefaults() o.IntuneSettings.SetDefaults() o.DataRetentionSettings.SetDefaults() + o.MobileEphemeralModeSettings.SetDefaults() o.RateLimitSettings.SetDefaults() o.LogSettings.SetDefaults() o.ExperimentalAuditSettings.SetDefaults() @@ -4373,6 +4430,10 @@ func (o *Config) IsValid() *AppError { return appErr } + if appErr := o.MobileEphemeralModeSettings.isValid(); appErr != nil { + return appErr + } + if appErr := o.GuestAccountsSettings.IsValid(); appErr != nil { return appErr } diff --git a/server/public/model/config_test.go b/server/public/model/config_test.go index 41cf045f181..96859087064 100644 --- a/server/public/model/config_test.go +++ b/server/public/model/config_test.go @@ -2975,6 +2975,121 @@ func TestConfigAccessTagsMapToValidPermissions(t *testing.T) { checkStruct(t, reflect.TypeFor[Config](), "Config") } +func TestMobileEphemeralModeSettingsDefaults(t *testing.T) { + c := Config{} + c.SetDefaults() + + require.False(t, *c.MobileEphemeralModeSettings.Enable) + require.Equal(t, MobileEphemeralModeDefaultDisconnectionTimeoutSeconds, *c.MobileEphemeralModeSettings.DisconnectionTimeoutSeconds) + require.Equal(t, MobileEphemeralModeDefaultOfflinePersistenceTimerHours, *c.MobileEphemeralModeSettings.OfflinePersistenceTimerHours) + require.Equal(t, MobileEphemeralModeDefaultAutoCacheCleanupDays, *c.MobileEphemeralModeSettings.AutoCacheCleanupDays) +} + +func TestMobileEphemeralModeSettingsIsValid(t *testing.T) { + testCases := []struct { + name string + settings MobileEphemeralModeSettings + expectError bool + errorId string + }{ + { + name: "disabled settings should be valid", + settings: MobileEphemeralModeSettings{ + Enable: NewPointer(false), + }, + expectError: false, + }, + { + name: "enabled with valid values", + settings: MobileEphemeralModeSettings{ + Enable: NewPointer(true), + DisconnectionTimeoutSeconds: NewPointer(120), + OfflinePersistenceTimerHours: NewPointer(24), + AutoCacheCleanupDays: NewPointer(7), + }, + expectError: false, + }, + { + name: "invalid disconnection timeout above max", + settings: MobileEphemeralModeSettings{ + Enable: NewPointer(true), + DisconnectionTimeoutSeconds: NewPointer(MobileEphemeralModeMaxDisconnectionTimeoutSeconds + 1), + OfflinePersistenceTimerHours: NewPointer(0), + AutoCacheCleanupDays: NewPointer(0), + }, + expectError: true, + errorId: "model.config.is_valid.mobile_ephemeral_mode.disconnection_timeout.app_error", + }, + { + name: "invalid offline persistence above max", + settings: MobileEphemeralModeSettings{ + Enable: NewPointer(true), + DisconnectionTimeoutSeconds: NewPointer(60), + OfflinePersistenceTimerHours: NewPointer(MobileEphemeralModeMaxOfflinePersistenceTimerHours + 1), + AutoCacheCleanupDays: NewPointer(0), + }, + expectError: true, + errorId: "model.config.is_valid.mobile_ephemeral_mode.offline_persistence.app_error", + }, + { + name: "invalid auto cache cleanup above max", + settings: MobileEphemeralModeSettings{ + Enable: NewPointer(true), + DisconnectionTimeoutSeconds: NewPointer(60), + OfflinePersistenceTimerHours: NewPointer(0), + AutoCacheCleanupDays: NewPointer(MobileEphemeralModeMaxAutoCacheCleanupDays + 1), + }, + expectError: true, + errorId: "model.config.is_valid.mobile_ephemeral_mode.auto_cache_cleanup.app_error", + }, + { + name: "invalid negative disconnection timeout", + settings: MobileEphemeralModeSettings{ + Enable: NewPointer(true), + DisconnectionTimeoutSeconds: NewPointer(-1), + OfflinePersistenceTimerHours: NewPointer(0), + AutoCacheCleanupDays: NewPointer(0), + }, + expectError: true, + errorId: "model.config.is_valid.mobile_ephemeral_mode.disconnection_timeout.app_error", + }, + { + name: "invalid negative offline persistence", + settings: MobileEphemeralModeSettings{ + Enable: NewPointer(true), + DisconnectionTimeoutSeconds: NewPointer(60), + OfflinePersistenceTimerHours: NewPointer(-1), + AutoCacheCleanupDays: NewPointer(0), + }, + expectError: true, + errorId: "model.config.is_valid.mobile_ephemeral_mode.offline_persistence.app_error", + }, + { + name: "invalid negative auto cache cleanup", + settings: MobileEphemeralModeSettings{ + Enable: NewPointer(true), + DisconnectionTimeoutSeconds: NewPointer(60), + OfflinePersistenceTimerHours: NewPointer(0), + AutoCacheCleanupDays: NewPointer(-1), + }, + expectError: true, + errorId: "model.config.is_valid.mobile_ephemeral_mode.auto_cache_cleanup.app_error", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := tc.settings.isValid() + if tc.expectError { + require.NotNil(t, err) + require.Equal(t, tc.errorId, err.Id) + } else { + require.Nil(t, err) + } + }) + } +} + func TestNativeAppSettingsIsValid(t *testing.T) { t.Run("defaults are valid", func(t *testing.T) { cfg := Config{} diff --git a/server/public/model/feature_flags.go b/server/public/model/feature_flags.go index 68465f32025..3b0e698d168 100644 --- a/server/public/model/feature_flags.go +++ b/server/public/model/feature_flags.go @@ -125,6 +125,9 @@ type FeatureFlags struct { // Gates the per-channel Discoverable toggle and the channel-join-request flow that lets // non-members find a private channel in Browse Channels and request to join it. DiscoverableChannels bool + + // Enable Mobile Ephemeral Mode for controlling data persistence on mobile devices + MobileEphemeralMode bool } func (f *FeatureFlags) SetDefaults() { @@ -183,6 +186,8 @@ func (f *FeatureFlags) SetDefaults() { f.ManagedChannelCategories = false f.DiscoverableChannels = false + + f.MobileEphemeralMode = false } // ToMap returns the feature flags as a map[string]string diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx index a62ea3cd26c..bcf95f93107 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition.tsx @@ -2373,6 +2373,76 @@ const AdminDefinition: AdminDefinitionType = { }, ], }, + { + key: 'MobileSecuritySettings.EphemeralMode', + title: 'Mobile Ephemeral Mode', + description: defineMessage({id: 'admin.mobileSecurity.sections.ephemeralMode.description', defaultMessage: 'Configure data persistence and cache management policies for mobile devices.'}), + license_sku: LicenseSkus.EnterpriseAdvanced, + component: LicensedSectionContainer, + componentProps: { + requiredSku: LicenseSkus.EnterpriseAdvanced, + featureDiscoveryConfig: { + featureName: 'mobile_ephemeral_mode', + title: defineMessage({id: 'admin.mobileSecurity.ephemeralMode_feature_discovery.title', defaultMessage: 'Control mobile data persistence with Mobile Ephemeral Mode'}), + description: defineMessage({id: 'admin.mobileSecurity.ephemeralMode_feature_discovery.description', defaultMessage: 'With Mattermost Enterprise Advanced, you can enable Mobile Ephemeral Mode to enforce data persistence policies on mobile devices. Configure disconnection timeouts, offline data retention, and automatic cache cleanup.'}), + learnMoreURL: 'https://docs.mattermost.com', + }, + }, + isHidden: it.configIsFalse('FeatureFlags', 'MobileEphemeralMode'), + settings: [ + { + type: 'banner', + label: defineMessage({id: 'admin.mobileSecurity.ephemeralMode.banner', defaultMessage: 'Changes to these settings are delivered to connected devices in real time. Offline devices will continue operating under their last-known settings until they re-establish a server connection. Timer state persists across app and device restarts.'}), + banner_type: 'info', + }, + { + type: 'bool', + key: 'MobileEphemeralModeSettings.Enable', + label: defineMessage({id: 'admin.mobileSecurity.ephemeralMode.enableTitle', defaultMessage: 'Enable Mobile Ephemeral Mode:'}), + help_text: defineMessage({id: 'admin.mobileSecurity.ephemeralMode.enableDescription', defaultMessage: 'When enabled, mobile clients will follow the server-configured ephemeral data policies. Disconnected devices will clean up cached data based on the configured timers.'}), + }, + { + type: 'number', + key: 'MobileEphemeralModeSettings.DisconnectionTimeoutSeconds', + label: defineMessage({id: 'admin.mobileSecurity.ephemeralMode.disconnectionTimeoutTitle', defaultMessage: 'Disconnection Timeout (seconds):'}), + help_text: defineMessage({id: 'admin.mobileSecurity.ephemeralMode.disconnectionTimeoutDescription', defaultMessage: 'Grace period after losing server connection before the device is considered offline. Helps avoid false triggers from brief network interruptions. Values below 5 are not recommended.'}), + placeholder: defineMessage({id: 'admin.mobileSecurity.ephemeralMode.disconnectionTimeout.placeholder', defaultMessage: 'E.g.: 60'}), + isDisabled: it.stateIsFalse('MobileEphemeralModeSettings.Enable'), + validate: validators.numberInRange(0, 600, defineMessage({ + id: 'admin.mobileSecurity.ephemeralMode.disconnectionTimeout.range', + defaultMessage: 'Must be a number between 0 and 600 seconds (10 minutes).', + })), + }, + { + type: 'number', + key: 'MobileEphemeralModeSettings.OfflinePersistenceTimerHours', + label: defineMessage({id: 'admin.mobileSecurity.ephemeralMode.offlinePersistenceTitle', defaultMessage: 'Offline Persistence Timer (hours):'}), + help_text: defineMessage({id: 'admin.mobileSecurity.ephemeralMode.offlinePersistenceDescription', defaultMessage: 'How long cached content is kept after the device goes offline. When the timer expires, cached content is deleted but session credentials are preserved. Set to 0 for immediate cleanup.'}), + disabled_help_text: defineMessage({id: 'admin.mobileSecurity.ephemeralMode.offlinePersistence.disabled', defaultMessage: 'How long cached content is kept after the device goes offline. When the timer expires, cached content is deleted but session credentials are preserved. Set to 0 for immediate cleanup. Requires Mobile Ephemeral Mode to be enabled and Auto Cache Cleanup to be greater than 0.'}), + placeholder: defineMessage({id: 'admin.mobileSecurity.ephemeralMode.offlinePersistence.placeholder', defaultMessage: 'E.g.: 24'}), + isDisabled: it.any( + it.stateIsFalse('MobileEphemeralModeSettings.Enable'), + it.stateEquals('MobileEphemeralModeSettings.AutoCacheCleanupDays', 0), + ), + validate: validators.numberInRange(0, 72, defineMessage({ + id: 'admin.mobileSecurity.ephemeralMode.offlinePersistence.range', + defaultMessage: 'Must be a number between 0 and 72 hours (3 days).', + })), + }, + { + type: 'number', + key: 'MobileEphemeralModeSettings.AutoCacheCleanupDays', + label: defineMessage({id: 'admin.mobileSecurity.ephemeralMode.autoCacheCleanupTitle', defaultMessage: 'Auto Cache Cleanup (days):'}), + help_text: defineMessage({id: 'admin.mobileSecurity.ephemeralMode.autoCacheCleanupDescription', defaultMessage: 'Controls the maximum age of any content cached on the device, regardless of connection status. Prevents unbounded accumulation of sensitive data. Set to 0 for zero-persistence mode where content is never persisted to disk.'}), + placeholder: defineMessage({id: 'admin.mobileSecurity.ephemeralMode.autoCacheCleanup.placeholder', defaultMessage: 'E.g.: 7'}), + isDisabled: it.stateIsFalse('MobileEphemeralModeSettings.Enable'), + validate: validators.numberInRange(0, 60, defineMessage({ + id: 'admin.mobileSecurity.ephemeralMode.autoCacheCleanup.range', + defaultMessage: 'Must be a number between 0 and 60 days.', + })), + }, + ], + }, ], }, }, diff --git a/webapp/channels/src/components/admin_console/admin_definition_helpers.test.tsx b/webapp/channels/src/components/admin_console/admin_definition_helpers.test.tsx index 963b2279b14..91a51483d0d 100644 --- a/webapp/channels/src/components/admin_console/admin_definition_helpers.test.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition_helpers.test.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {it} from './admin_definition_helpers'; +import {it, validators} from './admin_definition_helpers'; describe('AdminDefinitionHelpers - stateEqualsOrDefault', () => { test('should return true when state value equals expected value', () => { @@ -45,3 +45,22 @@ describe('AdminDefinitionHelpers - stateEqualsOrDefault', () => { expect(checker({}, undefinedStateWithDifferentExpected)).toBe(false); }); }); + +describe('AdminDefinitionHelpers - validators.numberInRange', () => { + const validate = validators.numberInRange(0, 60, 'out of range'); + + test('should return valid for in-range numbers', () => { + expect(validate(0).isValid()).toBe(true); + expect(validate(30).isValid()).toBe(true); + expect(validate(60).isValid()).toBe(true); + }); + + test('should return invalid for out-of-range numbers', () => { + expect(validate(-1).isValid()).toBe(false); + expect(validate(61).isValid()).toBe(false); + }); + + test('should return valid for NaN since the server backfills empty inputs with defaults', () => { + expect(validate(NaN).isValid()).toBe(true); + }); +}); diff --git a/webapp/channels/src/components/admin_console/admin_definition_helpers.tsx b/webapp/channels/src/components/admin_console/admin_definition_helpers.tsx index e4c15053bd5..ca914b01e5d 100644 --- a/webapp/channels/src/components/admin_console/admin_definition_helpers.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition_helpers.tsx @@ -74,6 +74,7 @@ export const validators = { isRequired: (text: MessageDescriptor | string) => (value: string) => new ValidationResult(Boolean(value), text), minValue: (min: number, text: MessageDescriptor | string) => (value: number) => new ValidationResult((value >= min), text), maxValue: (max: number, text: MessageDescriptor | string) => (value: number) => new ValidationResult((value <= max), text), + numberInRange: (min: number, max: number, text: MessageDescriptor | string) => (value: number) => new ValidationResult(Number.isNaN(value) || (value >= min && value <= max), text), }; export const usesLegacyOauth = (config: Partial, state: any, license?: ClientLicense, enterpriseReady?: boolean, consoleAccess?: ConsoleAccess, cloud?: CloudState) => { diff --git a/webapp/channels/src/components/admin_console/admin_definition_mobile_ephemeral_mode.test.tsx b/webapp/channels/src/components/admin_console/admin_definition_mobile_ephemeral_mode.test.tsx new file mode 100644 index 00000000000..13691dfff88 --- /dev/null +++ b/webapp/channels/src/components/admin_console/admin_definition_mobile_ephemeral_mode.test.tsx @@ -0,0 +1,129 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {AdminConfig} from '@mattermost/types/config'; + +import {LicenseSkus} from 'utils/constants'; + +import AdminDefinition from './admin_definition'; +import type {AdminDefinitionSetting, AdminDefinitionConfigSchemaSection} from './types'; + +describe('AdminDefinition - Mobile Ephemeral Mode Settings', () => { + const getEphemeralModeSections = () => { + const mobileSecuritySection = AdminDefinition.environment.subsections.mobile_security; + const sections = 'sections' in mobileSecuritySection.schema! ? mobileSecuritySection.schema.sections : undefined; + return sections; + }; + + const getEphemeralModeSection = () => { + const sections = getEphemeralModeSections(); + return sections?.find((section: AdminDefinitionConfigSchemaSection) => section.key === 'MobileSecuritySettings.EphemeralMode'); + }; + + const getEphemeralModeSettings = () => { + const section = getEphemeralModeSection(); + return section?.settings || []; + }; + + test('should include Mobile Ephemeral Mode section in mobile_security', () => { + const section = getEphemeralModeSection(); + expect(section).toBeDefined(); + }); + + test('should include Enable setting', () => { + const settings = getEphemeralModeSettings(); + const enableSetting = settings.find((s: AdminDefinitionSetting) => s.key === 'MobileEphemeralModeSettings.Enable'); + + expect(enableSetting).toBeDefined(); + expect(enableSetting?.type).toBe('bool'); + expect(enableSetting?.label).toBeDefined(); + expect(enableSetting?.help_text).toBeDefined(); + }); + + test('should include info banner', () => { + const settings = getEphemeralModeSettings(); + const bannerSetting = settings.find((s: AdminDefinitionSetting) => s.type === 'banner'); + + expect(bannerSetting).toBeDefined(); + }); + + test('settings should have proper translation message descriptors', () => { + const settings = getEphemeralModeSettings(); + const settingsWithLabels = settings.filter((s: AdminDefinitionSetting) => s.key?.includes('MobileEphemeralMode')); + + settingsWithLabels.forEach((setting: AdminDefinitionSetting) => { + if (setting.label && typeof setting.label === 'object') { + expect('id' in setting.label).toBe(true); + expect('defaultMessage' in setting.label).toBe(true); + } + + if (setting.help_text && typeof setting.help_text === 'object' && !('$$typeof' in setting.help_text)) { + expect('id' in setting.help_text).toBe(true); + expect('defaultMessage' in setting.help_text).toBe(true); + } + }); + }); + + test('should use LicensedSectionContainer with Enterprise Advanced', () => { + const section = getEphemeralModeSection(); + + expect(section?.component).toBeDefined(); + expect(section?.license_sku).toBe(LicenseSkus.EnterpriseAdvanced); + expect(section?.componentProps).toBeDefined(); + expect(section?.componentProps?.requiredSku).toBe(LicenseSkus.EnterpriseAdvanced); + expect(section?.componentProps?.featureDiscoveryConfig).toBeDefined(); + expect(section?.componentProps?.featureDiscoveryConfig?.featureName).toBe('mobile_ephemeral_mode'); + }); + + test('isHidden should return true when feature flag is disabled', () => { + const section = getEphemeralModeSection(); + expect(section?.isHidden).toBeDefined(); + expect(typeof section?.isHidden).toBe('function'); + + const mockConfig: Partial = {FeatureFlags: {MobileEphemeralMode: false}}; + const isHiddenFn = section!.isHidden as (config: Partial) => boolean; + expect(isHiddenFn(mockConfig)).toBe(true); + }); + + test('isHidden should return false when feature flag is enabled', () => { + const section = getEphemeralModeSection(); + + const mockConfig: Partial = {FeatureFlags: {MobileEphemeralMode: true}}; + const isHiddenFn = section!.isHidden as (config: Partial) => boolean; + expect(isHiddenFn(mockConfig)).toBe(false); + }); + + test('should include DisconnectionTimeoutSeconds number setting', () => { + const settings = getEphemeralModeSettings(); + const setting = settings.find((s: AdminDefinitionSetting) => s.key === 'MobileEphemeralModeSettings.DisconnectionTimeoutSeconds'); + + expect(setting).toBeDefined(); + expect(setting?.type).toBe('number'); + expect(setting?.isDisabled).toBeDefined(); + }); + + test('should include OfflinePersistenceTimerHours number setting', () => { + const settings = getEphemeralModeSettings(); + const setting = settings.find((s: AdminDefinitionSetting) => s.key === 'MobileEphemeralModeSettings.OfflinePersistenceTimerHours'); + + expect(setting).toBeDefined(); + expect(setting?.type).toBe('number'); + expect(setting?.isDisabled).toBeDefined(); + }); + + test('should include AutoCacheCleanupDays number setting', () => { + const settings = getEphemeralModeSettings(); + const setting = settings.find((s: AdminDefinitionSetting) => s.key === 'MobileEphemeralModeSettings.AutoCacheCleanupDays'); + + expect(setting).toBeDefined(); + expect(setting?.type).toBe('number'); + expect(setting?.isDisabled).toBeDefined(); + }); + + test('OfflinePersistenceTimerHours should have disabled_help_text for zero-persistence mode', () => { + const settings = getEphemeralModeSettings(); + const setting = settings.find((s: AdminDefinitionSetting) => s.key === 'MobileEphemeralModeSettings.OfflinePersistenceTimerHours'); + + expect(setting?.disabled_help_text).toBeDefined(); + }); +}); diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 65f3402ac17..cc13c1b910c 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -1870,11 +1870,30 @@ "admin.mobileSecurity.allowPdfLinkNavigationTitle": "Allow Link Navigation in Secure PDFs:", "admin.mobileSecurity.biometricsDescription": "Enforces biometric authentication (with PIN/passcode fallback) before accessing the app. Users will be prompted based on session activity and server switching rules.", "admin.mobileSecurity.biometricsTitle": "Enable Biometric Authentication:", + "admin.mobileSecurity.ephemeralMode_feature_discovery.description": "With Mattermost Enterprise Advanced, you can enable Mobile Ephemeral Mode to enforce data persistence policies on mobile devices. Configure disconnection timeouts, offline data retention, and automatic cache cleanup.", + "admin.mobileSecurity.ephemeralMode_feature_discovery.title": "Control mobile data persistence with Mobile Ephemeral Mode", + "admin.mobileSecurity.ephemeralMode.autoCacheCleanup.placeholder": "E.g.: 7", + "admin.mobileSecurity.ephemeralMode.autoCacheCleanup.range": "Must be a number between 0 and 60 days.", + "admin.mobileSecurity.ephemeralMode.autoCacheCleanupDescription": "Controls the maximum age of any content cached on the device, regardless of connection status. Prevents unbounded accumulation of sensitive data. Set to 0 for zero-persistence mode where content is never persisted to disk.", + "admin.mobileSecurity.ephemeralMode.autoCacheCleanupTitle": "Auto Cache Cleanup (days):", + "admin.mobileSecurity.ephemeralMode.banner": "Changes to these settings are delivered to connected devices in real time. Offline devices will continue operating under their last-known settings until they re-establish a server connection. Timer state persists across app and device restarts.", + "admin.mobileSecurity.ephemeralMode.disconnectionTimeout.placeholder": "E.g.: 60", + "admin.mobileSecurity.ephemeralMode.disconnectionTimeout.range": "Must be a number between 0 and 600 seconds (10 minutes).", + "admin.mobileSecurity.ephemeralMode.disconnectionTimeoutDescription": "Grace period after losing server connection before the device is considered offline. Helps avoid false triggers from brief network interruptions. Values below 5 are not recommended.", + "admin.mobileSecurity.ephemeralMode.disconnectionTimeoutTitle": "Disconnection Timeout (seconds):", + "admin.mobileSecurity.ephemeralMode.enableDescription": "When enabled, mobile clients will follow the server-configured ephemeral data policies. Disconnected devices will clean up cached data based on the configured timers.", + "admin.mobileSecurity.ephemeralMode.enableTitle": "Enable Mobile Ephemeral Mode:", + "admin.mobileSecurity.ephemeralMode.offlinePersistence.disabled": "How long cached content is kept after the device goes offline. When the timer expires, cached content is deleted but session credentials are preserved. Set to 0 for immediate cleanup. Requires Mobile Ephemeral Mode to be enabled and Auto Cache Cleanup to be greater than 0.", + "admin.mobileSecurity.ephemeralMode.offlinePersistence.placeholder": "E.g.: 24", + "admin.mobileSecurity.ephemeralMode.offlinePersistence.range": "Must be a number between 0 and 72 hours (3 days).", + "admin.mobileSecurity.ephemeralMode.offlinePersistenceDescription": "How long cached content is kept after the device goes offline. When the timer expires, cached content is deleted but session credentials are preserved. Set to 0 for immediate cleanup.", + "admin.mobileSecurity.ephemeralMode.offlinePersistenceTitle": "Offline Persistence Timer (hours):", "admin.mobileSecurity.jailbreakDescription": "Prevents access to the app on devices detected as jailbroken or rooted. If a device fails the security check, users will be denied access or prompted to switch to a compliant server.", "admin.mobileSecurity.jailbreakTitle": "Enable Jailbreak/Root Protection:", "admin.mobileSecurity.mobileAllowDownloads": "Site Configuration > File Sharing and Downloads > Allow File Downloads on Mobile", "admin.mobileSecurity.screenCaptureDescription": "Blocks screenshots and screen recordings when using the mobile app. Screenshots will appear blank, and screen recordings will blur (iOS) or show a black screen (Android). Also applies when switching apps.", "admin.mobileSecurity.screenCaptureTitle": "Prevent Screen Capture:", + "admin.mobileSecurity.sections.ephemeralMode.description": "Configure data persistence and cache management policies for mobile devices.", "admin.mobileSecurity.sections.general.description": "Configure device security features for the mobile app.", "admin.mobileSecurity.sections.intune.description": "Configure Microsoft Intune Mobile Application Management (MAM) for App Protection Policies.", "admin.mobileSecurity.secureFilePreviewDescription": "Prevents file downloads, previews, and sharing for most file types, even if {mobileAllowDownloads} is enabled. Allows in-app previews for PDFs, videos, and images only. Files are stored temporarily in the app's cache and cannot be exported or shared.", diff --git a/webapp/channels/src/utils/admin_console_index.test.tsx b/webapp/channels/src/utils/admin_console_index.test.tsx index 2da36d664e8..3c83b111f77 100644 --- a/webapp/channels/src/utils/admin_console_index.test.tsx +++ b/webapp/channels/src/utils/admin_console_index.test.tsx @@ -29,8 +29,8 @@ describe('AdminConsoleIndex.generateIndex', () => { expect(idx.search('saml')).toEqual([ 'authentication/saml', 'environment/session_lengths', - 'authentication/email', 'environment/mobile_security', + 'authentication/email', 'experimental/features', ]); expect(idx.search('nginx')).toEqual([ diff --git a/webapp/platform/types/src/config.ts b/webapp/platform/types/src/config.ts index b77216eb20a..c5e6fc8899e 100644 --- a/webapp/platform/types/src/config.ts +++ b/webapp/platform/types/src/config.ts @@ -837,6 +837,13 @@ export type IntuneSettings = { AuthService?: string; }; +export type MobileEphemeralModeSettings = { + Enable: boolean; + DisconnectionTimeoutSeconds: number; + OfflinePersistenceTimerHours: number; + AutoCacheCleanupDays: number; +}; + export type ClusterSettings = { Enable: boolean; ClusterName: string; @@ -1105,6 +1112,7 @@ export type AdminConfig = { AccessControlSettings: AccessControlSettings; ContentFlaggingSettings: ContentFlaggingSettings; AutoTranslationSettings: AutoTranslationSettings; + MobileEphemeralModeSettings: MobileEphemeralModeSettings; }; export type ReplicaLagSetting = { From 23b4d8275bb2d8d8649e67cbd07e8bc564aa58d0 Mon Sep 17 00:00:00 2001 From: Andre Vasconcelos Date: Tue, 19 May 2026 00:05:26 +0300 Subject: [PATCH 2/2] MM-68197 Show classification banners in web and desktop apps (#36490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Classification Markings admin console page Adds a new admin console page under Site Configuration for managing classification markings. This allows system administrators to define classification levels (e.g., UNCLASSIFIED, SECRET, TOP SECRET) with associated colors and rank ordering, which will be used for system-wide and per-channel classification banners. The page includes: - Enable/disable toggle backed by the property field system (field existence = enabled) - Country preset dropdown (US DoD, NATO, UK GSCP, Canada, Australia PSPF) that auto-fills standard classification levels - Editable classification levels table with drag-and-drop reorder, inline text editing, color picker, and delete - Auto-switch to "Custom" preset when levels are manually modified - Confirmation dialog when switching presets would overwrite custom data Also adds: - ClassificationMarkings feature flag (default off) - Generic property field client methods (get/create/patch/delete) for the /api/v4/properties/ endpoints - Enterprise license + feature flag gating on the admin page Co-Authored-By: Claude Opus 4.6 (1M context) * Fix classification markings: add validation, error handling, and system object type - Add "system" as a valid property field object type so the classification markings API calls succeed - Surface load errors instead of silently swallowing them (only suppress 404 for unconfigured state) - Validate before save: require at least one level, non-empty names, and no duplicates - Default to custom preset with empty levels on first open - Add section strings to searchableStrings for admin console search Co-Authored-By: Claude Opus 4.6 (1M context) * Move classification field to CPA group targeting users Store the classification markings property field in the custom_profile_attributes group with object_type 'user' instead of the attributes group with object_type 'system'. Clear target_id for PSAv2 system target compliance and mark the field as admin-managed. Co-Authored-By: Claude Opus 4.6 (1M context) * Stabilize preset option IDs and add danger warning on preset switch Hardcode deterministic IDs for all preset classification levels so switching away and back preserves option IDs, preventing orphaned property values. Compare only level data (not preset label) for change detection so cosmetic preset switches don't trigger false save states. Show a danger modal with red confirm button when changing presets on an existing field, warning about system-wide impact on classified resources. The warning appears once per session then allows frictionless switching. Co-Authored-By: Claude Opus 4.6 (1M context) * Remove system object type from property fields Not needed yet — will be added when system/channel banners are implemented. Co-Authored-By: Claude Opus 4.6 (1M context) * Fix ESLint errors in classification markings admin page Fix import ordering and remove unused generateId import. Co-Authored-By: Claude Opus 4.6 (1M context) * Address CodeRabbit review feedback for classification markings - Register property field API endpoints when ClassificationMarkings flag is enabled (not just IntegratedBoards) to prevent 404s - Preserve preset option IDs when creating a new classification field instead of blanking them with empty strings - Add sysconsole read/write permission constants for classification markings across server and webapp, and wire up resource-level permission checks in the admin definition Co-Authored-By: Claude Opus 4.6 (1M context) * Add rank attribute to classification marking options Co-Authored-By: Claude Opus 4.6 (1M context) * Add classification markings permissions migration and read-only support Add a permissions migration to grant classification markings sysconsole permissions to existing roles on upgrade. Wire up the disabled prop so read-only users can view but not edit classification settings. Register the permission in the Delegated Granular Administration UI. Co-Authored-By: Claude Opus 4.6 (1M context) * Paginate loadField to find classification field beyond first page Co-Authored-By: Claude Opus 4.6 (1M context) * Fix lint errors and warnings in classification markings Co-Authored-By: Claude Opus 4.6 (1M context) * Remove classification markings sysconsole permissions; gate on sysadmin instead Classification markings admin page no longer uses feature-specific read/write permissions. Visibility is gated on license + feature flag, editing is gated on system admin role. This avoids coupling feature-specific permissions to the generic property service. Co-Authored-By: Claude Opus 4.6 (1M context) * Set sysadmin-level permissions on classification markings field creation Co-Authored-By: Claude Opus 4.6 (1M context) * Use stable IDs instead of array indices for classification level operations Switch updateLevel/deleteLevel to identify levels by ID rather than index, sort levels by rank on load, and extract i18n strings. Co-Authored-By: Claude Opus 4.6 (1M context) * Refactor classification markings into extracted helper functions Co-Authored-By: Claude Opus 4.6 (1M context) * Add tests for classification markings admin console feature Add unit and component tests covering: - Pure function tests for detectPreset, optionsToLevels, levelsToOptions, processClassificationField, and fetchClassificationField pagination logic - React component tests for rendering states, validation, and user interactions - Client4 property field method tests for URL construction and HTTP verbs - Server routing test verifying routes register with ClassificationMarkings flag - Feature flag default and serialization test Export pure functions from classification_markings.tsx to enable direct testing. Co-Authored-By: Claude Opus 4.6 (1M context) * Fix lint errors in classification markings tests Co-Authored-By: Claude Opus 4.6 (1M context) * Fix test compilation error * Fix color input auto-filling after 3 hex characters in classification markings Buffer ColorInput onChange in a LevelColorCell wrapper so the table doesn't re-render mid-typing, preventing the input from losing its focus-guarded local state. Co-Authored-By: Claude Opus 4.6 (1M context) * Fixing style issues with color picker z-index * Added fix to prevent immediate dismissal when clicking inside color picker * Adding E2E test suite for configuration * Removing duplicates * Fixing unrelated linter error * Fixing test linting issues * Updating tests to skip appropriately * Matching configuration to UX specs * Fixing style lint * Added informational banner for presentational nature of markings * Enabling the markings flag on playwright server * Added missing feature flag to e2e test environment in ci * Reverting changes to color_input - Not needed as we're using a custom component * Added and polished global banner configuration * Refactoring webapp for readability - Separating components - Adding unit tests - Isolating helper methods into utilities * Fixing linter errors * linter fix * Manually fixing linter issues * Separating global classification component * Added persistence of classification marking configuration * Changing LevelID with LevelName * Making changes for PR reviews * Changing property object of classification field to template * syncing i18n file * Removing inaccurate note from comments * PR fixes for UX review * Cleaning up unused value * Added GlobalClassificationBanner component - Made sure it syncs on change by using normal configuration values on it - Works with "top" and "top_and_bottom" - Renders on both root and admin_console * Adding E2E test cases for global classification * Linter fixes, i18n extract * PR Fixes * Linter fix * Matching default messages * Fixing type errors * Fixing pipeline and runtime errors * Fixing announcementbar rendering on top of global classifications * Increasing banner & font sizes * Fixing font size to 12px instead of 16px - I read it wrong * Replacing config values with property * Test linter fixes * Fixing type errors and go format error * Making changes needed to align with specs - Ensuring system_classification is a separate linked property that differs from the template - Saving the global classification banner values as a propertyvalue * Added missing arguments in e2e tests * Added missing conditions for useEffect - Also fixing E2E error in pipeline * Fixing issues with V1 and V2 group mismatch * Fixes for linter errors and coderabbit review * Addressing more issues found by coderabbit * Fixing issues found by coderabbit * Migrating to use system properties * Ran all linters and prettier - Resolving coding style drift that happened from not running prettier on the webapp (even though CI doesn't check for this) * Undoing the prettier changes in webapp * Cleaning up unwanted autoformatted changes * Reverting prettier changes to clean diff * Fixing E2E test * Import fixes in test * Applying changes for PR feedback * Fixing issues with failing e2e tests * Changing key of selection from name to id * Replacing field setup in E2E tests to use levelId instead of levelName * Added classification setup per channel on channel creation * WIP: Adding classification banner integrated with channel banners - Using a hook to resolve which values should be evaluated when displaying the banner * Fixing style of dropdown input for classifications * Fixing visual issues with dropdown inputs * Adding E2E Tests and linter fixes * General fixes and improvements * Applying linter fixes * Resolving lingering linter issues * Updated snapshot and extracted i18n * Adding test cleanup to prevent failures due to duplicates * Addressing nitpick comment for test mapping of values * Applying more fixes to E2E tests * Improving test coverage and e2e test cleanup * Resolving type issues * Refactoring classification constant names an documentation * Ensuring propertyvalue only stores single id, storing banner text in banner_info * Fixing issues with linter alongside style issues on header * Updating test assertion to account for fallback * Fixing issues found during testing - Removing custom selection from being an option and turned it into a state - Ensuring only system administrators can set channel classification levels * Fixing z-index issue with color input popover * Setting classification level to lowest available value when switching it on * Updating unit tests to match new spec for preselection --------- Co-authored-by: David Krauser Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: David Krauser Co-authored-by: Mattermost Build --- .../channel_classification.spec.ts | 381 ++++++++++++++ .../channel_classification/helpers.ts | 126 +++++ .../sidebar_icon_realtime_update.spec.ts | 30 +- .../classification_markings.spec.ts | 67 +++ .../classification_markings_helpers.ts | 10 + .../channels/src/actions/websocket_actions.ts | 28 +- .../__snapshots__/color_setting.test.tsx.snap | 12 + .../classification_markings.test.tsx | 469 ++++++++++++++++-- .../classification_markings.tsx | 39 +- .../classification_markings/utils/index.ts | 160 ++++-- .../utils/preset_dropdown_styles.ts | 2 +- .../channel_banner/channel_banner.tsx | 31 +- .../channel_settings_configuration_tab.scss | 41 +- ...hannel_settings_configuration_tab.test.tsx | 365 ++++++++++++++ .../channel_settings_configuration_tab.tsx | 257 +++++++++- .../channel_settings_modal.scss | 9 +- .../channels/src/components/color_input.tsx | 32 +- .../useChannelClassificationBanner.test.ts | 300 +++++++++++ .../hooks/useChannelClassificationBanner.ts | 113 +++++ .../hooks/useClassificationMarkings.test.ts | 298 +++++++++++ .../common/hooks/useClassificationMarkings.ts | 110 ++++ .../global_classification_banner.test.tsx | 40 +- .../global_classification_banner.tsx | 47 +- .../new_channel_modal/new_channel_modal.scss | 116 +++++ .../new_channel_modal/new_channel_modal.tsx | 163 +++++- webapp/channels/src/i18n/en.json | 8 + .../src/sass/components/_color-input.scss | 12 +- webapp/platform/client/src/client4.ts | 7 + 28 files changed, 3075 insertions(+), 198 deletions(-) create mode 100644 e2e-tests/playwright/specs/functional/channels/channel_classification/channel_classification.spec.ts create mode 100644 e2e-tests/playwright/specs/functional/channels/channel_classification/helpers.ts create mode 100644 webapp/channels/src/components/common/hooks/useChannelClassificationBanner.test.ts create mode 100644 webapp/channels/src/components/common/hooks/useChannelClassificationBanner.ts create mode 100644 webapp/channels/src/components/common/hooks/useClassificationMarkings.test.ts create mode 100644 webapp/channels/src/components/common/hooks/useClassificationMarkings.ts diff --git a/e2e-tests/playwright/specs/functional/channels/channel_classification/channel_classification.spec.ts b/e2e-tests/playwright/specs/functional/channels/channel_classification/channel_classification.spec.ts new file mode 100644 index 00000000000..9905e302950 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/channel_classification/channel_classification.spec.ts @@ -0,0 +1,381 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/** + * Channel Classification E2E tests. + * Tests the classification level assignment feature on both new and existing channels. + * + * Prerequisites: Enterprise-tier license + ClassificationMarkings feature flag enabled. + */ + +import {expect, test, getAdminClient, licenseTier} from '@mattermost/playwright-lib'; +import type {PlaywrightExtended} from '@mattermost/playwright-lib'; + +import { + TEST_LEVELS, + setClassificationMarkingsFeatureFlag, + setupClassificationWithChannelField, + deleteClassificationFieldsIfExist, +} from './helpers'; +import type {ClassificationLevel} from './helpers'; + +let classificationLevels: ClassificationLevel[] = []; +let setupComplete = false; + +// Teams created by pw.initSetup() in each test are tracked here and deleted in +// afterEach so local environments don't accumulate stale teams across runs. +const createdTeamIds: string[] = []; + +async function initSetupTracked(pw: PlaywrightExtended) { + const setup = await pw.initSetup(); + createdTeamIds.push(setup.team.id); + return setup; +} + +test.beforeAll(async () => { + const {adminClient} = await getAdminClient(); + const license = await adminClient.getClientLicenseOld(); + if (licenseTier(license.SkuShortName) < 20) { + return; + } + + await setClassificationMarkingsFeatureFlag(adminClient, true); + const setup = await setupClassificationWithChannelField(adminClient); + classificationLevels = setup.levels; + setupComplete = true; +}); + +test.afterAll(async () => { + if (!setupComplete) { + return; + } + const {adminClient} = await getAdminClient(); + try { + await deleteClassificationFieldsIfExist(adminClient); + } catch { + // Best-effort cleanup + } +}); + +test.beforeEach(async () => { + const {adminClient} = await getAdminClient(); + const license = await adminClient.getClientLicenseOld(); + test.skip(licenseTier(license.SkuShortName) < 20, 'Channel classification requires Enterprise-tier license'); + test.skip(!setupComplete, 'Classification levels were not set up'); + + const config = await adminClient.getConfig(); + test.skip( + config.FeatureFlags.ClassificationMarkings !== true, + 'ClassificationMarkings feature flag could not be enabled', + ); +}); + +test.afterEach(async () => { + if (createdTeamIds.length === 0) { + return; + } + const ids = createdTeamIds.splice(0); + try { + const {adminClient} = await getAdminClient({skipLog: true}); + await Promise.allSettled(ids.map((id) => adminClient.deleteTeam(id))); + } catch { + // Best-effort cleanup + } +}); + +test.describe('Channel Classification - New channel creation', () => { + test('Enabling classification toggle without selecting values prevents channel creation', async ({pw}) => { + const {adminUser, team} = await initSetupTracked(pw); + const {channelsPage} = await pw.testBrowser.login(adminUser); + await channelsPage.goto(team.name); + await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000}); + + const newChannelModal = await channelsPage.openNewChannelModal(); + await newChannelModal.fillDisplayName(`test-${pw.random.id()}`); + await newChannelModal.publicTypeButton.click(); + + // Create button should be enabled before toggling classification + await expect(newChannelModal.createButton).toBeEnabled(); + + // Enable classification toggle + const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button'); + await classificationToggle.click(); + + // Create button should be disabled (no classification level selected, no banner text) + await expect(newChannelModal.createButton).toBeDisabled(); + }); + + test('Classification dropdown displays the correct levels from the template', async ({pw}) => { + const {adminUser, team} = await initSetupTracked(pw); + const {channelsPage} = await pw.testBrowser.login(adminUser); + await channelsPage.goto(team.name); + await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000}); + + const newChannelModal = await channelsPage.openNewChannelModal(); + await newChannelModal.fillDisplayName(`test-${pw.random.id()}`); + + // Enable classification toggle + const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button'); + await classificationToggle.click(); + + // Open the classification dropdown + const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel'); + await dropdownContainer.click(); + + // Verify all test levels are present in the dropdown menu + const menu = channelsPage.page.locator('.DropDown__menu'); + await expect(menu).toBeVisible(); + for (const level of TEST_LEVELS) { + await expect(menu.getByText(level.name, {exact: true})).toBeVisible(); + } + }); + + test('User can append text to the Banner Text field after selecting a classification', async ({pw}) => { + const {adminUser, team} = await initSetupTracked(pw); + const {channelsPage} = await pw.testBrowser.login(adminUser); + await channelsPage.goto(team.name); + await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000}); + + const selectedLevel = classificationLevels.find((l) => l.name === 'SECRET'); + expect(selectedLevel).toBeDefined(); + + const newChannelModal = await channelsPage.openNewChannelModal(); + await newChannelModal.fillDisplayName(`test-${pw.random.id()}`); + + // Enable classification toggle + const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button'); + await classificationToggle.click(); + + // Select a classification level + const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel'); + await dropdownContainer.click(); + const menu = channelsPage.page.locator('.DropDown__menu'); + await menu.getByText(selectedLevel!.name, {exact: true}).click(); + + // Banner text should be auto-populated with the bold level name + const bannerTextbox = channelsPage.page.locator('#channel_classification_banner_text'); + await expect(bannerTextbox).toBeVisible(); + const currentValue = await bannerTextbox.inputValue(); + expect(currentValue).toContain(selectedLevel!.name); + + // Append custom text to the banner + await bannerTextbox.click(); + await bannerTextbox.press('End'); + await bannerTextbox.pressSequentially(' - Custom suffix'); + + const updatedValue = await bannerTextbox.inputValue(); + expect(updatedValue).toContain('Custom suffix'); + }); + + test('Creating channel with classification shows banner with correct color', async ({pw}) => { + const {adminUser, team} = await initSetupTracked(pw); + const {channelsPage} = await pw.testBrowser.login(adminUser); + await channelsPage.goto(team.name); + await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000}); + + const selectedLevel = classificationLevels.find((l) => l.name === 'SECRET'); + expect(selectedLevel).toBeDefined(); + + const newChannelModal = await channelsPage.openNewChannelModal(); + await newChannelModal.fillDisplayName(`classified-${pw.random.id()}`); + await newChannelModal.publicTypeButton.click(); + + // Enable classification toggle + const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button'); + await classificationToggle.click(); + + // Select the classification level + const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel'); + await dropdownContainer.click(); + const menu = channelsPage.page.locator('.DropDown__menu'); + await menu.getByText(selectedLevel!.name, {exact: true}).click(); + + // Wait for banner text to auto-populate, then create the channel + const bannerTextbox = channelsPage.page.locator('#channel_classification_banner_text'); + await expect(bannerTextbox).toBeVisible(); + await expect(bannerTextbox).not.toHaveValue(''); + + await newChannelModal.create(); + + // Should be redirected to the new channel and center view loads + await expect(channelsPage.page).toHaveURL(/\/channels\//, {timeout: 30000}); + await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 30000}); + + // Channel banner should be visible (allow extra time for property value fetch) + const banner = channelsPage.page.getByTestId('channel_banner_container'); + await expect(banner).toBeVisible({timeout: 30000}); + + // Verify the banner has the correct background color + const actualBackgroundColor = await banner.evaluate((el) => { + return window.getComputedStyle(el).getPropertyValue('background-color'); + }); + const expectedRgb = hexToRgb(selectedLevel!.color); + expect(actualBackgroundColor).toBe(expectedRgb); + + // Verify the banner contains the classification level name (rendered from **SECRET** markdown) + await expect(banner).toContainText(selectedLevel!.name); + }); +}); + +test.describe('Channel Classification - Existing channel settings', () => { + test('Classification toggle can be enabled from channel settings', async ({pw}) => { + const {adminUser, team, adminClient} = await initSetupTracked(pw); + + const channel = await adminClient.createChannel( + pw.random.channel({teamId: team.id, name: `cls-${pw.random.id()}`, displayName: `Cls ${pw.random.id()}`}), + ); + await adminClient.addToChannel(adminUser.id, channel.id); + + const {channelsPage} = await pw.testBrowser.login(adminUser); + await channelsPage.goto(team.name, channel.name); + await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000}); + + const channelSettingsModal = await channelsPage.openChannelSettings(); + await channelSettingsModal.openConfigurationTab(); + + // The classification toggle should be available + const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button'); + await expect(classificationToggle).toBeVisible(); + + // Toggle it on + const classes = await classificationToggle.getAttribute('class'); + if (!classes?.includes('active')) { + await classificationToggle.click(); + } + + // Toggle should now be active + await expect(classificationToggle).toHaveClass(/active/); + }); + + test('Classification level can be set once toggle is enabled', async ({pw}) => { + const {adminUser, team, adminClient} = await initSetupTracked(pw); + + const channel = await adminClient.createChannel( + pw.random.channel({teamId: team.id, name: `cls-${pw.random.id()}`, displayName: `Cls ${pw.random.id()}`}), + ); + await adminClient.addToChannel(adminUser.id, channel.id); + + const {channelsPage} = await pw.testBrowser.login(adminUser); + await channelsPage.goto(team.name, channel.name); + await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000}); + + const channelSettingsModal = await channelsPage.openChannelSettings(); + await channelSettingsModal.openConfigurationTab(); + + // Enable classification toggle + const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button'); + await classificationToggle.click(); + + // Classification level dropdown should be visible + const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel'); + await expect(dropdownContainer).toBeVisible(); + + // Open dropdown and select a level + await dropdownContainer.click(); + const menu = channelsPage.page.locator('.DropDown__menu'); + await expect(menu).toBeVisible(); + + const selectedLevel = classificationLevels.find((l) => l.name === 'CONFIDENTIAL'); + expect(selectedLevel).toBeDefined(); + await menu.getByText(selectedLevel!.name, {exact: true}).click(); + + // The dropdown should now show the selected value + await expect(dropdownContainer.getByText(selectedLevel!.name, {exact: true})).toBeVisible(); + }); + + test('Selecting classification locks banner toggle active and disabled, with matching color', async ({pw}) => { + const {adminUser, team, adminClient} = await initSetupTracked(pw); + + const channel = await adminClient.createChannel( + pw.random.channel({teamId: team.id, name: `cls-${pw.random.id()}`, displayName: `Cls ${pw.random.id()}`}), + ); + await adminClient.addToChannel(adminUser.id, channel.id); + + const {channelsPage} = await pw.testBrowser.login(adminUser); + await channelsPage.goto(team.name, channel.name); + await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000}); + + const channelSettingsModal = await channelsPage.openChannelSettings(); + await channelSettingsModal.openConfigurationTab(); + + // Enable classification and select a level + const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button'); + await classificationToggle.click(); + + const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel'); + await dropdownContainer.click(); + + const selectedLevel = classificationLevels.find((l) => l.name === 'SECRET'); + expect(selectedLevel).toBeDefined(); + const menu = channelsPage.page.locator('.DropDown__menu'); + await menu.getByText(selectedLevel!.name, {exact: true}).click(); + + // The channel banner toggle should now be forced active and disabled + const bannerToggle = channelsPage.page.getByTestId('channelBannerToggle-button'); + await expect(bannerToggle).toBeVisible(); + await expect(bannerToggle).toHaveClass(/active/); + await expect(bannerToggle).toBeDisabled(); + + // Banner color input should be locked to the classification color + const colorInput = channelsPage.page.locator('#channel_banner_banner_background_color_picker-inputColorValue'); + await expect(colorInput).toBeVisible(); + const colorValue = await colorInput.inputValue(); + expect(colorValue.toLowerCase().replace('#', '')).toBe(selectedLevel!.color.toLowerCase().replace('#', '')); + }); + + test('Editing banner text and saving updates the banner in real time', async ({pw}) => { + const {adminUser, team, adminClient} = await initSetupTracked(pw); + + const channel = await adminClient.createChannel( + pw.random.channel({teamId: team.id, name: `cls-${pw.random.id()}`, displayName: `Cls ${pw.random.id()}`}), + ); + await adminClient.addToChannel(adminUser.id, channel.id); + + const {channelsPage} = await pw.testBrowser.login(adminUser); + await channelsPage.goto(team.name, channel.name); + await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000}); + + const channelSettingsModal = await channelsPage.openChannelSettings(); + const configurationTab = await channelSettingsModal.openConfigurationTab(); + + // Enable classification and select a level + const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button'); + await classificationToggle.click(); + + const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel'); + await dropdownContainer.click(); + + const selectedLevel = classificationLevels.find((l) => l.name === 'TOP SECRET'); + expect(selectedLevel).toBeDefined(); + const menu = channelsPage.page.locator('.DropDown__menu'); + await menu.getByText(selectedLevel!.name, {exact: true}).click(); + + // Edit the banner text to a custom value + const customBannerText = 'TOP SECRET - Handle via COMINT channels only'; + const bannerTextbox = channelsPage.page.locator('#channel_banner_banner_text_textbox'); + await expect(bannerTextbox).toBeVisible(); + await bannerTextbox.fill(customBannerText); + + // Save the changes + await configurationTab.save(); + await channelSettingsModal.close(); + + // The channel banner should now show the custom text with the classification color + const banner = channelsPage.page.getByTestId('channel_banner_container'); + await expect(banner).toBeVisible({timeout: 30000}); + await expect(banner).toContainText(customBannerText); + + const actualBackgroundColor = await banner.evaluate((el) => { + return window.getComputedStyle(el).getPropertyValue('background-color'); + }); + expect(actualBackgroundColor).toBe(hexToRgb(selectedLevel!.color)); + }); +}); + +function hexToRgb(hex: string): string { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + if (!result) { + return hex; + } + return `rgb(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)})`; +} diff --git a/e2e-tests/playwright/specs/functional/channels/channel_classification/helpers.ts b/e2e-tests/playwright/specs/functional/channels/channel_classification/helpers.ts new file mode 100644 index 00000000000..17814a7a9fc --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/channel_classification/helpers.ts @@ -0,0 +1,126 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Client4} from '@mattermost/client'; + +const PROPERTY_GROUP = 'classification_markings'; +const TEMPLATE_OBJECT_TYPE = 'template'; +const CHANNEL_OBJECT_TYPE = 'channel'; +const TARGET_TYPE = 'system'; +const CLASSIFICATION_FIELD_NAME = 'classification'; +const CHANNEL_LINKED_FIELD_NAME = 'channel_classification'; + +export const TEST_LEVELS = [ + {name: 'UNCLASSIFIED', color: '#007A33', rank: 1}, + {name: 'CONFIDENTIAL', color: '#0033A0', rank: 2}, + {name: 'SECRET', color: '#C8102E', rank: 3}, + {name: 'TOP SECRET', color: '#FF8C00', rank: 4}, +]; + +/** + * Sets the ClassificationMarkings feature flag via server config. + */ +export async function setClassificationMarkingsFeatureFlag(adminClient: Client4, enabled: boolean) { + const config = await adminClient.getConfig(); + await adminClient.updateConfig({ + ...config, + FeatureFlags: { + ...config.FeatureFlags, + ClassificationMarkings: enabled, + }, + } as Awaited>); +} + +/** + * Deletes existing classification fields (channel linked, system linked, and template) + * to provide a clean slate. + */ +export async function deleteClassificationFieldsIfExist(adminClient: Client4) { + // Delete channel linked fields first + try { + const channelFields = await adminClient.getPropertyFields(PROPERTY_GROUP, CHANNEL_OBJECT_TYPE, TARGET_TYPE, ''); + for (const f of channelFields.filter((f) => f.name === CHANNEL_LINKED_FIELD_NAME && f.delete_at === 0)) { + await adminClient.deletePropertyField(PROPERTY_GROUP, CHANNEL_OBJECT_TYPE, f.id); + } + } catch { + // May not exist + } + + // Delete system linked fields + for (const objectType of ['system', 'user'] as const) { + try { + const linkedFields = await adminClient.getPropertyFields(PROPERTY_GROUP, objectType, TARGET_TYPE, ''); + for (const f of linkedFields.filter( + (f) => f.name === 'system_classification' && f.delete_at === 0 && f.linked_field_id, + )) { + await adminClient.deletePropertyField(PROPERTY_GROUP, objectType, f.id); + } + } catch { + // May not exist + } + } + + // Delete template fields + try { + const fields = await adminClient.getPropertyFields(PROPERTY_GROUP, TEMPLATE_OBJECT_TYPE, TARGET_TYPE); + for (const f of fields.filter((f) => f.name === CLASSIFICATION_FIELD_NAME && f.delete_at === 0)) { + await adminClient.deletePropertyField(PROPERTY_GROUP, TEMPLATE_OBJECT_TYPE, f.id); + } + } catch { + // May not exist + } +} + +export type ClassificationLevel = { + id: string; + name: string; + color: string; + rank: number; +}; + +export type SetupResult = { + templateFieldId: string; + channelFieldId: string; + levels: ClassificationLevel[]; +}; + +/** + * Creates the full classification setup: template field + channel linked field. + * Returns the created fields and the resolved levels (with server-assigned IDs). + */ +export async function setupClassificationWithChannelField( + adminClient: Client4, + levels: Array<{name: string; color: string; rank: number}> = TEST_LEVELS, +): Promise { + await deleteClassificationFieldsIfExist(adminClient); + + // Create template field + const templateField = await adminClient.createPropertyField(PROPERTY_GROUP, TEMPLATE_OBJECT_TYPE, { + name: CLASSIFICATION_FIELD_NAME, + type: 'select', + target_type: TARGET_TYPE, + target_id: '', + attrs: { + options: levels.map((l) => ({id: '', name: l.name, color: l.color, rank: l.rank})), + managed: 'admin', + }, + permission_field: 'sysadmin', + permission_values: 'sysadmin', + permission_options: 'sysadmin', + } as Parameters[2]); + + // Create channel linked field + const channelField = await adminClient.createPropertyField(PROPERTY_GROUP, CHANNEL_OBJECT_TYPE, { + name: CHANNEL_LINKED_FIELD_NAME, + type: 'select', + target_type: TARGET_TYPE, + target_id: '', + linked_field_id: templateField.id, + } as Parameters[2]); + + // Resolve levels with server-assigned IDs + const options = (templateField.attrs?.options ?? []) as ClassificationLevel[]; + const resolvedLevels = options.sort((a, b) => a.rank - b.rank); + + return {templateFieldId: templateField.id, channelFieldId: channelField.id, levels: resolvedLevels}; +} diff --git a/e2e-tests/playwright/specs/functional/channels/channel_privacy/sidebar_icon_realtime_update.spec.ts b/e2e-tests/playwright/specs/functional/channels/channel_privacy/sidebar_icon_realtime_update.spec.ts index fec6158ab2d..ea18fcbbf72 100644 --- a/e2e-tests/playwright/specs/functional/channels/channel_privacy/sidebar_icon_realtime_update.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/channel_privacy/sidebar_icon_realtime_update.spec.ts @@ -1,14 +1,38 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {expect, test} from '@mattermost/playwright-lib'; +import {expect, test, getAdminClient} from '@mattermost/playwright-lib'; +import type {PlaywrightExtended} from '@mattermost/playwright-lib'; + +// Teams created by pw.initSetup() in each test are tracked here and deleted in +// afterEach so local environments don't accumulate stale teams across runs. +const createdTeamIds: string[] = []; + +async function initSetupTracked(pw: PlaywrightExtended) { + const setup = await pw.initSetup(); + createdTeamIds.push(setup.team.id); + return setup; +} + +test.afterEach(async () => { + if (createdTeamIds.length === 0) { + return; + } + const ids = createdTeamIds.splice(0); + try { + const {adminClient} = await getAdminClient({skipLog: true}); + await Promise.allSettled(ids.map((id) => adminClient.deleteTeam(id))); + } catch { + // Best-effort cleanup + } +}); test( 'sidebar icon updates from globe to lock when channel converted to private via API', {tag: ['@channels', '@channel_privacy']}, async ({pw}) => { // # Initialize setup - const {adminClient, user, team} = await pw.initSetup(); + const {adminClient, user, team} = await initSetupTracked(pw); // # Create a public channel const channel = await adminClient.createChannel( @@ -47,7 +71,7 @@ test( {tag: ['@channels', '@channel_privacy']}, async ({pw}) => { // # Initialize setup - const {adminClient, user, team} = await pw.initSetup(); + const {adminClient, user, team} = await initSetupTracked(pw); // # Create a private channel const channel = await adminClient.createChannel( diff --git a/e2e-tests/playwright/specs/functional/system_console/site_configuration/classification_markings.spec.ts b/e2e-tests/playwright/specs/functional/system_console/site_configuration/classification_markings.spec.ts index 57881df8610..0d4dd3e9d03 100644 --- a/e2e-tests/playwright/specs/functional/system_console/site_configuration/classification_markings.spec.ts +++ b/e2e-tests/playwright/specs/functional/system_console/site_configuration/classification_markings.spec.ts @@ -498,6 +498,73 @@ test.describe('System Console - Classification markings', () => { }, ); + /** + * @objective Verify that modifying a preset's levels (rename, delete, add) automatically + * switches the dropdown to "Custom classification levels", and selecting a real preset + * again removes the Custom option from the dropdown. + */ + test( + 'MM-T6212 classification markings: modifying a preset switches dropdown to Custom', + {tag: ['@system_console', '@classification_markings']}, + async ({pw}) => { + const {adminUser, adminClient} = await pw.initSetup(); + + await setClassificationMarkingsFeatureFlag(adminClient, true); + await deleteClassificationMarkingsFieldIfExists(adminClient); + + const {systemConsolePage} = await pw.testBrowser.login(adminUser); + const {page} = systemConsolePage; + await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH); + await page.waitForLoadState('networkidle'); + + // # Enable classification markings and select NATO preset + await page.locator('input[name="classificationEnabled"][value="true"]').click(); + await selectClassificationPreset(page, 'NATO'); + + const presetControl = page.getByTestId('classificationPreset'); + await expect(presetControl).toContainText('NATO'); + + // # Rename the first level — this should switch to Custom + const firstLevelInput = page.getByLabel('Classification level name').first(); + await firstLevelInput.clear(); + await firstLevelInput.fill('MY CUSTOM LEVEL'); + + // * Preset dropdown should now show "Custom classification levels" + await expect(presetControl).toContainText('Custom classification levels'); + + // # Open the preset dropdown and verify "Custom classification levels" is listed + await presetControl.click(); + const menu = page.locator('.DropDown__menu'); + await expect(menu).toBeVisible(); + await expect(menu.getByText('Custom classification levels', {exact: true})).toBeVisible(); + + // # Select a real preset (Canada) — should show the confirmation modal + await menu.getByText('Canada', {exact: true}).click(); + await expect(page.getByText('Change classification preset?')).toBeVisible(); + + // # Confirm the preset change + await page.getByRole('button', {name: 'Change preset'}).click(); + + // * Dropdown now shows Canada, no longer Custom + await expect(presetControl).toContainText('Canada'); + + // # Open the dropdown again and verify Custom is no longer listed + await presetControl.click(); + const menuAfterSwitch = page.locator('.DropDown__menu'); + await expect(menuAfterSwitch).toBeVisible(); + await expect(menuAfterSwitch.getByText('Custom classification levels', {exact: true})).not.toBeVisible(); + + // # Close menu by pressing Escape + await page.keyboard.press('Escape'); + + // # Delete a level from the Canada preset + await page.getByRole('button', {name: 'Delete level'}).first().click(); + + // * Should switch back to Custom + await expect(presetControl).toContainText('Custom classification levels'); + }, + ); + /** * @objective Validate that saving with global banner enabled but no level selected shows an error. */ diff --git a/e2e-tests/playwright/specs/functional/system_console/site_configuration/classification_markings_helpers.ts b/e2e-tests/playwright/specs/functional/system_console/site_configuration/classification_markings_helpers.ts index 34cbec1b01f..32e1a8ceb03 100644 --- a/e2e-tests/playwright/specs/functional/system_console/site_configuration/classification_markings_helpers.ts +++ b/e2e-tests/playwright/specs/functional/system_console/site_configuration/classification_markings_helpers.ts @@ -35,6 +35,16 @@ export async function setClassificationMarkingsFeatureFlag(adminClient: Client4, * (clean slate for E2E). Linked field is deleted first to avoid deletion-protection errors. */ export async function deleteClassificationMarkingsFieldIfExists(adminClient: Client4) { + // Delete channel linked fields first (created by channel classification tests). + try { + const channelFields = await adminClient.getPropertyFields(PROPERTY_GROUP, 'channel', TARGET_TYPE, ''); + for (const f of channelFields.filter((f) => f.name === 'channel_classification' && f.delete_at === 0)) { + await adminClient.deletePropertyField(PROPERTY_GROUP, 'channel', f.id); + } + } catch { + // May not exist; ignore. + } + // Clean up both the current 'system' object type and the legacy 'user' object type // to handle stale data from earlier versions of the feature. for (const objectType of [LINKED_OBJECT_TYPE, 'user'] as const) { diff --git a/webapp/channels/src/actions/websocket_actions.ts b/webapp/channels/src/actions/websocket_actions.ts index 843edb78a89..d9cb60c6155 100644 --- a/webapp/channels/src/actions/websocket_actions.ts +++ b/webapp/channels/src/actions/websocket_actions.ts @@ -155,12 +155,11 @@ import {isThreadOpen, isThreadManuallyUnread} from 'selectors/views/threads'; import store from 'stores/redux_store'; import { - GROUP_NAME, - OBJECT_TYPE, - TARGET_TYPE, - TARGET_ID, - LINKED_OBJECT_TYPE, - SYSTEM_FIELD_TARGET_ID, + CLASSIFICATIONS_GROUP_NAME, + CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE, + CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, + CLASSIFICATIONS_FIELD_TARGET_TYPE, + CLASSIFICATIONS_FIELD_TARGET_ID, } from 'components/admin_console/classification_markings/utils'; import {EntityType, invalidateAccessControlAttributesCache} from 'components/common/hooks/useAccessControlAttributes'; import DialogRouter from 'components/dialog_router'; @@ -345,17 +344,22 @@ export function reconnect() { // Refresh classification fields and values on reconnect when the feature flag is active if (getFeatureFlagValue(state, 'ClassificationMarkings') === 'true') { dispatch( - fetchPropertyFields(GROUP_NAME, OBJECT_TYPE, TARGET_TYPE, TARGET_ID), + fetchPropertyFields( + CLASSIFICATIONS_GROUP_NAME, + CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE, + CLASSIFICATIONS_FIELD_TARGET_TYPE, + CLASSIFICATIONS_FIELD_TARGET_ID, + ), ); dispatch( fetchPropertyFields( - GROUP_NAME, - LINKED_OBJECT_TYPE, - TARGET_TYPE, - SYSTEM_FIELD_TARGET_ID, + CLASSIFICATIONS_GROUP_NAME, + CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, + CLASSIFICATIONS_FIELD_TARGET_TYPE, + CLASSIFICATIONS_FIELD_TARGET_ID, ), ); - dispatch(fetchSystemPropertyValues(GROUP_NAME)); + dispatch(fetchSystemPropertyValues(CLASSIFICATIONS_GROUP_NAME)); } if (state.websocket.lastDisconnectAt) { diff --git a/webapp/channels/src/components/admin_console/__snapshots__/color_setting.test.tsx.snap b/webapp/channels/src/components/admin_console/__snapshots__/color_setting.test.tsx.snap index 3e35be13cfb..736ab5ba0c5 100644 --- a/webapp/channels/src/components/admin_console/__snapshots__/color_setting.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/__snapshots__/color_setting.test.tsx.snap @@ -127,6 +127,18 @@ exports[`components/ColorSetting should match snapshot, disabled 1`] = ` type="text" value="#fff" /> + + +
= {}): PropertyField { return { id: 'field1', - group_id: GROUP_NAME, - name: FIELD_NAME, + group_id: CLASSIFICATIONS_GROUP_NAME, + name: CLASSIFICATIONS_TEMPLATE_FIELD_NAME, type: 'select', attrs: {options: []}, target_id: '', - target_type: TARGET_TYPE, - object_type: OBJECT_TYPE, + target_type: CLASSIFICATIONS_FIELD_TARGET_TYPE, + object_type: CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE, create_at: 1000, update_at: 1000, delete_at: 0, @@ -58,13 +61,13 @@ function makePropertyField(overrides: Partial = {}): PropertyFiel function makeLinkedField(overrides: Partial = {}): PropertyField { return { id: 'linked_field1', - group_id: GROUP_NAME, - name: LINKED_FIELD_NAME, + group_id: CLASSIFICATIONS_GROUP_NAME, + name: CLASSIFICATIONS_SYSTEM_FIELD_NAME, type: 'select', attrs: {actions: []}, - target_id: SYSTEM_FIELD_TARGET_ID, - target_type: TARGET_TYPE, - object_type: LINKED_OBJECT_TYPE, + target_id: CLASSIFICATIONS_FIELD_TARGET_ID, + target_type: CLASSIFICATIONS_FIELD_TARGET_TYPE, + object_type: CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, linked_field_id: 'field1', create_at: 2000, update_at: 2000, @@ -75,12 +78,32 @@ function makeLinkedField(overrides: Partial = {}): PropertyField }; } +function makeChannelLinkedField(overrides: Partial = {}): PropertyField { + return { + id: 'channel_field1', + group_id: CLASSIFICATIONS_GROUP_NAME, + name: CLASSIFICATIONS_CHANNEL_FIELD_NAME, + type: 'select', + attrs: {}, + target_id: '', + target_type: CLASSIFICATIONS_FIELD_TARGET_TYPE, + object_type: CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, + linked_field_id: 'field1', + create_at: 4000, + update_at: 4000, + delete_at: 0, + created_by: 'user1', + updated_by: 'user1', + ...overrides, + }; +} + function makeSystemValue(fieldId: string, optionId: string): PropertyValue { return { id: 'value1', - target_id: SYSTEM_VALUE_TARGET_ID, - target_type: LINKED_OBJECT_TYPE, - group_id: GROUP_NAME, + target_id: CLASSIFICATIONS_SYSTEM_VALUE_TARGET_ID, + target_type: CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, + group_id: CLASSIFICATIONS_GROUP_NAME, field_id: fieldId, value: optionId, create_at: 3000, @@ -311,6 +334,105 @@ describe('fetchClassificationField', () => { }); }); +describe('fetchChannelClassificationField', () => { + beforeEach(() => { + jest.clearAllMocks(); + + // Reset mockResolvedValueOnce queues that may carry over from the + // fetchClassificationField "stop after 500 items" test. + (Client4.getPropertyFields as jest.Mock).mockReset?.(); + }); + + test('should return the matching channel-linked field from first page', async () => { + const expected = makeChannelLinkedField(); + jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([ + makeChannelLinkedField({id: 'other', name: 'other_field'}), + expected, + ]); + + const result = await fetchChannelClassificationField(); + expect(result).toEqual(expected); + expect(Client4.getPropertyFields).toHaveBeenCalledTimes(1); + expect(Client4.getPropertyFields).toHaveBeenCalledWith( + CLASSIFICATIONS_GROUP_NAME, + CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, + CLASSIFICATIONS_FIELD_TARGET_TYPE, + '', + expect.any(Object), + ); + }); + + test('should skip channel fields without linked_field_id', async () => { + const orphan = makeChannelLinkedField({id: 'orphan', linked_field_id: ''}); + const linked = makeChannelLinkedField({id: 'linked'}); + jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([orphan, linked]); + + const result = await fetchChannelClassificationField(); + expect(result).toEqual(linked); + }); + + test('should skip soft-deleted channel-linked fields', async () => { + const deleted = makeChannelLinkedField({id: 'deleted', delete_at: 999}); + const active = makeChannelLinkedField({id: 'active'}); + jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([deleted, active]); + + const result = await fetchChannelClassificationField(); + expect(result).toEqual(active); + }); + + test('should paginate using cursor when field not found on first page', async () => { + const page1 = [ + makeChannelLinkedField({id: 'p1', name: 'other1', create_at: 100}), + makeChannelLinkedField({id: 'p2', name: 'other2', create_at: 200}), + ]; + const expected = makeChannelLinkedField({id: 'found'}); + const page2 = [expected]; + + jest.spyOn(Client4, 'getPropertyFields'). + mockResolvedValueOnce(page1). + mockResolvedValueOnce(page2); + + const result = await fetchChannelClassificationField(); + expect(result).toEqual(expected); + expect(Client4.getPropertyFields).toHaveBeenCalledTimes(2); + + const secondCallArgs = (Client4.getPropertyFields as jest.Mock).mock.calls[1]; + expect(secondCallArgs[4]).toEqual({cursorId: 'p2', cursorCreateAt: 200}); + }); + + test('should return undefined when field list is empty', async () => { + jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([]); + + const result = await fetchChannelClassificationField(); + expect(result).toBeUndefined(); + }); + + test('should return undefined when no pages contain a valid channel-linked field', async () => { + jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([ + makeChannelLinkedField({id: 'irrelevant', name: 'other'}), + ]).mockResolvedValueOnce([]); + + const result = await fetchChannelClassificationField(); + expect(result).toBeUndefined(); + }); + + test('should stop after 500 items to avoid infinite loop', async () => { + const makePage = (startId: number) => + Array.from({length: 100}, (_, i) => + makeChannelLinkedField({id: `id_${startId + i}`, name: `other_${startId + i}`, create_at: startId + i}), + ); + + const spy = jest.spyOn(Client4, 'getPropertyFields'); + for (let i = 0; i < 6; i++) { + spy.mockResolvedValueOnce(makePage(i * 100)); + } + + const result = await fetchChannelClassificationField(); + expect(result).toBeUndefined(); + expect(Client4.getPropertyFields).toHaveBeenCalledTimes(5); + }); +}); + describe('ClassificationMarkings component', () => { beforeEach(() => { jest.clearAllMocks(); @@ -405,6 +527,65 @@ describe('ClassificationMarkings component', () => { expect(screen.getByText('Classification levels')).toBeInTheDocument(); }); + test('should not show Custom option in preset dropdown when a named preset is active', async () => { + const usPreset = presets.find((p) => p.id === 'us')!; + const field = makePropertyField({ + attrs: { + options: usPreset.levels.map((l) => ({ + id: l.id, + name: l.name, + color: l.color, + rank: l.rank, + })), + }, + }); + jest.spyOn(Client4, 'getPropertyFields'). + mockResolvedValueOnce([field]). + mockResolvedValueOnce([]); // linked field + + renderWithContext(, BASE_STATE); + + await screen.findByText('Classification levels'); + + // The selected value in the dropdown should be US, not Custom + expect(screen.queryByText('Custom classification levels')).not.toBeInTheDocument(); + }); + + test('should show Custom indicator after editing a level', async () => { + const usPreset = presets.find((p) => p.id === 'us')!; + const field = makePropertyField({ + attrs: { + options: usPreset.levels.map((l) => ({ + id: l.id, + name: l.name, + color: l.color, + rank: l.rank, + })), + }, + }); + jest.spyOn(Client4, 'getPropertyFields'). + mockResolvedValueOnce([field]). + mockResolvedValueOnce([]); // linked field + + renderWithContext(, BASE_STATE); + + await screen.findByText('Classification levels'); + + const user = userEvent.setup(); + + // Initially shows US preset, not Custom + expect(screen.queryByText('Custom classification levels')).not.toBeInTheDocument(); + + // Edit the first level name to trigger switchToCustom + const nameInputs = screen.getAllByRole('textbox', {name: /Classification level name/i}); + await user.clear(nameInputs[0]); + await user.type(nameInputs[0], 'MODIFIED'); + await user.tab(); + + // Custom should now appear as the selected dropdown value + expect(screen.getByText('Custom classification levels')).toBeInTheDocument(); + }); + test('should detect hasChanges when toggling enabled', async () => { jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([]); @@ -501,7 +682,8 @@ describe('ClassificationMarkings component', () => { jest.spyOn(Client4, 'getPropertyFields'). mockResolvedValueOnce([field]). // template field - mockResolvedValueOnce([linkedField]); // linked field (existing, no banner actions) + mockResolvedValueOnce([linkedField]). // linked field (existing, no banner actions) + mockResolvedValueOnce([makeChannelLinkedField()]); // channel-linked field exists during save jest.spyOn(Client4, 'patchPropertyField'). mockResolvedValueOnce(patchedTemplate). // patch template mockResolvedValueOnce(linkedField); // patch linked @@ -521,8 +703,8 @@ describe('ClassificationMarkings component', () => { await waitFor(() => { expect(Client4.patchPropertyField).toHaveBeenCalledWith( - GROUP_NAME, - OBJECT_TYPE, + CLASSIFICATIONS_GROUP_NAME, + CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE, 'field1', expect.objectContaining({ attrs: expect.objectContaining({ @@ -740,7 +922,8 @@ describe('GlobalClassificationIndicators section', () => { jest.spyOn(Client4, 'getPropertyFields'). mockResolvedValueOnce([field]). - mockResolvedValueOnce([linked]); + mockResolvedValueOnce([linked]). + mockResolvedValueOnce([makeChannelLinkedField()]); // channel-linked field already exists during save jest.spyOn(Client4, 'getSystemPropertyValues'). mockResolvedValueOnce([sysValue]); @@ -772,8 +955,8 @@ describe('GlobalClassificationIndicators section', () => { await waitFor(() => { // Template field patched without global_banner in attrs. expect(Client4.patchPropertyField).toHaveBeenCalledWith( - GROUP_NAME, - OBJECT_TYPE, + CLASSIFICATIONS_GROUP_NAME, + CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE, 'field1', expect.objectContaining({ attrs: expect.objectContaining({options: expect.any(Array)}), @@ -781,7 +964,7 @@ describe('GlobalClassificationIndicators section', () => { ); expect(Client4.patchPropertyField).not.toHaveBeenCalledWith( expect.anything(), - OBJECT_TYPE, + CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE, expect.anything(), expect.objectContaining({ attrs: expect.objectContaining({global_banner: expect.anything()}), @@ -790,8 +973,8 @@ describe('GlobalClassificationIndicators section', () => { // Linked field patched with updated actions (top_and_bottom). expect(Client4.patchPropertyField).toHaveBeenCalledWith( - GROUP_NAME, - LINKED_OBJECT_TYPE, + CLASSIFICATIONS_GROUP_NAME, + CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, 'linked_field1', expect.objectContaining({ attrs: expect.objectContaining({ @@ -819,7 +1002,8 @@ describe('GlobalClassificationIndicators section', () => { jest.spyOn(Client4, 'getPropertyFields'). mockResolvedValueOnce([field]). - mockResolvedValueOnce([linked]); + mockResolvedValueOnce([linked]). + mockResolvedValueOnce([makeChannelLinkedField()]); // channel-linked field already exists during save jest.spyOn(Client4, 'patchPropertyField'). mockResolvedValueOnce(patchedTemplate). @@ -840,8 +1024,8 @@ describe('GlobalClassificationIndicators section', () => { await waitFor(() => { // Template field saved without global_banner. expect(Client4.patchPropertyField).toHaveBeenCalledWith( - GROUP_NAME, - OBJECT_TYPE, + CLASSIFICATIONS_GROUP_NAME, + CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE, 'field1', expect.not.objectContaining({ attrs: expect.objectContaining({global_banner: expect.anything()}), @@ -850,8 +1034,8 @@ describe('GlobalClassificationIndicators section', () => { // Linked field patched with empty actions (banner disabled). expect(Client4.patchPropertyField).toHaveBeenCalledWith( - GROUP_NAME, - LINKED_OBJECT_TYPE, + CLASSIFICATIONS_GROUP_NAME, + CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, 'linked_field1', expect.objectContaining({ attrs: expect.objectContaining({actions: []}), @@ -869,12 +1053,13 @@ describe('GlobalClassificationIndicators section', () => { jest.spyOn(Client4, 'getPropertyFields'). mockResolvedValueOnce([field]). - mockResolvedValueOnce([linked]); + mockResolvedValueOnce([linked]). + mockResolvedValueOnce([]); const deleteOrder: string[] = []; const deleteFieldSpy = jest.spyOn(Client4, 'deletePropertyField'); deleteFieldSpy.mockImplementation(async (_group, objectType, _id) => { - deleteOrder.push(objectType === LINKED_OBJECT_TYPE ? `linked:${_id}` : `template:${_id}`); + deleteOrder.push(objectType === CLASSIFICATIONS_SYSTEM_OBJECT_TYPE ? `linked:${_id}` : `template:${_id}`); return {status: 'OK'}; }); @@ -915,3 +1100,213 @@ describe('GlobalClassificationIndicators section', () => { } }); }); + +describe('Channel classification linked field branches', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should create channel-linked field when none exists during save', async () => { + const field = makePropertyField({ + attrs: {options: [{id: 'lvl1', name: 'UNCLASSIFIED', color: '#007A33', rank: 1}]}, + }); + const linked = makeLinkedField({attrs: {actions: []}}); + const patchedTemplate = makePropertyField({ + attrs: {options: [{id: 'lvl1', name: 'MODIFIED', color: '#007A33', rank: 1}]}, + }); + const patchedLinked = makeLinkedField({attrs: {actions: []}}); + const createdChannelField = makeChannelLinkedField(); + + jest.spyOn(Client4, 'getPropertyFields'). + mockResolvedValueOnce([field]). // template field load + mockResolvedValueOnce([linked]). // linked field load + mockResolvedValueOnce([]); // channel-linked field lookup during save -> none + + jest.spyOn(Client4, 'patchPropertyField'). + mockResolvedValueOnce(patchedTemplate). + mockResolvedValueOnce(patchedLinked); + + const createSpy = jest.spyOn(Client4, 'createPropertyField'). + mockResolvedValueOnce(createdChannelField); + + renderWithContext(, BASE_STATE); + await screen.findByText('Classification levels'); + + const user = userEvent.setup(); + const nameInput = screen.getByRole('textbox', {name: /Classification level name/i}); + await user.clear(nameInput); + await user.type(nameInput, 'MODIFIED'); + await user.tab(); + + await user.click(await screen.findByText('Save')); + + await waitFor(() => { + expect(createSpy).toHaveBeenCalledWith( + CLASSIFICATIONS_GROUP_NAME, + CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, + expect.objectContaining({ + name: CLASSIFICATIONS_CHANNEL_FIELD_NAME, + linked_field_id: 'field1', + }), + ); + }); + await act(async () => {}); + }); + + test('should not create channel-linked field when one already exists during save', async () => { + const field = makePropertyField({ + attrs: {options: [{id: 'lvl1', name: 'UNCLASSIFIED', color: '#007A33', rank: 1}]}, + }); + const linked = makeLinkedField({attrs: {actions: []}}); + const patchedTemplate = makePropertyField({ + attrs: {options: [{id: 'lvl1', name: 'MODIFIED', color: '#007A33', rank: 1}]}, + }); + const patchedLinked = makeLinkedField({attrs: {actions: []}}); + const existingChannelField = makeChannelLinkedField(); + + jest.spyOn(Client4, 'getPropertyFields'). + mockResolvedValueOnce([field]). + mockResolvedValueOnce([linked]). + mockResolvedValueOnce([existingChannelField]); // channel field exists + + jest.spyOn(Client4, 'patchPropertyField'). + mockResolvedValueOnce(patchedTemplate). + mockResolvedValueOnce(patchedLinked); + + const createSpy = jest.spyOn(Client4, 'createPropertyField'); + + const {store} = renderWithContext(, BASE_STATE); + await screen.findByText('Classification levels'); + + const user = userEvent.setup(); + const nameInput = screen.getByRole('textbox', {name: /Classification level name/i}); + await user.clear(nameInput); + await user.type(nameInput, 'MODIFIED'); + await user.tab(); + + await user.click(await screen.findByText('Save')); + + await waitFor(() => { + expect(Client4.patchPropertyField).toHaveBeenCalled(); + }); + await act(async () => {}); + + // Channel field must not be created since one already exists. + expect(createSpy).not.toHaveBeenCalledWith( + expect.anything(), + CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, + expect.anything(), + ); + + // Existing channel field must be pushed into the store alongside the saved template + // and linked field so consumers that read from Redux get it immediately. + const fieldsById = store.getState().entities.properties.fields.byId; + expect(fieldsById[existingChannelField.id]).toEqual(existingChannelField); + }); + + test('should delete channel-linked field before linked and template when disabling', async () => { + const field = makePropertyField({ + attrs: {options: [{id: 'lvl1', name: 'UNCLASSIFIED', color: '#007A33', rank: 1}]}, + }); + const linked = makeLinkedField({attrs: {actions: []}}); + const channel = makeChannelLinkedField(); + + jest.spyOn(Client4, 'getPropertyFields'). + mockResolvedValueOnce([field]). // template field load + mockResolvedValueOnce([linked]). // linked field load + mockResolvedValueOnce([channel]); // channel field lookup during disable + + const deleteOrder: string[] = []; + jest.spyOn(Client4, 'deletePropertyField').mockImplementation(async (_group, objectType, id) => { + if (objectType === CLASSIFICATIONS_CHANNEL_OBJECT_TYPE) { + deleteOrder.push(`channel:${id}`); + } else if (objectType === CLASSIFICATIONS_SYSTEM_OBJECT_TYPE) { + deleteOrder.push(`linked:${id}`); + } else { + deleteOrder.push(`template:${id}`); + } + return {status: 'OK'}; + }); + + // Suppress noisy "not configured to support act" warnings from the bulk state reset. + const origError = console.error; + console.error = (...args: Parameters) => { + if (typeof args[0] === 'string' && args[0].includes('not configured to support act')) { + return; + } + origError(...args); + }; + + try { + renderWithContext(, BASE_STATE); + await screen.findByText('Global Classification Indicators'); + + const user = userEvent.setup(); + + await act(async () => { + await user.click(screen.getByTestId('classificationEnabledfalse')); + }); + + await act(async () => { + await user.click(screen.getByText('Save')); + }); + + await waitFor(() => { + expect(deleteOrder).toHaveLength(3); + }); + await act(async () => {}); + + expect(deleteOrder[0]).toBe(`channel:${channel.id}`); + expect(deleteOrder[1]).toBe('linked:linked_field1'); + expect(deleteOrder[2]).toBe('template:field1'); + } finally { + console.error = origError; + } + }); + + test('should not attempt to delete channel-linked field when none exists', async () => { + const field = makePropertyField({ + attrs: {options: [{id: 'lvl1', name: 'UNCLASSIFIED', color: '#007A33', rank: 1}]}, + }); + const linked = makeLinkedField({attrs: {actions: []}}); + + jest.spyOn(Client4, 'getPropertyFields'). + mockResolvedValueOnce([field]). + mockResolvedValueOnce([linked]). + mockResolvedValueOnce([]); // no channel field exists + + const deletedTypes: string[] = []; + jest.spyOn(Client4, 'deletePropertyField').mockImplementation(async (_group, objectType) => { + deletedTypes.push(objectType); + return {status: 'OK'}; + }); + + const origError = console.error; + console.error = (...args: Parameters) => { + if (typeof args[0] === 'string' && args[0].includes('not configured to support act')) { + return; + } + origError(...args); + }; + + try { + renderWithContext(, BASE_STATE); + await screen.findByText('Global Classification Indicators'); + + const user = userEvent.setup(); + + await act(async () => { + await user.click(screen.getByTestId('classificationEnabledfalse')); + }); + await act(async () => { + await user.click(screen.getByText('Save')); + }); + + await waitFor(() => { + expect(deletedTypes).toEqual([CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE]); + }); + } finally { + console.error = origError; + } + }); +}); diff --git a/webapp/channels/src/components/admin_console/classification_markings/classification_markings.tsx b/webapp/channels/src/components/admin_console/classification_markings/classification_markings.tsx index e780ff8f274..50bc328ac6f 100644 --- a/webapp/channels/src/components/admin_console/classification_markings/classification_markings.tsx +++ b/webapp/channels/src/components/admin_console/classification_markings/classification_markings.tsx @@ -37,12 +37,15 @@ import { DEFAULT_GLOBAL_BANNER, DISPLAY_BANNER_TOP, actionsToGlobalBanner, + fetchChannelClassificationField, fetchClassificationField, fetchLinkedClassificationField, fetchSystemClassificationValue, processClassificationField, + saveCreateChannelLinkedField, saveCreateField, saveCreateLinkedField, + saveDeleteChannelLinkedField, saveDeleteField, saveDeleteLinkedField, savePatchField, @@ -216,20 +219,21 @@ export default function ClassificationMarkings({disabled}: Props) { }, []); const presetDropdownOptions = useMemo((): ValueType[] => { - return [ - ...presets.map((p) => ({value: p.id, label: p.label})), - { + const options = presets.map((p) => ({value: p.id, label: p.label})); + if (presetId === PRESET_CUSTOM) { + options.push({ value: PRESET_CUSTOM, label: formatMessage({ id: 'admin.classification_markings.preset.custom', defaultMessage: 'Custom classification levels', }), - }, - ]; - }, [formatMessage]); + }); + } + return options; + }, [formatMessage, presetId]); const presetDropdownValue = useMemo(() => { - return presetDropdownOptions.find((o) => o.value === presetId) ?? presetDropdownOptions[presetDropdownOptions.length - 1]!; + return presetDropdownOptions.find((o) => o.value === presetId) ?? presetDropdownOptions[0]!; }, [presetDropdownOptions, presetId]); const handlePresetDropdownChange = useCallback((selected: ValueType | null) => { @@ -237,10 +241,6 @@ export default function ClassificationMarkings({disabled}: Props) { return; } const newPresetId = selected.value; - if (newPresetId === PRESET_CUSTOM) { - setPresetId(PRESET_CUSTOM); - return; - } if (levels.length > 0) { setConfirmPresetSwitch(newPresetId); return; @@ -370,9 +370,16 @@ export default function ClassificationMarkings({disabled}: Props) { savedLinked = await savePatchLinkedField(savedLinked.id, effectiveBanner); } + // Ensure the channel_classification linked field exists as part of the set. // Push saved fields into Redux eagerly so the banner updates // atomically rather than waiting for out-of-order WS events. - dispatch({type: PropertyTypes.RECEIVED_PROPERTY_FIELDS, data: {fields: [savedTemplate, savedLinked]}}); + const existingChannelField = await fetchChannelClassificationField(); + if (existingChannelField) { + dispatch({type: PropertyTypes.RECEIVED_PROPERTY_FIELDS, data: {fields: [savedTemplate, savedLinked, existingChannelField]}}); + } else { + const savedChannelField = await saveCreateChannelLinkedField(savedTemplate.id); + dispatch({type: PropertyTypes.RECEIVED_PROPERTY_FIELDS, data: {fields: [savedTemplate, savedLinked, savedChannelField]}}); + } setExistingField(savedTemplate); setExistingLinkedField(savedLinked); @@ -383,7 +390,13 @@ export default function ClassificationMarkings({disabled}: Props) { setInitialGlobalBanner(effectiveBanner); setInitialEnabled(true); } else if (templateField) { - // Linked field must be deleted before the template (deletion protection). + // Linked fields must be deleted before the template (deletion protection). + // Order: channel field -> system field -> template. + const channelField = await fetchChannelClassificationField(); + if (channelField) { + await saveDeleteChannelLinkedField(channelField.id); + dispatch({type: PropertyTypes.PROPERTY_FIELD_DELETED, data: {fieldId: channelField.id}}); + } if (linkedField) { await saveDeleteLinkedField(linkedField.id); dispatch({type: PropertyTypes.PROPERTY_FIELD_DELETED, data: {fieldId: linkedField.id}}); diff --git a/webapp/channels/src/components/admin_console/classification_markings/utils/index.ts b/webapp/channels/src/components/admin_console/classification_markings/utils/index.ts index e8e8b7a1004..78631443dbe 100644 --- a/webapp/channels/src/components/admin_console/classification_markings/utils/index.ts +++ b/webapp/channels/src/components/admin_console/classification_markings/utils/index.ts @@ -8,30 +8,48 @@ import {Client4} from 'mattermost-redux/client'; import type {ClassificationLevel} from './presets'; import {PRESET_CUSTOM, presets} from './presets'; -export const GROUP_NAME = 'classification_markings'; - -// OBJECT_TYPE is 'template' so the classification field acts as the canonical schema -// (a Linked Properties template). Per-channel fields will link to it and inherit its options. -export const OBJECT_TYPE = 'template'; -export const TARGET_TYPE = 'system'; - -// TARGET_ID is intentionally empty for system-scoped template fields. -export const TARGET_ID = ''; -export const FIELD_NAME = 'classification'; -export const LINKED_FIELD_NAME = 'system_classification'; - -// The linked field uses the 'system' object type introduced in #36250. -// System fields are canonicalized server-side: target_type='system', target_id=''. -// System values use the sentinel target_id 'system' and dedicated API routes. -export const LINKED_OBJECT_TYPE = 'system'; - -// System-scoped fields have target_id '' on the field definition. -export const SYSTEM_FIELD_TARGET_ID = ''; - -// The sentinel target_id used by the server for system-scoped property values. -export const SYSTEM_VALUE_TARGET_ID = 'system'; - -// Actions stored on the linked field's attrs.actions to control banner display. +// --------------------------------------------------------------------------- +// Property-field identifiers for the classification-markings feature. +// +// Three logical fields participate: +// 1. Template field — canonical schema (Linked Properties template). The +// admin defines the level options here; per-channel +// and system fields link to it and inherit them. +// 2. System field — linked-to-template; drives the GLOBAL banner. Lives +// on the dedicated 'system' object-type path +// introduced in #36250. +// 3. Channel field — linked-to-template; drives PER-CHANNEL banners. +// +// All three fields are scoped server-side as system fields, so they share the +// same field-level target attributes (`target_type='system'`, `target_id=''`). +// Property *values* for the system field are stored on the dedicated system +// endpoint and use the sentinel target_id 'system'. +// --------------------------------------------------------------------------- + +// Property-field group identifying all classification-markings entities. +export const CLASSIFICATIONS_GROUP_NAME = 'classification_markings'; + +// Field-level target attributes shared by template, system, and channel fields. +// `target_type` is always 'system'; `target_id` is empty for system-scoped +// field definitions (the server canonicalizes both). +export const CLASSIFICATIONS_FIELD_TARGET_TYPE = 'system'; +export const CLASSIFICATIONS_FIELD_TARGET_ID = ''; + +// Template field — the canonical schema. +export const CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE = 'template'; +export const CLASSIFICATIONS_TEMPLATE_FIELD_NAME = 'classification'; + +// System field — drives the global banner. Property *values* live on the +// dedicated system endpoint and use the sentinel target_id 'system'. +export const CLASSIFICATIONS_SYSTEM_OBJECT_TYPE = 'system'; +export const CLASSIFICATIONS_SYSTEM_FIELD_NAME = 'system_classification'; +export const CLASSIFICATIONS_SYSTEM_VALUE_TARGET_ID = 'system'; + +// Channel field — drives the per-channel banner. +export const CLASSIFICATIONS_CHANNEL_OBJECT_TYPE = 'channel'; +export const CLASSIFICATIONS_CHANNEL_FIELD_NAME = 'channel_classification'; + +// Actions stored on the linked fields' attrs.actions to control banner placement. export const DISPLAY_BANNER_TOP = 'display_banner_top'; export const DISPLAY_BANNER_BOTTOM = 'display_banner_bottom'; @@ -143,8 +161,14 @@ export async function fetchClassificationField(): Promise f.name === FIELD_NAME && f.delete_at === 0); + const fields = await Client4.getPropertyFields( // eslint-disable-line no-await-in-loop + CLASSIFICATIONS_GROUP_NAME, + CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE, + CLASSIFICATIONS_FIELD_TARGET_TYPE, + CLASSIFICATIONS_FIELD_TARGET_ID, + {cursorId, cursorCreateAt}, + ); + const found = fields.find((f: PropertyField) => f.name === CLASSIFICATIONS_TEMPLATE_FIELD_NAME && f.delete_at === 0); if (found || fields.length === 0) { return found; } @@ -160,11 +184,11 @@ export async function fetchClassificationField(): Promise { const options = levelsToOptions(levels); - return Client4.createPropertyField(GROUP_NAME, OBJECT_TYPE, { - name: FIELD_NAME, + return Client4.createPropertyField(CLASSIFICATIONS_GROUP_NAME, CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE, { + name: CLASSIFICATIONS_TEMPLATE_FIELD_NAME, type: 'select' as PropertyField['type'], - target_type: TARGET_TYPE, - target_id: TARGET_ID, + target_type: CLASSIFICATIONS_FIELD_TARGET_TYPE, + target_id: CLASSIFICATIONS_FIELD_TARGET_ID, attrs: {options, managed: 'admin'}, permission_field: 'sysadmin', permission_values: 'sysadmin', @@ -173,17 +197,17 @@ export async function saveCreateField(levels: ClassificationLevel[]): Promise { - await Client4.deletePropertyField(GROUP_NAME, OBJECT_TYPE, fieldId); + await Client4.deletePropertyField(CLASSIFICATIONS_GROUP_NAME, CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE, fieldId); } export async function savePatchField(fieldId: string, levels: ClassificationLevel[]): Promise { const options = levelsToOptions(levels); - return Client4.patchPropertyField(GROUP_NAME, OBJECT_TYPE, fieldId, { + return Client4.patchPropertyField(CLASSIFICATIONS_GROUP_NAME, CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE, fieldId, { attrs: {options}, } as Partial); } -// --- Linked system classification field API --- +// --- System field API (drives the global banner) --- export async function fetchLinkedClassificationField(): Promise { const maxItems = 500; @@ -192,8 +216,14 @@ export async function fetchLinkedClassificationField(): Promise f.name === LINKED_FIELD_NAME && f.delete_at === 0 && f.linked_field_id); + const fields = await Client4.getPropertyFields( // eslint-disable-line no-await-in-loop + CLASSIFICATIONS_GROUP_NAME, + CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, + CLASSIFICATIONS_FIELD_TARGET_TYPE, + CLASSIFICATIONS_FIELD_TARGET_ID, + {cursorId, cursorCreateAt}, + ); + const found = fields.find((f: PropertyField) => f.name === CLASSIFICATIONS_SYSTEM_FIELD_NAME && f.delete_at === 0 && f.linked_field_id); if (found || fields.length === 0) { return found; } @@ -208,11 +238,11 @@ export async function fetchLinkedClassificationField(): Promise { - return Client4.createPropertyField(GROUP_NAME, LINKED_OBJECT_TYPE, { - name: LINKED_FIELD_NAME, + return Client4.createPropertyField(CLASSIFICATIONS_GROUP_NAME, CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, { + name: CLASSIFICATIONS_SYSTEM_FIELD_NAME, type: 'select' as PropertyField['type'], - target_type: TARGET_TYPE, - target_id: SYSTEM_FIELD_TARGET_ID, + target_type: CLASSIFICATIONS_FIELD_TARGET_TYPE, + target_id: CLASSIFICATIONS_FIELD_TARGET_ID, linked_field_id: templateFieldId, attrs: { actions: placementToActions(config), @@ -221,7 +251,7 @@ export async function saveCreateLinkedField(templateFieldId: string, config: Glo } export async function savePatchLinkedField(linkedFieldId: string, config: GlobalBannerConfig): Promise { - return Client4.patchPropertyField(GROUP_NAME, LINKED_OBJECT_TYPE, linkedFieldId, { + return Client4.patchPropertyField(CLASSIFICATIONS_GROUP_NAME, CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, linkedFieldId, { attrs: { actions: placementToActions(config), }, @@ -229,7 +259,7 @@ export async function savePatchLinkedField(linkedFieldId: string, config: Global } export async function saveDeleteLinkedField(fieldId: string): Promise { - await Client4.deletePropertyField(GROUP_NAME, LINKED_OBJECT_TYPE, fieldId); + await Client4.deletePropertyField(CLASSIFICATIONS_GROUP_NAME, CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, fieldId); } // --- System classification property value API --- @@ -239,7 +269,7 @@ export async function saveDeleteLinkedField(fieldId: string): Promise { * Uses the dedicated system values endpoint (no target_id in URL). */ export async function fetchSystemClassificationValue(linkedFieldId: string): Promise { - const values = await Client4.getSystemPropertyValues(GROUP_NAME); + const values = await Client4.getSystemPropertyValues(CLASSIFICATIONS_GROUP_NAME); const match = ((values as Array>) ?? []).find((v) => v.field_id === linkedFieldId); return match?.value; } @@ -250,7 +280,51 @@ export async function fetchSystemClassificationValue(linkedFieldId: string): Pro * Returns the saved property values so callers can eagerly update the store. */ export async function saveUpsertSystemValue(linkedFieldId: string, optionId: string): Promise>> { - return Client4.patchSystemPropertyValues(GROUP_NAME, [ + return Client4.patchSystemPropertyValues(CLASSIFICATIONS_GROUP_NAME, [ {field_id: linkedFieldId, value: optionId}, ]); } + +// --- Channel field API (drives per-channel banners) --- + +export async function fetchChannelClassificationField(): Promise { + const maxItems = 500; + let fetched = 0; + let cursorId: string | undefined; + let cursorCreateAt: number | undefined; + + while (fetched < maxItems) { + const fields = await Client4.getPropertyFields( // eslint-disable-line no-await-in-loop + CLASSIFICATIONS_GROUP_NAME, + CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, + CLASSIFICATIONS_FIELD_TARGET_TYPE, + CLASSIFICATIONS_FIELD_TARGET_ID, + {cursorId, cursorCreateAt}, + ); + const found = fields.find((f: PropertyField) => f.name === CLASSIFICATIONS_CHANNEL_FIELD_NAME && f.delete_at === 0 && f.linked_field_id); + if (found || fields.length === 0) { + return found; + } + + fetched += fields.length; + const last = fields[fields.length - 1]; + cursorId = last.id; + cursorCreateAt = last.create_at; + } + + return undefined; +} + +export async function saveCreateChannelLinkedField(templateFieldId: string): Promise { + return Client4.createPropertyField(CLASSIFICATIONS_GROUP_NAME, CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, { + name: CLASSIFICATIONS_CHANNEL_FIELD_NAME, + type: 'select' as PropertyField['type'], + target_type: CLASSIFICATIONS_FIELD_TARGET_TYPE, + target_id: CLASSIFICATIONS_FIELD_TARGET_ID, + linked_field_id: templateFieldId, + }); +} + +export async function saveDeleteChannelLinkedField(fieldId: string): Promise { + await Client4.deletePropertyField(CLASSIFICATIONS_GROUP_NAME, CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, fieldId); +} diff --git a/webapp/channels/src/components/admin_console/classification_markings/utils/preset_dropdown_styles.ts b/webapp/channels/src/components/admin_console/classification_markings/utils/preset_dropdown_styles.ts index 18c9ccb4c52..e8395f69f94 100644 --- a/webapp/channels/src/components/admin_console/classification_markings/utils/preset_dropdown_styles.ts +++ b/webapp/channels/src/components/admin_console/classification_markings/utils/preset_dropdown_styles.ts @@ -66,6 +66,6 @@ export const classificationPresetDropdownStyles: StylesConfig = { }), menuPortal: (provided) => ({ ...provided, - zIndex: 200, + zIndex: 1100, }), }; diff --git a/webapp/channels/src/components/channel_banner/channel_banner.tsx b/webapp/channels/src/components/channel_banner/channel_banner.tsx index cc0e3449a8d..e826cd7d0ff 100644 --- a/webapp/channels/src/components/channel_banner/channel_banner.tsx +++ b/webapp/channels/src/components/channel_banner/channel_banner.tsx @@ -6,12 +6,14 @@ import {useIntl} from 'react-intl'; import {useSelector} from 'react-redux'; import {WithTooltip} from '@mattermost/shared/components/tooltip'; +import type {ChannelBanner} from '@mattermost/types/channels'; import {selectShowChannelBanner} from 'mattermost-redux/selectors/entities/channel_banner'; import {getChannelBanner} from 'mattermost-redux/selectors/entities/channels'; import {getLicense} from 'mattermost-redux/selectors/entities/general'; import {getContrastingSimpleColor} from 'mattermost-redux/utils/theme_utils'; +import useChannelClassificationBanner from 'components/common/hooks/useChannelClassificationBanner'; import Markdown from 'components/markdown'; import {isMinimumEnterpriseAdvancedLicense} from 'utils/license_utils'; @@ -35,7 +37,17 @@ export default function ChannelBanner({channelId}: Props) { const license = useSelector(getLicense); const licenseEnabled = isMinimumEnterpriseAdvancedLicense(license); const channelBannerConfigured = useSelector((state: GlobalState) => selectShowChannelBanner(state, channelId)); - const showChannelBanner = licenseEnabled && channelBannerConfigured; + const showNativeBanner = licenseEnabled && channelBannerConfigured; + + const classificationBanner = useChannelClassificationBanner(channelId); + + // Classification property value takes priority over native banner_info + const effectiveBanner: ChannelBanner | undefined = classificationBanner.hasClassification ? + classificationBanner.classificationBanner : + channelBannerInfo; + + const showBanner = classificationBanner.hasClassification || showNativeBanner; + const textContainerRef = useRef(null); const [tooltipNeeded, setTooltipNeeded] = React.useState(false); @@ -48,31 +60,30 @@ export default function ChannelBanner({channelId}: Props) { const isOverflowingVertically = textContainerRef.current.offsetHeight < textContainerRef.current.scrollHeight; setTooltipNeeded(isOverflowingHorizontally || isOverflowingVertically); - }, [channelBannerInfo?.text]); + }, [effectiveBanner?.text]); const intl = useIntl(); const channelBannerTextAriaLabel = intl.formatMessage({id: 'channel_banner.aria_label', defaultMessage: 'Channel banner text'}); const content = ( ); const channelBannerStyle = useMemo(() => { return { - backgroundColor: channelBannerInfo?.background_color, + backgroundColor: effectiveBanner?.background_color, }; - }, [channelBannerInfo]); + }, [effectiveBanner]); const channelBannerTextStyle = useMemo(() => { - // this is just to satisfy type checks. - if (!channelBannerInfo || !channelBannerInfo.background_color) { + if (!effectiveBanner || !effectiveBanner.background_color) { return {}; } - const color = getContrastingSimpleColor(channelBannerInfo.background_color); + const color = getContrastingSimpleColor(effectiveBanner.background_color); // The CSS variable is declared here, and is being used in the stylesheet being imported in this component. // This is needed because if the user sets background color a share of blue similar to the default link color, @@ -82,9 +93,9 @@ export default function ChannelBanner({channelId}: Props) { color, '--channel-banner-text-color': color, }; - }, [channelBannerInfo]); + }, [effectiveBanner]); - if (!channelBannerInfo || !showChannelBanner) { + if (!effectiveBanner || !showBanner) { return null; } diff --git a/webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.scss b/webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.scss index b88c0b901cb..d01aa15cc76 100644 --- a/webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.scss +++ b/webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.scss @@ -8,6 +8,10 @@ flex-direction: column; gap: 32px; + &--with-save-panel { + padding-bottom: 80px; + } + &__configurationDivider { border: none; border-top: 1px solid rgba(var(--center-channel-color-rgb), 0.08); @@ -62,7 +66,8 @@ right: 5px; } - #channel_banner_banner_text_textbox { + #channel_banner_banner_text_textbox, + #channel_classification_banner_text_textbox { min-height: 40px; max-height: 200px; } @@ -105,5 +110,39 @@ .AdvancedTextbox { margin-top: 0 !important; } + + .DropdownInput.Input_container { + margin-top: 0; + + .Input_fieldset { + padding: 0; + border: none; + box-shadow: none; + + &:hover, + &:focus-within { + border: none; + box-shadow: none; + } + } + + .Input_wrapper { + padding: 0; + margin: 0; + } + + .DropDown__control { + height: 40px; + min-height: 40px; + } + + .DropDown__value-container { + height: 38px; + } + + .DropDown__indicators { + height: 38px; + } + } } } diff --git a/webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.test.tsx b/webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.test.tsx index 8eddbd5ef36..59172664479 100644 --- a/webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.test.tsx +++ b/webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.test.tsx @@ -2,6 +2,12 @@ // See LICENSE.txt for license information. import React from 'react'; +import type {MockStoreEnhanced} from 'redux-mock-store'; + +import {PropertyTypes} from 'mattermost-redux/action_types'; + +import useChannelClassificationBanner from 'components/common/hooks/useChannelClassificationBanner'; +import useClassificationMarkings from 'components/common/hooks/useClassificationMarkings'; import {renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing_utils'; import {TestHelper} from 'utils/test_helper'; @@ -25,6 +31,8 @@ jest.mock('mattermost-redux/client', () => ({ {remote_id: 'remote1', name: 'nebula', display_name: 'Nebula Networks'}, {remote_id: 'remote2', name: 'cascade', display_name: 'Cascade Collaborative'}, ]), + getPropertyValues: jest.fn().mockResolvedValue([]), + patchPropertyValues: jest.fn().mockResolvedValue([]), }, })); @@ -36,6 +44,29 @@ jest.mock('mattermost-redux/selectors/entities/shared_channels', () => { }; }); +jest.mock('components/common/hooks/useChannelClassificationBanner'); +jest.mock('components/common/hooks/useClassificationMarkings'); + +const mockedUseClassificationMarkings = useClassificationMarkings as jest.MockedFunction; +const mockedUseChannelClassificationBanner = useChannelClassificationBanner as jest.MockedFunction; + +// Default classification state: feature unavailable. Individual tests can override. +beforeEach(() => { + mockedUseClassificationMarkings.mockReturnValue({ + available: false, + loading: false, + templateField: null, + channelField: null, + levels: [], + }); + mockedUseChannelClassificationBanner.mockReturnValue({ + hasClassification: false, + classificationBanner: undefined, + classificationId: undefined, + bannerText: undefined, + }); +}); + // Mock the ShowFormat component to make it easier to test jest.mock('components/advanced_text_editor/show_formatting/show_formatting', () => ( jest.fn().mockImplementation((props) => ( @@ -711,4 +742,338 @@ describe('ChannelSettingsConfigurationTab', () => { }); }); }); + + describe('Classification', () => { + const SYSADMIN_USER_ID = 'sysadmin_user_1'; + const sysAdminState = { + entities: { + users: { + currentUserId: SYSADMIN_USER_ID, + profiles: { + [SYSADMIN_USER_ID]: {id: SYSADMIN_USER_ID, roles: 'system_admin system_user'}, + }, + }, + }, + }; + + const TEMPLATE_FIELD_ID = 'template_field_1'; + const CHANNEL_FIELD_ID = 'channel_field_1'; + const LEVEL_UNCLASSIFIED = {id: 'lvl_unclass', name: 'UNCLASSIFIED', color: '#007A33', rank: 1}; + const LEVEL_SECRET = {id: 'lvl_secret', name: 'SECRET', color: '#C8102E', rank: 2}; + + const templateField = { + id: TEMPLATE_FIELD_ID, + group_id: 'classification_markings', + name: 'classification', + type: 'select' as const, + attrs: {options: [LEVEL_UNCLASSIFIED, LEVEL_SECRET]}, + target_id: '', + target_type: 'system', + object_type: 'template', + create_at: 1, + update_at: 1, + delete_at: 0, + created_by: 'u1', + updated_by: 'u1', + }; + + const channelField = { + ...templateField, + id: CHANNEL_FIELD_ID, + name: 'channel_classification', + object_type: 'channel', + linked_field_id: TEMPLATE_FIELD_ID, + attrs: {}, + }; + + function enableClassification(initialBanner: {hasClassification: boolean; classificationId?: string; bannerText?: string} = {hasClassification: false}) { + mockedUseClassificationMarkings.mockReturnValue({ + available: true, + loading: false, + templateField, + channelField, + levels: [LEVEL_UNCLASSIFIED, LEVEL_SECRET], + }); + mockedUseChannelClassificationBanner.mockReturnValue({ + hasClassification: initialBanner.hasClassification, + classificationBanner: initialBanner.hasClassification ? { + enabled: true, + text: initialBanner.bannerText || '', + background_color: '#007A33', + } : undefined, + classificationId: initialBanner.classificationId, + bannerText: initialBanner.bannerText, + }); + } + + it('renders the Classification section when feature is available', () => { + enableClassification(); + renderWithContext( + , + sysAdminState, + ); + + expect(screen.getByText('Classification')).toBeInTheDocument(); + expect(screen.getByTestId('channelClassificationToggle-button')).toBeInTheDocument(); + }); + + it('does not render the Classification section when feature is unavailable', () => { + renderWithContext(, sysAdminState); + + expect(screen.queryByText('Classification')).not.toBeInTheDocument(); + }); + + it('does not render the Classification section for non-sysadmin users', () => { + enableClassification(); + renderWithContext( + , + ); + + expect(screen.queryByText('Classification')).not.toBeInTheDocument(); + }); + + it('auto-selects the lowest-rank level when classification is toggled on', async () => { + const {Client4} = require('mattermost-redux/client'); + const {patchChannel} = require('mattermost-redux/actions/channels'); + patchChannel.mockReturnValue({type: 'MOCK_ACTION', data: {}}); + Client4.patchPropertyValues.mockClear(); + enableClassification(); + + renderWithContext( + , + sysAdminState, + ); + + await userEvent.click(screen.getByTestId('channelClassificationToggle-button')); + + // The lowest-rank level (UNCLASSIFIED) should be auto-selected in the dropdown. + const dropdown = screen.getByTestId('channelClassificationLevel'); + expect(dropdown).toHaveTextContent(LEVEL_UNCLASSIFIED.name); + + // Save button should be enabled since a level is pre-selected. + const saveButton = await screen.findByRole('button', {name: 'Save'}); + expect(saveButton).toBeEnabled(); + }); + + it('saves banner_info via patchChannel when banner text is edited while classification is active', async () => { + const {patchChannel} = require('mattermost-redux/actions/channels'); + patchChannel.mockReturnValue({type: 'MOCK_ACTION', data: {}}); + + enableClassification({ + hasClassification: true, + classificationId: LEVEL_UNCLASSIFIED.id, + bannerText: `**${LEVEL_UNCLASSIFIED.name}**`, + }); + + renderWithContext( + , + sysAdminState, + ); + + const textInput = await screen.findByTestId('channel_banner_banner_text_textbox'); + await userEvent.clear(textInput); + await userEvent.type(textInput, 'Updated text'); + + const saveButton = await screen.findByRole('button', {name: 'Save'}); + await userEvent.click(saveButton); + + await waitFor(() => { + expect(patchChannel).toHaveBeenCalledWith( + 'channel1', + expect.objectContaining({ + banner_info: expect.objectContaining({ + enabled: true, + text: 'Updated text', + }), + }), + ); + }); + }); + + it('does not call patchPropertyValues when classification enabled/id has not changed', async () => { + const {Client4} = require('mattermost-redux/client'); + const {patchChannel} = require('mattermost-redux/actions/channels'); + patchChannel.mockReturnValue({type: 'MOCK_ACTION', data: {}}); + + enableClassification({ + hasClassification: true, + classificationId: LEVEL_UNCLASSIFIED.id, + bannerText: `**${LEVEL_UNCLASSIFIED.name}**`, + }); + + renderWithContext( + , + sysAdminState, + ); + + // Edit only the banner text without changing the classification toggle or level. + const textInput = await screen.findByTestId('channel_banner_banner_text_textbox'); + await userEvent.clear(textInput); + await userEvent.type(textInput, 'Edited banner'); + + const saveButton = await screen.findByRole('button', {name: 'Save'}); + await userEvent.click(saveButton); + + // patchChannel should be called (banner text changed), but patchPropertyValues + // should NOT be called because classification enabled/id are unchanged. + await waitFor(() => { + expect(patchChannel).toHaveBeenCalled(); + }); + + expect(Client4.patchPropertyValues).not.toHaveBeenCalled(); + }); + + it('removes classification by patching value to null and dispatching PROPERTY_VALUE_DELETED', async () => { + const {Client4} = require('mattermost-redux/client'); + Client4.patchPropertyValues.mockResolvedValueOnce([]); + enableClassification({ + hasClassification: true, + classificationId: LEVEL_UNCLASSIFIED.id, + bannerText: `**${LEVEL_UNCLASSIFIED.name}**`, + }); + + const {store} = renderWithContext( + , + sysAdminState, + {useMockedStore: true}, + ); + + // Toggle classification off (it starts on because of `hasClassification: true`). + await userEvent.click(screen.getByTestId('channelClassificationToggle-button')); + + const saveButton = await screen.findByRole('button', {name: 'Save'}); + await userEvent.click(saveButton); + + await waitFor(() => { + expect(Client4.patchPropertyValues).toHaveBeenCalledWith( + 'classification_markings', + 'channel', + 'channel1', + [{field_id: CHANNEL_FIELD_ID, value: null}], + ); + }); + + await waitFor(() => { + const actions = (store as unknown as MockStoreEnhanced).getActions(); + expect(actions.some((a) => a.type === PropertyTypes.PROPERTY_VALUE_DELETED)).toBe(true); + }); + }); + + it('resets classification form to initial state when Reset is clicked', async () => { + enableClassification({ + hasClassification: true, + classificationId: LEVEL_UNCLASSIFIED.id, + bannerText: `**${LEVEL_UNCLASSIFIED.name}**`, + }); + + renderWithContext( + , + sysAdminState, + ); + + // Toggle off → triggers changes → Save panel appears with Reset. + const toggle = screen.getByTestId('channelClassificationToggle-button'); + await userEvent.click(toggle); + + const resetButton = await screen.findByRole('button', {name: 'Reset'}); + await userEvent.click(resetButton); + + // After reset, the Save/Reset panel should be gone and the toggle re-enabled. + await waitFor(() => { + expect(screen.queryByRole('button', {name: 'Reset'})).not.toBeInTheDocument(); + }); + expect(toggle).toHaveClass('active'); + }); + + it('shows an error in the SaveChangesPanel when patchPropertyValues rejects', async () => { + const {Client4} = require('mattermost-redux/client'); + const {patchChannel} = require('mattermost-redux/actions/channels'); + patchChannel.mockReturnValue({type: 'MOCK_ACTION', data: {}}); + Client4.patchPropertyValues.mockRejectedValueOnce({message: 'Server boom'}); + + // Start without classification, so toggling it on creates a classification change. + enableClassification({hasClassification: false}); + + renderWithContext( + , + sysAdminState, + ); + + // Enable classification toggle — lowest-rank level is auto-selected. + await userEvent.click(screen.getByTestId('channelClassificationToggle-button')); + + // Save button should be enabled (level auto-selected). + const saveButton = await screen.findByRole('button', {name: 'Save'}); + expect(saveButton).toBeEnabled(); + + // Click save to trigger the patchPropertyValues rejection. + await userEvent.click(saveButton); + + await waitFor(() => { + expect(screen.getByText(/Server boom/)).toBeInTheDocument(); + }); + }); + + it('shows an error when patchPropertyValues rejects with pre-existing classification', async () => { + const {Client4} = require('mattermost-redux/client'); + const {patchChannel} = require('mattermost-redux/actions/channels'); + patchChannel.mockReturnValue({type: 'MOCK_ACTION', data: {}}); + Client4.patchPropertyValues.mockRejectedValueOnce({message: 'Server boom'}); + + // Start classified → toggle off → toggle back on triggers hasClassificationChanges. + enableClassification({ + hasClassification: true, + classificationId: LEVEL_UNCLASSIFIED.id, + bannerText: `**${LEVEL_UNCLASSIFIED.name}**`, + }); + + renderWithContext( + , + sysAdminState, + ); + + // Toggle off then on to create a classification state change. + const toggle = screen.getByTestId('channelClassificationToggle-button'); + await userEvent.click(toggle); + await userEvent.click(toggle); + + // Now toggle off again — this creates a "disable" change that calls patchPropertyValues(null). + await userEvent.click(toggle); + + const saveButton = await screen.findByRole('button', {name: 'Save'}); + await userEvent.click(saveButton); + + await waitFor(() => { + const errorPanel = screen.getByText(/Server boom/).closest('.SaveChangesPanel'); + expect(errorPanel).toHaveClass('error'); + }); + }); + }); }); diff --git a/webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.tsx b/webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.tsx index 8dc703ea236..9e0d9b70176 100644 --- a/webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.tsx +++ b/webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.tsx @@ -2,21 +2,34 @@ // See LICENSE.txt for license information. import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {useIntl} from 'react-intl'; +import {FormattedMessage, useIntl} from 'react-intl'; import {useDispatch, useSelector} from 'react-redux'; import type {Channel} from '@mattermost/types/channels'; import type {ServerError} from '@mattermost/types/errors'; +import {PropertyTypes} from 'mattermost-redux/action_types'; import {patchChannel} from 'mattermost-redux/actions/channels'; import {fetchChannelRemotes} from 'mattermost-redux/actions/shared_channels'; import {Client4} from 'mattermost-redux/client'; import {isChannelAutotranslated as isChannelAutotranslatedSelector} from 'mattermost-redux/selectors/entities/channels'; import {getRemotesForChannel} from 'mattermost-redux/selectors/entities/shared_channels'; - +import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users'; + +import {ColorSwatch, LevelOptionLabel} from 'components/admin_console/classification_markings/classification_markings_styled'; +import { + CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, + CLASSIFICATIONS_GROUP_NAME, +} from 'components/admin_console/classification_markings/utils'; +import {classificationPresetDropdownStyles} from 'components/admin_console/classification_markings/utils/preset_dropdown_styles'; import ColorInput from 'components/color_input'; +import useChannelClassificationBanner from 'components/common/hooks/useChannelClassificationBanner'; +import useClassificationMarkings from 'components/common/hooks/useClassificationMarkings'; import useDidUpdate from 'components/common/hooks/useDidUpdate'; import ConfirmModal from 'components/confirm_modal'; +import DropdownInput from 'components/dropdown_input'; +import type {ValueType} from 'components/dropdown_input'; +import SectionNotice from 'components/section_notice'; import type {TextboxElement} from 'components/textbox'; import Toggle from 'components/toggle'; import AdvancedTextbox from 'components/widgets/advanced_textbox/advanced_textbox'; @@ -30,8 +43,8 @@ import type {WorkspaceWithStatus} from './share_channel_with_workspaces/types'; import './channel_settings_configuration_tab.scss'; -const CHANNEL_BANNER_MAX_CHARACTER_LIMIT = 1024; -const CHANNEL_BANNER_MIN_CHARACTER_LIMIT = 0; +export const CHANNEL_BANNER_MAX_CHARACTER_LIMIT = 1024; +export const CHANNEL_BANNER_MIN_CHARACTER_LIMIT = 0; const DEFAULT_CHANNEL_BANNER = { enabled: false, @@ -88,6 +101,96 @@ function ChannelSettingsConfigurationTab({ const [characterLimitExceeded, setCharacterLimitExceeded] = useState(false); const hasBannerChanges = bannerHasChanges(initialBannerInfo, updatedChannelBanner); + const classificationBanner = useChannelClassificationBanner(channel.id); + + const classification = useClassificationMarkings(); + const isSystemAdmin = useSelector(isCurrentUserSystemAdmin); + const canManageClassification = classification.available && isSystemAdmin; + const [classificationEnabled, setClassificationEnabled] = useState(classificationBanner.hasClassification); + const [selectedClassificationId, setSelectedClassificationId] = useState(classificationBanner.classificationId || ''); + + const bannerLockedByClassification = classificationEnabled && Boolean(selectedClassificationId); + + useEffect(() => { + setClassificationEnabled(classificationBanner.hasClassification); + setSelectedClassificationId(classificationBanner.classificationId || ''); + + if (classificationBanner.hasClassification && classificationBanner.classificationBanner) { + setUpdatedChannelBanner((prev) => ({ + ...prev, + enabled: true, + text: classificationBanner.classificationBanner?.text ?? prev.text, + background_color: classificationBanner.classificationBanner?.background_color || prev.background_color || DEFAULT_CHANNEL_BANNER.background_color, + })); + } + }, [classificationBanner.hasClassification, classificationBanner.classificationId, classificationBanner.classificationBanner]); + + const classificationOptions = useMemo(() => { + return classification.levels. + filter((l) => l.name.trim() !== ''). + map((l) => ({value: l.id, label: l.name.trim(), color: l.color})); + }, [classification.levels]); + + const selectedClassificationOption = useMemo(() => { + return classificationOptions.find((o) => o.value === selectedClassificationId); + }, [classificationOptions, selectedClassificationId]); + + const formatClassificationOptionLabel = useCallback((option: ValueType) => { + const levelOption = option as ValueType & {color: string}; + return ( + + + {levelOption.label} + + ); + }, []); + + const selectedClassificationColor = useMemo((): string => { + const level = classification.levels.find((l) => l.id === selectedClassificationId); + return level?.color || ''; + }, [classification.levels, selectedClassificationId]); + + const initialClassificationState = useMemo(() => ({ + enabled: classificationBanner.hasClassification, + classificationId: classificationBanner.classificationId || '', + }), [classificationBanner.hasClassification, classificationBanner.classificationId]); + + const hasClassificationChanges = classificationEnabled !== initialClassificationState.enabled || + selectedClassificationId !== initialClassificationState.classificationId; + + const handleClassificationToggle = useCallback(() => { + setClassificationEnabled((prev) => { + if (!prev) { + const lowestRank = classification.levels[0]; + if (lowestRank) { + setSelectedClassificationId(lowestRank.id); + setUpdatedChannelBanner((banner) => ({ + ...banner, + enabled: true, + text: `**${lowestRank.name}**`, + background_color: lowestRank.color, + })); + } else { + setUpdatedChannelBanner((banner) => ({...banner, enabled: true})); + } + } + return !prev; + }); + }, [classification.levels]); + + const handleClassificationLevelChange = useCallback((selected: ValueType) => { + setSelectedClassificationId(selected.value); + const level = classification.levels.find((l) => l.id === selected.value); + if (level) { + setUpdatedChannelBanner((prev) => ({ + ...prev, + enabled: true, + text: `**${level.name}**`, + background_color: level.color, + })); + } + }, [classification.levels]); + const handleBannerToggle = useCallback(() => { const newValue = !updatedChannelBanner.enabled; const toUpdate = { @@ -250,6 +353,7 @@ function ChannelSettingsConfigurationTab({ // Common const hasUnsavedChanges = hasBannerChanges || hasAutoTranslationChanges || + hasClassificationChanges || (canManageSharedChannels && hasWorkspaceChanges); useEffect(() => { @@ -297,7 +401,15 @@ function ChannelSettingsConfigurationTab({ updated.autotranslation = isChannelAutotranslated; } - if (hasAutoTranslationChanges || hasBannerChanges) { + if (hasClassificationChanges && classificationEnabled && selectedClassificationId) { + updated.banner_info = { + text: updatedChannelBanner.text?.trim() || '', + background_color: updatedChannelBanner.background_color?.trim() || '', + enabled: true, + }; + } + + if (hasAutoTranslationChanges || hasBannerChanges || (hasClassificationChanges && classificationEnabled && selectedClassificationId)) { const {error} = await dispatch(patchChannel(channel.id, updated)); if (error) { handleServerError(error as ServerError); @@ -305,6 +417,36 @@ function ChannelSettingsConfigurationTab({ } } + if (hasClassificationChanges && classification.channelField) { + if (classificationEnabled && selectedClassificationId) { + try { + const values = await Client4.patchPropertyValues( + CLASSIFICATIONS_GROUP_NAME, + CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, + channel.id, + [{field_id: classification.channelField.id, value: selectedClassificationId}], + ); + dispatch({type: PropertyTypes.RECEIVED_PROPERTY_VALUES, data: {values}}); + } catch (err) { + handleServerError(err as ServerError); + return false; + } + } else if (!classificationEnabled && initialClassificationState.enabled) { + try { + await Client4.patchPropertyValues( + CLASSIFICATIONS_GROUP_NAME, + CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, + channel.id, + [{field_id: classification.channelField.id, value: null}], + ); + dispatch({type: PropertyTypes.PROPERTY_VALUE_DELETED, data: {targetId: channel.id, fieldId: classification.channelField.id}}); + } catch (err) { + handleServerError(err as ServerError); + return false; + } + } + } + if (canManageSharedChannels && hasWorkspaceChanges) { const initialIds = new Set((initialRemotes || []).map((r) => r.remote_id || r.name)); const currentIds = new Set(workspaceRemotes.map((r) => r.remote_id || r.name)); @@ -352,16 +494,21 @@ function ChannelSettingsConfigurationTab({ }, [ canManageSharedChannels, channel, + classification.channelField, + classificationEnabled, dispatch, formatMessage, handleServerError, hasAutoTranslationChanges, hasBannerChanges, + hasClassificationChanges, hasWorkspaceChanges, initialBannerInfo, + initialClassificationState.enabled, initialIsChannelAutotranslated, initialRemotes, isChannelAutotranslated, + selectedClassificationId, updatedChannelBanner, workspaceRemotes, ]); @@ -414,6 +561,10 @@ function ChannelSettingsConfigurationTab({ setFormError(''); setSaveChangesPanelState(undefined); setCharacterLimitExceeded(false); + + setClassificationEnabled(initialClassificationState.enabled); + setSelectedClassificationId(initialClassificationState.classificationId); + if (canManageSharedChannels) { setSharingEnabled(initialSharingEnabled.current); if (initialRemotes) { @@ -421,19 +572,21 @@ function ChannelSettingsConfigurationTab({ setShareChannelKey(Date.now()); } } - }, [canManageSharedChannels, initialBannerInfo, initialRemotes]); + }, [canManageSharedChannels, initialBannerInfo, initialClassificationState, initialRemotes]); const handleClose = useCallback(() => { setSaveChangesPanelState(undefined); setRequireConfirm(false); }, []); + const classificationFormInvalid = classificationEnabled && !selectedClassificationId; const hasErrors = Boolean(formError) || characterLimitExceeded || + classificationFormInvalid || showTabSwitchError; return ( -
+
{canManageSharedChannels && ( <> )} - {canManageSharedChannels && canManageBanner && ( + {canManageSharedChannels && (canManageClassification || canManageBanner) && ( +
+ )} + + {canManageClassification && ( + <> +
+
+ + + + + + +
+ +
+ +
+
+ + {classificationEnabled && ( +
+
+ + } + text={formatMessage({id: 'admin.classification_markings.notice.body', defaultMessage: 'Markings are not tied to access control decisions at this time and are for display purposes only.'})} + /> +
+ +
+ + + +
+ +
+
+
+ )} + + )} + + {canManageClassification && canManageBanner && (
)} @@ -491,9 +723,9 @@ function ChannelSettingsConfigurationTab({ id='channelBannerToggle' ariaLabel={bannerHeading} size='btn-md' - disabled={false} + disabled={bannerLockedByClassification} onToggle={handleBannerToggle} - toggled={updatedChannelBanner.enabled} + toggled={bannerLockedByClassification || updatedChannelBanner.enabled} tabIndex={0} toggleClassName='btn-toggle-primary' /> @@ -501,7 +733,7 @@ function ChannelSettingsConfigurationTab({
{ - updatedChannelBanner.enabled && + (bannerLockedByClassification || updatedChannelBanner.enabled) &&
{/*Banner text section*/}
@@ -544,7 +776,8 @@ function ChannelSettingsConfigurationTab({
diff --git a/webapp/channels/src/components/channel_settings_modal/channel_settings_modal.scss b/webapp/channels/src/components/channel_settings_modal/channel_settings_modal.scss index 4cb085aca46..9a6b22e61bc 100644 --- a/webapp/channels/src/components/channel_settings_modal/channel_settings_modal.scss +++ b/webapp/channels/src/components/channel_settings_modal/channel_settings_modal.scss @@ -13,7 +13,7 @@ line-height: 20px; } - label.Input_subheading { + .Input_subheading { color: rgba(var(--center-channel-color-rgb), 0.64); font-family: Open Sans, sans-serif; font-size: 12px; @@ -30,10 +30,15 @@ border-radius: var(--radius-l); box-shadow: var(--elevation-6); + .modal-header { + flex-shrink: 0; + } + .modal-body { display: flex; width: auto; - min-height: 150px; + min-height: 0; + flex: 1 1 auto; flex-direction: column; margin: 0; gap: 24px; diff --git a/webapp/channels/src/components/color_input.tsx b/webapp/channels/src/components/color_input.tsx index c8764b955d8..a02d9250b23 100644 --- a/webapp/channels/src/components/color_input.tsx +++ b/webapp/channels/src/components/color_input.tsx @@ -122,23 +122,21 @@ const ColorInput = ({ disabled={isDisabled} data-testid='color-inputColorValue' /> - {!isDisabled && - - - - } + + + {isOpened && (
({ + __esModule: true, + ...jest.requireActual('react-redux'), +})); + +const CHANNEL_ID = 'channel_id_1'; +const FIELD_ID = 'channel_field_1'; + +function makeChannelField(overrides: Partial = {}): PropertyField { + return { + id: FIELD_ID, + group_id: CLASSIFICATIONS_GROUP_NAME, + name: CLASSIFICATIONS_CHANNEL_FIELD_NAME, + type: 'select', + attrs: {}, + target_id: '', + target_type: CLASSIFICATIONS_FIELD_TARGET_TYPE, + object_type: CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, + linked_field_id: 'template1', + create_at: 1000, + update_at: 1000, + delete_at: 0, + created_by: 'user1', + updated_by: 'user1', + ...overrides, + }; +} + +function makePropertyValue(value: string | null): PropertyValue { + return { + id: 'value1', + target_id: CHANNEL_ID, + target_type: CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, + group_id: CLASSIFICATIONS_GROUP_NAME, + field_id: FIELD_ID, + value: value as string, + create_at: 2000, + update_at: 2000, + delete_at: 0, + created_by: 'user1', + updated_by: 'user1', + }; +} + +const SAMPLE_LEVELS: ClassificationLevel[] = [ + {id: 'lvl1', name: 'UNCLASSIFIED', color: '#007A33', rank: 1}, + {id: 'lvl2', name: 'SECRET', color: '#C8102E', rank: 2}, +]; + +type PartialState = Parameters[1]; + +function stateWithValue( + value: PropertyValue | undefined, + bannerInfo?: {enabled?: boolean; text?: string; background_color?: string}, +): PartialState { + return { + entities: { + channels: { + channels: { + [CHANNEL_ID]: { + id: CHANNEL_ID, + banner_info: bannerInfo, + }, + }, + }, + properties: { + values: { + byTargetId: value ? {[CHANNEL_ID]: {[FIELD_ID]: value}} : {}, + }, + }, + }, + } as PartialState; +} + +function mockClassification(overrides: Partial = {}) { + return jest.spyOn(ClassificationHook, 'default').mockReturnValue({ + available: true, + loading: false, + templateField: null, + channelField: makeChannelField(), + levels: SAMPLE_LEVELS, + ...overrides, + }); +} + +describe('useChannelClassificationBanner', () => { + const dispatchMock = jest.fn(); + + beforeAll(() => { + jest.spyOn(ReactRedux, 'useDispatch').mockImplementation(() => dispatchMock); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + beforeEach(() => { + dispatchMock.mockClear(); + jest.spyOn(Client4, 'getPropertyValues').mockResolvedValue([]); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.spyOn(ReactRedux, 'useDispatch').mockImplementation(() => dispatchMock); + }); + + test('returns hasClassification=false when no property value exists for the channel', () => { + mockClassification(); + + const {result} = renderHookWithContext( + () => useChannelClassificationBanner(CHANNEL_ID), + stateWithValue(undefined), + ); + + expect(result.current.hasClassification).toBe(false); + expect(result.current.classificationBanner).toBeUndefined(); + expect(result.current.classificationId).toBeUndefined(); + expect(result.current.bannerText).toBeUndefined(); + }); + + test('returns hasClassification=false when property value contains null value', () => { + mockClassification(); + const value = makePropertyValue(null); + + const {result} = renderHookWithContext( + () => useChannelClassificationBanner(CHANNEL_ID), + stateWithValue(value), + ); + + expect(result.current.hasClassification).toBe(false); + expect(result.current.classificationBanner).toBeUndefined(); + }); + + test('maps a valid string classification_id to the matching level banner shape with text from banner_info', () => { + mockClassification(); + const value = makePropertyValue('lvl2'); + + const {result} = renderHookWithContext( + () => useChannelClassificationBanner(CHANNEL_ID), + stateWithValue(value, {enabled: true, text: '**SECRET**', background_color: '#C8102E'}), + ); + + expect(result.current.hasClassification).toBe(true); + expect(result.current.classificationId).toBe('lvl2'); + expect(result.current.bannerText).toBe('**SECRET**'); + expect(result.current.classificationBanner).toEqual({ + enabled: true, + text: '**SECRET**', + background_color: '#C8102E', + }); + }); + + test('falls back to level name when banner_info.text is missing but classification is set', () => { + mockClassification(); + const value = makePropertyValue('lvl1'); + + const {result} = renderHookWithContext( + () => useChannelClassificationBanner(CHANNEL_ID), + stateWithValue(value), + ); + + expect(result.current.hasClassification).toBe(true); + expect(result.current.classificationId).toBe('lvl1'); + expect(result.current.bannerText).toBe('**UNCLASSIFIED**'); + expect(result.current.classificationBanner).toEqual({ + enabled: true, + text: '**UNCLASSIFIED**', + background_color: '#007A33', + }); + }); + + test('returns hasClassification=false when the referenced level no longer exists', () => { + mockClassification(); + const value = makePropertyValue('deleted_lvl'); + + const {result} = renderHookWithContext( + () => useChannelClassificationBanner(CHANNEL_ID), + stateWithValue(value), + ); + + expect(result.current.hasClassification).toBe(false); + expect(result.current.classificationBanner).toBeUndefined(); + }); + + test('returns hasClassification=false for legacy object-shaped property values', () => { + mockClassification(); + + // Simulate a pre-migration object-shaped value that should be treated as invalid + const legacyValue = { + id: 'value1', + target_id: CHANNEL_ID, + target_type: CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, + group_id: CLASSIFICATIONS_GROUP_NAME, + field_id: FIELD_ID, + value: {classification_id: 'lvl1', banner_text: 'test'} as unknown as string, + create_at: 2000, + update_at: 2000, + delete_at: 0, + created_by: 'user1', + updated_by: 'user1', + }; + + const {result} = renderHookWithContext( + () => useChannelClassificationBanner(CHANNEL_ID), + stateWithValue(legacyValue as PropertyValue), + ); + + expect(result.current.hasClassification).toBe(false); + expect(result.current.classificationBanner).toBeUndefined(); + }); + + test('returns empty state and skips fetching when channelField is missing', () => { + mockClassification({channelField: null, available: false}); + + const fetchSpy = jest.spyOn(Client4, 'getPropertyValues'); + + const {result} = renderHookWithContext( + () => useChannelClassificationBanner(CHANNEL_ID), + stateWithValue(undefined), + ); + + expect(result.current.hasClassification).toBe(false); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + test('returns empty state when classification is unavailable (feature flag/license off)', () => { + mockClassification({available: false, channelField: makeChannelField(), levels: []}); + + const fetchSpy = jest.spyOn(Client4, 'getPropertyValues'); + + const {result} = renderHookWithContext( + () => useChannelClassificationBanner(CHANNEL_ID), + stateWithValue(undefined), + ); + + expect(result.current.hasClassification).toBe(false); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + test('does not attempt to fetch when channelId is empty', () => { + mockClassification(); + const fetchSpy = jest.spyOn(Client4, 'getPropertyValues'); + + const {result} = renderHookWithContext( + () => useChannelClassificationBanner(''), + stateWithValue(undefined), + ); + + expect(result.current.hasClassification).toBe(false); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + test('fetches property values when none exist and classification is available', async () => { + mockClassification(); + const fetchSpy = jest.spyOn(Client4, 'getPropertyValues').mockResolvedValue([]); + + renderHookWithContext( + () => useChannelClassificationBanner(CHANNEL_ID), + stateWithValue(undefined), + ); + + await Promise.resolve(); + expect(fetchSpy).toHaveBeenCalledWith(CLASSIFICATIONS_GROUP_NAME, CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, CHANNEL_ID); + }); + + test('silently ignores fetch errors (channel may not have classification set)', async () => { + mockClassification(); + jest.spyOn(Client4, 'getPropertyValues').mockRejectedValue(new Error('404')); + + const {result} = renderHookWithContext( + () => useChannelClassificationBanner(CHANNEL_ID), + stateWithValue(undefined), + ); + + await Promise.resolve(); + expect(result.current.hasClassification).toBe(false); + }); +}); diff --git a/webapp/channels/src/components/common/hooks/useChannelClassificationBanner.ts b/webapp/channels/src/components/common/hooks/useChannelClassificationBanner.ts new file mode 100644 index 00000000000..cc757b8d5c1 --- /dev/null +++ b/webapp/channels/src/components/common/hooks/useChannelClassificationBanner.ts @@ -0,0 +1,113 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useEffect, useMemo} from 'react'; +import {useDispatch, useSelector} from 'react-redux'; + +import type {ChannelBanner} from '@mattermost/types/channels'; +import type {PropertyValue} from '@mattermost/types/properties'; +import type {GlobalState} from '@mattermost/types/store'; + +import {PropertyTypes} from 'mattermost-redux/action_types'; +import {Client4} from 'mattermost-redux/client'; +import {getChannelBanner} from 'mattermost-redux/selectors/entities/channels'; +import {getPropertyValueForTargetField} from 'mattermost-redux/selectors/entities/properties'; + +import { + CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, + CLASSIFICATIONS_GROUP_NAME, +} from 'components/admin_console/classification_markings/utils'; + +import useClassificationMarkings from './useClassificationMarkings'; + +export type ChannelClassificationBannerState = { + hasClassification: boolean; + classificationBanner: ChannelBanner | undefined; + classificationId: string | undefined; + bannerText: string | undefined; +}; + +/** + * Resolves the effective banner display for a channel by checking whether a + * classification property value exists. If one does, its color (from the level + * definition) and text (from the channel's banner_info) take priority over + * the channel's native banner_info. + * + * The PropertyValue stores only the classification_id (a plain string). + * The banner text lives in channel.banner_info.text so that the property + * value stays a single scalar. + */ +export default function useChannelClassificationBanner(channelId: string): ChannelClassificationBannerState { + const dispatch = useDispatch(); + const classification = useClassificationMarkings(); + + const fieldId = classification.channelField?.id ?? ''; + + const propertyValue = useSelector((state: GlobalState) => { + if (!fieldId || !channelId) { + return undefined; + } + return getPropertyValueForTargetField(state, channelId, fieldId) as PropertyValue | undefined; + }); + + const channelBannerInfo = useSelector((state: GlobalState) => getChannelBanner(state, channelId)); + + useEffect(() => { + if (!channelId || !classification.available || !classification.channelField) { + return; + } + + if (!propertyValue) { + Client4.getPropertyValues( + CLASSIFICATIONS_GROUP_NAME, + CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, + channelId, + ).then((values) => { + if (values && values.length > 0) { + dispatch({ + type: PropertyTypes.RECEIVED_PROPERTY_VALUES, + data: {values}, + }); + } + }).catch(() => { + // Silently ignore - channel may not have a classification set + }); + } + }, [channelId, classification.available, classification.channelField, propertyValue, dispatch]); + + return useMemo((): ChannelClassificationBannerState => { + const noClassification: ChannelClassificationBannerState = { + hasClassification: false, + classificationBanner: undefined, + classificationId: undefined, + bannerText: undefined, + }; + + if (!propertyValue || !propertyValue.value) { + return noClassification; + } + + const classificationId = propertyValue.value; + if (typeof classificationId !== 'string') { + return noClassification; + } + + const level = classification.levels.find((l) => l.id === classificationId); + if (!level) { + return noClassification; + } + + const bannerText = channelBannerInfo?.text ?? `**${level.name}**`; + + return { + hasClassification: true, + classificationBanner: { + enabled: true, + text: bannerText, + background_color: level.color, + }, + classificationId, + bannerText, + }; + }, [propertyValue, classification.levels, channelBannerInfo]); +} diff --git a/webapp/channels/src/components/common/hooks/useClassificationMarkings.test.ts b/webapp/channels/src/components/common/hooks/useClassificationMarkings.test.ts new file mode 100644 index 00000000000..bd7557dccfa --- /dev/null +++ b/webapp/channels/src/components/common/hooks/useClassificationMarkings.test.ts @@ -0,0 +1,298 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import * as ReactRedux from 'react-redux'; + +import type {PropertyField} from '@mattermost/types/properties'; +import type {GlobalState} from '@mattermost/types/store'; + +import { + CLASSIFICATIONS_CHANNEL_FIELD_NAME, + CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, + CLASSIFICATIONS_FIELD_TARGET_TYPE, + CLASSIFICATIONS_GROUP_NAME, + CLASSIFICATIONS_TEMPLATE_FIELD_NAME, + CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE, +} from 'components/admin_console/classification_markings/utils'; + +import {renderHookWithContext} from 'tests/react_testing_utils'; + +import useClassificationMarkings, {selectClassificationTemplateField} from './useClassificationMarkings'; + +type PartialState = Parameters[1]; + +jest.mock('react-redux', () => ({ + __esModule: true, + ...jest.requireActual('react-redux'), +})); + +function makeTemplateField(overrides: Partial = {}): PropertyField { + return { + id: 'template1', + group_id: CLASSIFICATIONS_GROUP_NAME, + name: CLASSIFICATIONS_TEMPLATE_FIELD_NAME, + type: 'select', + attrs: {options: [{id: 'lvl1', name: 'UNCLASSIFIED', color: '#007A33', rank: 1}]}, + target_id: '', + target_type: CLASSIFICATIONS_FIELD_TARGET_TYPE, + object_type: CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE, + create_at: 1000, + update_at: 1000, + delete_at: 0, + created_by: 'user1', + updated_by: 'user1', + ...overrides, + }; +} + +function makeChannelField(overrides: Partial = {}): PropertyField { + return { + id: 'channel1', + group_id: CLASSIFICATIONS_GROUP_NAME, + name: CLASSIFICATIONS_CHANNEL_FIELD_NAME, + type: 'select', + attrs: {}, + target_id: '', + target_type: CLASSIFICATIONS_FIELD_TARGET_TYPE, + object_type: CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, + linked_field_id: 'template1', + create_at: 2000, + update_at: 2000, + delete_at: 0, + created_by: 'user1', + updated_by: 'user1', + ...overrides, + }; +} + +const ENTERPRISE_LICENSE = {IsLicensed: 'true', SkuShortName: 'enterprise'}; +const STARTER_LICENSE = {IsLicensed: 'true', SkuShortName: 'starter'}; + +function stateWith({featureFlag, license, fields = {}}: { + featureFlag?: string; + license?: typeof ENTERPRISE_LICENSE | typeof STARTER_LICENSE | Record; + fields?: Record; +}): PartialState { + return { + entities: { + general: { + config: featureFlag === undefined ? {} : {FeatureFlagClassificationMarkings: featureFlag}, + license: license ?? {}, + }, + properties: { + fields: {byId: fields}, + }, + }, + } as PartialState; +} + +describe('useClassificationMarkings', () => { + const dispatchMock = jest.fn(); + + beforeAll(() => { + jest.spyOn(ReactRedux, 'useDispatch').mockImplementation(() => dispatchMock); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + beforeEach(() => { + dispatchMock.mockClear(); + }); + + test('returns available=false when feature flag is disabled', () => { + const {result} = renderHookWithContext( + () => useClassificationMarkings(), + stateWith({featureFlag: 'false', license: ENTERPRISE_LICENSE}), + ); + + expect(result.current.available).toBe(false); + expect(result.current.loading).toBe(false); + expect(result.current.levels).toEqual([]); + expect(dispatchMock).not.toHaveBeenCalled(); + }); + + test('returns available=false when feature flag is missing from config', () => { + const {result} = renderHookWithContext( + () => useClassificationMarkings(), + stateWith({license: ENTERPRISE_LICENSE}), + ); + + expect(result.current.available).toBe(false); + expect(result.current.loading).toBe(false); + expect(dispatchMock).not.toHaveBeenCalled(); + }); + + test('returns available=false when license is not Enterprise', () => { + const {result} = renderHookWithContext( + () => useClassificationMarkings(), + stateWith({featureFlag: 'true', license: STARTER_LICENSE}), + ); + + expect(result.current.available).toBe(false); + expect(result.current.loading).toBe(false); + expect(dispatchMock).not.toHaveBeenCalled(); + }); + + test('returns available=false when license is missing entirely', () => { + const {result} = renderHookWithContext( + () => useClassificationMarkings(), + stateWith({featureFlag: 'true', license: {}}), + ); + + expect(result.current.available).toBe(false); + expect(dispatchMock).not.toHaveBeenCalled(); + }); + + test('returns loading=true and dispatches fetches when flag and license are on but no fields are loaded', () => { + const {result} = renderHookWithContext( + () => useClassificationMarkings(), + stateWith({featureFlag: 'true', license: ENTERPRISE_LICENSE}), + ); + + expect(result.current.loading).toBe(true); + expect(result.current.available).toBe(false); + expect(result.current.templateField).toBeNull(); + expect(result.current.channelField).toBeNull(); + expect(result.current.levels).toEqual([]); + + // The hook dispatches one fetch for the template field and one for the channel field. + expect(dispatchMock).toHaveBeenCalledTimes(2); + }); + + test('returns available=true and derives levels when template field is loaded', () => { + const template = makeTemplateField({ + attrs: { + options: [ + {id: 'lvl2', name: 'SECRET', color: '#C8102E', rank: 2}, + {id: 'lvl1', name: 'UNCLASSIFIED', color: '#007A33', rank: 1}, + ], + }, + }); + + const {result} = renderHookWithContext( + () => useClassificationMarkings(), + stateWith({featureFlag: 'true', license: ENTERPRISE_LICENSE, fields: {template1: template}}), + ); + + expect(result.current.available).toBe(true); + expect(result.current.loading).toBe(false); + expect(result.current.templateField).toBe(template); + expect(result.current.levels).toHaveLength(2); + + // Levels are sorted by rank ascending. + expect(result.current.levels[0].name).toBe('UNCLASSIFIED'); + expect(result.current.levels[1].name).toBe('SECRET'); + }); + + test('returns available=false when template field exists but has no levels', () => { + const template = makeTemplateField({attrs: {options: []}}); + + const {result} = renderHookWithContext( + () => useClassificationMarkings(), + stateWith({featureFlag: 'true', license: ENTERPRISE_LICENSE, fields: {template1: template}}), + ); + + expect(result.current.available).toBe(false); + expect(result.current.loading).toBe(false); + expect(result.current.templateField).toBe(template); + expect(result.current.levels).toEqual([]); + }); + + test('exposes channelField when it exists in the store', () => { + const template = makeTemplateField(); + const channel = makeChannelField(); + + const {result} = renderHookWithContext( + () => useClassificationMarkings(), + stateWith({ + featureFlag: 'true', + license: ENTERPRISE_LICENSE, + fields: {template1: template, channel1: channel}, + }), + ); + + expect(result.current.channelField).toBe(channel); + expect(result.current.templateField).toBe(template); + }); + + test('returns channelField=null when channel-linked field is missing linked_field_id', () => { + const template = makeTemplateField(); + const orphan = makeChannelField({id: 'orphan', linked_field_id: ''}); + + const {result} = renderHookWithContext( + () => useClassificationMarkings(), + stateWith({ + featureFlag: 'true', + license: ENTERPRISE_LICENSE, + fields: {template1: template, orphan}, + }), + ); + + expect(result.current.channelField).toBeNull(); + }); + + test('returns channelField=null when channel-linked field is soft-deleted', () => { + const template = makeTemplateField(); + const deleted = makeChannelField({delete_at: 9999}); + + const {result} = renderHookWithContext( + () => useClassificationMarkings(), + stateWith({ + featureFlag: 'true', + license: ENTERPRISE_LICENSE, + fields: {template1: template, channel1: deleted}, + }), + ); + + expect(result.current.channelField).toBeNull(); + }); + + test('does not dispatch fetch when both fields are already in the store', () => { + const template = makeTemplateField(); + const channel = makeChannelField(); + + renderHookWithContext( + () => useClassificationMarkings(), + stateWith({ + featureFlag: 'true', + license: ENTERPRISE_LICENSE, + fields: {template1: template, channel1: channel}, + }), + ); + + expect(dispatchMock).not.toHaveBeenCalled(); + }); +}); + +describe('selectClassificationTemplateField', () => { + function fullState(fields: Record = {}): GlobalState { + return stateWith({fields}) as unknown as GlobalState; + } + + test('returns undefined when properties store is empty', () => { + expect(selectClassificationTemplateField(fullState())).toBeUndefined(); + }); + + test('returns undefined when properties.fields.byId is missing', () => { + const state = {entities: {properties: {fields: {}}}} as unknown as GlobalState; + expect(selectClassificationTemplateField(state)).toBeUndefined(); + }); + + test('returns the matching template field by name and object_type', () => { + const template = makeTemplateField(); + expect(selectClassificationTemplateField(fullState({template1: template}))).toBe(template); + }); + + test('ignores soft-deleted template fields', () => { + const deleted = makeTemplateField({delete_at: 9999}); + expect(selectClassificationTemplateField(fullState({template1: deleted}))).toBeUndefined(); + }); + + test('ignores fields with different object_type or name', () => { + const wrongName = makeTemplateField({name: 'something_else'}); + const wrongType = makeTemplateField({id: 'other', object_type: 'system'}); + expect(selectClassificationTemplateField(fullState({wrongName, wrongType}))).toBeUndefined(); + }); +}); diff --git a/webapp/channels/src/components/common/hooks/useClassificationMarkings.ts b/webapp/channels/src/components/common/hooks/useClassificationMarkings.ts new file mode 100644 index 00000000000..d2264b0349a --- /dev/null +++ b/webapp/channels/src/components/common/hooks/useClassificationMarkings.ts @@ -0,0 +1,110 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useEffect, useMemo} from 'react'; +import {useDispatch, useSelector} from 'react-redux'; + +import type {PropertyField, PropertyFieldOption} from '@mattermost/types/properties'; +import type {GlobalState} from '@mattermost/types/store'; + +import {fetchPropertyFields} from 'mattermost-redux/actions/properties'; +import {getFeatureFlagValue, getLicense} from 'mattermost-redux/selectors/entities/general'; + +import { + CLASSIFICATIONS_CHANNEL_FIELD_NAME, + CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, + CLASSIFICATIONS_FIELD_TARGET_ID, + CLASSIFICATIONS_FIELD_TARGET_TYPE, + CLASSIFICATIONS_GROUP_NAME, + CLASSIFICATIONS_TEMPLATE_FIELD_NAME, + CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE, + optionsToLevels, +} from 'components/admin_console/classification_markings/utils'; +import type {ClassificationLevel} from 'components/admin_console/classification_markings/utils/presets'; + +import {isEnterpriseLicense} from 'utils/license_utils'; + +export function selectClassificationTemplateField(state: GlobalState): PropertyField | undefined { + const byId = state.entities.properties?.fields?.byId; + if (!byId) { + return undefined; + } + return Object.values(byId).find( + (f) => f.object_type === CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE && f.name === CLASSIFICATIONS_TEMPLATE_FIELD_NAME && f.delete_at === 0, + ); +} + +function selectChannelClassificationField(state: GlobalState): PropertyField | undefined { + const byId = state.entities.properties?.fields?.byId; + if (!byId) { + return undefined; + } + return Object.values(byId).find( + (f) => f.object_type === CLASSIFICATIONS_CHANNEL_OBJECT_TYPE && f.name === CLASSIFICATIONS_CHANNEL_FIELD_NAME && f.linked_field_id && f.delete_at === 0, + ); +} + +export type ClassificationMarkingsState = { + available: boolean; + loading: boolean; + templateField: PropertyField | null; + channelField: PropertyField | null; + levels: ClassificationLevel[]; +}; + +/** + * Reusable hook that gates classification markings availability. + * Returns available=true only when all 3 conditions are met: + * 1. ClassificationMarkings feature flag is enabled + * 2. Enterprise license is active + * 3. Template classification field exists with at least one level configured + * + * Also fetches the channel_classification linked field for consumers that need it. + */ +export default function useClassificationMarkings(): ClassificationMarkingsState { + const dispatch = useDispatch(); + + const featureEnabled = useSelector( + (state: GlobalState) => getFeatureFlagValue(state, 'ClassificationMarkings') === 'true', + ); + const license = useSelector(getLicense); + const hasEnterpriseLicense = isEnterpriseLicense(license); + const templateField = useSelector(selectClassificationTemplateField) ?? null; + const channelField = useSelector(selectChannelClassificationField) ?? null; + + useEffect(() => { + if (!featureEnabled || !hasEnterpriseLicense) { + return; + } + if (!templateField) { + dispatch(fetchPropertyFields( + CLASSIFICATIONS_GROUP_NAME, + CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE, + CLASSIFICATIONS_FIELD_TARGET_TYPE, + CLASSIFICATIONS_FIELD_TARGET_ID, + )); + } + if (!channelField) { + dispatch(fetchPropertyFields( + CLASSIFICATIONS_GROUP_NAME, + CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, + CLASSIFICATIONS_FIELD_TARGET_TYPE, + CLASSIFICATIONS_FIELD_TARGET_ID, + )); + } + }, [featureEnabled, hasEnterpriseLicense, templateField, channelField, dispatch]); + + const levels = useMemo((): ClassificationLevel[] => { + if (!templateField) { + return []; + } + const options = (templateField.attrs?.options as PropertyFieldOption[]) || []; + return optionsToLevels(options); + }, [templateField]); + + const loading = featureEnabled && hasEnterpriseLicense && !templateField; + + const available = featureEnabled && hasEnterpriseLicense && levels.length > 0; + + return {available, loading, templateField, channelField, levels}; +} diff --git a/webapp/channels/src/components/global_classification_banner/global_classification_banner.test.tsx b/webapp/channels/src/components/global_classification_banner/global_classification_banner.test.tsx index 74ae87c750c..77270c20d90 100644 --- a/webapp/channels/src/components/global_classification_banner/global_classification_banner.test.tsx +++ b/webapp/channels/src/components/global_classification_banner/global_classification_banner.test.tsx @@ -11,12 +11,12 @@ import {Client4} from 'mattermost-redux/client'; import { DISPLAY_BANNER_BOTTOM, DISPLAY_BANNER_TOP, - GROUP_NAME, - LINKED_OBJECT_TYPE, - OBJECT_TYPE, - SYSTEM_FIELD_TARGET_ID, - SYSTEM_VALUE_TARGET_ID, - TARGET_TYPE, + CLASSIFICATIONS_FIELD_TARGET_ID, + CLASSIFICATIONS_FIELD_TARGET_TYPE, + CLASSIFICATIONS_GROUP_NAME, + CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, + CLASSIFICATIONS_SYSTEM_VALUE_TARGET_ID, + CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE, } from 'components/admin_console/classification_markings/utils'; import {renderWithContext, screen} from 'tests/react_testing_utils'; @@ -35,11 +35,11 @@ const LINKED_FIELD_ID = 'linked_field1'; function makeTemplateField(options: Array<{id: string; name: string; color: string}>): PropertyField { return { id: TEMPLATE_FIELD_ID, - group_id: GROUP_NAME, + group_id: CLASSIFICATIONS_GROUP_NAME, name: 'classification', type: 'select', - object_type: OBJECT_TYPE, - target_type: TARGET_TYPE, + object_type: CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE, + target_type: CLASSIFICATIONS_FIELD_TARGET_TYPE, target_id: '', create_at: 1000, update_at: 1000, @@ -56,12 +56,12 @@ function makeTemplateField(options: Array<{id: string; name: string; color: stri function makeLinkedField(actions: string[], options: Array<{id: string; name: string; color: string}> = []): PropertyField { return { id: LINKED_FIELD_ID, - group_id: GROUP_NAME, + group_id: CLASSIFICATIONS_GROUP_NAME, name: 'system_classification', type: 'select', - object_type: LINKED_OBJECT_TYPE, - target_type: TARGET_TYPE, - target_id: SYSTEM_FIELD_TARGET_ID, + object_type: CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, + target_type: CLASSIFICATIONS_FIELD_TARGET_TYPE, + target_id: CLASSIFICATIONS_FIELD_TARGET_ID, linked_field_id: TEMPLATE_FIELD_ID, create_at: 2000, update_at: 2000, @@ -78,9 +78,9 @@ function makeLinkedField(actions: string[], options: Array<{id: string; name: st function makeSystemValue(optionId: string): PropertyValue { return { id: 'value1', - target_id: SYSTEM_VALUE_TARGET_ID, - target_type: LINKED_OBJECT_TYPE, - group_id: GROUP_NAME, + target_id: CLASSIFICATIONS_SYSTEM_VALUE_TARGET_ID, + target_type: CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, + group_id: CLASSIFICATIONS_GROUP_NAME, field_id: LINKED_FIELD_ID, value: optionId, create_at: 3000, @@ -317,10 +317,10 @@ describe('GlobalClassificationBanner', () => { ); expect(Client4.getPropertyFields).toHaveBeenCalledWith( - GROUP_NAME, - LINKED_OBJECT_TYPE, - TARGET_TYPE, - SYSTEM_FIELD_TARGET_ID, + CLASSIFICATIONS_GROUP_NAME, + CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, + CLASSIFICATIONS_FIELD_TARGET_TYPE, + CLASSIFICATIONS_FIELD_TARGET_ID, expect.anything(), ); }); diff --git a/webapp/channels/src/components/global_classification_banner/global_classification_banner.tsx b/webapp/channels/src/components/global_classification_banner/global_classification_banner.tsx index 4ed33322b7c..a7d81a46f66 100644 --- a/webapp/channels/src/components/global_classification_banner/global_classification_banner.tsx +++ b/webapp/channels/src/components/global_classification_banner/global_classification_banner.tsx @@ -13,19 +13,18 @@ import {getPropertyValueForTargetField} from 'mattermost-redux/selectors/entitie import {getContrastingSimpleColor} from 'mattermost-redux/utils/theme_utils'; import { + CLASSIFICATIONS_FIELD_TARGET_ID, + CLASSIFICATIONS_FIELD_TARGET_TYPE, + CLASSIFICATIONS_GROUP_NAME, + CLASSIFICATIONS_SYSTEM_FIELD_NAME, + CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, + CLASSIFICATIONS_SYSTEM_VALUE_TARGET_ID, + CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE, DISPLAY_BANNER_BOTTOM, DISPLAY_BANNER_TOP, - FIELD_NAME, - GROUP_NAME, - LINKED_FIELD_NAME, - LINKED_OBJECT_TYPE, - OBJECT_TYPE, - SYSTEM_FIELD_TARGET_ID, - SYSTEM_VALUE_TARGET_ID, - TARGET_ID, - TARGET_TYPE, findOptionById, } from 'components/admin_console/classification_markings/utils'; +import {selectClassificationTemplateField} from 'components/common/hooks/useClassificationMarkings'; import './global_classification_banner.scss'; @@ -35,16 +34,6 @@ type Props = { position: 'top' | 'bottom'; }; -function selectClassificationTemplateField(state: GlobalState): PropertyField | undefined { - const byId = state.entities.properties?.fields?.byId; - if (!byId) { - return undefined; - } - return Object.values(byId).find( - (f) => f.object_type === OBJECT_TYPE && f.name === FIELD_NAME && f.delete_at === 0, - ); -} - function selectLinkedSystemField(state: GlobalState): PropertyField | undefined { const byId = state.entities.properties?.fields?.byId; if (!byId) { @@ -53,7 +42,7 @@ function selectLinkedSystemField(state: GlobalState): PropertyField | undefined // The linked system field has object_type 'system' and a linked_field_id set. return Object.values(byId).find( - (f) => f.object_type === LINKED_OBJECT_TYPE && f.name === LINKED_FIELD_NAME && f.linked_field_id && f.delete_at === 0, + (f) => f.object_type === CLASSIFICATIONS_SYSTEM_OBJECT_TYPE && f.name === CLASSIFICATIONS_SYSTEM_FIELD_NAME && f.linked_field_id && f.delete_at === 0, ); } @@ -66,7 +55,7 @@ export default function GlobalClassificationBanner({position}: Props) { if (!linkedField) { return undefined; } - return getPropertyValueForTargetField(state, SYSTEM_VALUE_TARGET_ID, linkedField.id) as PropertyValue | undefined; + return getPropertyValueForTargetField(state, CLASSIFICATIONS_SYSTEM_VALUE_TARGET_ID, linkedField.id) as PropertyValue | undefined; }); // Bootstrap: fetch template fields, the linked system field, and system property values. @@ -80,13 +69,23 @@ export default function GlobalClassificationBanner({position}: Props) { return; } if (!templateField) { - dispatch(fetchPropertyFields(GROUP_NAME, OBJECT_TYPE, TARGET_TYPE, TARGET_ID)); + dispatch(fetchPropertyFields( + CLASSIFICATIONS_GROUP_NAME, + CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE, + CLASSIFICATIONS_FIELD_TARGET_TYPE, + CLASSIFICATIONS_FIELD_TARGET_ID, + )); } if (!linkedField) { - dispatch(fetchPropertyFields(GROUP_NAME, LINKED_OBJECT_TYPE, TARGET_TYPE, SYSTEM_FIELD_TARGET_ID)); + dispatch(fetchPropertyFields( + CLASSIFICATIONS_GROUP_NAME, + CLASSIFICATIONS_SYSTEM_OBJECT_TYPE, + CLASSIFICATIONS_FIELD_TARGET_TYPE, + CLASSIFICATIONS_FIELD_TARGET_ID, + )); } if (linkedField && !systemValue) { - dispatch(fetchSystemPropertyValues(GROUP_NAME)); + dispatch(fetchSystemPropertyValues(CLASSIFICATIONS_GROUP_NAME)); } }, [featureEnabled, templateField, linkedField, systemValue, dispatch]); diff --git a/webapp/channels/src/components/new_channel_modal/new_channel_modal.scss b/webapp/channels/src/components/new_channel_modal/new_channel_modal.scss index ad60b6fc00d..c50702ff858 100644 --- a/webapp/channels/src/components/new_channel_modal/new_channel_modal.scss +++ b/webapp/channels/src/components/new_channel_modal/new_channel_modal.scss @@ -1,8 +1,124 @@ .new-channel-modal { + .modal-content { + display: flex; + overflow: hidden; + flex-direction: column; + } + + .GenericModal__wrapper { + display: flex; + overflow: hidden; + min-height: 0; + flex: 1 1 auto; + flex-direction: column; + } + + .modal-header { + flex-shrink: 0; + } + + .modal-body { + min-height: 0; + flex: 1 1 auto; + overflow-y: auto; + } + + .modal-footer { + flex-shrink: 0; + } + + .new-channel-modal-classification__fields .DropdownInput.Input_container { + margin-top: 0; + + .Input_fieldset { + padding: 0; + border: none; + box-shadow: none; + + &:hover, + &:focus-within { + border: none; + box-shadow: none; + } + } + + .Input_wrapper { + padding: 0; + margin: 0; + } + + .DropdownInput__indicatorsContainer { + margin-right: 0; + } + } + .new-channel-modal-type-selector { margin-top: 24px; } + .new-channel-modal-classification { + padding-top: 16px; + border-top: 1px solid rgba(var(--center-channel-color-rgb), 0.08); + margin-top: 24px; + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + + h4 { + margin: 0; + font-size: 14px; + font-weight: 600; + line-height: 20px; + } + } + + &__description { + margin: 4px 0 0; + color: rgba(var(--center-channel-color-rgb), 0.72); + font-size: 12px; + line-height: 16px; + } + + &__fields { + margin-top: 16px; + } + + &__field-row { + display: flex; + align-items: center; + gap: 16px; + + &:first-child { + margin-bottom: 16px; + } + } + + &__field-label { + width: 140px; + flex-shrink: 0; + margin: 0; + color: var(--center-channel-color); + font-size: 14px; + font-weight: 600; + line-height: 20px; + } + + &__field-input { + min-width: 0; + flex: 1; + + .AdvancedTextbox #PreviewInputTextButton { + position: absolute; + z-index: 10; + top: 7px; + right: 7px; + } + } + + } + .new-channel-modal-purpose-container { margin-top: 28px; 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 b9ecc8ec33b..8e0a62c924e 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 @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import classNames from 'classnames'; -import React, {useCallback, useRef, useState} from 'react'; +import React, {useCallback, useMemo, useRef, useState} from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; import {useDispatch, useSelector} from 'react-redux'; @@ -14,18 +14,36 @@ import type {ServerError} from '@mattermost/types/errors'; import {setNewChannelWithBoardPreference} from 'mattermost-redux/actions/boards'; import {createChannel} from 'mattermost-redux/actions/channels'; +import {Client4} from 'mattermost-redux/client'; import Permissions from 'mattermost-redux/constants/permissions'; import Preferences from 'mattermost-redux/constants/preferences'; import {areManagedCategoriesEnabled, isChannelCategorySortingEnabled, makeGetSidebarCategoryNamesForTeam} from 'mattermost-redux/selectors/entities/channel_categories'; import {get as getPreference} from 'mattermost-redux/selectors/entities/preferences'; import {haveICurrentChannelPermission} from 'mattermost-redux/selectors/entities/roles'; import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams'; +import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users'; import {switchToChannel} from 'actions/views/channel'; import {closeModal} from 'actions/views/modals'; +import {ColorSwatch, LevelOptionLabel} from 'components/admin_console/classification_markings/classification_markings_styled'; +import { + CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, + CLASSIFICATIONS_GROUP_NAME, +} from 'components/admin_console/classification_markings/utils'; +import {classificationPresetDropdownStyles} from 'components/admin_console/classification_markings/utils/preset_dropdown_styles'; import CategorySelector from 'components/category_selector/category_selector'; import ChannelNameFormField from 'components/channel_name_form_field/channel_name_form_field'; +import { + CHANNEL_BANNER_MAX_CHARACTER_LIMIT, + CHANNEL_BANNER_MIN_CHARACTER_LIMIT, +} from 'components/channel_settings_modal/channel_settings_configuration_tab'; +import useClassificationMarkings from 'components/common/hooks/useClassificationMarkings'; +import DropdownInput from 'components/dropdown_input'; +import type {ValueType} from 'components/dropdown_input'; +import type {TextboxElement} from 'components/textbox'; +import Toggle from 'components/toggle'; +import AdvancedTextbox from 'components/widgets/advanced_textbox/advanced_textbox'; import Input from 'components/widgets/inputs/input/input'; import PublicPrivateSelector from 'components/widgets/public-private-selector/public-private-selector'; @@ -82,6 +100,46 @@ const NewChannelModal = () => { const [defaultCategoryName, setDefaultCategoryName] = useState(undefined); const [managedCategoryName, setManagedCategoryName] = useState(undefined); + const classification = useClassificationMarkings(); + const isSystemAdmin = useSelector(isCurrentUserSystemAdmin); + const canManageClassification = classification.available && isSystemAdmin; + const [classificationEnabled, setClassificationEnabled] = useState(false); + const [selectedClassificationId, setSelectedClassificationId] = useState(''); + const [bannerText, setBannerText] = useState(''); + const [bannerTextPreview, setBannerTextPreview] = useState(false); + + const classificationOptions = useMemo(() => { + return classification.levels. + filter((l) => l.name.trim() !== ''). + map((l) => ({value: l.id, label: l.name.trim(), color: l.color})); + }, [classification.levels]); + + const selectedClassificationOption = useMemo((): ValueType | undefined => { + return classificationOptions.find((o) => o.value === selectedClassificationId); + }, [classificationOptions, selectedClassificationId]); + + const selectedClassificationLevel = useMemo(() => { + return classification.levels.find((l) => l.id === selectedClassificationId); + }, [classification.levels, selectedClassificationId]); + + const handleClassificationLevelChange = useCallback((selected: ValueType) => { + setSelectedClassificationId(selected.value); + const level = classification.levels.find((l) => l.id === selected.value); + if (level) { + setBannerText(`**${level.name}**`); + } + }, [classification.levels]); + + const formatClassificationOptionLabel = useCallback((option: ValueType) => { + const levelOption = option as ValueType & {color: string}; + return ( + + + {levelOption.label} + + ); + }, []); + // create a board along with the channel const createBoardFromChannelPlugin = useSelector((state: GlobalState) => state.plugins.components.CreateBoardFromTemplate); const newChannelWithBoardPulsatingDotState = useSelector((state: GlobalState) => getPreference(state, Preferences.APP_BAR, Preferences.NEW_CHANNEL_WITH_BOARD_TOUR_SHOWED, '')); @@ -117,6 +175,13 @@ const NewChannelModal = () => { update_at: 0, default_category_name: defaultCategoryName, managed_category_name: managedCategoryName, + ...(classificationEnabled && selectedClassificationId && bannerText ? { + banner_info: { + enabled: true, + text: bannerText, + background_color: selectedClassificationLevel?.color || '', + }, + } : {}), }; try { @@ -126,6 +191,19 @@ const NewChannelModal = () => { return; } + if (classificationEnabled && selectedClassificationId && classification.channelField && bannerText) { + try { + await Client4.patchPropertyValues( + CLASSIFICATIONS_GROUP_NAME, + CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, + newChannel!.id, + [{field_id: classification.channelField.id, value: selectedClassificationId}], + ); + } catch { + // Classification save failure should not block channel creation + } + } + handleOnModalCancel(); // If template selected, create a new board from this template @@ -227,7 +305,8 @@ const NewChannelModal = () => { e.stopPropagation(); }; - const canCreate = displayName && !urlError && type && !purposeError && !serverError && canCreateFromPluggable && !channelInputError; + const classificationValid = !classificationEnabled || (Boolean(selectedClassificationId) && bannerText.trim().length > 0); + const canCreate = displayName && !urlError && type && !purposeError && !serverError && canCreateFromPluggable && !channelInputError && classificationValid; const newBoardInfoIcon = ( { /> }
+ {canManageClassification && ( +
+
+
+

+ +

+
+ setClassificationEnabled(!classificationEnabled)} + toggleClassName='btn-toggle-primary' + size='btn-md' + ariaLabel={formatMessage({id: 'channel_modal.classification.toggle_label', defaultMessage: 'Channel classification'})} + /> +
+

+ +

+ {classificationEnabled && ( +
+
+ + + +
+ +
+
+ {selectedClassificationLevel && ( +
+ + + +
+ {}} + useChannelMentions={false} + onChange={(e: React.ChangeEvent) => setBannerText(e.target.value)} + preview={bannerTextPreview} + togglePreview={() => setBannerTextPreview(!bannerTextPreview)} + createMessage={formatMessage({id: 'channel_modal.classification.banner_placeholder', defaultMessage: 'Banner text'})} + maxLength={CHANNEL_BANNER_MAX_CHARACTER_LIMIT} + minLength={CHANNEL_BANNER_MIN_CHARACTER_LIMIT} + /> +
+
+ )} +
+ )} +
+ )}
); diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index cc13c1b910c..a8450a2e1f4 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -4071,6 +4071,11 @@ "channel_menu.bookmarks.addLink": "Add a link", "channel_modal.alreadyExist": "A channel with that URL already exists", "channel_modal.cancel": "Cancel", + "channel_modal.classification.banner_label": "Banner text", + "channel_modal.classification.banner_placeholder": "Banner text", + "channel_modal.classification.level_label": "Classification level", + "channel_modal.classification.toggle_description": "When enabled, classification markings will appear for this channel. Individual channels cannot have a classification level lower than the global classification level.", + "channel_modal.classification.toggle_label": "Channel classification", "channel_modal.create_board.tooltip_description": "Use any of our templates to manage your tasks or start from scratch with your own!", "channel_modal.create_board.tooltip_title": "Manage your task with a board", "channel_modal.createNew": "Create channel", @@ -4159,6 +4164,9 @@ "channel_settings.archive.button": "Archive this channel", "channel_settings.archive.warning": "Archiving a channel removes it from the user interface, but doesn't permanently delete the channel. New messages can't be posted to archived channels.", "channel_settings.channel_info_tab.name": "Channel Info", + "channel_settings.classification.description": "When enabled, a classification level can be set for the channel with configurable indicators.", + "channel_settings.classification.level_label": "Classification level", + "channel_settings.classification.title": "Classification", "channel_settings.error_banner_color_required": "Banner color is required", "channel_settings.error_banner_text_required": "Banner text is required", "channel_settings.error_display_name_required": "Channel name is required", diff --git a/webapp/channels/src/sass/components/_color-input.scss b/webapp/channels/src/sass/components/_color-input.scss index 1d65c4015cb..b403e60625e 100644 --- a/webapp/channels/src/sass/components/_color-input.scss +++ b/webapp/channels/src/sass/components/_color-input.scss @@ -1,4 +1,5 @@ @use "utils/functions"; +@use "utils/variables"; .color-input { position: relative; @@ -30,11 +31,20 @@ border-radius: 0 4px 4px 0; background: functions.v(center-channel-bg) !important; line-height: 0; + + &--disabled { + background: rgba(var(--center-channel-color-rgb), 0.1) !important; + cursor: not-allowed; + + .color-icon { + cursor: not-allowed; + } + } } .color-popover { position: absolute; - z-index: 12; + z-index: variables.$z-index-popover; top: 100%; right: 0; padding-top: 8px; diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 1c7d16a424a..d464978ddaa 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -2210,6 +2210,13 @@ export default class Client4 { ); }; + patchPropertyValues = (groupName: string, objectType: string, targetId: string, items: Array<{field_id: string; value: T}>) => { + return this.doFetch>>( + `${this.getBaseRoute()}/properties/groups/${groupName}/${objectType}/values/${targetId}`, + {method: 'PATCH', body: JSON.stringify(items)}, + ); + }; + // Remote Clusters Routes getRemoteClusters = (options: {