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/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/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/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/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/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" /> + + +
{ 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/components/admin_console/classification_markings/classification_markings.test.tsx b/webapp/channels/src/components/admin_console/classification_markings/classification_markings.test.tsx index aff2a98b1a9..feb7075fd33 100644 --- a/webapp/channels/src/components/admin_console/classification_markings/classification_markings.test.tsx +++ b/webapp/channels/src/components/admin_console/classification_markings/classification_markings.test.tsx @@ -17,16 +17,19 @@ import { levelsToOptions, processClassificationField, fetchClassificationField, - GROUP_NAME, - OBJECT_TYPE, - LINKED_OBJECT_TYPE, - SYSTEM_FIELD_TARGET_ID, - SYSTEM_VALUE_TARGET_ID, - TARGET_TYPE, - FIELD_NAME, - LINKED_FIELD_NAME, - DISPLAY_BANNER_TOP, + fetchChannelClassificationField, + CLASSIFICATIONS_CHANNEL_FIELD_NAME, + CLASSIFICATIONS_CHANNEL_OBJECT_TYPE, + 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_FIELD_NAME, + CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE, DISPLAY_BANNER_BOTTOM, + DISPLAY_BANNER_TOP, } from './utils'; import type {ClassificationLevel} from './utils/presets'; import {PRESET_CUSTOM, presets} from './utils/presets'; @@ -39,13 +42,13 @@ jest.mock('mattermost-redux/client'); function makePropertyField(overrides: Partial = {}): 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 65f3402ac17..a8450a2e1f4 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.", @@ -4052,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", @@ -4140,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/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/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: { 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 = {