diff --git a/apps/teams-test-app/src/components/IntuneAPIs.tsx b/apps/teams-test-app/src/components/IntuneAPIs.tsx new file mode 100644 index 0000000000..2a8dd3c505 --- /dev/null +++ b/apps/teams-test-app/src/components/IntuneAPIs.tsx @@ -0,0 +1,46 @@ +import { intune } from '@microsoft/teams-js'; +import React, { ReactElement } from 'react'; + +import { ApiWithoutInput, ApiWithTextInput } from './utils'; +import { ModuleWrapper } from './utils/ModuleWrapper'; + +const CheckIntuneCapability = (): React.ReactElement => + ApiWithoutInput({ + name: 'checkIntuneCapability', + title: 'Check Intune Capability', + onClick: async () => `Intune module ${intune.isSupported() ? 'is' : 'is not'} supported`, + }); + +const IsSaveToLocationAllowed = (): React.ReactElement => + ApiWithTextInput({ + name: 'isSaveToLocationAllowed', + title: 'Is Save To Location Allowed', + onClick: async (input) => { + const location = input as intune.SaveLocation; + const result = await intune.isSaveToLocationAllowed(location); + return `Save to ${input}: ${result ? 'Allowed' : 'Not Allowed'}`; + }, + defaultInput: JSON.stringify(intune.SaveLocation.LOCAL), + }); + +const IsOpenFromLocationAllowed = (): React.ReactElement => + ApiWithTextInput({ + name: 'isOpenFromLocationAllowed', + title: 'Is Open From Location Allowed', + onClick: async (input) => { + const location = input as intune.OpenLocation; + const result = await intune.isOpenFromLocationAllowed(location); + return `Open from ${input}: ${result ? 'Allowed' : 'Not Allowed'}`; + }, + defaultInput: JSON.stringify(intune.OpenLocation.LOCAL), + }); + +const IntuneAPIs = (): ReactElement => ( + + + + + +); + +export default IntuneAPIs; diff --git a/apps/teams-test-app/src/pages/TestApp.tsx b/apps/teams-test-app/src/pages/TestApp.tsx index 153184ed42..5e9f451819 100644 --- a/apps/teams-test-app/src/pages/TestApp.tsx +++ b/apps/teams-test-app/src/pages/TestApp.tsx @@ -21,6 +21,7 @@ import DialogUrlBotAPIs from '../components/DialogUrlBotAPIs'; import DialogUrlParentCommunicationAPIs from '../components/DialogUrlParentCommunicationAPIs'; import GeoLocationAPIs from '../components/GeoLocationAPIs'; import HostEntityTabAPIs from '../components/HostEntityTabAPIs'; +import IntuneAPIs from '../components/IntuneAPIs'; import Links from '../components/Links'; import LocationAPIs from '../components/LocationAPIs'; import LogAPIs from '../components/LogsAPIs'; @@ -135,6 +136,7 @@ export const TestApp: React.FC = () => { { name: 'FullTrustAPIs', component: }, { name: 'GeoLocationAPIs', component: }, { name: 'HostEntityTabAPIs', component: }, + { name: 'IntuneAPIs', component: }, { name: 'Links', component: }, { name: 'LocationAPIs', component: }, { name: 'LogAPIs', component: }, diff --git a/packages/teams-js/src/internal/telemetry.ts b/packages/teams-js/src/internal/telemetry.ts index a8c21875dc..10c2f8f56d 100644 --- a/packages/teams-js/src/internal/telemetry.ts +++ b/packages/teams-js/src/internal/telemetry.ts @@ -184,6 +184,8 @@ export const enum ApiName { Interactive_GetNtpTime = 'interactive.getNtpTime', Interactive_RegisterClientId = 'interactive.registerClientId', Interactive_SetFluidContainerId = 'interactive.setFluidContainerId', + Intune_IsSaveToLocationAllowed = 'intune.isSaveToLocationAllowed', + Intune_IsOpenFromLocationAllowed = 'intune.isOpenFromLocationAllowed', Location_GetLocation = 'location.getLocation', Location_ShowLocation = 'location.showLocation', Logs_Receive = 'log.receive', diff --git a/packages/teams-js/src/public/index.ts b/packages/teams-js/src/public/index.ts index 2b847459d5..49b9ab12e4 100644 --- a/packages/teams-js/src/public/index.ts +++ b/packages/teams-js/src/public/index.ts @@ -78,6 +78,7 @@ export { } from './appWindow'; export * as menus from './menus'; export * as media from './media'; +export * as intune from './intune'; export * as secondaryBrowser from './secondaryBrowser'; export * as location from './location'; export * as meeting from './meeting/meeting'; diff --git a/packages/teams-js/src/public/intune.ts b/packages/teams-js/src/public/intune.ts new file mode 100644 index 0000000000..04f7e4a5f2 --- /dev/null +++ b/packages/teams-js/src/public/intune.ts @@ -0,0 +1,137 @@ +/** + * Intune Mobile Application Management (MAM) policy APIs. + * + * These APIs allow MOS apps to query Intune MAM policy decisions + * for UX purposes (e.g., disabling a "Save" button when the policy + * disallows saving to a given location). + * + * Security note: + * - These APIs are NOT a security boundary. + * - All enforcement MUST happen in native HubSDK / host code paths. + * - Policy values can change while the app is running; consumers should + * call policy APIs immediately before executing the related action. + * + * @beta + * @module + */ + +import { sendAndHandleSdkError } from '../internal/communication'; +import { ensureInitialized } from '../internal/internalAPIs'; +import { ApiName, ApiVersionNumber, getApiVersionTag } from '../internal/telemetry'; +import { errorNotSupportedOnPlatform, FrameContexts } from './constants'; +import { runtime } from './runtime'; + +/** + * v2 APIs telemetry file: All of APIs in this capability file should send out API version v2 ONLY + */ +const intuneTelemetryVersionNumber: ApiVersionNumber = ApiVersionNumber.V_2; + +/** + * Locations to which organizational data may be saved. + * Equivalent to MAM SDK SaveLocation (Android) / IntuneMAMSaveLocation (iOS). + */ +export enum SaveLocation { + /** OneDrive for Business */ + ONEDRIVE_FOR_BUSINESS = 'ONEDRIVE_FOR_BUSINESS', + /** SharePoint */ + SHAREPOINT = 'SHAREPOINT', + /** Box */ + BOX = 'BOX', + /** Dropbox */ + DROPBOX = 'DROPBOX', + /** Google Drive */ + GOOGLE_DRIVE = 'GOOGLE_DRIVE', + /** Local device storage */ + LOCAL = 'LOCAL', + /** Account document storage */ + ACCOUNT_DOCUMENT = 'ACCOUNT_DOCUMENT', + /** Device photo library */ + PHOTO_LIBRARY = 'PHOTO_LIBRARY', + /** Other / unrecognized location */ + OTHER = 'OTHER', +} + +/** + * Locations from which data may be opened/imported into the app. + * Equivalent to MAM SDK OpenLocation (Android) / IntuneMAMOpenLocation (iOS). + */ +export enum OpenLocation { + /** OneDrive for Business */ + ONEDRIVE_FOR_BUSINESS = 'ONEDRIVE_FOR_BUSINESS', + /** SharePoint */ + SHAREPOINT = 'SHAREPOINT', + /** Device camera */ + CAMERA = 'CAMERA', + /** Local device storage */ + LOCAL = 'LOCAL', + /** Account document storage */ + ACCOUNT_DOCUMENT = 'ACCOUNT_DOCUMENT', + /** Device photo library */ + PHOTO_LIBRARY = 'PHOTO_LIBRARY', + /** Other / unrecognized location */ + OTHER = 'OTHER', +} + +/** + * Checks whether saving organizational data to the specified location + * is allowed by the current Intune MAM policy. + * + * This API is intended for app UX decisions only. + * Native enforcement MUST be performed by HubSDK / host. + * + * @param saveLocation - The target save location to check against policy. + * @returns true if saving to the location is allowed, false otherwise. + * + * @throws Error if {@linkcode app.initialize} has not successfully completed + * + * @beta + */ +export async function isSaveToLocationAllowed(saveLocation: SaveLocation): Promise { + ensureInitialized(runtime, FrameContexts.content, FrameContexts.task); + if (!isSupported()) { + throw errorNotSupportedOnPlatform; + } + return sendAndHandleSdkError( + getApiVersionTag(intuneTelemetryVersionNumber, ApiName.Intune_IsSaveToLocationAllowed), + 'intune.isSaveToLocationAllowed', + saveLocation, + ); +} + +/** + * Checks whether opening/importing data from the specified location + * is allowed by the current Intune MAM policy. + * + * This API is intended for app UX decisions only. + * Native enforcement MUST be performed by HubSDK / host. + * + * @param openLocation - The source location to check against policy. + * @returns true if opening from the location is allowed, false otherwise. + * + * @throws Error if {@linkcode app.initialize} has not successfully completed + * + * @beta + */ +export async function isOpenFromLocationAllowed(openLocation: OpenLocation): Promise { + ensureInitialized(runtime, FrameContexts.content, FrameContexts.task); + if (!isSupported()) { + throw errorNotSupportedOnPlatform; + } + return sendAndHandleSdkError( + getApiVersionTag(intuneTelemetryVersionNumber, ApiName.Intune_IsOpenFromLocationAllowed), + 'intune.isOpenFromLocationAllowed', + openLocation, + ); +} + +/** + * Checks if the Intune MAM capability is supported by the host. + * @returns boolean to represent whether the Intune capability is supported + * + * @throws Error if {@linkcode app.initialize} has not successfully completed + * + * @beta + */ +export function isSupported(): boolean { + return ensureInitialized(runtime) && runtime.supports.intune ? true : false; +} diff --git a/packages/teams-js/src/public/runtime.ts b/packages/teams-js/src/public/runtime.ts index b258e58619..ecafa7598e 100644 --- a/packages/teams-js/src/public/runtime.ts +++ b/packages/teams-js/src/public/runtime.ts @@ -45,6 +45,7 @@ interface IRuntimeV1 extends IBaseRuntime { readonly geoLocation?: { readonly map?: {}; }; + readonly intune?: {}; readonly location?: {}; readonly logs?: {}; readonly mail?: {}; @@ -107,6 +108,7 @@ interface IRuntimeV2 extends IBaseRuntime { readonly map?: {}; }; readonly interactive?: {}; + readonly intune?: {}; readonly secondaryBrowser?: {}; readonly location?: {}; readonly logs?: {}; @@ -174,6 +176,7 @@ interface IRuntimeV3 extends IBaseRuntime { readonly map?: {}; }; readonly interactive?: {}; + readonly intune?: {}; readonly secondaryBrowser?: {}; readonly location?: {}; readonly logs?: {}; @@ -266,6 +269,7 @@ interface IRuntimeV4 extends IBaseRuntime { readonly tab?: {}; }; readonly interactive?: {}; + readonly intune?: {}; readonly secondaryBrowser?: {}; readonly location?: {}; readonly logs?: {}; diff --git a/packages/teams-js/test/public/intune.spec.ts b/packages/teams-js/test/public/intune.spec.ts new file mode 100644 index 0000000000..6e20f705c8 --- /dev/null +++ b/packages/teams-js/test/public/intune.spec.ts @@ -0,0 +1,237 @@ +import { errorLibraryNotInitialized } from '../../src/internal/constants'; +import { GlobalVars } from '../../src/internal/globalVars'; +import { FrameContexts } from '../../src/public'; +import { intune } from '../../src/public'; +import * as app from '../../src/public/app/app'; +import { errorNotSupportedOnPlatform } from '../../src/public/constants'; +import { _minRuntimeConfigToUninitialize } from '../../src/public/runtime'; +import { Utils } from '../utils'; + +/* cSpell:disable */ + +const allowedContexts = [FrameContexts.content, FrameContexts.task]; + +describe('intune', () => { + const utils = new Utils(); + + beforeEach(() => { + utils.processMessage = null; + utils.messages = []; + utils.childMessages = []; + utils.childWindow.closed = false; + GlobalVars.frameContext = undefined; + app._initialize(utils.mockWindow); + }); + + afterEach(() => { + if (app._uninitialize) { + utils.setRuntimeConfig(_minRuntimeConfigToUninitialize); + app._uninitialize(); + } + }); + + describe('isSupported', () => { + it('should return false if the runtime says intune is not supported', async () => { + await utils.initializeWithContext(FrameContexts.content); + utils.setRuntimeConfig({ apiVersion: 4, supports: {} }); + expect(intune.isSupported()).not.toBeTruthy(); + }); + + it('should return true if the runtime says intune is supported', async () => { + await utils.initializeWithContext(FrameContexts.content); + utils.setRuntimeConfig({ apiVersion: 4, supports: { intune: {} } }); + expect(intune.isSupported()).toBeTruthy(); + }); + + it('should throw if called before initialization', () => { + utils.uninitializeRuntimeConfig(); + expect(() => intune.isSupported()).toThrowError(new Error(errorLibraryNotInitialized)); + }); + }); + + describe('isSaveToLocationAllowed', () => { + it('should not allow calls before initialization', async () => { + expect.assertions(1); + try { + await intune.isSaveToLocationAllowed(intune.SaveLocation.LOCAL); + } catch (e) { + expect(e).toEqual(new Error(errorLibraryNotInitialized)); + } + }); + + Object.keys(FrameContexts) + .map((k) => FrameContexts[k]) + .forEach((frameContext) => { + if (allowedContexts.includes(frameContext)) { + it(`should allow calls from ${frameContext} context`, async () => { + await utils.initializeWithContext(frameContext); + utils.setRuntimeConfig({ apiVersion: 4, supports: { intune: {} } }); + + const promise = intune.isSaveToLocationAllowed(intune.SaveLocation.LOCAL); + const message = utils.findMessageByFunc('intune.isSaveToLocationAllowed'); + expect(message).not.toBeNull(); + + await utils.respondToMessage(message!, null, true); + await expect(promise).resolves.toBe(true); + }); + } else { + it(`should not allow calls from ${frameContext} context`, async () => { + expect.assertions(1); + await utils.initializeWithContext(frameContext); + utils.setRuntimeConfig({ apiVersion: 4, supports: { intune: {} } }); + try { + await intune.isSaveToLocationAllowed(intune.SaveLocation.LOCAL); + } catch (e) { + expect(e).toMatchObject( + new Error( + `This call is only allowed in following contexts: ${JSON.stringify(allowedContexts)}. Current context: "${frameContext}".`, + ), + ); + } + }); + } + }); + + it('should not allow calls if runtime does not support intune', async () => { + expect.assertions(1); + await utils.initializeWithContext(FrameContexts.content); + utils.setRuntimeConfig({ apiVersion: 4, supports: {} }); + try { + await intune.isSaveToLocationAllowed(intune.SaveLocation.LOCAL); + } catch (e) { + expect(e).toEqual(errorNotSupportedOnPlatform); + } + }); + + it('should send the correct message with the save location', async () => { + await utils.initializeWithContext(FrameContexts.content); + utils.setRuntimeConfig({ apiVersion: 4, supports: { intune: {} } }); + + const promise = intune.isSaveToLocationAllowed(intune.SaveLocation.ONEDRIVE_FOR_BUSINESS); + const message = utils.findMessageByFunc('intune.isSaveToLocationAllowed'); + + expect(message).not.toBeNull(); + expect(message!.args!.length).toEqual(1); + expect(message!.args![0]).toBe(intune.SaveLocation.ONEDRIVE_FOR_BUSINESS); + + await utils.respondToMessage(message!, null, true); + await expect(promise).resolves.toBe(true); + }); + + it('should return false when the policy disallows saving', async () => { + await utils.initializeWithContext(FrameContexts.content); + utils.setRuntimeConfig({ apiVersion: 4, supports: { intune: {} } }); + + const promise = intune.isSaveToLocationAllowed(intune.SaveLocation.BOX); + const message = utils.findMessageByFunc('intune.isSaveToLocationAllowed'); + + await utils.respondToMessage(message!, null, false); + await expect(promise).resolves.toBe(false); + }); + + it('should throw when the host returns an error', async () => { + expect.assertions(1); + await utils.initializeWithContext(FrameContexts.content); + utils.setRuntimeConfig({ apiVersion: 4, supports: { intune: {} } }); + + const promise = intune.isSaveToLocationAllowed(intune.SaveLocation.LOCAL); + const message = utils.findMessageByFunc('intune.isSaveToLocationAllowed'); + + await utils.respondToMessage(message!, { errorCode: 500, message: 'Internal error' }); + await promise.catch((e) => expect(e).toMatchObject({ errorCode: 500, message: 'Internal error' })); + }); + }); + + describe('isOpenFromLocationAllowed', () => { + it('should not allow calls before initialization', async () => { + expect.assertions(1); + try { + await intune.isOpenFromLocationAllowed(intune.OpenLocation.LOCAL); + } catch (e) { + expect(e).toEqual(new Error(errorLibraryNotInitialized)); + } + }); + + Object.keys(FrameContexts) + .map((k) => FrameContexts[k]) + .forEach((frameContext) => { + if (allowedContexts.includes(frameContext)) { + it(`should allow calls from ${frameContext} context`, async () => { + await utils.initializeWithContext(frameContext); + utils.setRuntimeConfig({ apiVersion: 4, supports: { intune: {} } }); + + const promise = intune.isOpenFromLocationAllowed(intune.OpenLocation.LOCAL); + const message = utils.findMessageByFunc('intune.isOpenFromLocationAllowed'); + expect(message).not.toBeNull(); + + await utils.respondToMessage(message!, null, true); + await expect(promise).resolves.toBe(true); + }); + } else { + it(`should not allow calls from ${frameContext} context`, async () => { + expect.assertions(1); + await utils.initializeWithContext(frameContext); + utils.setRuntimeConfig({ apiVersion: 4, supports: { intune: {} } }); + try { + await intune.isOpenFromLocationAllowed(intune.OpenLocation.LOCAL); + } catch (e) { + expect(e).toMatchObject( + new Error( + `This call is only allowed in following contexts: ${JSON.stringify(allowedContexts)}. Current context: "${frameContext}".`, + ), + ); + } + }); + } + }); + + it('should not allow calls if runtime does not support intune', async () => { + expect.assertions(1); + await utils.initializeWithContext(FrameContexts.content); + utils.setRuntimeConfig({ apiVersion: 4, supports: {} }); + try { + await intune.isOpenFromLocationAllowed(intune.OpenLocation.LOCAL); + } catch (e) { + expect(e).toEqual(errorNotSupportedOnPlatform); + } + }); + + it('should send the correct message with the open location', async () => { + await utils.initializeWithContext(FrameContexts.content); + utils.setRuntimeConfig({ apiVersion: 4, supports: { intune: {} } }); + + const promise = intune.isOpenFromLocationAllowed(intune.OpenLocation.SHAREPOINT); + const message = utils.findMessageByFunc('intune.isOpenFromLocationAllowed'); + + expect(message).not.toBeNull(); + expect(message!.args!.length).toEqual(1); + expect(message!.args![0]).toBe(intune.OpenLocation.SHAREPOINT); + + await utils.respondToMessage(message!, null, true); + await expect(promise).resolves.toBe(true); + }); + + it('should return false when the policy disallows opening', async () => { + await utils.initializeWithContext(FrameContexts.content); + utils.setRuntimeConfig({ apiVersion: 4, supports: { intune: {} } }); + + const promise = intune.isOpenFromLocationAllowed(intune.OpenLocation.CAMERA); + const message = utils.findMessageByFunc('intune.isOpenFromLocationAllowed'); + + await utils.respondToMessage(message!, null, false); + await expect(promise).resolves.toBe(false); + }); + + it('should throw when the host returns an error', async () => { + expect.assertions(1); + await utils.initializeWithContext(FrameContexts.content); + utils.setRuntimeConfig({ apiVersion: 4, supports: { intune: {} } }); + + const promise = intune.isOpenFromLocationAllowed(intune.OpenLocation.LOCAL); + const message = utils.findMessageByFunc('intune.isOpenFromLocationAllowed'); + + await utils.respondToMessage(message!, { errorCode: 500, message: 'Internal error' }); + await promise.catch((e) => expect(e).toMatchObject({ errorCode: 500, message: 'Internal error' })); + }); + }); +});