diff --git a/docs/docs/cmd/teams/user/user-app-remove.mdx b/docs/docs/cmd/teams/user/user-app-remove.mdx index 604c210c8a7..30c0ee16798 100644 --- a/docs/docs/cmd/teams/user/user-app-remove.mdx +++ b/docs/docs/cmd/teams/user/user-app-remove.mdx @@ -13,8 +13,11 @@ m365 teams user app remove [options] ## Options ```md definition-list -`--id ` -: The unique id of the app instance installed for the user. +`--id [id]` +: The unique id of the app instance installed for the user. Specify either `id` or `name`. + +`--name [name]` +: Name of the app instance installed for the user. Specify either `id` or `name`. `--userId [userId]` : The ID of the user to uninstall the app for. Specify `userId` or `userName` but not both. @@ -35,18 +38,24 @@ Do not use the ID from the manifest of the zip app package or the id from the Mi ## Examples -Uninstall an app for the specified user. +Uninstall an app by id for the specified user. ```sh m365 teams user app remove --id YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY= --userId 2609af39-7775-4f94-a3dc-0dd67657e900 ``` -Uninstall an app for the specified user using its UPN. +Uninstall an app by id for the specified user using its UPN. ```sh m365 teams user app remove --id YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY= --userName admin@contoso.com ``` +Uninstall an app by name for the specified user. + +```sh +m365 teams user app remove --name HelloWorld --userId 2609af39-7775-4f94-a3dc-0dd67657e900 +``` + ## Response The command won't return a response on success. diff --git a/src/m365/teams/commands/user/user-app-remove.spec.ts b/src/m365/teams/commands/user/user-app-remove.spec.ts index aefcd8a5664..36d0907a011 100644 --- a/src/m365/teams/commands/user/user-app-remove.spec.ts +++ b/src/m365/teams/commands/user/user-app-remove.spec.ts @@ -56,6 +56,7 @@ describe(commands.USER_APP_REMOVE, () => { afterEach(() => { sinonUtil.restore([ + request.get, request.delete, Cli.promptForConfirmation ]); @@ -125,7 +126,7 @@ describe(commands.USER_APP_REMOVE, () => { assert(promptIssued); }); - it('aborts removing the app when confirmation prompt is not continued', async () => { + it('aborts removing the app by id when confirmation prompt is not continued', async () => { const requestDeleteSpy = sinon.stub(request, 'delete'); await command.action(logger, { @@ -137,7 +138,7 @@ describe(commands.USER_APP_REMOVE, () => { assert(requestDeleteSpy.notCalled); }); - it('removes the app for the specified user when confirmation is specified (debug)', async () => { + it('removes the app by id for the specified user when confirmation is specified (debug)', async () => { sinon.stub(request, 'delete').callsFake(async (opts) => { if ((opts.url as string).indexOf(`/users/${userId}/teamwork/installedApps/${appId}`) > -1) { return; @@ -155,7 +156,7 @@ describe(commands.USER_APP_REMOVE, () => { } as any); }); - it('removes the app for the specified user using username when confirmation is specified.', async () => { + it('removes the app by id for the specified user using username when confirmation is specified.', async () => { sinon.stub(request, 'delete').callsFake(async (opts) => { if ((opts.url as string).indexOf(`/users/${formatting.encodeQueryParameter(userName)}/teamwork/installedApps/${appId}`) > -1) { return Promise.resolve(); @@ -172,7 +173,7 @@ describe(commands.USER_APP_REMOVE, () => { } as any); }); - it('removes the app for the specified user when prompt is confirmed (debug)', async () => { + it('removes the app by id for the specified user when prompt is confirmed (debug)', async () => { sinon.stub(request, 'delete').callsFake((opts) => { if ((opts.url as string).indexOf(`/users/${userId}/teamwork/installedApps/${appId}`) > -1) { return Promise.resolve(); @@ -208,6 +209,90 @@ describe(commands.USER_APP_REMOVE, () => { } as any); }); + it('removes the app by name for the specified user when prompt is confirmed (debug)', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users/c527a470-a882-481c-981c-ee6efaba85c7/teamwork/installedApps?$expand=teamsAppDefinition&$filter=teamsAppDefinition/displayName eq 'TeamsApp'`) { + return { + "value": [ + { + "id": "YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY=", + "displayName": "TeamsApp" + } + ] + }; + } + + throw 'Invalid request'; + }); + + sinon.stub(request, 'delete').callsFake((opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users/c527a470-a882-481c-981c-ee6efaba85c7/teamwork/installedApps/YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY=`) { + return Promise.resolve(); + } + + throw 'Invalid request'; + }); + + sinonUtil.restore(Cli.promptForConfirmation); + sinon.stub(Cli, 'promptForConfirmation').resolves(true); + + await command.action(logger, { + options: { + userId: 'c527a470-a882-481c-981c-ee6efaba85c7', + name: 'TeamsApp', + debug: true + } + } as any); + }); + + it('fails to get teams app when app by name does not exists', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users/c527a470-a882-481c-981c-ee6efaba85c7/teamwork/installedApps?$expand=teamsAppDefinition&$filter=teamsAppDefinition/displayName eq 'TeamsApp'`) { + return { value: [] }; + } + + throw 'Invalid request'; + }); + + await assert.rejects(command.action(logger, { + options: { + debug: true, + userId: 'c527a470-a882-481c-981c-ee6efaba85c7', + name: 'TeamsApp', + force: true + } + } as any), new CommandError('The specified Teams app does not exist')); + }); + + it('handles error when multiple teams apps with the specified name found', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users/c527a470-a882-481c-981c-ee6efaba85c7/teamwork/installedApps?$expand=teamsAppDefinition&$filter=teamsAppDefinition/displayName eq 'TeamsApp'`) { + return { + "value": [ + { + "id": "ZDczZWVjZmQtYzFkNS00MzY2LWJkMjEtZDUyOTM1ZThkYjkxIyMxLjYuMC4wIyNQdWJsaXNoZWQ=", + "displayName": "TeamsApp" + }, + { + "id": "NmY0ODM2N2EtMjVmMC00NjNmLTlmMGQtMmFiZTBiYmYzNzRjIyMxLjAuMCMjUHVibGlzaGVk", + "displayName": "TeamsApp" + } + ] + }; + } + + throw 'Invalid request'; + }); + + await assert.rejects(command.action(logger, { + options: { + debug: true, + userId: 'c527a470-a882-481c-981c-ee6efaba85c7', + name: 'TeamsApp', + force: true + } + } as any), new CommandError('Multiple Teams apps with name TeamsApp found. Please choose one of these ids: ZDczZWVjZmQtYzFkNS00MzY2LWJkMjEtZDUyOTM1ZThkYjkxIyMxLjYuMC4wIyNQdWJsaXNoZWQ=, NmY0ODM2N2EtMjVmMC00NjNmLTlmMGQtMmFiZTBiYmYzNzRjIyMxLjAuMCMjUHVibGlzaGVk')); + }); it('correctly handles error while removing teams app', async () => { const error = { diff --git a/src/m365/teams/commands/user/user-app-remove.ts b/src/m365/teams/commands/user/user-app-remove.ts index d9d0680d7aa..48b558251da 100644 --- a/src/m365/teams/commands/user/user-app-remove.ts +++ b/src/m365/teams/commands/user/user-app-remove.ts @@ -12,8 +12,9 @@ interface CommandArgs { } interface Options extends GlobalOptions { - id: string; - userId?: string; + id?: string; + name?: string; + userId: string; userName?: string; force?: boolean; } @@ -39,6 +40,8 @@ class TeamsUserAppRemoveCommand extends GraphCommand { #initTelemetry(): void { this.telemetry.push((args: CommandArgs) => { Object.assign(this.telemetryProperties, { + id: typeof args.options.id !== 'undefined', + name: typeof args.options.name !== 'undefined', userId: typeof args.options.userId !== 'undefined', userName: typeof args.options.userName !== 'undefined', force: (!!args.options.force).toString() @@ -49,7 +52,10 @@ class TeamsUserAppRemoveCommand extends GraphCommand { #initOptions(): void { this.options.unshift( { - option: '--id ' + option: '--id [id]' + }, + { + option: '--name [name]' }, { option: '--userId [userId]' @@ -80,17 +86,23 @@ class TeamsUserAppRemoveCommand extends GraphCommand { } #initOptionSets(): void { + this.optionSets.push({ options: ['id', 'name'] }); this.optionSets.push({ options: ['userId', 'userName'] }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { const removeApp = async (): Promise => { + const appId: string = await this.getAppId(args); // validation ensures that here we have either userId or userName const userId: string = (args.options.userId ?? args.options.userName) as string; const endpoint: string = `${this.resource}/v1.0`; + if (this.verbose) { + await logger.logToStderr(`Removing app with ID ${args.options.id} for user ${args.options.userId}`); + } + const requestOptions: CliRequestOptions = { - url: `${endpoint}/users/${formatting.encodeQueryParameter(userId)}/teamwork/installedApps/${args.options.id}`, + url: `${endpoint}/users/${formatting.encodeQueryParameter(userId)}/teamwork/installedApps/${appId}`, headers: { 'accept': 'application/json;odata.metadata=none' }, @@ -110,12 +122,38 @@ class TeamsUserAppRemoveCommand extends GraphCommand { } else { const result = await Cli.promptForConfirmation({ message: `Are you sure you want to remove the app with id ${args.options.id} for user ${args.options.userId ?? args.options.userName}?` }); - if (result) { await removeApp(); } } } + + private async getAppId(args: CommandArgs): Promise { + if (args.options.id) { + return args.options.id; + } + + const requestOptions: CliRequestOptions = { + url: `${this.resource}/v1.0/users/${args.options.userId}/teamwork/installedApps?$expand=teamsAppDefinition&$filter=teamsAppDefinition/displayName eq '${formatting.encodeQueryParameter(args.options.name as string)}'`, + headers: { + accept: 'application/json;odata.metadata=none' + }, + responseType: 'json' + }; + + const response = await request.get<{ value: { id: string; }[] }>(requestOptions); + const app: { id: string; } | undefined = response.value[0]; + + if (!app) { + throw `The specified Teams app does not exist`; + } + + if (response.value.length > 1) { + throw `Multiple Teams apps with name ${args.options.name} found. Please choose one of these ids: ${response.value.map(x => x.id).join(', ')}`; + } + + return app.id; + } } -export default new TeamsUserAppRemoveCommand(); \ No newline at end of file +export default new TeamsUserAppRemoveCommand();