diff --git a/docs/docs/cmd/teams/user/user-app-remove.mdx b/docs/docs/cmd/teams/user/user-app-remove.mdx index cda4387a971..604c210c8a7 100644 --- a/docs/docs/cmd/teams/user/user-app-remove.mdx +++ b/docs/docs/cmd/teams/user/user-app-remove.mdx @@ -16,8 +16,11 @@ m365 teams user app remove [options] `--id ` : The unique id of the app instance installed for the user. -`--userId ` -: The ID of the user to uninstall the app for. +`--userId [userId]` +: The ID of the user to uninstall the app for. Specify `userId` or `userName` but not both. + +`--userName [userName]` +: The UPN of the user to uninstall the app for. Specify `userId` or `userName` but not both. `-f, --force` : Confirm removal of app for user. @@ -38,6 +41,12 @@ Uninstall an app for the specified user. m365 teams user app remove --id YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY= --userId 2609af39-7775-4f94-a3dc-0dd67657e900 ``` +Uninstall an app for the specified user using its UPN. + +```sh +m365 teams user app remove --id YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY= --userName admin@contoso.com +``` + ## 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 d2e479b23d1..858b4389164 100644 --- a/src/m365/teams/commands/user/user-app-remove.spec.ts +++ b/src/m365/teams/commands/user/user-app-remove.spec.ts @@ -6,6 +6,7 @@ import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { Logger } from '../../../../cli/Logger.js'; import { CommandError } from '../../../../Command.js'; import request from '../../../../request.js'; +import { formatting } from '../../../../utils/formatting.js'; import { telemetry } from '../../../../telemetry.js'; import { pid } from '../../../../utils/pid.js'; import { session } from '../../../../utils/session.js'; @@ -14,6 +15,9 @@ import commands from '../../commands.js'; import command from './user-app-remove.js'; describe(commands.USER_APP_REMOVE, () => { + const userId = '15d7a78e-fd77-4599-97a5-dbb6372846c6'; + const userName = 'contoso@contoso.onmicrosoft.com'; + const appId = 'YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY='; let log: string[]; let logger: Logger; let promptOptions: any; @@ -72,7 +76,17 @@ describe(commands.USER_APP_REMOVE, () => { const actual = await command.validate({ options: { userId: 'invalid', - id: 'YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY=' + id: appId + } + }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if the userName is not a valid UPN.', async () => { + const actual = await command.validate({ + options: { + userName: "no-an-email", + id: appId } }, commandInfo); assert.notStrictEqual(actual, true); @@ -81,8 +95,18 @@ describe(commands.USER_APP_REMOVE, () => { it('passes validation when the input is correct', async () => { const actual = await command.validate({ options: { - id: 'YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY=', - userId: '15d7a78e-fd77-4599-97a5-dbb6372846c5' + id: appId, + userId: userId + } + }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation when the input is correct (userName)', async () => { + const actual = await command.validate({ + options: { + id: appId, + userName: userName } }, commandInfo); assert.strictEqual(actual, true); @@ -91,8 +115,8 @@ describe(commands.USER_APP_REMOVE, () => { it('prompts before removing the app when confirmation argument is not passed', async () => { await command.action(logger, { options: { - userId: 'c527a470-a882-481c-981c-ee6efaba85c7', - id: 'YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY=' + userId: userId, + id: appId } } as any); let promptIssued = false; @@ -107,8 +131,8 @@ describe(commands.USER_APP_REMOVE, () => { await command.action(logger, { options: { - userId: 'c527a470-a882-481c-981c-ee6efaba85c7', - id: 'YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY=' + userId: userId, + id: appId } } as any); assert(requestDeleteSpy.notCalled); @@ -116,7 +140,7 @@ describe(commands.USER_APP_REMOVE, () => { it('removes the app for the specified user when confirmation is specified (debug)', async () => { sinon.stub(request, 'delete').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/users/c527a470-a882-481c-981c-ee6efaba85c7/teamwork/installedApps/YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY=`) > -1) { + if ((opts.url as string).indexOf(`/users/${userId}/teamwork/installedApps/${appId}`) > -1) { return; } throw 'Invalid request'; @@ -124,17 +148,34 @@ describe(commands.USER_APP_REMOVE, () => { await command.action(logger, { options: { - userId: 'c527a470-a882-481c-981c-ee6efaba85c7', - id: 'YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY=', + userId: userId, + id: appId, debug: true, force: true } } as any); }); + it('removes the app 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(); + } + throw 'Invalid request'; + }); + + await command.action(logger, { + options: { + userName: userName, + id: appId, + force: true + } + } as any); + }); + it('removes the app for the specified user when prompt is confirmed (debug)', async () => { sinon.stub(request, 'delete').callsFake((opts) => { - if ((opts.url as string).indexOf(`/users/c527a470-a882-481c-981c-ee6efaba85c7/teamwork/installedApps/YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY=`) > -1) { + if ((opts.url as string).indexOf(`/users/${userId}/teamwork/installedApps/${appId}`) > -1) { return Promise.resolve(); } throw 'Invalid request'; @@ -145,13 +186,30 @@ describe(commands.USER_APP_REMOVE, () => { await command.action(logger, { options: { - userId: 'c527a470-a882-481c-981c-ee6efaba85c7', - id: 'YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY=', + userId: userId, + id: appId, debug: true } } as any); }); + it('removes the app for the specified user using username', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if ((opts.url as string).indexOf(`/users/${userId}/teamwork/installedApps/${appId}`) > -1) { + return Promise.resolve(); + } + throw 'Invalid request'; + }); + + await command.action(logger, { + options: { + userName: userName, + id: appId + } + } as any); + }); + + it('correctly handles error while removing teams app', async () => { const error = { "error": { @@ -166,7 +224,7 @@ describe(commands.USER_APP_REMOVE, () => { }; sinon.stub(request, 'delete').callsFake(async (opts) => { - if ((opts.url as string).indexOf(`/users/c527a470-a882-481c-981c-ee6efaba85c7/teamwork/installedApps/YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY=`) > -1) { + if ((opts.url as string).indexOf(`/users/${userId}/teamwork/installedApps/${appId}`) > -1) { throw error; } throw 'Invalid request'; @@ -174,8 +232,8 @@ describe(commands.USER_APP_REMOVE, () => { await assert.rejects(command.action(logger, { options: { - userId: 'c527a470-a882-481c-981c-ee6efaba85c7', - id: 'YzUyN2E0NzAtYTg4Mi00ODFjLTk4MWMtZWU2ZWZhYmE4NWM3IyM0ZDFlYTA0Ny1mMTk2LTQ1MGQtYjJlOS0wZDI4NTViYTA1YTY=', + userId: userId, + id: appId, force: true } } as any), new CommandError(error.error.message)); diff --git a/src/m365/teams/commands/user/user-app-remove.ts b/src/m365/teams/commands/user/user-app-remove.ts index eb98a07bf51..e4dd78350ea 100644 --- a/src/m365/teams/commands/user/user-app-remove.ts +++ b/src/m365/teams/commands/user/user-app-remove.ts @@ -2,6 +2,7 @@ import { Cli } from '../../../../cli/Cli.js'; import { Logger } from '../../../../cli/Logger.js'; import GlobalOptions from '../../../../GlobalOptions.js'; import request, { CliRequestOptions } from '../../../../request.js'; +import { formatting } from '../../../../utils/formatting.js'; import { validation } from '../../../../utils/validation.js'; import GraphCommand from '../../../base/GraphCommand.js'; import commands from '../../commands.js'; @@ -13,6 +14,7 @@ interface CommandArgs { interface Options extends GlobalOptions { id: string; userId: string; + userName: string; force?: boolean; } @@ -31,11 +33,14 @@ class TeamsUserAppRemoveCommand extends GraphCommand { this.#initTelemetry(); this.#initOptions(); this.#initValidators(); + this.#initOptionSets(); } #initTelemetry(): void { this.telemetry.push((args: CommandArgs) => { Object.assign(this.telemetryProperties, { + userId: typeof args.options.userId !== 'undefined', + userName: typeof args.options.userName !== 'undefined', force: (!!args.options.force).toString() }); }); @@ -47,7 +52,10 @@ class TeamsUserAppRemoveCommand extends GraphCommand { option: '--id ' }, { - option: '--userId ' + option: '--userId [userId]' + }, + { + option: '--userName [userName]' }, { option: '-f, --force' @@ -58,21 +66,30 @@ class TeamsUserAppRemoveCommand extends GraphCommand { #initValidators(): void { this.validators.push( async (args: CommandArgs) => { - if (!validation.isValidGuid(args.options.userId)) { + if (args.options.userId && !validation.isValidGuid(args.options.userId)) { return `${args.options.userId} is not a valid GUID`; } + if (args.options.userName && !validation.isValidUserPrincipalName(args.options.userName)) { + return `${args.options.userName} is not a valid userName`; + } + return true; } ); } + #initOptionSets(): void { + this.optionSets.push({ options: ['userId', 'userName'] }); + } + public async commandAction(logger: Logger, args: CommandArgs): Promise { const removeApp = async (): Promise => { + const userId: string = args.options.userId ?? args.options.userName; const endpoint: string = `${this.resource}/v1.0`; const requestOptions: CliRequestOptions = { - url: `${endpoint}/users/${args.options.userId}/teamwork/installedApps/${args.options.id}`, + url: `${endpoint}/users/${formatting.encodeQueryParameter(userId)}/teamwork/installedApps/${args.options.id}`, headers: { 'accept': 'application/json;odata.metadata=none' }, @@ -95,7 +112,7 @@ class TeamsUserAppRemoveCommand extends GraphCommand { type: 'confirm', name: 'continue', default: false, - message: `Are you sure you want to remove the app with id ${args.options.id} for user ${args.options.userId}?` + 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.continue) {