diff --git a/docs/docs/cmd/spo/user/user-remove.mdx b/docs/docs/cmd/spo/user/user-remove.mdx index e1aa23d7aa5..658c2f63c30 100644 --- a/docs/docs/cmd/spo/user/user-remove.mdx +++ b/docs/docs/cmd/spo/user/user-remove.mdx @@ -14,13 +14,25 @@ m365 spo user remove [options] ```md definition-list `-u, --webUrl ` -: URL of the web to remove user +: URL of the web to remove user. Use either `id` or `loginName` or `email`, `userName`, `entraGroupId`, or `entraGroupName`, but not all. `--id [id]` -: ID of the user to remove from web +: ID of the user to remove from web. Use either `id` or `loginName` or `email`, `userName`, `entraGroupId`, or `entraGroupName`, but not all. `--loginName [loginName]` -: Login name of the site user to remove +: Login name of the user to remove from web. Use either `id` or `loginName` or `email`, `userName`, `entraGroupId`, or `entraGroupName`, but not all. + +`--userName [userName]` +: User name of the user to remove from web. Use either `id` or `loginName` or `email`, `userName`, `entraGroupId`, or `entraGroupName`, but not all. + +`--email [email]` +: Email of the user to remove from web. Use either `id` or `loginName` or `email`, `userName`, `entraGroupId`, or `entraGroupName`, but not all. + +`--entraGroupId [entraGroupId]` +: Object ID of the Entra group ID to remove. Use either `id` or `loginName` or `email`, `userName`, `entraGroupId`, or `entraGroupName`, but not all. + +`--entraGroupName [entraGroupName]` +: Name of the Entra group to remove. Use either `id` or `loginName` or `email`, `userName`, `entraGroupId`, or `entraGroupName`, but not all. `-f, --force` : Do not prompt for confirmation before removing user from web @@ -28,24 +40,43 @@ m365 spo user remove [options] -## Remarks - -Use either `id` or `loginName`, but not both - ## Examples -Removes user with id _10_ from a web without prompting for confirmation +Removes user by id from a web without prompting for confirmation ```sh m365 spo user remove --webUrl "https://contoso.sharepoint.com/sites/HR" --id 10 --force ``` -Removes user with login name _i:0#.f|membership|john.doe@mytenant.onmicrosoft.com_ from a web +Removes user by loginName from a web ```sh m365 spo user remove --webUrl "https://contoso.sharepoint.com/sites/HR" --loginName "i:0#.f|membership|john.doe@mytenant.onmicrosoft.com" ``` +Removes user by userName from a web + +```sh +m365 spo user remove --webUrl "https://contoso.sharepoint.com/sites/HR" --userName "john.doe_hotmail.com#ext#@mytenant.onmicrosoft.com" +``` + +Removes user by email from a web + +```sh +m365 spo user remove --webUrl "https://contoso.sharepoint.com/sites/HR" --email "john.doe@mytenant.onmicrosoft.com" +``` + +Removes user by entraGroupId from a web + +```sh +m365 spo user remove --webUrl "https://contoso.sharepoint.com/sites/HR" --entraGroupId f832a493-de73-4fef-87ed-8c6fffd91be6 +``` + +Removes user by entraGroupName from a web + +```sh +m365 spo user remove --webUrl _https://contoso.sharepoint.com/sites/HR_ --entraGroupName "Test Members" +``` ## Response The command won't return a response on success. diff --git a/src/m365/spo/commands/user/user-remove.spec.ts b/src/m365/spo/commands/user/user-remove.spec.ts index 64d9c5e3bb3..67407bb2472 100644 --- a/src/m365/spo/commands/user/user-remove.spec.ts +++ b/src/m365/spo/commands/user/user-remove.spec.ts @@ -4,17 +4,106 @@ import auth from '../../../../Auth.js'; import { cli } from '../../../../cli/cli.js'; import { CommandInfo } from '../../../../cli/CommandInfo.js'; import { Logger } from '../../../../cli/Logger.js'; +import { formatting } from '../../../../utils/formatting.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 { spo } from '../../../../utils/spo.js'; import commands from '../../commands.js'; import command from './user-remove.js'; import { settingsNames } from '../../../../settingsNames.js'; describe(commands.USER_REMOVE, () => { + const validUserName = 'john.deo_hotmail.com#ext#@contoso.onmicrosoft.com'; + const validEmail = 'john.deo@contoso.onmicrosoft.com'; + const validEntraGroupId = '2056d2f6-3257-4253-8cfc-b73393e414e5'; + const validEntraM365GroupName = 'Finance'; + const validEntraSecurityGroupName = 'EntraGroupTest'; + const validLoginName = `i:0#.f|membership|${validUserName}`; + const validWebUrl = 'https://contoso.sharepoint.com/subsite'; + const groupM365Response = { + value: [{ + "id": "2056d2f6-3257-4253-8cfc-b73393e414e5", + "deletedDateTime": null, + "classification": null, + "createdDateTime": "2017-11-29T03:27:05Z", + "description": "This is the Contoso Finance Group. Please come here and check out the latest news, posts, files, and more.", + "displayName": "Finance", + "groupTypes": [ + "Unified" + ], + "mail": "finance@contoso.onmicrosoft.com", + "mailEnabled": true, + "mailNickname": "finance", + "onPremisesLastSyncDateTime": null, + "onPremisesProvisioningErrors": [], + "onPremisesSecurityIdentifier": null, + "onPremisesSyncEnabled": null, + "preferredDataLocation": null, + "proxyAddresses": [ + "SMTP:finance@contoso.onmicrosoft.com" + ], + "renewedDateTime": "2017-11-29T03:27:05Z", + "securityEnabled": false, + "visibility": "Public" + }] + }; + + const groupSecurityResponse = { + value: [{ + "id": "2056d2f6-3257-4253-8cfc-b73393e414e5", + "deletedDateTime": null, + "classification": null, + "createdDateTime": "2024-01-27T16:02:56Z", + "creationOptions": [], + "description": "Entra Group Test", + "displayName": "EntraGroupTest", + "expirationDateTime": null, + "groupTypes": [], + "isAssignableToRole": true, + "mail": null, + "mailEnabled": false, + "mailNickname": "f45205a2-d", + "membershipRule": null, + "membershipRuleProcessingState": null, + "onPremisesDomainName": null, + "onPremisesLastSyncDateTime": null, + "onPremisesNetBiosName": null, + "onPremisesSamAccountName": null, + "onPremisesSecurityIdentifier": null, + "onPremisesSyncEnabled": null, + "preferredDataLocation": null, + "preferredLanguage": null, + "proxyAddresses": [], + "renewedDateTime": "2024-01-27T16:02:56Z", + "resourceBehaviorOptions": [], + "resourceProvisioningOptions": [], + "securityEnabled": true, + "securityIdentifier": "S-1-12-1-1968173404-1154184881-1694549896-3083850660", + "theme": null, + "visibility": "Private", + "onPremisesProvisioningErrors": [], + "serviceProvisioningErrors": [] + }] + }; + const userResponse = { + "Id": 10, + "IsHiddenInUI": false, + "LoginName": validLoginName, + "Title": "John Doe", + "PrincipalType": 1, + "Email": validEmail, + "Expiration": "", + "IsEmailAuthenticationGuestUser": false, + "IsShareByEmailGuestUser": false, + "IsSiteAdmin": false, + "UserId": { "NameId": "10010001b0c19a2", "NameIdIssuer": "urn:federation:microsoftonline" }, + "UserPrincipalName": validUserName + }; + let log: any[]; let requests: any[]; let logger: Logger; @@ -54,7 +143,9 @@ describe(commands.USER_REMOVE, () => { afterEach(() => { sinonUtil.restore([ request.post, + request.get, cli.promptForConfirmation, + spo.getUserByEmail, cli.getSettingWithDefaultValue ]); }); @@ -72,24 +163,23 @@ describe(commands.USER_REMOVE, () => { assert.notStrictEqual(command.description, null); }); - it('fails validation if id or loginName options are not passed', async () => { + it('fails validation if id or loginName or userName or email or entraGroupName or entraGroupId options are not passed', async () => { sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { if (settingName === settingsNames.prompt) { return false; } - return defaultValue; }); const actual = await command.validate({ options: { - webUrl: 'https://contoso.sharepoint.com' + webUrl: validWebUrl } }, commandInfo); assert.notStrictEqual(actual, true); }); - it('fails validation if id or loginname options are passed', async () => { + it('fails validation if more than one of the options userName or email or entraGroupName or entraGroupId are passed', async () => { sinon.stub(cli, 'getSettingWithDefaultValue').callsFake((settingName, defaultValue) => { if (settingName === settingsNames.prompt) { return false; @@ -100,9 +190,9 @@ describe(commands.USER_REMOVE, () => { const actual = await command.validate({ options: { - webUrl: 'https://contoso.sharepoint.com', + webUrl: validWebUrl, id: 10, - loginName: "i:0#.f|membership|john.doe@mytenant.onmicrosoft.com" + loginName: validLoginName } }, commandInfo); assert.notStrictEqual(actual, true); @@ -119,6 +209,57 @@ describe(commands.USER_REMOVE, () => { assert.notStrictEqual(actual, true); }); + it('fails validation if entraGroupId is not a valid id', async () => { + const actual = await command.validate({ options: { webUrl: validWebUrl, entraGroupId: 'invalid' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if id is not a valid number', async () => { + const actual = await command.validate({ options: { webUrl: validWebUrl, id: 'invalid' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if userName is not a valid user principal name', async () => { + const actual = await command.validate({ options: { webUrl: validWebUrl, userName: 'invalid' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if email is not a valid user principal name', async () => { + const actual = await command.validate({ options: { webUrl: validWebUrl, email: 'invalid' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('passes validation url is valid and id is passed', async () => { + const actual = await command.validate({ options: { webUrl: validWebUrl, id: 1 } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation if the url is valid and email is passed', async () => { + const actual = await command.validate({ options: { webUrl: validWebUrl, email: validEmail } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation if the url is valid and loginName is passed', async () => { + const actual = await command.validate({ options: { webUrl: validWebUrl, loginName: validLoginName } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation if the url is valid and userName is passed', async () => { + const actual = await command.validate({ options: { webUrl: validWebUrl, userName: validUserName } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation if the url is valid and entraGroupName is passed', async () => { + const actual = await command.validate({ options: { webUrl: validWebUrl, entraGroupName: validEntraM365GroupName } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation if the url is valid and entraGroupId is passed', async () => { + const actual = await command.validate({ options: { webUrl: validWebUrl, entraGroupId: validEntraGroupId } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('should prompt before removing user using id from web when confirmation argument not passed ', async () => { await command.action(logger, { options: @@ -146,7 +287,7 @@ describe(commands.USER_REMOVE, () => { it('removes user by id successfully without prompting with confirmation argument', async () => { sinon.stub(request, 'post').callsFake(async (opts) => { requests.push(opts); - if ((opts.url as string).indexOf('_api/web/siteusers/removebyid(10)') > -1) { + if (opts.url === `${validWebUrl}/_api/web/siteusers/removebyid(10)`) { return true; } throw 'Invalid request'; @@ -154,14 +295,14 @@ describe(commands.USER_REMOVE, () => { await command.action(logger, { options: { - webUrl: "https://contoso.sharepoint.com/subsite", + webUrl: validWebUrl, id: 10, force: true } }); let correctRequestIssued = false; requests.forEach(r => { - if (r.url.indexOf(`_api/web/siteusers/removebyid(10)`) > -1 && + if (r.url === `${validWebUrl}/_api/web/siteusers/removebyid(10)` && r.headers['accept'] === 'application/json;odata=nometadata') { correctRequestIssued = true; } @@ -172,7 +313,7 @@ describe(commands.USER_REMOVE, () => { it('removes user by login name successfully without prompting with confirmation argument', async () => { sinon.stub(request, 'post').callsFake(async (opts) => { requests.push(opts); - if (opts.url === "https://contoso.sharepoint.com/subsite/_api/web/siteusers/removeByLoginName('i%3A0%23.f%7Cmembership%7Cparker%40tenant.onmicrosoft.com')") { + if (opts.url === `${validWebUrl}/_api/web/siteusers/removeByLoginName('i%3A0%23.f%7Cmembership%7Cparker%40tenant.onmicrosoft.com')`) { return true; } throw 'Invalid request'; @@ -180,14 +321,14 @@ describe(commands.USER_REMOVE, () => { await command.action(logger, { options: { - webUrl: "https://contoso.sharepoint.com/subsite", + webUrl: validWebUrl, loginName: "i:0#.f|membership|parker@tenant.onmicrosoft.com", force: true } }); let correctRequestIssued = false; requests.forEach(r => { - if (r.url.indexOf(`_api/web/siteusers/removeByLoginName('i%3A0%23.f%7Cmembership%7Cparker%40tenant.onmicrosoft.com')`) > -1 && + if (r.url === `${validWebUrl}/_api/web/siteusers/removeByLoginName('i%3A0%23.f%7Cmembership%7Cparker%40tenant.onmicrosoft.com')` && r.headers['accept'] === 'application/json;odata=nometadata') { correctRequestIssued = true; } @@ -198,7 +339,7 @@ describe(commands.USER_REMOVE, () => { it('removes user by id successfully from web when prompt confirmed', async () => { sinon.stub(request, 'post').callsFake(async (opts) => { requests.push(opts); - if ((opts.url as string).indexOf('_api/web/siteusers/removebyid(10)') > -1) { + if (opts.url === `${validWebUrl}/_api/web/siteusers/removebyid(10)`) { return true; } throw 'Invalid request'; @@ -208,13 +349,13 @@ describe(commands.USER_REMOVE, () => { sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { options: { - webUrl: "https://contoso.sharepoint.com/subsite", + webUrl: validWebUrl, id: 10 } }); let correctRequestIssued = false; requests.forEach(r => { - if (r.url.indexOf(`_api/web/siteusers/removebyid(10)`) > -1 && + if (r.url === `${validWebUrl}/_api/web/siteusers/removebyid(10)` && r.headers['accept'] === 'application/json;odata=nometadata') { correctRequestIssued = true; } @@ -225,7 +366,7 @@ describe(commands.USER_REMOVE, () => { it('removes user by login name successfully from web when prompt confirmed', async () => { sinon.stub(request, 'post').callsFake(async (opts) => { requests.push(opts); - if ((opts.url as string).indexOf(`_api/web/siteusers/removeByLoginName`) > -1) { + if (opts.url === `${validWebUrl}/_api/web/siteusers/removeByLoginName('${formatting.encodeQueryParameter(validLoginName)}')`) { return true; } throw 'Invalid request'; @@ -235,13 +376,13 @@ describe(commands.USER_REMOVE, () => { sinon.stub(cli, 'promptForConfirmation').resolves(true); await command.action(logger, { options: { - webUrl: "https://contoso.sharepoint.com/subsite", - loginName: "i:0#.f|membership|john.doe@mytenant.onmicrosoft.com" + webUrl: validWebUrl, + loginName: validLoginName } }); let correctRequestIssued = false; requests.forEach(r => { - if (r.url.indexOf(`_api/web/siteusers/removeByLoginName`) > -1 && + if (r.url === `${validWebUrl}/_api/web/siteusers/removeByLoginName('${formatting.encodeQueryParameter(validLoginName)}')` && r.headers['accept'] === 'application/json;odata=nometadata') { correctRequestIssued = true; } @@ -252,7 +393,7 @@ describe(commands.USER_REMOVE, () => { it('removes user from web successfully without prompting with confirmation argument (verbose)', async () => { sinon.stub(request, 'post').callsFake(async (opts) => { requests.push(opts); - if ((opts.url as string).indexOf('_api/web/siteusers/removebyid(10)') > -1) { + if (opts.url === `${validWebUrl}/_api/web/siteusers/removebyid(10)`) { return true; } throw 'Invalid request'; @@ -261,14 +402,14 @@ describe(commands.USER_REMOVE, () => { await command.action(logger, { options: { verbose: true, - webUrl: "https://contoso.sharepoint.com/subsite", + webUrl: validWebUrl, id: 10, force: true } }); let correctRequestIssued = false; requests.forEach(r => { - if (r.url.indexOf(`_api/web/siteusers/removebyid(10)`) > -1 && + if (r.url === `${validWebUrl}/_api/web/siteusers/removebyid(10)` && r.headers['accept'] === 'application/json;odata=nometadata') { correctRequestIssued = true; } @@ -279,7 +420,7 @@ describe(commands.USER_REMOVE, () => { it('removes user from web successfully without prompting with confirmation argument (debug)', async () => { sinon.stub(request, 'post').callsFake(async (opts) => { requests.push(opts); - if ((opts.url as string).indexOf('_api/web/siteusers/removebyid(10)') > -1) { + if (opts.url === `${validWebUrl}/_api/web/siteusers/removebyid(10)`) { return true; } throw 'Invalid request'; @@ -288,14 +429,14 @@ describe(commands.USER_REMOVE, () => { await command.action(logger, { options: { debug: true, - webUrl: "https://contoso.sharepoint.com/subsite", + webUrl: validWebUrl, id: 10, force: true } }); let correctRequestIssued = false; requests.forEach(r => { - if (r.url.indexOf(`_api/web/siteusers/removebyid(10)`) > -1 && + if (r.url === `${validWebUrl}/_api/web/siteusers/removebyid(10)` && r.headers['accept'] === 'application/json;odata=nometadata') { correctRequestIssued = true; } @@ -303,10 +444,144 @@ describe(commands.USER_REMOVE, () => { assert(correctRequestIssued); }); - it('handles error when removing using from web', async () => { + it('removes user by email successfully without prompting with confirmation argument', async () => { + let removeRequestIssued = false; + sinon.stub(spo, 'getUserByEmail').resolves(userResponse); sinon.stub(request, 'post').callsFake(async (opts) => { requests.push(opts); - if ((opts.url as string).indexOf('_api/web/siteusers/removebyid(10)') > -1) { + if (opts.url === `${validWebUrl}/_api/web/siteusers/removebyid(10)`) { + removeRequestIssued = true; + return Promise.resolve(); + } + throw `Invalid request`; + }); + + await command.action(logger, { + options: { + debug: true, + webUrl: validWebUrl, + email: validEmail, + force: true + } + }); + assert(removeRequestIssued); + }); + + it('removes user by username successfully without prompting with confirmation argument', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + requests.push(opts); + if (opts.url === `${validWebUrl}/_api/web/siteusers?$filter=UserPrincipalName eq ('${formatting.encodeQueryParameter(validUserName)}')`) { + return { + "value": [userResponse] + }; + } + throw `Invalid request`; + }); + + sinon.stub(request, 'post').callsFake(async (opts) => { + requests.push(opts); + if (opts.url === `${validWebUrl}/_api/web/siteusers/removebyid(10)`) { + return true; + } + throw `Invalid request`; + }); + + await command.action(logger, { + options: { + debug: true, + webUrl: validWebUrl, + userName: validUserName, + force: true + } + }); + assert(true); + }); + + it('removes user by entraGroupId successfully without prompting with confirmation argument', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/groups/${validEntraGroupId}`) { + return groupM365Response.value[0]; + } + + throw 'Invalid request'; + }); + + sinon.stub(request, 'post').callsFake(async (opts) => { + requests.push(opts); + if (opts.url === `${validWebUrl}/_api/web/siteusers/removeByLoginName('c:0o.c|federateddirectoryclaimprovider|${validEntraGroupId}')`) { + return true; + } + throw 'Invalid request'; + }); + + await command.action(logger, { + options: { + webUrl: validWebUrl, + entraGroupId: validEntraGroupId, + force: true + } + }); + assert(true); + }); + + it('removes m365 group by entraGroupName successfully without prompting with confirmation argument', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/groups?$filter=displayName eq '${validEntraM365GroupName}'`) { + return groupM365Response; + } + + throw 'Invalid request'; + }); + + sinon.stub(request, 'post').callsFake(async (opts) => { + requests.push(opts); + if (opts.url === `${validWebUrl}/_api/web/siteusers/removeByLoginName('c:0o.c|federateddirectoryclaimprovider|${validEntraGroupId}')`) { + return true; + } + throw 'Invalid request'; + }); + + await command.action(logger, { + options: { + debug: true, + webUrl: validWebUrl, + entraGroupName: validEntraM365GroupName, + force: true + } + }); + assert(true); + }); + + it('removes security group by entraGroupName successfully without prompting with confirmation argument', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/groups?$filter=displayName eq '${validEntraSecurityGroupName}'`) { + return groupSecurityResponse; + } + + throw 'Invalid request'; + }); + + sinon.stub(request, 'post').callsFake(async (opts) => { + requests.push(opts); + if (opts.url === `${validWebUrl}/_api/web/siteusers/removeByLoginName('c:0t.c|tenant|${validEntraGroupId}')`) { + return true; + } + throw 'Invalid request'; + }); + + await command.action(logger, { + options: { + webUrl: validWebUrl, + entraGroupName: validEntraSecurityGroupName, + force: true + } + } as any); + }); + + it('handles error when removing user using from web', async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + requests.push(opts); + if (opts.url === `${validWebUrl}/_api/web/siteusers/removebyid(10)`) { throw 'An error has occurred'; } throw 'Invalid request'; @@ -320,4 +595,26 @@ describe(commands.USER_REMOVE, () => { } } as any), new CommandError('An error has occurred')); }); + + it('handles generic error when user not found when username is passed without prompting with confirmation argument', async () => { + const err = `User not found: ${validUserName}`; + + sinon.stub(request, 'get').callsFake(async (opts) => { + requests.push(opts); + if (opts.url === `${validWebUrl}/_api/web/siteusers?$filter=UserPrincipalName eq ('${formatting.encodeQueryParameter(validUserName)}')`) { + return { "value": [] }; + } + throw 'Invalid request'; + }); + + await assert.rejects(command.action(logger, { + options: { + debug: true, + webUrl: validWebUrl, + userName: validUserName, + force: true + } + }), new CommandError(err)); + }); }); + diff --git a/src/m365/spo/commands/user/user-remove.ts b/src/m365/spo/commands/user/user-remove.ts index 944b5f0e5fc..3316753cdeb 100644 --- a/src/m365/spo/commands/user/user-remove.ts +++ b/src/m365/spo/commands/user/user-remove.ts @@ -1,20 +1,43 @@ +import { Group } from '@microsoft/microsoft-graph-types'; import { cli } from '../../../../cli/cli.js'; import { Logger } from '../../../../cli/Logger.js'; import GlobalOptions from '../../../../GlobalOptions.js'; +import { spo } from '../../../../utils/spo.js'; import request, { CliRequestOptions } from '../../../../request.js'; +import { entraGroup } from '../../../../utils/entraGroup.js'; import { formatting } from '../../../../utils/formatting.js'; import { validation } from '../../../../utils/validation.js'; import SpoCommand from '../../../base/SpoCommand.js'; import commands from '../../commands.js'; +interface SpoUser { + Id: number; + IsHiddenInUI: boolean; + Title: string; + PrincipalType: number; + Email: string; + Expiration: string; + IsEmailAuthenticationGuestUser: boolean; + IsShareByEmailGuestUser: boolean; + IsSiteAdmin: boolean; + UserId: { + NameId: string; + NameIdIssuer: string; + urn: string; + }; + UserPrincipalName: string; +}; interface CommandArgs { options: Options; } - interface Options extends GlobalOptions { webUrl: string; id?: string; loginName?: string; + email?: string; + userName?: string; + entraGroupId?: string; + entraGroupName?: string; force: boolean; } @@ -39,9 +62,13 @@ class SpoUserRemoveCommand extends SpoCommand { #initTelemetry(): void { this.telemetry.push((args: CommandArgs) => { Object.assign(this.telemetryProperties, { - id: (!(!args.options.id)).toString(), - loginName: (!(!args.options.loginName)).toString(), - force: (!(!args.options.force)).toString() + id: typeof args.options.id !== 'undefined', + loginName: typeof args.options.loginName !== 'undefined', + email: typeof args.options.email !== 'undefined', + userName: typeof args.options.userName !== 'undefined', + entraGroupId: typeof args.options.entraGroupId !== 'undefined', + entraGroupName: typeof args.options.entraGroupName !== 'undefined', + force: !!args.options.force }); }); } @@ -57,6 +84,18 @@ class SpoUserRemoveCommand extends SpoCommand { { option: '--loginName [loginName]' }, + { + option: '--email [email]' + }, + { + option: '--userName [userName]' + }, + { + option: '--entraGroupId [entraGroupId]' + }, + { + option: '--entraGroupName [entraGroupName]' + }, { option: '-f, --force' } @@ -66,13 +105,35 @@ class SpoUserRemoveCommand extends SpoCommand { #initValidators(): void { this.validators.push( async (args: CommandArgs) => { - return validation.isValidSharePointUrl(args.options.webUrl); + const isValidSharePointUrl: boolean | string = validation.isValidSharePointUrl(args.options.webUrl); + if (isValidSharePointUrl !== true) { + return isValidSharePointUrl; + } + + if (args.options.id && isNaN(parseInt(args.options.id))) { + return `Specified id ${args.options.id} is not a number`; + } + + if (args.options.entraGroupId && !validation.isValidGuid(args.options.entraGroupId)) { + return `${args.options.entraId} is not a valid GUID.`; + } + + if (args.options.userName && !validation.isValidUserPrincipalName(args.options.userName)) { + return `${args.options.userName} is not a valid userName.`; + } + + if (args.options.email && !validation.isValidUserPrincipalName(args.options.email)) { + return `${args.options.email} is not a valid email.`; + } + return true; } ); } #initOptionSets(): void { - this.optionSets.push({ options: ['id', 'loginName'] }); + this.optionSets.push({ + options: ['id', 'loginName', 'email', 'userName', 'entraGroupId', 'entraGroupName'] + }); } public async commandAction(logger: Logger, args: CommandArgs): Promise { @@ -92,15 +153,62 @@ class SpoUserRemoveCommand extends SpoCommand { if (this.verbose) { await logger.logToStderr(`Removing user from subsite ${options.webUrl} ...`); } + try { + let requestUrl: string = `${encodeURI(options.webUrl)}/_api/web/siteusers/`; + if (options.id) { + requestUrl += `removebyid(${options.id})`; + } + else if (options.loginName) { + requestUrl += `removeByLoginName('${formatting.encodeQueryParameter(options.loginName as string)}')`; + } + else if (options.email) { + const user = await spo.getUserByEmail(options.webUrl, options.email, logger, this.verbose); + requestUrl += `removebyid(${user.Id})`; + } + else if (options.userName) { + const user = await this.getUser(options); + + if (!user) { + throw new Error(`User not found: ${options.userName}`); + } - let requestUrl: string = `${encodeURI(options.webUrl)}/_api/web/siteusers/`; - if (options.id) { - requestUrl += `removebyid(${options.id})`; + if (this.verbose) { + await logger.logToStderr(`Removing user ${user.Title} ...`); + } + requestUrl += `removebyid(${user.Id})`; + } + else if (options.entraGroupId || options.entraGroupName) { + const entraGroup = await this.getEntraGroup(options); + if (this.verbose) { + await logger.logToStderr(`Removing entra group ${entraGroup?.displayName} ...`); + } + //for entra groups, M365 groups have an associated email and security groups don't + if (entraGroup?.mail) { + //M365 group is prefixed with c:0o.c|federateddirectoryclaimprovider + requestUrl += `removeByLoginName('c:0o.c|federateddirectoryclaimprovider|${entraGroup.id}')`; + } + else { + //security group is prefixed with c:0t.c|tenant + requestUrl += `removeByLoginName('c:0t.c|tenant|${entraGroup?.id}')`; + } + } + + const requestOptions: CliRequestOptions = { + url: requestUrl, + headers: { + accept: 'application/json;odata=nometadata' + }, + responseType: 'json' + }; + await request.post(requestOptions); } - else if (options.loginName) { - requestUrl += `removeByLoginName('${formatting.encodeQueryParameter(options.loginName as string)}')`; + catch (err: any) { + this.handleRejectedODataJsonPromise(err); } + } + private async getUser(options: GlobalOptions): Promise { + const requestUrl: string = `${options.webUrl}/_api/web/siteusers?$filter=UserPrincipalName eq ('${formatting.encodeQueryParameter(options.userName)}')`; const requestOptions: CliRequestOptions = { url: requestUrl, headers: { @@ -109,13 +217,15 @@ class SpoUserRemoveCommand extends SpoCommand { responseType: 'json' }; - try { - await request.post(requestOptions); - } - catch (err: any) { - this.handleRejectedODataJsonPromise(err); - } + const userInstance = await request.get(requestOptions); + return (userInstance as { + value: SpoUser[]; + }).value[0]; + } + + private async getEntraGroup(options: GlobalOptions): Promise { + return options.entraGroupId ? await entraGroup.getGroupById(options.entraGroupId) : await entraGroup.getGroupByDisplayName(options.entraGroupName); } } -export default new SpoUserRemoveCommand(); \ No newline at end of file +export default new SpoUserRemoveCommand();