Mm 68282 admin ephemeral mode (#36194)

* adds feature flag to enable mattermost ephemeral mode

* add ephemeral mode config settings to system console

When feature flag is set to true a new section for Mobile Ephemeral Mode
settings shows under the Mobile Security section in case a valid Enterprise
Advanced License is active.

* adds Mobile Ephemeral Mode settings playwright tests

* improve descriptions for settings

* improves error messages and hints

* move validation to common helper and add new tests

* reverts package-lock.json changes

* proper struct alignment

* proper message sorting in json file

* use generic doc url for MEM section while docs are not ready

* Proper formatting for playwright tests

* fixes test
This commit is contained in:
Carlos Garcia
2026-05-18 22:23:58 +02:00
committed by GitHub
parent 9bd77d3fc4
commit 548183d748
18 changed files with 919 additions and 4 deletions
@@ -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,
},
};
@@ -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,
@@ -94,6 +94,40 @@ export class TextInputSetting {
}
}
/**
* Number Input Setting - represents a number input field
* Uses getByRole('spinbutton') since <input type="number"> 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<string> {
return (await this.input.inputValue()) ?? '';
}
async clear() {
await this.input.clear();
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
}
/**
* Dropdown Setting - represents a select dropdown
*/
@@ -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;
@@ -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');
},
);
+14
View File
@@ -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)
}
}
}
}
+134
View File
@@ -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))
}
})
}
}
+12
View File
@@ -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"
+61
View File
@@ -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
}
+115
View File
@@ -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{}
+5
View File
@@ -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
@@ -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.',
})),
},
],
},
],
},
},
@@ -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);
});
});
@@ -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<AdminConfig>, state: any, license?: ClientLicense, enterpriseReady?: boolean, consoleAccess?: ConsoleAccess, cloud?: CloudState) => {
@@ -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<AdminConfig> = {FeatureFlags: {MobileEphemeralMode: false}};
const isHiddenFn = section!.isHidden as (config: Partial<AdminConfig>) => boolean;
expect(isHiddenFn(mockConfig)).toBe(true);
});
test('isHidden should return false when feature flag is enabled', () => {
const section = getEphemeralModeSection();
const mockConfig: Partial<AdminConfig> = {FeatureFlags: {MobileEphemeralMode: true}};
const isHiddenFn = section!.isHidden as (config: Partial<AdminConfig>) => 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();
});
});
+19
View File
@@ -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.",
@@ -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([
+8
View File
@@ -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 = {