diff --git a/e2e-tests/playwright/lib/src/server/default_config.ts b/e2e-tests/playwright/lib/src/server/default_config.ts index 3c8abca6f6a..b4448dd89b9 100644 --- a/e2e-tests/playwright/lib/src/server/default_config.ts +++ b/e2e-tests/playwright/lib/src/server/default_config.ts @@ -790,6 +790,7 @@ const defaultServerConfig: AdminConfig = { IntegratedBoards: false, CJKSearch: false, ManagedChannelCategories: false, + MobileEphemeralMode: true, }, ImportSettings: { Directory: './import', @@ -868,4 +869,10 @@ const defaultServerConfig: AdminConfig = { LLMServiceID: '', }, }, + MobileEphemeralModeSettings: { + Enable: false, + DisconnectionTimeoutSeconds: 60, + OfflinePersistenceTimerHours: 24, + AutoCacheCleanupDays: 7, + }, }; diff --git a/e2e-tests/playwright/lib/src/ui/components/index.ts b/e2e-tests/playwright/lib/src/ui/components/index.ts index babea003a09..1d7e83a3488 100644 --- a/e2e-tests/playwright/lib/src/ui/components/index.ts +++ b/e2e-tests/playwright/lib/src/ui/components/index.ts @@ -54,7 +54,13 @@ import BurnOnReadTimerChip from './channels/burn_on_read_timer_chip'; import BurnOnReadConcealedPlaceholder from './channels/burn_on_read_concealed_placeholder'; import BurnOnReadConfirmationModal from './channels/burn_on_read_confirmation_modal'; // System Console Components -import {AdminSectionPanel, DropdownSetting, RadioSetting, TextInputSetting} from './system_console/base_components'; +import { + AdminSectionPanel, + DropdownSetting, + NumberInputSetting, + RadioSetting, + TextInputSetting, +} from './system_console/base_components'; import DelegatedGranularAdministration from './system_console/sections/user_management/delegated_granular_administration'; import UserDetail from './system_console/sections/user_management/user_detail'; import EditionAndLicense from './system_console/sections/about/edition_and_license'; @@ -132,6 +138,7 @@ const components = { EditionAndLicense, MobileSecurity, Notifications, + NumberInputSetting, RadioSetting, UsersAndTeams, SystemConsoleFeatureDiscovery, @@ -210,6 +217,7 @@ export { EditionAndLicense, MobileSecurity, Notifications, + NumberInputSetting, RadioSetting, UsersAndTeams, SystemConsoleFeatureDiscovery, diff --git a/e2e-tests/playwright/lib/src/ui/components/system_console/base_components.ts b/e2e-tests/playwright/lib/src/ui/components/system_console/base_components.ts index 2cd2c04cc3b..48ed352a8b3 100644 --- a/e2e-tests/playwright/lib/src/ui/components/system_console/base_components.ts +++ b/e2e-tests/playwright/lib/src/ui/components/system_console/base_components.ts @@ -94,6 +94,40 @@ export class TextInputSetting { } } +/** + * Number Input Setting - represents a number input field + * Uses getByRole('spinbutton') since has ARIA role spinbutton + */ +export class NumberInputSetting { + readonly container: Locator; + readonly label: Locator; + readonly input: Locator; + readonly helpText: Locator; + + constructor(container: Locator, labelText: string) { + this.container = container; + this.label = container.getByText(labelText); + this.input = container.getByRole('spinbutton'); + this.helpText = container.locator('.help-text'); + } + + async fill(value: string) { + await this.input.fill(value); + } + + async getValue(): Promise { + return (await this.input.inputValue()) ?? ''; + } + + async clear() { + await this.input.clear(); + } + + async toBeVisible() { + await expect(this.container).toBeVisible(); + } +} + /** * Dropdown Setting - represents a select dropdown */ diff --git a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/environment/mobile_security.ts b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/environment/mobile_security.ts index f171dc11cc8..0eb97a64ec2 100644 --- a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/environment/mobile_security.ts +++ b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/environment/mobile_security.ts @@ -3,7 +3,13 @@ import {Locator, expect} from '@playwright/test'; -import {RadioSetting, TextInputSetting, DropdownSetting, AdminSectionPanel} from '../../base_components'; +import { + RadioSetting, + TextInputSetting, + NumberInputSetting, + DropdownSetting, + AdminSectionPanel, +} from '../../base_components'; /** * System Console -> Environment -> Mobile Security @@ -17,6 +23,7 @@ export default class MobileSecurity { // Panels readonly generalMobileSecurity: GeneralMobileSecurityPanel; readonly microsoftIntune: MicrosoftIntunePanel; + readonly mobileEphemeralMode: MobileEphemeralModePanel; // Save section readonly saveButton: Locator; @@ -33,6 +40,9 @@ export default class MobileSecurity { this.microsoftIntune = new MicrosoftIntunePanel( container.locator('.AdminSectionPanel').filter({hasText: 'Microsoft Intune'}), ); + this.mobileEphemeralMode = new MobileEphemeralModePanel( + container.locator('.AdminSectionPanel').filter({hasText: 'Mobile Ephemeral Mode'}), + ); this.saveButton = container.getByRole('button', {name: 'Save'}); this.errorMessage = container.locator('.error-message'); @@ -77,6 +87,20 @@ export default class MobileSecurity { get clientId() { return this.microsoftIntune.clientId; } + + // Convenience shortcuts for Mobile Ephemeral Mode settings + get enableMobileEphemeralMode() { + return this.mobileEphemeralMode.enableMobileEphemeralMode; + } + get disconnectionTimeout() { + return this.mobileEphemeralMode.disconnectionTimeout; + } + get offlinePersistenceTimer() { + return this.mobileEphemeralMode.offlinePersistenceTimer; + } + get autoCacheCleanup() { + return this.mobileEphemeralMode.autoCacheCleanup; + } } class GeneralMobileSecurityPanel extends AdminSectionPanel { @@ -105,6 +129,33 @@ class GeneralMobileSecurityPanel extends AdminSectionPanel { } } +class MobileEphemeralModePanel extends AdminSectionPanel { + readonly enableMobileEphemeralMode: RadioSetting; + readonly disconnectionTimeout: NumberInputSetting; + readonly offlinePersistenceTimer: NumberInputSetting; + readonly autoCacheCleanup: NumberInputSetting; + + constructor(container: Locator) { + super(container, 'Mobile Ephemeral Mode'); + + this.enableMobileEphemeralMode = new RadioSetting( + this.body.getByRole('group', {name: /Enable Mobile Ephemeral Mode/}), + ); + this.disconnectionTimeout = new NumberInputSetting( + this.body.locator('.form-group').filter({hasText: 'Disconnection Timeout (seconds):'}), + 'Disconnection Timeout (seconds):', + ); + this.offlinePersistenceTimer = new NumberInputSetting( + this.body.locator('.form-group').filter({hasText: 'Offline Persistence Timer (hours):'}), + 'Offline Persistence Timer (hours):', + ); + this.autoCacheCleanup = new NumberInputSetting( + this.body.locator('.form-group').filter({hasText: 'Auto Cache Cleanup (days):'}), + 'Auto Cache Cleanup (days):', + ); + } +} + class MicrosoftIntunePanel extends AdminSectionPanel { readonly enableIntuneMAM: RadioSetting; readonly authProvider: DropdownSetting; diff --git a/e2e-tests/playwright/specs/functional/system_console/mobile_security.spec.ts b/e2e-tests/playwright/specs/functional/system_console/mobile_security.spec.ts index 44053d653bb..e4f64c21a36 100644 --- a/e2e-tests/playwright/specs/functional/system_console/mobile_security.spec.ts +++ b/e2e-tests/playwright/specs/functional/system_console/mobile_security.spec.ts @@ -507,3 +507,231 @@ test('should disable Intune inputs when toggle is off', async ({pw}) => { expect(await systemConsolePage.mobileSecurity.tenantId.input.isDisabled()).toBe(false); expect(await systemConsolePage.mobileSecurity.clientId.input.isDisabled()).toBe(false); }); + +/** + * @objective Verify timer settings are disabled when Mobile Ephemeral Mode is not enabled, and become editable when enabled + */ +test( + 'should disable Mobile Ephemeral Mode sub-settings when toggle is off and enable them when toggle is on', + {tag: '@mobile_ephemeral_mode'}, + async ({pw}) => { + const {adminUser, adminClient} = await pw.initSetup(); + + const license = await adminClient.getClientLicenseOld(); + + test.skip( + license.SkuShortName !== 'advanced', + 'Skipping test - server does not have enterprise advanced license', + ); + + const config = await adminClient.getConfig(); + test.skip( + config.FeatureFlags.MobileEphemeralMode !== true && config.FeatureFlags.MobileEphemeralMode !== 'true', + 'Skipping test - MobileEphemeralMode feature flag is not enabled on the server', + ); + + if (!adminUser) { + throw new Error('Failed to create admin user'); + } + + // # Log in as admin + const {systemConsolePage} = await pw.testBrowser.login(adminUser); + + // # Visit system console + await systemConsolePage.goto(); + await systemConsolePage.toBeVisible(); + + // # Go to Mobile Security section + await systemConsolePage.sidebar.mobileSecurity.click(); + await systemConsolePage.mobileSecurity.toBeVisible(); + + // * Verify Mobile Ephemeral Mode toggle is off by default + await systemConsolePage.mobileSecurity.enableMobileEphemeralMode.toBeFalse(); + + // * Verify all sub-settings are disabled + expect(await systemConsolePage.mobileSecurity.disconnectionTimeout.input.isDisabled()).toBe(true); + expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.input.isDisabled()).toBe(true); + expect(await systemConsolePage.mobileSecurity.autoCacheCleanup.input.isDisabled()).toBe(true); + + // # Enable Mobile Ephemeral Mode toggle + await systemConsolePage.mobileSecurity.enableMobileEphemeralMode.selectTrue(); + + // * Verify all sub-settings are now enabled + expect(await systemConsolePage.mobileSecurity.disconnectionTimeout.input.isDisabled()).toBe(false); + expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.input.isDisabled()).toBe(false); + expect(await systemConsolePage.mobileSecurity.autoCacheCleanup.input.isDisabled()).toBe(false); + }, +); + +/** + * @objective Verify all Mobile Ephemeral Mode settings persist after save and navigation + */ +test( + 'should save and persist all Mobile Ephemeral Mode settings after navigation', + {tag: '@mobile_ephemeral_mode'}, + async ({pw}) => { + const {adminUser, adminClient} = await pw.initSetup(); + + const license = await adminClient.getClientLicenseOld(); + + test.skip( + license.SkuShortName !== 'advanced', + 'Skipping test - server does not have enterprise advanced license', + ); + + const config = await adminClient.getConfig(); + test.skip( + config.FeatureFlags.MobileEphemeralMode !== true && config.FeatureFlags.MobileEphemeralMode !== 'true', + 'Skipping test - MobileEphemeralMode feature flag is not enabled on the server', + ); + + if (!adminUser) { + throw new Error('Failed to create admin user'); + } + + // # Enable Mobile Ephemeral Mode setting via config API + config.MobileEphemeralModeSettings.Enable = true; + await adminClient.updateConfig(config); + + // # Log in as admin + const {systemConsolePage} = await pw.testBrowser.login(adminUser); + + // # Visit system console + await systemConsolePage.goto(); + await systemConsolePage.toBeVisible(); + + // # Go to Mobile Security section + await systemConsolePage.sidebar.mobileSecurity.click(); + await systemConsolePage.mobileSecurity.toBeVisible(); + + // # Set custom values + await systemConsolePage.mobileSecurity.disconnectionTimeout.fill('120'); + await systemConsolePage.mobileSecurity.offlinePersistenceTimer.fill('48'); + await systemConsolePage.mobileSecurity.autoCacheCleanup.fill('14'); + + // # Save settings + await systemConsolePage.mobileSecurity.save(); + await pw.waitUntil(async () => (await systemConsolePage.mobileSecurity.saveButton.textContent()) === 'Save'); + + // # Navigate away and back + await systemConsolePage.sidebar.users.click(); + await systemConsolePage.users.toBeVisible(); + await systemConsolePage.sidebar.mobileSecurity.click(); + await systemConsolePage.mobileSecurity.toBeVisible(); + + // * Verify Mobile Ephemeral Mode is still enabled + await systemConsolePage.mobileSecurity.enableMobileEphemeralMode.toBeTrue(); + + // * Verify all values persisted correctly + expect(await systemConsolePage.mobileSecurity.disconnectionTimeout.getValue()).toBe('120'); + expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.getValue()).toBe('48'); + expect(await systemConsolePage.mobileSecurity.autoCacheCleanup.getValue()).toBe('14'); + }, +); + +/** + * @objective Verify offline persistence timer is disabled when auto cache cleanup is set to 0 (zero-persistence mode) + */ +test( + 'should disable offline persistence timer when auto cache cleanup is set to zero', + {tag: '@mobile_ephemeral_mode'}, + async ({pw}) => { + const {adminUser, adminClient} = await pw.initSetup(); + + const license = await adminClient.getClientLicenseOld(); + + test.skip( + license.SkuShortName !== 'advanced', + 'Skipping test - server does not have enterprise advanced license', + ); + + const config = await adminClient.getConfig(); + test.skip( + config.FeatureFlags.MobileEphemeralMode !== true && config.FeatureFlags.MobileEphemeralMode !== 'true', + 'Skipping test - MobileEphemeralMode feature flag is not enabled on the server', + ); + + if (!adminUser) { + throw new Error('Failed to create admin user'); + } + + // # Enable Mobile Ephemeral Mode setting via config API + config.MobileEphemeralModeSettings.Enable = true; + await adminClient.updateConfig(config); + + // # Log in as admin + const {systemConsolePage} = await pw.testBrowser.login(adminUser); + + // # Visit system console + await systemConsolePage.goto(); + await systemConsolePage.toBeVisible(); + + // # Go to Mobile Security section + await systemConsolePage.sidebar.mobileSecurity.click(); + await systemConsolePage.mobileSecurity.toBeVisible(); + + // * Verify offline persistence timer is enabled + expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.input.isDisabled()).toBe(false); + + // # Set auto cache cleanup to 0 + await systemConsolePage.mobileSecurity.autoCacheCleanup.clear(); + await systemConsolePage.mobileSecurity.autoCacheCleanup.fill('0'); + + // * Verify offline persistence timer is now disabled + expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.input.isDisabled()).toBe(true); + + // # Set auto cache cleanup back to 7 + await systemConsolePage.mobileSecurity.autoCacheCleanup.clear(); + await systemConsolePage.mobileSecurity.autoCacheCleanup.fill('7'); + + // * Verify offline persistence timer is enabled again + expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.input.isDisabled()).toBe(false); + }, +); + +/** + * @objective Verify Mobile Ephemeral Mode settings show correct defaults on first enable + */ +test( + 'should show correct default values when Mobile Ephemeral Mode is first enabled', + {tag: '@mobile_ephemeral_mode'}, + async ({pw}) => { + const {adminUser, adminClient} = await pw.initSetup(); + + const license = await adminClient.getClientLicenseOld(); + + test.skip( + license.SkuShortName !== 'advanced', + 'Skipping test - server does not have enterprise advanced license', + ); + + const config = await adminClient.getConfig(); + test.skip( + config.FeatureFlags.MobileEphemeralMode !== true && config.FeatureFlags.MobileEphemeralMode !== 'true', + 'Skipping test - MobileEphemeralMode feature flag is not enabled on the server', + ); + + if (!adminUser) { + throw new Error('Failed to create admin user'); + } + + // # Log in as admin + const {systemConsolePage} = await pw.testBrowser.login(adminUser); + + // # Visit system console + await systemConsolePage.goto(); + await systemConsolePage.toBeVisible(); + + // # Go to Mobile Security section + await systemConsolePage.sidebar.mobileSecurity.click(); + await systemConsolePage.mobileSecurity.toBeVisible(); + + // # Enable Mobile Ephemeral Mode + await systemConsolePage.mobileSecurity.enableMobileEphemeralMode.selectTrue(); + + // * Verify default values + expect(await systemConsolePage.mobileSecurity.disconnectionTimeout.getValue()).toBe('60'); + expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.getValue()).toBe('24'); + expect(await systemConsolePage.mobileSecurity.autoCacheCleanup.getValue()).toBe('7'); + }, +); diff --git a/server/config/client.go b/server/config/client.go index d56c9d7e70e..5b255f1af97 100644 --- a/server/config/client.go +++ b/server/config/client.go @@ -255,6 +255,20 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li props["AutoTranslationLanguages"] = "" } props["RestrictDMAndGMAutotranslation"] = strconv.FormatBool(*c.AutoTranslationSettings.RestrictDMAndGM) + + if c.FeatureFlags.MobileEphemeralMode { + ephemeralEnabled := c.MobileEphemeralModeSettings.Enable != nil && *c.MobileEphemeralModeSettings.Enable + props["MobileEphemeralModeEnabled"] = strconv.FormatBool(ephemeralEnabled) + if c.MobileEphemeralModeSettings.DisconnectionTimeoutSeconds != nil { + props["MobileEphemeralModeDisconnectionTimeoutSeconds"] = strconv.Itoa(*c.MobileEphemeralModeSettings.DisconnectionTimeoutSeconds) + } + if c.MobileEphemeralModeSettings.OfflinePersistenceTimerHours != nil { + props["MobileEphemeralModeOfflinePersistenceTimerHours"] = strconv.Itoa(*c.MobileEphemeralModeSettings.OfflinePersistenceTimerHours) + } + if c.MobileEphemeralModeSettings.AutoCacheCleanupDays != nil { + props["MobileEphemeralModeAutoCacheCleanupDays"] = strconv.Itoa(*c.MobileEphemeralModeSettings.AutoCacheCleanupDays) + } + } } } diff --git a/server/config/client_test.go b/server/config/client_test.go index 159d80a8a03..53a9511fd86 100644 --- a/server/config/client_test.go +++ b/server/config/client_test.go @@ -20,6 +20,7 @@ func TestGetClientConfig(t *testing.T) { telemetryID string license *model.License expectedFields map[string]string + absentFields []string }{ { "unlicensed", @@ -48,6 +49,7 @@ func TestGetClientConfig(t *testing.T) { "WebsocketPort": "80", "WebsocketSecurePort": "443", }, + nil, }, { "licensed, but not for theme management", @@ -71,6 +73,7 @@ func TestGetClientConfig(t *testing.T) { "EmailNotificationContentsType": "full", "AllowCustomThemes": "true", }, + nil, }, { "licensed for theme management", @@ -93,6 +96,7 @@ func TestGetClientConfig(t *testing.T) { "EmailNotificationContentsType": "full", "AllowCustomThemes": "false", }, + nil, }, { "licensed for enforcement", @@ -110,6 +114,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "EnforceMultifactorAuthentication": "true", }, + nil, }, { "default marketplace", @@ -123,6 +128,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "IsDefaultMarketplace": "true", }, + nil, }, { "non-default marketplace", @@ -136,6 +142,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "IsDefaultMarketplace": "false", }, + nil, }, { "enable ShowFullName prop", @@ -149,6 +156,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "ShowFullName": "true", }, + nil, }, { "enable UseAnonymousURLs prop", @@ -162,6 +170,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "UseAnonymousURLs": "true", }, + nil, }, { "Custom groups professional license", @@ -174,6 +183,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "EnableCustomGroups": "true", }, + nil, }, { "Custom groups enterprise license", @@ -186,6 +196,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "EnableCustomGroups": "true", }, + nil, }, { "Custom groups other license", @@ -198,6 +209,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "EnableCustomGroups": "false", }, + nil, }, { "Shared channels other license", @@ -216,6 +228,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "ExperimentalSharedChannels": "false", }, + nil, }, { "licensed for shared channels", @@ -234,6 +247,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "ExperimentalSharedChannels": "true", }, + nil, }, { "Shared channels professional license", @@ -252,6 +266,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "ExperimentalSharedChannels": "true", }, + nil, }, { "disable EnableUserStatuses", @@ -265,6 +280,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "EnableUserStatuses": "false", }, + nil, }, { "Shared channels enterprise license", @@ -283,6 +299,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "ExperimentalSharedChannels": "true", }, + nil, }, { "Disable App Bar", @@ -296,6 +313,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "DisableAppBar": "true", }, + nil, }, { "default EnableJoinLeaveMessage", @@ -305,6 +323,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "EnableJoinLeaveMessageByDefault": "true", }, + nil, }, { "disable EnableJoinLeaveMessage", @@ -318,6 +337,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "EnableJoinLeaveMessageByDefault": "false", }, + nil, }, { "test key for GiphySdkKey", @@ -331,6 +351,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "GiphySdkKey": model.ServiceSettingsDefaultGiphySdkKeyTest, }, + nil, }, { "report a problem values", @@ -350,6 +371,7 @@ func TestGetClientConfig(t *testing.T) { "ReportAProblemMail": "mail", "AllowDownloadLogs": "true", }, + nil, }, { "access control settings enabled", @@ -365,6 +387,7 @@ func TestGetClientConfig(t *testing.T) { "EnableAttributeBasedAccessControl": "true", "EnableUserManagedAttributes": "true", }, + nil, }, { "access control settings disabled", @@ -380,6 +403,7 @@ func TestGetClientConfig(t *testing.T) { "EnableAttributeBasedAccessControl": "false", "EnableUserManagedAttributes": "false", }, + nil, }, { "access control settings default", @@ -390,6 +414,7 @@ func TestGetClientConfig(t *testing.T) { "EnableAttributeBasedAccessControl": "false", "EnableUserManagedAttributes": "false", }, + nil, }, { "burn on read enabled", @@ -405,6 +430,7 @@ func TestGetClientConfig(t *testing.T) { "EnableBurnOnRead": "true", "BurnOnReadDurationSeconds": "1800", }, + nil, }, { "burn on read disabled", @@ -420,6 +446,7 @@ func TestGetClientConfig(t *testing.T) { "EnableBurnOnRead": "false", "BurnOnReadDurationSeconds": "600", }, + nil, }, { "burn on read default", @@ -430,6 +457,7 @@ func TestGetClientConfig(t *testing.T) { "EnableBurnOnRead": "true", "BurnOnReadDurationSeconds": "600", // 10 minutes in seconds }, + nil, }, { "mobile watermark uses experimental settings", @@ -446,6 +474,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "ExperimentalEnableWatermark": "true", }, + nil, }, { "Intune MAM enabled with Enterprise Advanced license and Office365 AuthService", @@ -466,6 +495,7 @@ func TestGetClientConfig(t *testing.T) { "IntuneMAMEnabled": "true", "IntuneScope": "api://87654321-4321-4321-4321-210987654321/login.mattermost", }, + nil, }, { "Intune MAM disabled when not enabled", @@ -485,6 +515,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "IntuneMAMEnabled": "false", }, + nil, }, { "Intune MAM disabled when TenantId is missing", @@ -504,6 +535,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "IntuneMAMEnabled": "false", }, + nil, }, { "Intune MAM disabled when ClientId is missing", @@ -523,6 +555,7 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "IntuneMAMEnabled": "false", }, + nil, }, { "Intune MAM not exposed with lower license tier", @@ -540,6 +573,7 @@ func TestGetClientConfig(t *testing.T) { SkuShortName: model.LicenseShortSkuProfessional, }, map[string]string{}, + []string{"IntuneMAMEnabled", "IntuneScope"}, }, { "Intune MAM not exposed without license", @@ -554,6 +588,7 @@ func TestGetClientConfig(t *testing.T) { "", nil, map[string]string{}, + []string{"IntuneMAMEnabled", "IntuneScope"}, }, { "Intune MAM enabled with Enterprise Advanced license and SAML AuthService", @@ -578,6 +613,7 @@ func TestGetClientConfig(t *testing.T) { "IntuneScope": "api://87654321-4321-4321-4321-210987654321/login.mattermost", "IntuneAuthService": "saml", }, + nil, }, { "Intune MAM disabled when AuthService is missing", @@ -597,6 +633,100 @@ func TestGetClientConfig(t *testing.T) { map[string]string{ "IntuneMAMEnabled": "false", }, + nil, + }, + { + "Mobile Ephemeral Mode enabled with custom values", + &model.Config{ + FeatureFlags: &model.FeatureFlags{MobileEphemeralMode: true}, + MobileEphemeralModeSettings: model.MobileEphemeralModeSettings{ + Enable: model.NewPointer(true), + DisconnectionTimeoutSeconds: model.NewPointer(120), + OfflinePersistenceTimerHours: model.NewPointer(48), + AutoCacheCleanupDays: model.NewPointer(14), + }, + }, + "", + &model.License{ + Features: &model.Features{}, + SkuShortName: model.LicenseShortSkuEnterpriseAdvanced, + }, + map[string]string{ + "MobileEphemeralModeEnabled": "true", + "MobileEphemeralModeDisconnectionTimeoutSeconds": "120", + "MobileEphemeralModeOfflinePersistenceTimerHours": "48", + "MobileEphemeralModeAutoCacheCleanupDays": "14", + }, + nil, + }, + { + "Mobile Ephemeral Mode disabled still exposes parameters", + &model.Config{ + FeatureFlags: &model.FeatureFlags{MobileEphemeralMode: true}, + MobileEphemeralModeSettings: model.MobileEphemeralModeSettings{ + Enable: model.NewPointer(false), + DisconnectionTimeoutSeconds: model.NewPointer(60), + OfflinePersistenceTimerHours: model.NewPointer(24), + AutoCacheCleanupDays: model.NewPointer(7), + }, + }, + "", + &model.License{ + Features: &model.Features{}, + SkuShortName: model.LicenseShortSkuEnterpriseAdvanced, + }, + map[string]string{ + "MobileEphemeralModeEnabled": "false", + "MobileEphemeralModeDisconnectionTimeoutSeconds": "60", + "MobileEphemeralModeOfflinePersistenceTimerHours": "24", + "MobileEphemeralModeAutoCacheCleanupDays": "7", + }, + nil, + }, + { + "Mobile Ephemeral Mode not exposed when feature flag is off", + &model.Config{ + FeatureFlags: &model.FeatureFlags{MobileEphemeralMode: false}, + MobileEphemeralModeSettings: model.MobileEphemeralModeSettings{ + Enable: model.NewPointer(true), + }, + }, + "", + &model.License{ + Features: &model.Features{}, + SkuShortName: model.LicenseShortSkuEnterpriseAdvanced, + }, + map[string]string{}, + []string{"MobileEphemeralModeEnabled", "MobileEphemeralModeDisconnectionTimeoutSeconds", "MobileEphemeralModeOfflinePersistenceTimerHours", "MobileEphemeralModeAutoCacheCleanupDays"}, + }, + { + "Mobile Ephemeral Mode not exposed without license", + &model.Config{ + FeatureFlags: &model.FeatureFlags{MobileEphemeralMode: true}, + MobileEphemeralModeSettings: model.MobileEphemeralModeSettings{ + Enable: model.NewPointer(true), + }, + }, + "", + nil, + map[string]string{}, + []string{"MobileEphemeralModeEnabled", "MobileEphemeralModeDisconnectionTimeoutSeconds", "MobileEphemeralModeOfflinePersistenceTimerHours", "MobileEphemeralModeAutoCacheCleanupDays"}, + }, + { + "Mobile Ephemeral Mode not exposed with lower license tier", + &model.Config{ + FeatureFlags: &model.FeatureFlags{MobileEphemeralMode: true}, + MobileEphemeralModeSettings: model.MobileEphemeralModeSettings{ + Enable: model.NewPointer(true), + }, + }, + "", + &model.License{ + Features: &model.Features{}, + SkuShortName: model.LicenseShortSkuProfessional, + }, + map[string]string{}, + []string{"MobileEphemeralModeEnabled", "MobileEphemeralModeDisconnectionTimeoutSeconds", "MobileEphemeralModeOfflinePersistenceTimerHours", "MobileEphemeralModeAutoCacheCleanupDays"}, }, } @@ -616,6 +746,10 @@ func TestGetClientConfig(t *testing.T) { assert.Equal(t, expectedValue, actualValue) } } + for _, absentField := range testCase.absentFields { + _, ok := configMap[absentField] + assert.False(t, ok, fmt.Sprintf("config should not contain %v", absentField)) + } }) } } diff --git a/server/i18n/en.json b/server/i18n/en.json index 01bb249870f..1f58855805c 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -11542,6 +11542,18 @@ "id": "model.config.is_valid.minimum_desktop_app_version.app_error", "translation": "Invalid version number. Must be a valid semantic version (e.g. 5.0.0)." }, + { + "id": "model.config.is_valid.mobile_ephemeral_mode.auto_cache_cleanup.app_error", + "translation": "Invalid Auto Cache Cleanup value. Must be between {{.Min}} and {{.Max}} days." + }, + { + "id": "model.config.is_valid.mobile_ephemeral_mode.disconnection_timeout.app_error", + "translation": "Invalid Disconnection Timeout value. Must be between {{.Min}} and {{.Max}} seconds." + }, + { + "id": "model.config.is_valid.mobile_ephemeral_mode.offline_persistence.app_error", + "translation": "Invalid Offline Persistence Timer value. Must be between {{.Min}} and {{.Max}} hours." + }, { "id": "model.config.is_valid.move_thread.domain_invalid.app_error", "translation": "Invalid domain for move thread settings" diff --git a/server/public/model/config.go b/server/public/model/config.go index e04fe346529..f001937219e 100644 --- a/server/public/model/config.go +++ b/server/public/model/config.go @@ -3442,6 +3442,61 @@ func (s *DataRetentionSettings) GetFileRetentionHours() int { return DataRetentionSettingsDefaultFileRetentionDays * 24 } +const ( + MobileEphemeralModeDefaultDisconnectionTimeoutSeconds = 60 + MobileEphemeralModeDefaultOfflinePersistenceTimerHours = 24 + MobileEphemeralModeDefaultAutoCacheCleanupDays = 7 + + MobileEphemeralModeMaxDisconnectionTimeoutSeconds = 600 + MobileEphemeralModeMaxOfflinePersistenceTimerHours = 72 + MobileEphemeralModeMaxAutoCacheCleanupDays = 60 +) + +type MobileEphemeralModeSettings struct { + Enable *bool `access:"environment_mobile_security"` + DisconnectionTimeoutSeconds *int `access:"environment_mobile_security"` + OfflinePersistenceTimerHours *int `access:"environment_mobile_security"` + AutoCacheCleanupDays *int `access:"environment_mobile_security"` +} + +func (s *MobileEphemeralModeSettings) SetDefaults() { + if s.Enable == nil { + s.Enable = NewPointer(false) + } + if s.DisconnectionTimeoutSeconds == nil { + s.DisconnectionTimeoutSeconds = NewPointer(MobileEphemeralModeDefaultDisconnectionTimeoutSeconds) + } + if s.OfflinePersistenceTimerHours == nil { + s.OfflinePersistenceTimerHours = NewPointer(MobileEphemeralModeDefaultOfflinePersistenceTimerHours) + } + if s.AutoCacheCleanupDays == nil { + s.AutoCacheCleanupDays = NewPointer(MobileEphemeralModeDefaultAutoCacheCleanupDays) + } +} + +func (s *MobileEphemeralModeSettings) isValid() *AppError { + if s.Enable == nil || !*s.Enable { + return nil + } + + if s.DisconnectionTimeoutSeconds == nil || *s.DisconnectionTimeoutSeconds < 0 || *s.DisconnectionTimeoutSeconds > MobileEphemeralModeMaxDisconnectionTimeoutSeconds { + return NewAppError("Config.IsValid", "model.config.is_valid.mobile_ephemeral_mode.disconnection_timeout.app_error", + map[string]any{"Min": 0, "Max": MobileEphemeralModeMaxDisconnectionTimeoutSeconds}, "", http.StatusBadRequest) + } + + if s.OfflinePersistenceTimerHours == nil || *s.OfflinePersistenceTimerHours < 0 || *s.OfflinePersistenceTimerHours > MobileEphemeralModeMaxOfflinePersistenceTimerHours { + return NewAppError("Config.IsValid", "model.config.is_valid.mobile_ephemeral_mode.offline_persistence.app_error", + map[string]any{"Min": 0, "Max": MobileEphemeralModeMaxOfflinePersistenceTimerHours}, "", http.StatusBadRequest) + } + + if s.AutoCacheCleanupDays == nil || *s.AutoCacheCleanupDays < 0 || *s.AutoCacheCleanupDays > MobileEphemeralModeMaxAutoCacheCleanupDays { + return NewAppError("Config.IsValid", "model.config.is_valid.mobile_ephemeral_mode.auto_cache_cleanup.app_error", + map[string]any{"Min": 0, "Max": MobileEphemeralModeMaxAutoCacheCleanupDays}, "", http.StatusBadRequest) + } + + return nil +} + type JobSettings struct { RunJobs *bool `access:"write_restrictable,cloud_restrictable"` // telemetry: none RunScheduler *bool `access:"write_restrictable,cloud_restrictable"` // telemetry: none @@ -4079,6 +4134,7 @@ type Config struct { AnalyticsSettings AnalyticsSettings ElasticsearchSettings ElasticsearchSettings DataRetentionSettings DataRetentionSettings + MobileEphemeralModeSettings MobileEphemeralModeSettings MessageExportSettings MessageExportSettings JobSettings JobSettings PluginSettings PluginSettings @@ -4194,6 +4250,7 @@ func (o *Config) SetDefaults() { o.NativeAppSettings.SetDefaults() o.IntuneSettings.SetDefaults() o.DataRetentionSettings.SetDefaults() + o.MobileEphemeralModeSettings.SetDefaults() o.RateLimitSettings.SetDefaults() o.LogSettings.SetDefaults() o.ExperimentalAuditSettings.SetDefaults() @@ -4373,6 +4430,10 @@ func (o *Config) IsValid() *AppError { return appErr } + if appErr := o.MobileEphemeralModeSettings.isValid(); appErr != nil { + return appErr + } + if appErr := o.GuestAccountsSettings.IsValid(); appErr != nil { return appErr } diff --git a/server/public/model/config_test.go b/server/public/model/config_test.go index 41cf045f181..96859087064 100644 --- a/server/public/model/config_test.go +++ b/server/public/model/config_test.go @@ -2975,6 +2975,121 @@ func TestConfigAccessTagsMapToValidPermissions(t *testing.T) { checkStruct(t, reflect.TypeFor[Config](), "Config") } +func TestMobileEphemeralModeSettingsDefaults(t *testing.T) { + c := Config{} + c.SetDefaults() + + require.False(t, *c.MobileEphemeralModeSettings.Enable) + require.Equal(t, MobileEphemeralModeDefaultDisconnectionTimeoutSeconds, *c.MobileEphemeralModeSettings.DisconnectionTimeoutSeconds) + require.Equal(t, MobileEphemeralModeDefaultOfflinePersistenceTimerHours, *c.MobileEphemeralModeSettings.OfflinePersistenceTimerHours) + require.Equal(t, MobileEphemeralModeDefaultAutoCacheCleanupDays, *c.MobileEphemeralModeSettings.AutoCacheCleanupDays) +} + +func TestMobileEphemeralModeSettingsIsValid(t *testing.T) { + testCases := []struct { + name string + settings MobileEphemeralModeSettings + expectError bool + errorId string + }{ + { + name: "disabled settings should be valid", + settings: MobileEphemeralModeSettings{ + Enable: NewPointer(false), + }, + expectError: false, + }, + { + name: "enabled with valid values", + settings: MobileEphemeralModeSettings{ + Enable: NewPointer(true), + DisconnectionTimeoutSeconds: NewPointer(120), + OfflinePersistenceTimerHours: NewPointer(24), + AutoCacheCleanupDays: NewPointer(7), + }, + expectError: false, + }, + { + name: "invalid disconnection timeout above max", + settings: MobileEphemeralModeSettings{ + Enable: NewPointer(true), + DisconnectionTimeoutSeconds: NewPointer(MobileEphemeralModeMaxDisconnectionTimeoutSeconds + 1), + OfflinePersistenceTimerHours: NewPointer(0), + AutoCacheCleanupDays: NewPointer(0), + }, + expectError: true, + errorId: "model.config.is_valid.mobile_ephemeral_mode.disconnection_timeout.app_error", + }, + { + name: "invalid offline persistence above max", + settings: MobileEphemeralModeSettings{ + Enable: NewPointer(true), + DisconnectionTimeoutSeconds: NewPointer(60), + OfflinePersistenceTimerHours: NewPointer(MobileEphemeralModeMaxOfflinePersistenceTimerHours + 1), + AutoCacheCleanupDays: NewPointer(0), + }, + expectError: true, + errorId: "model.config.is_valid.mobile_ephemeral_mode.offline_persistence.app_error", + }, + { + name: "invalid auto cache cleanup above max", + settings: MobileEphemeralModeSettings{ + Enable: NewPointer(true), + DisconnectionTimeoutSeconds: NewPointer(60), + OfflinePersistenceTimerHours: NewPointer(0), + AutoCacheCleanupDays: NewPointer(MobileEphemeralModeMaxAutoCacheCleanupDays + 1), + }, + expectError: true, + errorId: "model.config.is_valid.mobile_ephemeral_mode.auto_cache_cleanup.app_error", + }, + { + name: "invalid negative disconnection timeout", + settings: MobileEphemeralModeSettings{ + Enable: NewPointer(true), + DisconnectionTimeoutSeconds: NewPointer(-1), + OfflinePersistenceTimerHours: NewPointer(0), + AutoCacheCleanupDays: NewPointer(0), + }, + expectError: true, + errorId: "model.config.is_valid.mobile_ephemeral_mode.disconnection_timeout.app_error", + }, + { + name: "invalid negative offline persistence", + settings: MobileEphemeralModeSettings{ + Enable: NewPointer(true), + DisconnectionTimeoutSeconds: NewPointer(60), + OfflinePersistenceTimerHours: NewPointer(-1), + AutoCacheCleanupDays: NewPointer(0), + }, + expectError: true, + errorId: "model.config.is_valid.mobile_ephemeral_mode.offline_persistence.app_error", + }, + { + name: "invalid negative auto cache cleanup", + settings: MobileEphemeralModeSettings{ + Enable: NewPointer(true), + DisconnectionTimeoutSeconds: NewPointer(60), + OfflinePersistenceTimerHours: NewPointer(0), + AutoCacheCleanupDays: NewPointer(-1), + }, + expectError: true, + errorId: "model.config.is_valid.mobile_ephemeral_mode.auto_cache_cleanup.app_error", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := tc.settings.isValid() + if tc.expectError { + require.NotNil(t, err) + require.Equal(t, tc.errorId, err.Id) + } else { + require.Nil(t, err) + } + }) + } +} + func TestNativeAppSettingsIsValid(t *testing.T) { t.Run("defaults are valid", func(t *testing.T) { cfg := Config{} diff --git a/server/public/model/feature_flags.go b/server/public/model/feature_flags.go index 68465f32025..3b0e698d168 100644 --- a/server/public/model/feature_flags.go +++ b/server/public/model/feature_flags.go @@ -125,6 +125,9 @@ type FeatureFlags struct { // Gates the per-channel Discoverable toggle and the channel-join-request flow that lets // non-members find a private channel in Browse Channels and request to join it. DiscoverableChannels bool + + // Enable Mobile Ephemeral Mode for controlling data persistence on mobile devices + MobileEphemeralMode bool } func (f *FeatureFlags) SetDefaults() { @@ -183,6 +186,8 @@ func (f *FeatureFlags) SetDefaults() { f.ManagedChannelCategories = false f.DiscoverableChannels = false + + f.MobileEphemeralMode = false } // ToMap returns the feature flags as a map[string]string diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx index a62ea3cd26c..bcf95f93107 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition.tsx @@ -2373,6 +2373,76 @@ const AdminDefinition: AdminDefinitionType = { }, ], }, + { + key: 'MobileSecuritySettings.EphemeralMode', + title: 'Mobile Ephemeral Mode', + description: defineMessage({id: 'admin.mobileSecurity.sections.ephemeralMode.description', defaultMessage: 'Configure data persistence and cache management policies for mobile devices.'}), + license_sku: LicenseSkus.EnterpriseAdvanced, + component: LicensedSectionContainer, + componentProps: { + requiredSku: LicenseSkus.EnterpriseAdvanced, + featureDiscoveryConfig: { + featureName: 'mobile_ephemeral_mode', + title: defineMessage({id: 'admin.mobileSecurity.ephemeralMode_feature_discovery.title', defaultMessage: 'Control mobile data persistence with Mobile Ephemeral Mode'}), + description: defineMessage({id: 'admin.mobileSecurity.ephemeralMode_feature_discovery.description', defaultMessage: 'With Mattermost Enterprise Advanced, you can enable Mobile Ephemeral Mode to enforce data persistence policies on mobile devices. Configure disconnection timeouts, offline data retention, and automatic cache cleanup.'}), + learnMoreURL: 'https://docs.mattermost.com', + }, + }, + isHidden: it.configIsFalse('FeatureFlags', 'MobileEphemeralMode'), + settings: [ + { + type: 'banner', + label: defineMessage({id: 'admin.mobileSecurity.ephemeralMode.banner', defaultMessage: 'Changes to these settings are delivered to connected devices in real time. Offline devices will continue operating under their last-known settings until they re-establish a server connection. Timer state persists across app and device restarts.'}), + banner_type: 'info', + }, + { + type: 'bool', + key: 'MobileEphemeralModeSettings.Enable', + label: defineMessage({id: 'admin.mobileSecurity.ephemeralMode.enableTitle', defaultMessage: 'Enable Mobile Ephemeral Mode:'}), + help_text: defineMessage({id: 'admin.mobileSecurity.ephemeralMode.enableDescription', defaultMessage: 'When enabled, mobile clients will follow the server-configured ephemeral data policies. Disconnected devices will clean up cached data based on the configured timers.'}), + }, + { + type: 'number', + key: 'MobileEphemeralModeSettings.DisconnectionTimeoutSeconds', + label: defineMessage({id: 'admin.mobileSecurity.ephemeralMode.disconnectionTimeoutTitle', defaultMessage: 'Disconnection Timeout (seconds):'}), + help_text: defineMessage({id: 'admin.mobileSecurity.ephemeralMode.disconnectionTimeoutDescription', defaultMessage: 'Grace period after losing server connection before the device is considered offline. Helps avoid false triggers from brief network interruptions. Values below 5 are not recommended.'}), + placeholder: defineMessage({id: 'admin.mobileSecurity.ephemeralMode.disconnectionTimeout.placeholder', defaultMessage: 'E.g.: 60'}), + isDisabled: it.stateIsFalse('MobileEphemeralModeSettings.Enable'), + validate: validators.numberInRange(0, 600, defineMessage({ + id: 'admin.mobileSecurity.ephemeralMode.disconnectionTimeout.range', + defaultMessage: 'Must be a number between 0 and 600 seconds (10 minutes).', + })), + }, + { + type: 'number', + key: 'MobileEphemeralModeSettings.OfflinePersistenceTimerHours', + label: defineMessage({id: 'admin.mobileSecurity.ephemeralMode.offlinePersistenceTitle', defaultMessage: 'Offline Persistence Timer (hours):'}), + help_text: defineMessage({id: 'admin.mobileSecurity.ephemeralMode.offlinePersistenceDescription', defaultMessage: 'How long cached content is kept after the device goes offline. When the timer expires, cached content is deleted but session credentials are preserved. Set to 0 for immediate cleanup.'}), + disabled_help_text: defineMessage({id: 'admin.mobileSecurity.ephemeralMode.offlinePersistence.disabled', defaultMessage: 'How long cached content is kept after the device goes offline. When the timer expires, cached content is deleted but session credentials are preserved. Set to 0 for immediate cleanup. Requires Mobile Ephemeral Mode to be enabled and Auto Cache Cleanup to be greater than 0.'}), + placeholder: defineMessage({id: 'admin.mobileSecurity.ephemeralMode.offlinePersistence.placeholder', defaultMessage: 'E.g.: 24'}), + isDisabled: it.any( + it.stateIsFalse('MobileEphemeralModeSettings.Enable'), + it.stateEquals('MobileEphemeralModeSettings.AutoCacheCleanupDays', 0), + ), + validate: validators.numberInRange(0, 72, defineMessage({ + id: 'admin.mobileSecurity.ephemeralMode.offlinePersistence.range', + defaultMessage: 'Must be a number between 0 and 72 hours (3 days).', + })), + }, + { + type: 'number', + key: 'MobileEphemeralModeSettings.AutoCacheCleanupDays', + label: defineMessage({id: 'admin.mobileSecurity.ephemeralMode.autoCacheCleanupTitle', defaultMessage: 'Auto Cache Cleanup (days):'}), + help_text: defineMessage({id: 'admin.mobileSecurity.ephemeralMode.autoCacheCleanupDescription', defaultMessage: 'Controls the maximum age of any content cached on the device, regardless of connection status. Prevents unbounded accumulation of sensitive data. Set to 0 for zero-persistence mode where content is never persisted to disk.'}), + placeholder: defineMessage({id: 'admin.mobileSecurity.ephemeralMode.autoCacheCleanup.placeholder', defaultMessage: 'E.g.: 7'}), + isDisabled: it.stateIsFalse('MobileEphemeralModeSettings.Enable'), + validate: validators.numberInRange(0, 60, defineMessage({ + id: 'admin.mobileSecurity.ephemeralMode.autoCacheCleanup.range', + defaultMessage: 'Must be a number between 0 and 60 days.', + })), + }, + ], + }, ], }, }, diff --git a/webapp/channels/src/components/admin_console/admin_definition_helpers.test.tsx b/webapp/channels/src/components/admin_console/admin_definition_helpers.test.tsx index 963b2279b14..91a51483d0d 100644 --- a/webapp/channels/src/components/admin_console/admin_definition_helpers.test.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition_helpers.test.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {it} from './admin_definition_helpers'; +import {it, validators} from './admin_definition_helpers'; describe('AdminDefinitionHelpers - stateEqualsOrDefault', () => { test('should return true when state value equals expected value', () => { @@ -45,3 +45,22 @@ describe('AdminDefinitionHelpers - stateEqualsOrDefault', () => { expect(checker({}, undefinedStateWithDifferentExpected)).toBe(false); }); }); + +describe('AdminDefinitionHelpers - validators.numberInRange', () => { + const validate = validators.numberInRange(0, 60, 'out of range'); + + test('should return valid for in-range numbers', () => { + expect(validate(0).isValid()).toBe(true); + expect(validate(30).isValid()).toBe(true); + expect(validate(60).isValid()).toBe(true); + }); + + test('should return invalid for out-of-range numbers', () => { + expect(validate(-1).isValid()).toBe(false); + expect(validate(61).isValid()).toBe(false); + }); + + test('should return valid for NaN since the server backfills empty inputs with defaults', () => { + expect(validate(NaN).isValid()).toBe(true); + }); +}); diff --git a/webapp/channels/src/components/admin_console/admin_definition_helpers.tsx b/webapp/channels/src/components/admin_console/admin_definition_helpers.tsx index e4c15053bd5..ca914b01e5d 100644 --- a/webapp/channels/src/components/admin_console/admin_definition_helpers.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition_helpers.tsx @@ -74,6 +74,7 @@ export const validators = { isRequired: (text: MessageDescriptor | string) => (value: string) => new ValidationResult(Boolean(value), text), minValue: (min: number, text: MessageDescriptor | string) => (value: number) => new ValidationResult((value >= min), text), maxValue: (max: number, text: MessageDescriptor | string) => (value: number) => new ValidationResult((value <= max), text), + numberInRange: (min: number, max: number, text: MessageDescriptor | string) => (value: number) => new ValidationResult(Number.isNaN(value) || (value >= min && value <= max), text), }; export const usesLegacyOauth = (config: Partial, state: any, license?: ClientLicense, enterpriseReady?: boolean, consoleAccess?: ConsoleAccess, cloud?: CloudState) => { diff --git a/webapp/channels/src/components/admin_console/admin_definition_mobile_ephemeral_mode.test.tsx b/webapp/channels/src/components/admin_console/admin_definition_mobile_ephemeral_mode.test.tsx new file mode 100644 index 00000000000..13691dfff88 --- /dev/null +++ b/webapp/channels/src/components/admin_console/admin_definition_mobile_ephemeral_mode.test.tsx @@ -0,0 +1,129 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {AdminConfig} from '@mattermost/types/config'; + +import {LicenseSkus} from 'utils/constants'; + +import AdminDefinition from './admin_definition'; +import type {AdminDefinitionSetting, AdminDefinitionConfigSchemaSection} from './types'; + +describe('AdminDefinition - Mobile Ephemeral Mode Settings', () => { + const getEphemeralModeSections = () => { + const mobileSecuritySection = AdminDefinition.environment.subsections.mobile_security; + const sections = 'sections' in mobileSecuritySection.schema! ? mobileSecuritySection.schema.sections : undefined; + return sections; + }; + + const getEphemeralModeSection = () => { + const sections = getEphemeralModeSections(); + return sections?.find((section: AdminDefinitionConfigSchemaSection) => section.key === 'MobileSecuritySettings.EphemeralMode'); + }; + + const getEphemeralModeSettings = () => { + const section = getEphemeralModeSection(); + return section?.settings || []; + }; + + test('should include Mobile Ephemeral Mode section in mobile_security', () => { + const section = getEphemeralModeSection(); + expect(section).toBeDefined(); + }); + + test('should include Enable setting', () => { + const settings = getEphemeralModeSettings(); + const enableSetting = settings.find((s: AdminDefinitionSetting) => s.key === 'MobileEphemeralModeSettings.Enable'); + + expect(enableSetting).toBeDefined(); + expect(enableSetting?.type).toBe('bool'); + expect(enableSetting?.label).toBeDefined(); + expect(enableSetting?.help_text).toBeDefined(); + }); + + test('should include info banner', () => { + const settings = getEphemeralModeSettings(); + const bannerSetting = settings.find((s: AdminDefinitionSetting) => s.type === 'banner'); + + expect(bannerSetting).toBeDefined(); + }); + + test('settings should have proper translation message descriptors', () => { + const settings = getEphemeralModeSettings(); + const settingsWithLabels = settings.filter((s: AdminDefinitionSetting) => s.key?.includes('MobileEphemeralMode')); + + settingsWithLabels.forEach((setting: AdminDefinitionSetting) => { + if (setting.label && typeof setting.label === 'object') { + expect('id' in setting.label).toBe(true); + expect('defaultMessage' in setting.label).toBe(true); + } + + if (setting.help_text && typeof setting.help_text === 'object' && !('$$typeof' in setting.help_text)) { + expect('id' in setting.help_text).toBe(true); + expect('defaultMessage' in setting.help_text).toBe(true); + } + }); + }); + + test('should use LicensedSectionContainer with Enterprise Advanced', () => { + const section = getEphemeralModeSection(); + + expect(section?.component).toBeDefined(); + expect(section?.license_sku).toBe(LicenseSkus.EnterpriseAdvanced); + expect(section?.componentProps).toBeDefined(); + expect(section?.componentProps?.requiredSku).toBe(LicenseSkus.EnterpriseAdvanced); + expect(section?.componentProps?.featureDiscoveryConfig).toBeDefined(); + expect(section?.componentProps?.featureDiscoveryConfig?.featureName).toBe('mobile_ephemeral_mode'); + }); + + test('isHidden should return true when feature flag is disabled', () => { + const section = getEphemeralModeSection(); + expect(section?.isHidden).toBeDefined(); + expect(typeof section?.isHidden).toBe('function'); + + const mockConfig: Partial = {FeatureFlags: {MobileEphemeralMode: false}}; + const isHiddenFn = section!.isHidden as (config: Partial) => boolean; + expect(isHiddenFn(mockConfig)).toBe(true); + }); + + test('isHidden should return false when feature flag is enabled', () => { + const section = getEphemeralModeSection(); + + const mockConfig: Partial = {FeatureFlags: {MobileEphemeralMode: true}}; + const isHiddenFn = section!.isHidden as (config: Partial) => boolean; + expect(isHiddenFn(mockConfig)).toBe(false); + }); + + test('should include DisconnectionTimeoutSeconds number setting', () => { + const settings = getEphemeralModeSettings(); + const setting = settings.find((s: AdminDefinitionSetting) => s.key === 'MobileEphemeralModeSettings.DisconnectionTimeoutSeconds'); + + expect(setting).toBeDefined(); + expect(setting?.type).toBe('number'); + expect(setting?.isDisabled).toBeDefined(); + }); + + test('should include OfflinePersistenceTimerHours number setting', () => { + const settings = getEphemeralModeSettings(); + const setting = settings.find((s: AdminDefinitionSetting) => s.key === 'MobileEphemeralModeSettings.OfflinePersistenceTimerHours'); + + expect(setting).toBeDefined(); + expect(setting?.type).toBe('number'); + expect(setting?.isDisabled).toBeDefined(); + }); + + test('should include AutoCacheCleanupDays number setting', () => { + const settings = getEphemeralModeSettings(); + const setting = settings.find((s: AdminDefinitionSetting) => s.key === 'MobileEphemeralModeSettings.AutoCacheCleanupDays'); + + expect(setting).toBeDefined(); + expect(setting?.type).toBe('number'); + expect(setting?.isDisabled).toBeDefined(); + }); + + test('OfflinePersistenceTimerHours should have disabled_help_text for zero-persistence mode', () => { + const settings = getEphemeralModeSettings(); + const setting = settings.find((s: AdminDefinitionSetting) => s.key === 'MobileEphemeralModeSettings.OfflinePersistenceTimerHours'); + + expect(setting?.disabled_help_text).toBeDefined(); + }); +}); diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 65f3402ac17..cc13c1b910c 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -1870,11 +1870,30 @@ "admin.mobileSecurity.allowPdfLinkNavigationTitle": "Allow Link Navigation in Secure PDFs:", "admin.mobileSecurity.biometricsDescription": "Enforces biometric authentication (with PIN/passcode fallback) before accessing the app. Users will be prompted based on session activity and server switching rules.", "admin.mobileSecurity.biometricsTitle": "Enable Biometric Authentication:", + "admin.mobileSecurity.ephemeralMode_feature_discovery.description": "With Mattermost Enterprise Advanced, you can enable Mobile Ephemeral Mode to enforce data persistence policies on mobile devices. Configure disconnection timeouts, offline data retention, and automatic cache cleanup.", + "admin.mobileSecurity.ephemeralMode_feature_discovery.title": "Control mobile data persistence with Mobile Ephemeral Mode", + "admin.mobileSecurity.ephemeralMode.autoCacheCleanup.placeholder": "E.g.: 7", + "admin.mobileSecurity.ephemeralMode.autoCacheCleanup.range": "Must be a number between 0 and 60 days.", + "admin.mobileSecurity.ephemeralMode.autoCacheCleanupDescription": "Controls the maximum age of any content cached on the device, regardless of connection status. Prevents unbounded accumulation of sensitive data. Set to 0 for zero-persistence mode where content is never persisted to disk.", + "admin.mobileSecurity.ephemeralMode.autoCacheCleanupTitle": "Auto Cache Cleanup (days):", + "admin.mobileSecurity.ephemeralMode.banner": "Changes to these settings are delivered to connected devices in real time. Offline devices will continue operating under their last-known settings until they re-establish a server connection. Timer state persists across app and device restarts.", + "admin.mobileSecurity.ephemeralMode.disconnectionTimeout.placeholder": "E.g.: 60", + "admin.mobileSecurity.ephemeralMode.disconnectionTimeout.range": "Must be a number between 0 and 600 seconds (10 minutes).", + "admin.mobileSecurity.ephemeralMode.disconnectionTimeoutDescription": "Grace period after losing server connection before the device is considered offline. Helps avoid false triggers from brief network interruptions. Values below 5 are not recommended.", + "admin.mobileSecurity.ephemeralMode.disconnectionTimeoutTitle": "Disconnection Timeout (seconds):", + "admin.mobileSecurity.ephemeralMode.enableDescription": "When enabled, mobile clients will follow the server-configured ephemeral data policies. Disconnected devices will clean up cached data based on the configured timers.", + "admin.mobileSecurity.ephemeralMode.enableTitle": "Enable Mobile Ephemeral Mode:", + "admin.mobileSecurity.ephemeralMode.offlinePersistence.disabled": "How long cached content is kept after the device goes offline. When the timer expires, cached content is deleted but session credentials are preserved. Set to 0 for immediate cleanup. Requires Mobile Ephemeral Mode to be enabled and Auto Cache Cleanup to be greater than 0.", + "admin.mobileSecurity.ephemeralMode.offlinePersistence.placeholder": "E.g.: 24", + "admin.mobileSecurity.ephemeralMode.offlinePersistence.range": "Must be a number between 0 and 72 hours (3 days).", + "admin.mobileSecurity.ephemeralMode.offlinePersistenceDescription": "How long cached content is kept after the device goes offline. When the timer expires, cached content is deleted but session credentials are preserved. Set to 0 for immediate cleanup.", + "admin.mobileSecurity.ephemeralMode.offlinePersistenceTitle": "Offline Persistence Timer (hours):", "admin.mobileSecurity.jailbreakDescription": "Prevents access to the app on devices detected as jailbroken or rooted. If a device fails the security check, users will be denied access or prompted to switch to a compliant server.", "admin.mobileSecurity.jailbreakTitle": "Enable Jailbreak/Root Protection:", "admin.mobileSecurity.mobileAllowDownloads": "Site Configuration > File Sharing and Downloads > Allow File Downloads on Mobile", "admin.mobileSecurity.screenCaptureDescription": "Blocks screenshots and screen recordings when using the mobile app. Screenshots will appear blank, and screen recordings will blur (iOS) or show a black screen (Android). Also applies when switching apps.", "admin.mobileSecurity.screenCaptureTitle": "Prevent Screen Capture:", + "admin.mobileSecurity.sections.ephemeralMode.description": "Configure data persistence and cache management policies for mobile devices.", "admin.mobileSecurity.sections.general.description": "Configure device security features for the mobile app.", "admin.mobileSecurity.sections.intune.description": "Configure Microsoft Intune Mobile Application Management (MAM) for App Protection Policies.", "admin.mobileSecurity.secureFilePreviewDescription": "Prevents file downloads, previews, and sharing for most file types, even if {mobileAllowDownloads} is enabled. Allows in-app previews for PDFs, videos, and images only. Files are stored temporarily in the app's cache and cannot be exported or shared.", diff --git a/webapp/channels/src/utils/admin_console_index.test.tsx b/webapp/channels/src/utils/admin_console_index.test.tsx index 2da36d664e8..3c83b111f77 100644 --- a/webapp/channels/src/utils/admin_console_index.test.tsx +++ b/webapp/channels/src/utils/admin_console_index.test.tsx @@ -29,8 +29,8 @@ describe('AdminConsoleIndex.generateIndex', () => { expect(idx.search('saml')).toEqual([ 'authentication/saml', 'environment/session_lengths', - 'authentication/email', 'environment/mobile_security', + 'authentication/email', 'experimental/features', ]); expect(idx.search('nginx')).toEqual([ diff --git a/webapp/platform/types/src/config.ts b/webapp/platform/types/src/config.ts index b77216eb20a..c5e6fc8899e 100644 --- a/webapp/platform/types/src/config.ts +++ b/webapp/platform/types/src/config.ts @@ -837,6 +837,13 @@ export type IntuneSettings = { AuthService?: string; }; +export type MobileEphemeralModeSettings = { + Enable: boolean; + DisconnectionTimeoutSeconds: number; + OfflinePersistenceTimerHours: number; + AutoCacheCleanupDays: number; +}; + export type ClusterSettings = { Enable: boolean; ClusterName: string; @@ -1105,6 +1112,7 @@ export type AdminConfig = { AccessControlSettings: AccessControlSettings; ContentFlaggingSettings: ContentFlaggingSettings; AutoTranslationSettings: AutoTranslationSettings; + MobileEphemeralModeSettings: MobileEphemeralModeSettings; }; export type ReplicaLagSetting = {