mattermost-mobile-upstream/app/utils/alerts/index.ts
Elias Nahum f00701ec9a
On iOS, resuming after the 5-minute window showed no biometric prompt — (#10102)
* On iOS, resuming after the 5-minute window showed no biometric prompt —
just a blocking alert whose only button logged the user out. A failure to
evaluate device security was indistinguishable from a verdict that the
device is not secured. Android had the same class of bug, where
user-cancel codes led to logout.

Outcomes are now classified three ways, so only a genuine credential
rejection or a genuinely unsecured device acts destructively; cancels and
indeterminate results offer a retry. SecurityManager and ManagedApp share
the policy-free parts via app/utils/security/.

Also fixes a resume stacking a second prompt behind an open alert, a
backgroundSince bypass, and a latched flag that disabled the MDM PIN.

* feedback review

* gate app state

* fix tests
2026-09-02 15:03:13 +08:00

614 lines
24 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import Emm from '@mattermost/react-native-emm';
import {defineMessages} from 'react-intl';
import {Alert, Platform, type AlertButton} from 'react-native';
import {switchToServer} from '@actions/app/server';
import {logout} from '@actions/remote/session';
import DatabaseManager from '@database/manager';
import {DEFAULT_LOCALE, getLocalizedMessage} from '@i18n';
import {getServerCredentials} from '@init/credentials';
import {IntuneAuthRequiredReasons} from '@managers/intune_manager/types';
import {queryAllActiveServers} from '@queries/app/servers';
import {getConfigValue} from '@queries/servers/system';
import {getFullErrorMessage} from '@utils/errors';
import {logError} from '@utils/log';
export const messages = defineMessages({
not_secured_vendor_ios: {
id: 'mobile.managed.not_secured.ios.vendor',
defaultMessage: 'This device must be secured with biometrics or passcode to use {vendor}.\n\nGo to Settings > Face ID & Passcode.',
},
not_secured_vendor_android: {
id: 'mobile.managed.not_secured.android.vendor',
defaultMessage: 'This device must be secured with a screen lock to use {vendor}.',
},
not_secured_ios: {
id: 'mobile.managed.not_secured.ios',
defaultMessage: 'This device must be secured with biometrics or passcode to use Mattermost.\n\nGo to Settings > Face ID & Passcode.',
},
not_secured_android: {
id: 'mobile.managed.not_secured.android',
defaultMessage: 'This device must be secured with a screen lock to use Mattermost.',
},
blocked_by: {
id: 'mobile.managed.blocked_by',
defaultMessage: 'Blocked by {vendor}',
},
androidSettings: {
id: 'mobile.managed.settings',
defaultMessage: 'Go to settings',
},
securedBy: {
id: 'mobile.managed.secured_by',
defaultMessage: 'Secured by {vendor}',
},
logout: {
id: 'mobile.managed.logout',
defaultMessage: 'Logout',
},
switchServer: {
id: 'mobile.managed.switch_server',
defaultMessage: 'Switch server',
},
exit: {
id: 'mobile.managed.exit',
defaultMessage: 'Exit',
},
jailbreak: {
id: 'mobile.managed.jailbreak',
defaultMessage: 'Jailbroken or rooted devices are not trusted by {vendor}.',
},
biometric_failed: {
id: 'mobile.managed.biometric_failed',
defaultMessage: 'Biometric or Passcode authentication failed.',
},
retry: {
id: 'mobile.managed.retry',
defaultMessage: 'Retry',
},
auth_interrupted_message: {
id: 'security_manager.auth_interrupted_message',
defaultMessage: 'Authentication could not be completed. Please try again.',
},
authentication_required_title: {
id: 'security_manager.authentication_required_title',
defaultMessage: 'Authentication Required',
},
authentication_required_message: {
id: 'security_manager.authentication_required_message',
defaultMessage: 'Your organization requires you to sign in again to continue using Intune-managed accounts. The affected accounts have been unenrolled and signed out.',
},
consent_denied_title: {
id: 'security_manager.consent_denied_title',
defaultMessage: 'Consent Denied',
},
consent_denied_message: {
id: 'security_manager.consent_denied_message',
defaultMessage: 'You denied consent for Intune management. The affected accounts have been unenrolled and signed out.',
},
authentication_failed_title: {
id: 'security_manager.authentication_failed_title',
defaultMessage: 'Authentication Failed',
},
authentication_failed_message: {
id: 'security_manager.authentication_failed_message',
defaultMessage: 'Authentication failed. The affected accounts have been unenrolled and signed out. Please contact your IT administrator.',
},
okay: {
id: 'security_manager.okay',
defaultMessage: 'Okay',
},
access_blocked_title: {
id: 'security_manager.access_blocked_title',
defaultMessage: 'Access Blocked',
},
access_blocked_message: {
id: 'security_manager.access_blocked_message',
defaultMessage: 'Your organization has blocked access to this app. Please contact your IT administrator for assistance.',
},
identity_switch_required_title: {
id: 'security_manager.identity_switch_required_title',
defaultMessage: 'Identity Switch Required',
},
identity_switch_required_message: {
id: 'security_manager.identity_switch_required_message',
defaultMessage: 'Your organization requires you to switch accounts to continue using this app. Please contact your IT administrator for assistance.',
},
organization: {
id: 'security_manager.your_organization',
defaultMessage: 'your organization',
},
mam_enrollment_required_title: {
id: 'security_manager.mam_enrollment_required_title',
defaultMessage: 'Enrollment Required',
},
mam_enrollment_required_message: {
id: 'security_manager.mam_enrollment_required_message',
defaultMessage: '{organization} requires enrollment in Microsoft Intune to protect corporate data.',
},
mam_enrollment_failed_title: {
id: 'security_manager.mam_enrollment_failed_title',
defaultMessage: 'Enrollment Failed',
},
mam_enrollment_failed_message: {
id: 'security_manager.mam_enrollment_failed_message',
defaultMessage: 'Failed to enroll in Microsoft Intune. You will be logged out.',
},
mam_declined_title: {
id: 'security_manager.mam_declined_title',
defaultMessage: 'Enrollment Declined',
},
mam_declined_message: {
id: 'security_manager.mam_declined_message',
defaultMessage: '{organization} requires enrollment in Microsoft Intune. You can retry enrollment or logout.',
},
enroll_now: {
id: 'security_manager.enroll_now',
defaultMessage: 'Enroll Now',
},
cancel: {
id: 'security_manager.cancel',
defaultMessage: 'Cancel',
},
compliance_not_compliant: {
id: 'mobile.intune.compliance.not_compliant',
defaultMessage: "Your device doesn't meet the required app protection policy.",
},
compliance_network_failure: {
id: 'mobile.intune.compliance.network_failure',
defaultMessage: 'Could not reach the Intune service. Check your network and try again.',
},
compliance_service_failure: {
id: 'mobile.intune.compliance.service_failure',
defaultMessage: 'Intune service error. Please try again later.',
},
compliance_user_cancelled: {
id: 'mobile.intune.compliance.user_cancelled',
defaultMessage: 'Login was canceled. Please try again.',
},
compliance_alert_title: {
id: 'mobile.intune.compliance.alert_title',
defaultMessage: 'App Protection Required',
},
});
/**
* Switches to the previous server.
*/
const goToPreviousServer = async (lastAccessedServer: string) => {
// Switch to last accessed server
if (lastAccessedServer) {
await switchToServer(lastAccessedServer);
}
};
/**
* Builds the alert options for the alert.
*/
export const buildSecurityAlertOptions = async (
server: string, locale: string,
callback?: (value: boolean) => void,
retryCallback?: () => void,
) => {
const buttons: AlertButton[] = [];
const hasSessionToServer = await getServerCredentials(server);
const allServers = await queryAllActiveServers()?.fetch();
const activeServer = await DatabaseManager.getActiveServerUrl();
const otherServers = allServers?.filter((s) => s.url !== server).map((s) => s.url) || [];
if (server && hasSessionToServer) {
buttons.push({
text: getLocalizedMessage(locale, messages.logout.id, messages.logout.defaultMessage),
style: 'destructive',
onPress: async () => {
await logout(server, undefined);
callback?.(true);
},
});
}
// Without a server every other server matches the filter above, which would offer a
// managed user a way around the policy that just blocked them.
if (server && otherServers.length > 0) {
if (otherServers.length === 1 && otherServers[0] === activeServer) {
buttons.push({
text: getLocalizedMessage(locale, messages.okay.id, messages.okay.defaultMessage),
style: 'cancel',
onPress: () => {
callback?.(true);
},
});
} else {
buttons.push({
text: getLocalizedMessage(locale, messages.switchServer.id, messages.switchServer.defaultMessage),
style: 'cancel',
onPress: () => {
goToPreviousServer(otherServers[0]);
callback?.(true);
},
});
}
}
if (retryCallback && typeof retryCallback === 'function') {
buttons.push({
text: getLocalizedMessage(locale, messages.retry.id, messages.retry.defaultMessage),
style: 'default',
onPress: () => {
// Cleanup first, so state from the previous attempt (e.g. blur) does not
// survive into the retry.
callback?.(true);
retryCallback();
},
});
}
if (buttons.length === 0) {
buttons.push({
text: getLocalizedMessage(locale, messages.exit.id, messages.exit.defaultMessage),
style: 'destructive',
onPress: () => {
Emm.exitApp();
},
});
}
return buttons;
};
/**
* Shows an alert when the device is not trusted (jailbroken or rooted).
*/
export const showDeviceNotTrustedAlert = async (server: string, siteName: string | undefined, locale?: string) => {
let serverSiteName;
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(server);
serverSiteName = await getConfigValue(database, 'SiteName');
} catch (error) {
logError('showDeviceNotTrustedAlert: failed to get SiteName', getFullErrorMessage(error));
}
const resolvedLocale = locale || DEFAULT_LOCALE;
const buttons = await buildSecurityAlertOptions(server, resolvedLocale);
const securedBy = siteName || serverSiteName || 'Mattermost';
Alert.alert(
getLocalizedMessage(resolvedLocale, messages.blocked_by.id, messages.blocked_by.defaultMessage).replace('{vendor}', securedBy),
getLocalizedMessage(resolvedLocale, messages.jailbreak.id, messages.jailbreak.defaultMessage).
replace('{vendor}', securedBy),
buttons,
{cancelable: false},
);
};
/**
* Shows an alert when the device does not have biometrics or passcode set.
*/
export const showNotSecuredAlert = async (server: string, siteName: string | undefined, locale?: string, exitOnly = false) => {
const buttons: AlertButton[] = [];
let serverSiteName;
if (server) {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(server);
serverSiteName = await getConfigValue(database, 'SiteName');
} catch (error) {
logError('showNotSecuredAlert: failed to get SiteName', getFullErrorMessage(error));
}
}
const resolvedLocale = locale || DEFAULT_LOCALE;
const securedBy = siteName || serverSiteName || 'Mattermost';
let dismiss: () => void;
const dismissed = new Promise<void>((resolve) => {
dismiss = resolve;
});
if (Platform.OS === 'android') {
buttons.push({
text: getLocalizedMessage(resolvedLocale, messages.androidSettings.id, messages.androidSettings.defaultMessage),
onPress: () => {
Emm.openSecuritySettings();
dismiss();
},
});
}
if (exitOnly) {
buttons.push({
text: getLocalizedMessage(resolvedLocale, messages.exit.id, messages.exit.defaultMessage),
style: 'destructive',
onPress: () => {
dismiss();
Emm.exitApp();
},
});
} else {
const alertButtons = await buildSecurityAlertOptions(server, resolvedLocale, () => dismiss());
buttons.push(...alertButtons);
}
let message;
if (serverSiteName || siteName) {
const descriptor = Platform.select({ios: messages.not_secured_vendor_ios, default: messages.not_secured_vendor_android});
message = getLocalizedMessage(resolvedLocale, descriptor.id, descriptor.defaultMessage).replace('{vendor}', securedBy);
} else {
const descriptor = Platform.select({ios: messages.not_secured_ios, default: messages.not_secured_android});
message = getLocalizedMessage(resolvedLocale, descriptor.id, descriptor.defaultMessage);
}
Alert.alert(
getLocalizedMessage(resolvedLocale, messages.blocked_by.id, messages.blocked_by.defaultMessage).replace('{vendor}', securedBy),
message,
buttons,
{cancelable: false},
);
return dismissed;
};
/**
* Shows an alert when biometric authentication fails.
*/
export const showBiometricFailureAlert = async (server: string, blurOnAuthenticate: boolean, siteName: string | undefined, locale?: string, retryCallback?: () => void) => {
let serverSiteName;
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(server);
serverSiteName = await getConfigValue(database, 'SiteName');
} catch (error) {
logError('showBiometricFailureAlert: failed to get SiteName', getFullErrorMessage(error));
}
const resolvedLocale = locale || DEFAULT_LOCALE;
let dismiss: () => void;
const dismissed = new Promise<void>((resolve) => {
dismiss = resolve;
});
const buttons = await buildSecurityAlertOptions(server, resolvedLocale, () => {
if (blurOnAuthenticate) {
Emm.removeBlurEffect();
}
dismiss();
}, retryCallback);
const securedBy = siteName || serverSiteName || 'Mattermost';
Alert.alert(
getLocalizedMessage(resolvedLocale, messages.blocked_by.id, messages.blocked_by.defaultMessage).replace('{vendor}', securedBy),
getLocalizedMessage(resolvedLocale, messages.biometric_failed.id, messages.biometric_failed.defaultMessage),
buttons,
{cancelable: false},
);
return dismissed;
};
export const showBiometricFailureAlertForOrganization = async (server: string, locale?: string, retryCallback?: () => void) => {
const resolvedLocale = locale || DEFAULT_LOCALE;
const organization = getLocalizedMessage(resolvedLocale, messages.organization.id, messages.organization.defaultMessage);
return showBiometricFailureAlert(server, true, organization, locale, retryCallback);
};
/**
* Shows an alert when authentication could not be attempted or was interrupted.
*
* Buttons are built here rather than via buildSecurityAlertOptions so that no destructive
* option can appear: nobody failed authentication on this path, so the session must survive.
*/
export const showAuthenticationInterruptedAlert = async (server: string, siteName: string | undefined, locale?: string): Promise<'retry' | 'dismiss'> => {
const resolvedLocale = locale || DEFAULT_LOCALE;
const securedBy = siteName || 'Mattermost';
const allServers = await queryAllActiveServers()?.fetch();
const otherServers = allServers?.filter((s) => s.url !== server).map((s) => s.url) || [];
return new Promise((resolve) => {
const buttons: AlertButton[] = [];
if (otherServers.length > 0) {
buttons.push({
text: getLocalizedMessage(resolvedLocale, messages.switchServer.id, messages.switchServer.defaultMessage),
onPress: () => {
goToPreviousServer(otherServers[0]);
resolve('dismiss');
},
});
}
buttons.push({
text: getLocalizedMessage(resolvedLocale, messages.exit.id, messages.exit.defaultMessage),
style: 'destructive',
onPress: () => {
resolve('dismiss');
Emm.exitApp();
},
});
buttons.push({
text: getLocalizedMessage(resolvedLocale, messages.retry.id, messages.retry.defaultMessage),
style: 'default',
onPress: () => resolve('retry'),
});
Alert.alert(
getLocalizedMessage(resolvedLocale, messages.blocked_by.id, messages.blocked_by.defaultMessage).replace('{vendor}', securedBy),
getLocalizedMessage(resolvedLocale, messages.auth_interrupted_message.id, messages.auth_interrupted_message.defaultMessage),
buttons,
{cancelable: false},
);
});
};
/**
* Shows an alert when authentication is required when Intune fails.
*/
export const showAuthenticationRequiredAlert = async (reason?: string, locale?: string, callback?: () => void) => {
const resolvedLocale = locale || DEFAULT_LOCALE;
// Customize message based on reason
let title = getLocalizedMessage(resolvedLocale, messages.authentication_required_title.id, messages.authentication_required_title.defaultMessage);
let message = getLocalizedMessage(resolvedLocale, messages.authentication_required_message.id, messages.authentication_required_message.defaultMessage);
if (reason === IntuneAuthRequiredReasons.CONSENT_DENIED) {
title = getLocalizedMessage(resolvedLocale, messages.consent_denied_title.id, messages.consent_denied_title.defaultMessage);
message = getLocalizedMessage(resolvedLocale, messages.consent_denied_message.id, messages.consent_denied_message.defaultMessage);
} else if (reason === IntuneAuthRequiredReasons.AUTH_FAILED) {
title = getLocalizedMessage(resolvedLocale, messages.authentication_failed_title.id, messages.authentication_failed_title.defaultMessage);
message = getLocalizedMessage(resolvedLocale, messages.authentication_failed_message.id, messages.authentication_failed_message.defaultMessage);
}
Alert.alert(title, message, [{text: getLocalizedMessage(resolvedLocale, messages.okay.id, messages.okay.defaultMessage), onPress: callback}], {cancelable: false});
};
/**
* Shows an alert when Intune conditional access blocks access to the app.
*/
export const showConditionalAccessAlert = async (locale?: string, callback?: () => void) => {
const resolvedLocale = locale || DEFAULT_LOCALE;
Alert.alert(
getLocalizedMessage(resolvedLocale, messages.access_blocked_title.id, messages.access_blocked_title.defaultMessage),
getLocalizedMessage(resolvedLocale, messages.access_blocked_message.id, messages.access_blocked_message.defaultMessage),
[{text: getLocalizedMessage(resolvedLocale, messages.okay.id, messages.okay.defaultMessage), onPress: callback}],
{cancelable: false},
);
};
/**
* Shows an alert when Intune requires an identity switch.
*/
export const showIdentitySwitchRequiredAlert = async (locale?: string) => {
const resolvedLocale = locale || DEFAULT_LOCALE;
Alert.alert(
getLocalizedMessage(resolvedLocale, messages.identity_switch_required_title.id, messages.identity_switch_required_title.defaultMessage),
getLocalizedMessage(resolvedLocale, messages.identity_switch_required_message.id, messages.identity_switch_required_message.defaultMessage),
[{text: getLocalizedMessage(resolvedLocale, messages.okay.id, messages.okay.defaultMessage)}],
{cancelable: false},
);
};
/**
* Shows an alert when MAM enrollment is required.
* User can choose to enroll now or cancel (which logs them out).
*/
export const showMAMEnrollmentRequiredAlert = async (
siteName: string | undefined,
locale: string | undefined,
enrollCallback: () => void,
cancelCallback: () => void,
) => {
const resolvedLocale = locale || DEFAULT_LOCALE;
const organization = siteName || getLocalizedMessage(resolvedLocale, messages.organization.id, messages.organization.defaultMessage);
const message = getLocalizedMessage(resolvedLocale, messages.mam_enrollment_required_message.id, messages.mam_enrollment_required_message.defaultMessage).replace('{organization}', organization);
Alert.alert(
getLocalizedMessage(resolvedLocale, messages.mam_enrollment_required_title.id, messages.mam_enrollment_required_title.defaultMessage),
message,
[
{
text: getLocalizedMessage(resolvedLocale, messages.cancel.id, messages.cancel.defaultMessage),
style: 'cancel',
onPress: cancelCallback,
},
{
text: getLocalizedMessage(resolvedLocale, messages.enroll_now.id, messages.enroll_now.defaultMessage),
style: 'default',
onPress: enrollCallback,
},
],
{cancelable: false},
);
};
/**
* Shows an alert when MAM enrollment fails.
* User will be logged out after dismissing the alert.
*/
export const showMAMEnrollmentFailedAlert = async (
locale?: string,
callback?: () => void,
) => {
const resolvedLocale = locale || DEFAULT_LOCALE;
Alert.alert(
getLocalizedMessage(resolvedLocale, messages.mam_enrollment_failed_title.id, messages.mam_enrollment_failed_title.defaultMessage),
getLocalizedMessage(resolvedLocale, messages.mam_enrollment_failed_message.id, messages.mam_enrollment_failed_message.defaultMessage),
[{text: getLocalizedMessage(resolvedLocale, messages.okay.id, messages.okay.defaultMessage), onPress: callback}],
{cancelable: false},
);
};
/**
* Shows an alert when user declines MAM enrollment.
* Provides option to retry enrollment or logout.
*/
export const showMAMDeclinedAlert = async (
server: string,
siteName: string | undefined,
locale: string | undefined,
callback: (value: boolean) => void,
retryCallback: () => void,
) => {
const resolvedLocale = locale || DEFAULT_LOCALE;
const organization = siteName || getLocalizedMessage(resolvedLocale, messages.organization.id, messages.organization.defaultMessage);
const message = getLocalizedMessage(resolvedLocale, messages.mam_declined_message.id, messages.mam_declined_message.defaultMessage).replace('{organization}', organization);
const buttons = await buildSecurityAlertOptions(server, resolvedLocale, callback, retryCallback);
Alert.alert(
getLocalizedMessage(resolvedLocale, messages.mam_declined_title.id, messages.mam_declined_title.defaultMessage),
message,
buttons,
{cancelable: false},
);
};
/**
* Shows an alert when MAM compliance remediation fails (post-enrollment SDK-triggered check).
* Uses SDK-provided localized title/message when available, falls back to our own strings.
*/
export const showMAMComplianceFailedAlert = (
sdkTitle: string,
sdkMessage: string,
reason: string,
locale?: string,
callback?: () => void,
) => {
const resolvedLocale = locale || DEFAULT_LOCALE;
const title = sdkTitle || getLocalizedMessage(resolvedLocale, messages.compliance_alert_title.id, messages.compliance_alert_title.defaultMessage);
let message = sdkMessage;
if (!message) {
switch (reason) {
case 'not_compliant':
message = getLocalizedMessage(resolvedLocale, messages.compliance_not_compliant.id, messages.compliance_not_compliant.defaultMessage);
break;
case 'network_failure':
message = getLocalizedMessage(resolvedLocale, messages.compliance_network_failure.id, messages.compliance_network_failure.defaultMessage);
break;
case 'service_failure':
message = getLocalizedMessage(resolvedLocale, messages.compliance_service_failure.id, messages.compliance_service_failure.defaultMessage);
break;
case 'user_cancelled':
message = getLocalizedMessage(resolvedLocale, messages.compliance_user_cancelled.id, messages.compliance_user_cancelled.defaultMessage);
break;
default:
message = getLocalizedMessage(resolvedLocale, messages.compliance_service_failure.id, messages.compliance_service_failure.defaultMessage);
break;
}
}
Alert.alert(
title,
message,
[{text: getLocalizedMessage(resolvedLocale, messages.okay.id, messages.okay.defaultMessage), onPress: callback}],
{cancelable: false},
);
};