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 = {