diff --git a/docs/docs/cmd/viva/engage/engage-community-user-remove.mdx b/docs/docs/cmd/viva/engage/engage-community-user-remove.mdx new file mode 100644 index 0000000000..9d95a95fc8 --- /dev/null +++ b/docs/docs/cmd/viva/engage/engage-community-user-remove.mdx @@ -0,0 +1,55 @@ +import Global from '/docs/cmd/_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# viva engage community user remove + +Removes a specified user from a Microsoft 365 Viva Engage community + +## Usage + +```sh +m365 viva engage community user remove [options] +``` + +## Options + +```md definition-list +`-i, --communityId [communityId]` +: The ID of the Viva Engage community. Specify `communityId`, `communityDisplayName` or `entraGroupId`. + +`-n, --communityDisplayName [communityDisplayName]` +: The display name of the Viva Engage community. Specify `communityId`, `communityDisplayName` or `entraGroupId`. + +`--entraGroupId [entraGroupId]` +: The ID of the Microsoft 365 group. Specify `communityId`, `communityDisplayName` or `entraGroupId`. + +`--id [id]` +: Microsoft Entra ID of the user. Specify either `id` or `userName` but not both. + +`--userName [userName]` +: The user principal name of the user. Specify either `id` or `userName` but not both. + +`-f, --force` +: Don't prompt for confirming removing the user from the specified Viva Engage community. +``` + + + +## Examples + +Remove a user specified by ID as a member from a community specified by display name. + +```sh +m365 viva engage community user remove --communityDisplayName "All company" --id 098b9f52-f48c-4401-819f-29c33794c3f5 +``` + +Remove a user specified by UPN from a community specified by its group ID without confirmation. + +```sh +m365 viva engage community user remove --entraGroupId a03c0c35-ef9a-419b-8cab-f89e0a8d2d2a --userName john.doe@contoso.com --force +``` + +## Response + +The command won't return a response on success. diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index 929281df70..5c6072a3b1 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -4475,6 +4475,11 @@ const sidebars: SidebarsConfig = { label: 'engage community list', id: 'cmd/viva/engage/engage-community-list' }, + { + type: 'doc', + label: 'engage community user remove', + id: 'cmd/viva/engage/engage-community-user-remove' + }, { type: 'doc', label: 'engage group list', diff --git a/src/m365/viva/commands/engage/Community.ts b/src/m365/viva/commands/engage/Community.ts index ed5df59649..75f5405227 100644 --- a/src/m365/viva/commands/engage/Community.ts +++ b/src/m365/viva/commands/engage/Community.ts @@ -3,4 +3,5 @@ export interface Community { displayName: string; description?: string; privacy: string; + groupId: string; } \ No newline at end of file diff --git a/src/m365/viva/commands/engage/engage-community-user-remove.spec.ts b/src/m365/viva/commands/engage/engage-community-user-remove.spec.ts new file mode 100644 index 0000000000..2db76a5762 --- /dev/null +++ b/src/m365/viva/commands/engage/engage-community-user-remove.spec.ts @@ -0,0 +1,239 @@ + +import assert from 'assert'; +import sinon from 'sinon'; +import auth from '../../../../Auth.js'; +import { Logger } from '../../../../cli/Logger.js'; +import { CommandError } from '../../../../Command.js'; +import request from '../../../../request.js'; +import { telemetry } from '../../../../telemetry.js'; +import { pid } from '../../../../utils/pid.js'; +import { session } from '../../../../utils/session.js'; +import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import commands from '../../commands.js'; +import command from './engage-community-user-remove.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; +import { z } from 'zod'; +import { cli } from '../../../../cli/cli.js'; +import { vivaEngage } from '../../../../utils/vivaEngage.js'; +import { entraUser } from '../../../../utils/entraUser.js'; + +describe(commands.ENGAGE_COMMUNITY_USER_REMOVE, () => { + const communityId = 'eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiIzNjAyMDAxMTAwOSJ9'; + const communityDisplayName = 'All company'; + const entraGroupId = 'b6c35b51-ebca-445c-885a-63a67d24cb53'; + const userName = 'john@contoso.com'; + const userId = '3f2504e0-4f89-11d3-9a0c-0305e82c3301'; + + let log: string[]; + let logger: Logger; + let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').returns(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + auth.connection.active = true; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; + sinon.stub(entraUser, 'getUserIdByUpn').resolves(userId); + sinon.stub(vivaEngage, 'getEntraGroupIdByCommunityDisplayName').resolves(entraGroupId); + sinon.stub(vivaEngage, 'getEntraGroupIdByCommunityId').resolves(entraGroupId); + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + }); + + afterEach(() => { + sinonUtil.restore([ + request.delete, + cli.promptForConfirmation + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.ENGAGE_COMMUNITY_USER_REMOVE); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('fails validation if entraGroupId is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ + entraGroupId: 'invalid', + userName: userName + }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if id is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ + entraGroupId: entraGroupId, + id: 'invalid' + }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if userName is invalid user principal name', () => { + const actual = commandOptionsSchema.safeParse({ + entraGroupId: entraGroupId, + userName: 'invalid' + }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if communityId, communityDisplayName or entraGroupId are not specified', () => { + const actual = commandOptionsSchema.safeParse({}); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if communityId, communityDisplayName and entraGroupId are specified', () => { + const actual = commandOptionsSchema.safeParse({ + communityId: communityId, + communityDisplayName: communityDisplayName, + entraGroupId: entraGroupId, + id: userId + }); + assert.notStrictEqual(actual.success, true); + }); + + it('passes validation if communityId is specified', () => { + const actual = commandOptionsSchema.safeParse({ + communityId: communityId, + userName: userName + }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation if entraGroupId is specified with a proper GUID', () => { + const actual = commandOptionsSchema.safeParse({ + entraGroupId: entraGroupId, + userName: userName + }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation if communityDisplayName is specified', () => { + const actual = commandOptionsSchema.safeParse({ + communityDisplayName: communityDisplayName, + userName: userName + }); + assert.strictEqual(actual.success, true); + }); + + it('correctly removes user specified by id', async () => { + const deleteStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${entraGroupId}/owners/${userId}/$ref`) { + return; + } + + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${entraGroupId}/members/${userId}/$ref`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { communityDisplayName: communityDisplayName, id: userId, force: true, verbose: true } }); + assert(deleteStub.calledTwice); + }); + + it('correctly removes user by userName', async () => { + const deleteStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${entraGroupId}/owners/${userId}/$ref`) { + return; + } + + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${entraGroupId}/members/${userId}/$ref`) { + return; + } + throw 'Invalid request'; + }); + + await command.action(logger, { options: { communityId: communityId, verbose: true, userName: userName, force: true } }); + assert(deleteStub.calledTwice); + }); + + it('correctly removes user as member by userName', async () => { + const deleteStub = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${entraGroupId}/owners/${userId}/$ref`) { + throw { + response: { + status: 404, + data: { + message: 'Object does not exist...' + } + } + }; + } + + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${entraGroupId}/members/${userId}/$ref`) { + return; + } + throw 'Invalid request'; + }); + + sinonUtil.restore(cli.promptForConfirmation); + sinon.stub(cli, 'promptForConfirmation').resolves(true); + + await command.action(logger, { options: { communityId: communityId, verbose: true, userName: userName } }); + assert(deleteStub.calledTwice); + }); + + it('handles API error when removing user', async () => { + const errorMessage = 'Invalid object identifier'; + sinon.stub(request, 'delete').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${entraGroupId}/owners/${userId}/$ref`) { + throw { + response: { + status: 400, + data: { error: { 'odata.error': { message: { value: errorMessage } } } } + } + }; + } + + throw 'Invalid request'; + }); + + sinonUtil.restore(cli.promptForConfirmation); + sinon.stub(cli, 'promptForConfirmation').resolves(true); + + await assert.rejects(command.action(logger, { options: { entraGroupId: entraGroupId, id: userId } }), + new CommandError(errorMessage)); + }); + + it('prompts before removal when confirmation argument not passed', async () => { + const promptStub: sinon.SinonStub = sinon.stub(cli, 'promptForConfirmation').resolves(false); + + await command.action(logger, { options: { entraGroupId: entraGroupId, id: userId } }); + + assert(promptStub.called); + }); + + it('aborts execution when prompt not confirmed', async () => { + const deleteStub = sinon.stub(request, 'delete'); + sinon.stub(cli, 'promptForConfirmation').resolves(false); + + await command.action(logger, { options: { entraGroupId: entraGroupId, id: userId } }); + assert(deleteStub.notCalled); + }); +}); \ No newline at end of file diff --git a/src/m365/viva/commands/engage/engage-community-user-remove.ts b/src/m365/viva/commands/engage/engage-community-user-remove.ts index 1a989f070e..a0de421306 100644 --- a/src/m365/viva/commands/engage/engage-community-user-remove.ts +++ b/src/m365/viva/commands/engage/engage-community-user-remove.ts @@ -8,7 +8,6 @@ import { validation } from '../../../../utils/validation.js'; import { vivaEngage } from '../../../../utils/vivaEngage.js'; import request, { CliRequestOptions } from '../../../../request.js'; import { entraUser } from '../../../../utils/entraUser.js'; -import { formatting } from '../../../../utils/formatting.js'; import { cli } from '../../../../cli/cli.js'; const options = globalOptionsZod @@ -43,7 +42,7 @@ class VivaEngageCommunityUserRemoveCommand extends GraphCommand { } public get description(): string { - return 'Lists all users within a specified Microsoft 365 Viva Engage community'; + return 'Removes a specified user from a Microsoft 365 Viva Engage community'; } public get schema(): z.ZodTypeAny { @@ -69,7 +68,7 @@ class VivaEngageCommunityUserRemoveCommand extends GraphCommand { public async commandAction(logger: Logger, args: CommandArgs): Promise { try { if (args.options.force) { - + await this.deleteUserFromCommunity(args.options, logger); } else { const result = await cli.promptForConfirmation({ message: `Are you sure you want to remove the user ${args.options.id || args.options.userName} from the community ${args.options.communityDisplayName || args.options.communityId || args.options.entraGroupId}?` }); @@ -88,6 +87,38 @@ class VivaEngageCommunityUserRemoveCommand extends GraphCommand { if (this.verbose) { await logger.logToStderr('Removing user from community...'); } + + let entraGroupId = options.entraGroupId; + + if (options.communityDisplayName) { + entraGroupId = await vivaEngage.getEntraGroupIdByCommunityDisplayName(options.communityDisplayName); + } + else if (options.communityId) { + entraGroupId = await vivaEngage.getEntraGroupIdByCommunityId(options.communityId); + } + + const userId = options.id || await entraUser.getUserIdByUpn(options.userName!); + + await this.deleteUser(entraGroupId!, userId, 'owners'); + await this.deleteUser(entraGroupId!, userId, 'members'); + } + + private async deleteUser(entraGroupId: string, userId: string, role: string): Promise { + try { + const requestOptions: CliRequestOptions = { + url: `${this.resource}/v1.0/groups/${entraGroupId}/${role}/${userId}/$ref`, + headers: { + accept: 'application/json;odata.metadata=none' + }, + responseType: 'json' + }; + await request.delete(requestOptions); + } + catch (err: any) { + if (err.response.status !== 404) { + throw err.response.data; + } + } } } diff --git a/src/utils/vivaEngage.spec.ts b/src/utils/vivaEngage.spec.ts new file mode 100644 index 0000000000..baf42f2aed --- /dev/null +++ b/src/utils/vivaEngage.spec.ts @@ -0,0 +1,187 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import { cli } from '../cli/cli.js'; +import request from '../request.js'; +import { sinonUtil } from './sinonUtil.js'; +import { vivaEngage } from './vivaEngage.js'; +import { formatting } from './formatting.js'; +import { settingsNames } from '../settingsNames.js'; + +describe('utils/vivaEngage', () => { + const displayName = 'All Company'; + const invalidDisplayName = 'All Compayn'; + const entraGroupId = '0bed8b86-5026-4a93-ac7d-56750cc099f1'; + const communityId = 'eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiI0NzY5MTM1ODIwOSJ9'; + const communityResponse = { + "id": "eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiI0NzY5MTM1ODIwOSJ9", + "description": "This is the default group for everyone in the network", + "displayName": "All Company", + "privacy": "Public", + "groupId": "0bed8b86-5026-4a93-ac7d-56750cc099f1" + }; + const anotherCommunityResponse = { + "id": "eyJfdHlwZ0NzY5SIwiIiSJ9IwO6IaWQiOIMTM1ODikdyb3Vw", + "description": "Test only", + "displayName": "All Company", + "privacy": "Private", + "groupId": "0bed8b86-5026-4a93-ac7d-56750cc099f1" + }; + + afterEach(() => { + sinonUtil.restore([ + request.get, + cli.getSettingWithDefaultValue, + cli.handleMultipleResultsFound + ]); + }); + + it('correctly get single community id by name using getCommunityIdByDisplayName', async () => { + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/employeeExperience/communities?$filter=displayName eq '${formatting.encodeQueryParameter(displayName)}'`) { + return { + value: [ + communityResponse + ] + }; + } + + return 'Invalid Request'; + }); + + const actual = await vivaEngage.getCommunityIdByDisplayName(displayName); + assert.deepStrictEqual(actual, 'eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiI0NzY5MTM1ODIwOSJ9'); + }); + + it('handles selecting single community when multiple communities with the specified name found using getCommunityIdByDisplayName and cli is set to prompt', async () => { + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/employeeExperience/communities?$filter=displayName eq '${formatting.encodeQueryParameter(displayName)}'`) { + return { + value: [ + communityResponse, + anotherCommunityResponse + ] + }; + } + + return 'Invalid Request'; + }); + + sinon.stub(cli, 'handleMultipleResultsFound').resolves(communityResponse); + + const actual = await vivaEngage.getCommunityIdByDisplayName(displayName); + assert.deepStrictEqual(actual, 'eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiI0NzY5MTM1ODIwOSJ9'); + }); + + it('throws error message when no community was found using getCommunityIdByDisplayName', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/employeeExperience/communities?$filter=displayName eq '${formatting.encodeQueryParameter(invalidDisplayName)}'`) { + return { value: [] }; + } + + throw 'Invalid Request'; + }); + + await assert.rejects(vivaEngage.getCommunityIdByDisplayName(invalidDisplayName)), Error(`The specified Viva Engage community '${invalidDisplayName}' does not exist.`); + }); + + it('throws error message when multiple communities were found using getCommunityIdByDisplayName', async () => { + sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { + if (settingName === settingsNames.prompt) { + return false; + } + + return defaultValue; + }); + + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === `https://graph.microsoft.com/v1.0/employeeExperience/communities?$filter=displayName eq '${formatting.encodeQueryParameter(displayName)}'`) { + return { + value: [ + communityResponse, + anotherCommunityResponse + ] + }; + } + + return 'Invalid Request'; + }); + + await assert.rejects(vivaEngage.getCommunityIdByDisplayName(displayName), + Error(`Multiple Viva Engage communities with name '${displayName}' found. Found: ${communityResponse.id}, ${anotherCommunityResponse.id}.`)); + }); + + it('correctly get single community id by group id using getCommunityIdByEntraGroupId', async () => { + sinon.stub(request, 'get').callsFake(async opts => { + if (opts.url === 'https://graph.microsoft.com/v1.0/employeeExperience/communities?$select=id,groupId') { + return { + value: [ + communityResponse + ] + }; + } + + return 'Invalid Request'; + }); + + const actual = await vivaEngage.getCommunityIdByEntraGroupId(entraGroupId); + assert.deepStrictEqual(actual, 'eyJfdHlwZSI6Ikdyb3VwIiwiaWQiOiI0NzY5MTM1ODIwOSJ9'); + }); + + it('throws error message when no community was found using getCommunityIdByEntraGroupId', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === 'https://graph.microsoft.com/v1.0/employeeExperience/communities?$select=id,groupId') { + return { value: [] }; + } + + throw 'Invalid Request'; + }); + + await assert.rejects(vivaEngage.getCommunityIdByEntraGroupId(entraGroupId)), Error(`The Microsoft Entra group with id '${entraGroupId}' is not associated with any Viva Engage community.`); + }); + + it('correctly gets Entra group ID by community ID using getEntraGroupIdByCommunityId', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/employeeExperience/communities/${communityId}?$select=groupId`) { + return communityResponse; + } + + throw 'Invalid Request'; + }); + + const actual = await vivaEngage.getEntraGroupIdByCommunityId(communityId); + assert.deepStrictEqual(actual, '0bed8b86-5026-4a93-ac7d-56750cc099f1'); + }); + + it('throws error message when no Entra group ID was found using getEntraGroupIdByCommunityId', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/employeeExperience/communities/${communityId}?$select=groupId`) { + return null; + } + + throw 'Invalid Request'; + }); + + await assert.rejects(vivaEngage.getEntraGroupIdByCommunityId(communityId)), Error(`The specified Viva Engage community with ID '${communityId}' does not exist.`); + }); + + it('correctly gets Entra group ID by community display name using getEntraGroupIdByCommunityDisplayName', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/employeeExperience/communities/${communityId}?$select=groupId`) { + return communityResponse; + } + + if (opts.url === `https://graph.microsoft.com/v1.0/employeeExperience/communities?$filter=displayName eq '${formatting.encodeQueryParameter(displayName)}'`) { + return { + value: [ + communityResponse + ] + }; + } + + throw 'Invalid Request'; + }); + + const actual = await vivaEngage.getEntraGroupIdByCommunityDisplayName(displayName); + assert.deepStrictEqual(actual, entraGroupId); + }); +}); \ No newline at end of file diff --git a/src/utils/vivaEngage.ts b/src/utils/vivaEngage.ts new file mode 100644 index 0000000000..9fc4403a01 --- /dev/null +++ b/src/utils/vivaEngage.ts @@ -0,0 +1,79 @@ +import { cli } from '../cli/cli.js'; +import { Community } from '../m365/viva/commands/engage/Community.js'; +import request, { CliRequestOptions } from '../request.js'; +import { formatting } from './formatting.js'; +import { odata } from './odata.js'; + +export const vivaEngage = { + /** + * Get Viva Engage community ID by display name. + * @param displayName Community display name. + * @returns The ID of the Viva Engage community. + */ + async getCommunityIdByDisplayName(displayName: string): Promise { + const communities = await odata.getAllItems(`https://graph.microsoft.com/v1.0/employeeExperience/communities?$filter=displayName eq '${formatting.encodeQueryParameter(displayName)}'`); + + if (communities.length === 0) { + throw `The specified Viva Engage community '${displayName}' does not exist.`; + } + + if (communities.length > 1) { + const resultAsKeyValuePair = formatting.convertArrayToHashTable('id', communities); + const selectedCommunity = await cli.handleMultipleResultsFound(`Multiple Viva Engage communities with name '${displayName}' found.`, resultAsKeyValuePair); + return selectedCommunity.id; + } + + return communities[0].id; + }, + + /** + * Get Viva Engage community ID by Microsoft Entra group ID. + * Note: The Graph API doesn't support filtering by groupId, so we need to retrieve all communities and filter them in memory. + * @param entraGroupId The ID of the Microsoft Entra group. + * @returns The ID of the Viva Engage community. + */ + async getCommunityIdByEntraGroupId(entraGroupId: string): Promise { + const communities = await odata.getAllItems('https://graph.microsoft.com/v1.0/employeeExperience/communities?$select=id,groupId'); + + const filtereCommunities = communities.filter(c => c.groupId === entraGroupId); + + if (filtereCommunities.length === 0) { + throw `The Microsoft Entra group with id '${entraGroupId}' is not associated with any Viva Engage community.`; + } + + return filtereCommunities[0].id; + }, + + /** + * Get Viva Engage group ID by community ID. + * @param communityId The ID of the Viva Engage community. + * @returns The ID of the Viva Engage group. + */ + async getEntraGroupIdByCommunityId(communityId: string): Promise { + const requestOptions: CliRequestOptions = { + url: `https://graph.microsoft.com/v1.0/employeeExperience/communities/${communityId}?$select=groupId`, + headers: { + accept: 'application/json;odata.metadata=none' + }, + responseType: 'json' + }; + + const community = await request.get(requestOptions); + + if (!community) { + throw `The specified Viva Engage community with ID '${communityId}' does not exist.`; + } + + return community.groupId; + }, + + /** + * Get Viva Engage group ID by community display name. + * @param displayName Community display name. + * @returns The ID of the Viva Engage group. + */ + async getEntraGroupIdByCommunityDisplayName(displayName: string): Promise { + const communityId = await this.getCommunityIdByDisplayName(displayName); + return await this.getEntraGroupIdByCommunityId(communityId); + } +}; \ No newline at end of file