From 99382940bbe0d424ba930acb034308438fca7137 Mon Sep 17 00:00:00 2001 From: mkm17 Date: Sun, 9 Jun 2024 23:30:09 +0200 Subject: [PATCH] Adds `tenant site membership list` command Closes #5980 --- .eslintrc.cjs | 1 + .../tenant/tenant-site-membership-list.mdx | 122 ++++++ docs/src/config/sidebars.ts | 5 + src/m365/spo/commands.ts | 1 + .../tenant-site-membership-list.spec.ts | 385 ++++++++++++++++++ .../tenant/tenant-site-membership-list.ts | 166 ++++++++ src/utils/spo.spec.ts | 34 ++ src/utils/spo.ts | 150 +++++++ 8 files changed, 864 insertions(+) create mode 100644 docs/docs/cmd/spo/tenant/tenant-site-membership-list.mdx create mode 100644 src/m365/spo/commands/tenant/tenant-site-membership-list.spec.ts create mode 100644 src/m365/spo/commands/tenant/tenant-site-membership-list.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index d70e3f6dcc7..6a4f107a168 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -68,6 +68,7 @@ const dictionary = [ 'logout', 'management', 'member', + 'membership', 'messaging', 'model', 'multitenant', diff --git a/docs/docs/cmd/spo/tenant/tenant-site-membership-list.mdx b/docs/docs/cmd/spo/tenant/tenant-site-membership-list.mdx new file mode 100644 index 00000000000..bdf588fdaa1 --- /dev/null +++ b/docs/docs/cmd/spo/tenant/tenant-site-membership-list.mdx @@ -0,0 +1,122 @@ +import Global from '/docs/cmd/_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# spo tenant site membership list + +Retrieve information about default site groups' membership + +## Usage + +```sh +m365 spo tenant site membership list [options] +``` + +## Options + +```md definition-list +`-u, --siteUrl ` +: The URL of the site. + +`-r, --role [role]` +: Filter the results to include only users with the specified roles: `Owner`, `Member`, or `Visitor`. +``` + + + +## Remarks + +:::info + +To use this command, you must be a Global or SharePoint administrator. + +::: + +For other scenarios, refer to the [spo web get --withGroups](../web/web-get.mdx) and [spo group member list](../group/group-member-list.mdx) commands. + +## Examples + +Retrieve information about default site groups' owners, members, and visitors of the site. + +```sh +m365 spo tenant site membership list --siteUrl https://contoso.sharepoint.com +``` + +Retrieve information about site owners. + +```sh +m365 spo tenant site membership list --siteUrl https://contoso.sharepoint.com --role Owner +``` + +## Response + + + + + ```json + { + "AssociatedOwnerGroup": [ + { + "email": "jdoe@contoso.onmicrosoft.com", + "loginName": "i:0#.f|membership|jdoe@contoso.onmicrosoft.com", + "name": "John Doe", + "userPrincipalName": "jdoe@contoso.onmicrosoft.com" + } + ], + "AssociatedMemberGroup": [ + { + "email": "avance@contoso.onmicrosoft.com", + "loginName": "i:0#.f|membership|avance@contoso.onmicrosoft.com", + "name": "Adele Vance", + "userPrincipalName": "avance@contoso.onmicrosoft.com" + } + ], + "AssociatedVisitorGroup": [ + { + "email": "", + "loginName": "c:0-.f|rolemanager|spo-grid-all-users/dc109ffd-4298-487e-9cbc-6b9b1a2cd3e2", + "name": "Everyone except external users", + "userPrincipalName": null + } + ] + } + ``` + + + + ```text + email name userPrincipalName associatedGroupType + -------------------------------- ------------------------------ -------------------------------- ------------------- + jdoe@contoso.onmicrosoft.com John Doe jdoe@contoso.onmicrosoft.com Owner + ``` + + + + + ```csv + email,loginName,name,userPrincipalName,associatedGroupType + jdoe@contoso.onmicrosoft.com,i:0#.f|membership|jdoe@contoso.onmicrosoft.com,John Doe,jdoe@contoso.onmicrosoft.com,Owner + ``` + + + + + ```md + # spo tenant site membership list --siteUrl "https://contoso.sharepoint.com/sites/AudienceTest" + + Date: 11/08/2024 + + ## John Doe + + Property | Value + ---------|------- + email | jdoe@contoso.onmicrosoft.com + loginName | i:0#.f\|membership\|jdoe@contoso.onmicrosoft.com + name | John Doe + userPrincipalName | jdoe@contoso.onmicrosoft.com + associatedGroupType | Owner + + ``` + + + diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index 0370e28df09..7f920aa4997 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -3752,6 +3752,11 @@ const sidebars: SidebarsConfig = { type: 'doc', label: 'tenant site unarchive', id: 'cmd/spo/tenant/tenant-site-unarchive' + }, + { + type: 'doc', + label: 'tenant site membership list', + id: 'cmd/spo/tenant/tenant-site-membership-list' } ] }, diff --git a/src/m365/spo/commands.ts b/src/m365/spo/commands.ts index 5b03d224c85..e7323a44bb6 100644 --- a/src/m365/spo/commands.ts +++ b/src/m365/spo/commands.ts @@ -322,6 +322,7 @@ export default { TENANT_SETTINGS_LIST: `${prefix} tenant settings list`, TENANT_SETTINGS_SET: `${prefix} tenant settings set`, TENANT_SITE_ARCHIVE: `${prefix} tenant site archive`, + TENANT_SITE_MEMBERSHIP_LIST: `${prefix} tenant site membership list`, TENANT_SITE_RENAME: `${prefix} tenant site rename`, TENANT_SITE_UNARCHIVE: `${prefix} tenant site unarchive`, TERM_ADD: `${prefix} term add`, diff --git a/src/m365/spo/commands/tenant/tenant-site-membership-list.spec.ts b/src/m365/spo/commands/tenant/tenant-site-membership-list.spec.ts new file mode 100644 index 00000000000..951dfabb55b --- /dev/null +++ b/src/m365/spo/commands/tenant/tenant-site-membership-list.spec.ts @@ -0,0 +1,385 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import auth from '../../../../Auth.js'; +import { Logger } from '../../../../cli/Logger.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 './tenant-site-membership-list.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; +import { cli } from '../../../../cli/cli.js'; +import { CommandError } from '../../../../Command.js'; + +describe(commands.TENANT_SITE_MEMBERSHIP_LIST, () => { + let log: any[]; + let logger: Logger; + let loggerLogSpy: sinon.SinonSpy; + let commandInfo: CommandInfo; + const ownerMembershipList = [ + { + email: 'owner1Email@email.com', + loginName: 'i:0#.f|membership|owner1loginName@email.com', + name: 'owner1DisplayName', + userPrincipalName: 'owner1loginName' + }, + { + email: 'owner2Email@email.com', + loginName: 'i:0#.f|membership|owner2loginName@email.com', + name: 'owner2DisplayName', + userPrincipalName: 'owner2loginName' + } + ]; + const membersMembershipList = [ + { + email: 'member1Email@email.com', + loginName: 'i:0#.f|membership|member1loginName@email.com', + name: 'member1DisplayName', + userPrincipalName: 'member1loginName' + }, + { + email: 'member2Email@email.com', + loginName: 'i:0#.f|membership|member2loginName@email.com', + name: 'member2DisplayName', + userPrincipalName: 'member2loginName' + } + ]; + const visitorsMembershipList = [ + { + email: 'visitor1Email@email.com', + loginName: 'i:0#.f|membership|visitor1loginName@email.com', + name: 'visitor1DisplayName', + userPrincipalName: 'visitor1loginName' + }, + { + email: 'visitor2Email@email.com', + loginName: 'i:0#.f|membership|visitor2loginName@email.com', + name: 'visitor2DisplayName', + userPrincipalName: 'visitor2loginName' + } + ]; + const ownerMembershipListCSVOutput = [ + { + email: 'owner1Email@email.com', + loginName: 'i:0#.f|membership|owner1loginName@email.com', + name: 'owner1DisplayName', + userPrincipalName: 'owner1loginName', + associatedGroupType: 'Owner' + }, + { + email: 'owner2Email@email.com', + loginName: 'i:0#.f|membership|owner2loginName@email.com', + name: 'owner2DisplayName', + userPrincipalName: 'owner2loginName', + associatedGroupType: 'Owner' + } + ]; + const membersMembershipListCSVOutput = [ + { + email: 'member1Email@email.com', + loginName: 'i:0#.f|membership|member1loginName@email.com', + name: 'member1DisplayName', + userPrincipalName: 'member1loginName', + associatedGroupType: 'Member' + }, + { + email: 'member2Email@email.com', + loginName: 'i:0#.f|membership|member2loginName@email.com', + name: 'member2DisplayName', + userPrincipalName: 'member2loginName', + associatedGroupType: 'Member' + } + ]; + const visitorsMembershipListCSVOutput = [ + { + email: 'visitor1Email@email.com', + loginName: 'i:0#.f|membership|visitor1loginName@email.com', + name: 'visitor1DisplayName', + userPrincipalName: 'visitor1loginName', + associatedGroupType: 'Visitor' + }, + { + email: 'visitor2Email@email.com', + loginName: 'i:0#.f|membership|visitor2loginName@email.com', + name: 'visitor2DisplayName', + userPrincipalName: 'visitor2loginName', + associatedGroupType: 'Visitor' + } + ]; + const adminUrl = 'https://contoso-admin.sharepoint.com'; + const siteUrl = 'https://contoso.sharepoint.com/sites/site'; + const siteId = '00000000-0000-0000-0000-000000000010'; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').returns(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + sinon.stub(spo, 'getSpoAdminUrl').resolves(adminUrl); + auth.connection.active = true; + auth.connection.spoUrl = 'https://contoso.sharepoint.com'; + commandInfo = cli.getCommandInfo(command); + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + loggerLogSpy = sinon.spy(logger, 'log'); + }); + + afterEach(() => { + sinonUtil.restore([ + request.get, + request.post, + spo.getSiteAdminPropertiesByUrl + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + auth.connection.spoUrl = undefined; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.TENANT_SITE_MEMBERSHIP_LIST); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('passes validation if the role option is a valid role', async () => { + const actual = await command.validate({ options: { siteUrl: 'https://contoso.sharepoint.com', role: 'Owner' } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('fails validation if the siteUrl option is not a valid SharePoint URL', async () => { + const actual = await command.validate({ options: { siteUrl: 'foo' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if the role option is not a valid role', async () => { + const actual = await command.validate({ options: { siteUrl: 'https://contoso.sharepoint.com', role: 'foo' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('lists all site membership groups', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${adminUrl}/_api/SPO.Tenant/sites/GetSiteUserGroups?siteId='${siteId}'&userGroupIds=[0,1,2]`) { + return { value: [{ userGroup: ownerMembershipList }, { userGroup: membersMembershipList }, { userGroup: visitorsMembershipList }] }; + }; + + throw 'Invalid request'; + }); + + sinon.stub(spo, 'getSiteAdminPropertiesByUrl').resolves({ SiteId: siteId } as any); + + await command.action(logger, { options: { siteUrl: siteUrl, output: 'json' } }); + assert.deepStrictEqual(loggerLogSpy.lastCall.args[0], { + AssociatedOwnerGroup: ownerMembershipList, + AssociatedMemberGroup: membersMembershipList, + AssociatedVisitorGroup: visitorsMembershipList + }); + }); + + it('lists all site membership groups - just Owners group', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${adminUrl}/_api/SPO.Tenant/sites/GetSiteUserGroups?siteId='${siteId}'&userGroupIds=[0]`) { + return { value: [{ userGroup: ownerMembershipList }] }; + }; + + throw 'Invalid request'; + }); + + sinon.stub(spo, 'getSiteAdminPropertiesByUrl').resolves({ SiteId: siteId } as any); + + await command.action(logger, { options: { siteUrl: siteUrl, role: "Owner", output: 'json' } }); + assert.deepStrictEqual(loggerLogSpy.lastCall.args[0], { + AssociatedOwnerGroup: ownerMembershipList + }); + }); + + it('lists all site membership groups - just Members group', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${adminUrl}/_api/SPO.Tenant/sites/GetSiteUserGroups?siteId='${siteId}'&userGroupIds=[1]`) { + return { value: [{ userGroup: membersMembershipList }] }; + }; + + throw 'Invalid request'; + }); + + sinon.stub(spo, 'getSiteAdminPropertiesByUrl').resolves({ SiteId: siteId } as any); + + await command.action(logger, { options: { siteUrl: siteUrl, role: "Member", output: 'json' } }); + assert.deepStrictEqual(loggerLogSpy.lastCall.args[0], { + AssociatedMemberGroup: membersMembershipList + }); + }); + + it('lists all site membership groups - just Visitors group', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${adminUrl}/_api/SPO.Tenant/sites/GetSiteUserGroups?siteId='${siteId}'&userGroupIds=[2]`) { + return { value: [{ userGroup: visitorsMembershipList }] }; + }; + + throw 'Invalid request'; + }); + + sinon.stub(spo, 'getSiteAdminPropertiesByUrl').resolves({ SiteId: siteId } as any); + + await command.action(logger, { options: { siteUrl: siteUrl, role: "Visitor", output: 'json' } }); + assert.deepStrictEqual(loggerLogSpy.lastCall.args[0], { + AssociatedVisitorGroup: visitorsMembershipList + }); + }); + + it('lists all site membership groups - csv output', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${adminUrl}/_api/SPO.Tenant/sites/GetSiteUserGroups?siteId='${siteId}'&userGroupIds=[0,1,2]`) { + return { value: [{ userGroup: ownerMembershipList }, { userGroup: membersMembershipList }, { userGroup: visitorsMembershipList }] }; + }; + + throw 'Invalid request'; + }); + + sinon.stub(spo, 'getSiteAdminPropertiesByUrl').resolves({ SiteId: siteId } as any); + + await command.action(logger, { options: { siteUrl: siteUrl, output: 'csv' } }); + assert.deepStrictEqual(loggerLogSpy.lastCall.args[0], [ + ...ownerMembershipListCSVOutput, + ...membersMembershipListCSVOutput, + ...visitorsMembershipListCSVOutput + ]); + }); + + it('lists all site membership groups - text output', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${adminUrl}/_api/SPO.Tenant/sites/GetSiteUserGroups?siteId='${siteId}'&userGroupIds=[0,1,2]`) { + return { value: [{ userGroup: ownerMembershipList }, { userGroup: membersMembershipList }, { userGroup: visitorsMembershipList }] }; + }; + + throw 'Invalid request'; + }); + + sinon.stub(spo, 'getSiteAdminPropertiesByUrl').resolves({ SiteId: siteId } as any); + + await command.action(logger, { options: { siteUrl: siteUrl, output: 'text' } }); + assert.deepStrictEqual(loggerLogSpy.lastCall.args[0], [ + ...ownerMembershipListCSVOutput, + ...membersMembershipListCSVOutput, + ...visitorsMembershipListCSVOutput + ]); + }); + + it('lists all site membership groups - markdown output', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${adminUrl}/_api/SPO.Tenant/sites/GetSiteUserGroups?siteId='${siteId}'&userGroupIds=[0,1,2]`) { + return { value: [{ userGroup: ownerMembershipList }, { userGroup: membersMembershipList }, { userGroup: visitorsMembershipList }] }; + }; + + throw 'Invalid request'; + }); + + sinon.stub(spo, 'getSiteAdminPropertiesByUrl').resolves({ SiteId: siteId } as any); + + await command.action(logger, { options: { siteUrl: siteUrl, output: 'md' } }); + assert.deepStrictEqual(loggerLogSpy.lastCall.args[0], [ + ...ownerMembershipListCSVOutput, + ...membersMembershipListCSVOutput, + ...visitorsMembershipListCSVOutput + ]); + }); + + it('lists all site membership groups - text output when outputs are empty', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${adminUrl}/_api/SPO.Tenant/sites/GetSiteUserGroups?siteId='${siteId}'&userGroupIds=[0,1,2]`) { + return { value: [{ userGroup: [] }, { userGroup: [] }, { userGroup: [] }] }; + }; + + throw 'Invalid request'; + }); + + sinon.stub(spo, 'getSiteAdminPropertiesByUrl').resolves({ SiteId: siteId } as any); + + await command.action(logger, { options: { siteUrl: siteUrl, output: 'text' } }); + assert.deepStrictEqual(loggerLogSpy.lastCall.args[0], []); + }); + + it('lists all site membership groups - just Owners group - csv output', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${adminUrl}/_api/SPO.Tenant/sites/GetSiteUserGroups?siteId='${siteId}'&userGroupIds=[0]`) { + return { value: [{ userGroup: ownerMembershipList }] }; + }; + + throw 'Invalid request'; + }); + + sinon.stub(spo, 'getSiteAdminPropertiesByUrl').resolves({ SiteId: siteId } as any); + + await command.action(logger, { options: { siteUrl: siteUrl, role: "Owner", output: 'csv' } }); + assert.deepStrictEqual(loggerLogSpy.lastCall.args[0], [ + ...ownerMembershipListCSVOutput + ]); + }); + + it('lists all site membership groups - just Members group - csv output', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${adminUrl}/_api/SPO.Tenant/sites/GetSiteUserGroups?siteId='${siteId}'&userGroupIds=[1]`) { + return { value: [{ userGroup: membersMembershipList }] }; + }; + + throw 'Invalid request'; + }); + + sinon.stub(spo, 'getSiteAdminPropertiesByUrl').resolves({ SiteId: siteId } as any); + + await command.action(logger, { options: { siteUrl: siteUrl, role: "Member", output: 'csv' } }); + assert.deepStrictEqual(loggerLogSpy.lastCall.args[0], [ + ...membersMembershipListCSVOutput + ]); + }); + + it('lists all site membership groups - just Visitors group - csv output', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${adminUrl}/_api/SPO.Tenant/sites/GetSiteUserGroups?siteId='${siteId}'&userGroupIds=[2]`) { + return { value: [{ userGroup: visitorsMembershipList }] }; + }; + + throw 'Invalid request'; + }); + + sinon.stub(spo, 'getSiteAdminPropertiesByUrl').resolves({ SiteId: siteId } as any); + + await command.action(logger, { options: { siteUrl: siteUrl, role: "Visitor", output: 'csv' } }); + assert.deepStrictEqual(loggerLogSpy.lastCall.args[0], [ + ...visitorsMembershipListCSVOutput + ]); + }); + + it('correctly handles error when site is not found for specified site URL', async () => { + + sinon.stub(request, 'post').rejects({ + error: { + 'odata.error': { + code: "-1, Microsoft.Online.SharePoint.Common.SpoNoSiteException", message: { lang: "en-US", value: `Cannot get site ${siteUrl}.` } + } + } + }); + + await assert.rejects(command.action(logger, { options: { siteUrl: siteUrl, verbose: true } }), new CommandError(`Cannot get site ${siteUrl}.`)); + }); +}); \ No newline at end of file diff --git a/src/m365/spo/commands/tenant/tenant-site-membership-list.ts b/src/m365/spo/commands/tenant/tenant-site-membership-list.ts new file mode 100644 index 00000000000..eb70abb9ce5 --- /dev/null +++ b/src/m365/spo/commands/tenant/tenant-site-membership-list.ts @@ -0,0 +1,166 @@ +import GlobalOptions from '../../../../GlobalOptions.js'; +import { Logger } from '../../../../cli/Logger.js'; +import { odata } from '../../../../utils/odata.js'; +import { spo } from '../../../../utils/spo.js'; +import { validation } from '../../../../utils/validation.js'; +import SpoCommand from '../../../base/SpoCommand.js'; +import commands from '../../commands.js'; + +interface CommandArgs { + options: Options; +} + +interface Options extends GlobalOptions { + siteUrl: string; + role?: string; +} + +interface IMembershipResult { + userGroup: IUserInfo[]; +} + +interface IMembershipOutput { + AssociatedOwnerGroup?: IUserInfo[]; + AssociatedMemberGroup?: IUserInfo[]; + AssociatedVisitorGroup?: IUserInfo[]; +} + +interface IUserInfo { + email: string; + loginName: string; + name: string; + userPrincipalName: string; + associatedGroupType?: string; +} + +class SpoTenantSiteMembershipListCommand extends SpoCommand { + public static readonly RoleNames: string[] = ['Owner', 'Member', 'Visitor']; + + public get name(): string { + return commands.TENANT_SITE_MEMBERSHIP_LIST; + } + + public get description(): string { + return `Retrieve information about default site groups' membership`; + } + + public defaultProperties(): string[] | undefined { + return ['email', 'name', 'userPrincipalName', 'associatedGroupType']; + } + + constructor() { + super(); + + this.#initTelemetry(); + this.#initOptions(); + this.#initValidators(); + this.#initTypes(); + } + + #initTelemetry(): void { + this.telemetry.push((args: CommandArgs) => { + Object.assign(this.telemetryProperties, { + role: typeof args.options.role !== 'undefined' + }); + }); + } + + #initOptions(): void { + this.options.unshift( + { + option: '-u, --siteUrl ' + }, + { + option: '-r, --role [role]', + autocomplete: SpoTenantSiteMembershipListCommand.RoleNames + } + ); + } + + #initValidators(): void { + this.validators.push( + async (args: CommandArgs) => { + if (args.options.role && !SpoTenantSiteMembershipListCommand.RoleNames.some(roleName => roleName.toLocaleLowerCase() === args.options.role!.toLocaleLowerCase())) { + return `'${args.options.role}' is not a valid value for option 'role'. Valid values are: ${SpoTenantSiteMembershipListCommand.RoleNames.join(', ')}`; + } + + return validation.isValidSharePointUrl(args.options.siteUrl); + } + ); + } + + #initTypes(): void { + this.types.string.push('role', 'siteUrl'); + }; + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + try { + const spoAdminUrl: string = await spo.getSpoAdminUrl(logger, this.verbose); + const roleIds: string = this.getRoleIds(args.options.role); + const tenantSiteProperties = await spo.getSiteAdminPropertiesByUrl(args.options.siteUrl, false, logger, this.verbose); + + const response = await odata.getAllItems(`${spoAdminUrl}/_api/SPO.Tenant/sites/GetSiteUserGroups?siteId='${tenantSiteProperties.SiteId}'&userGroupIds=[${roleIds}]`); + const result = args.options.output === 'json' ? this.mapResult(response, args.options.role) : this.mapListResult(response, args.options.role); + + await logger.log(result); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } + + private getRoleIds(role?: string): string { + switch (role?.toLowerCase()) { + case 'owner': + return '0'; + case 'member': + return '1'; + case 'visitor': + return '2'; + default: + return '0,1,2'; + } + } + + private mapResult(response: IMembershipResult[], role?: string): IMembershipOutput { + switch (role?.toLowerCase()) { + case 'owner': + return { AssociatedOwnerGroup: response[0].userGroup }; + case 'member': + return { AssociatedMemberGroup: response[0].userGroup }; + case 'visitor': + return { AssociatedVisitorGroup: response[0].userGroup }; + default: + return { + AssociatedOwnerGroup: response[0].userGroup, + AssociatedMemberGroup: response[1].userGroup, + AssociatedVisitorGroup: response[2].userGroup + }; + } + } + + private mapListResult(response: IMembershipResult[], role?: string): IUserInfo[] { + const mapGroup = (groupIndex: number, groupType: string): IUserInfo[] => + response[groupIndex].userGroup.map(user => ({ + ...user, + associatedGroupType: groupType + })); + + switch (role?.toLowerCase()) { + case 'owner': + return mapGroup(0, 'Owner'); + case 'member': + return mapGroup(0, 'Member'); + case 'visitor': + return mapGroup(0, 'Visitor'); + default: + return [ + ...mapGroup(0, 'Owner'), + ...mapGroup(1, 'Member'), + ...mapGroup(2, 'Visitor') + ]; + } + } +} + +export default new SpoTenantSiteMembershipListCommand(); \ No newline at end of file diff --git a/src/utils/spo.spec.ts b/src/utils/spo.spec.ts index 51604369902..a4d8fbd29da 100644 --- a/src/utils/spo.spec.ts +++ b/src/utils/spo.spec.ts @@ -2998,4 +2998,38 @@ describe('utils/spo', () => { await assert.rejects(spo.getCopyJobResult('https://contoso.sharepoint.com/sites/sales', copyJobInfo), new Error('A file or folder with the name Company.png already exists at the destination.')); }); + + it(`Gets site properties without included details as admin using provided url`, async () => { + const siteId = 'b2307a39-e878-458b-bc90-03bc578531d6'; + const siteProperties = { SiteId: siteId }; + sinon.stub(spo, 'getSpoAdminUrl').resolves('https://contoso-admin.sharepoint.com'); + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://contoso-admin.sharepoint.com/_api/SPO.Tenant/GetSitePropertiesByUrl`) { + return siteProperties; + }; + + throw 'Invalid request'; + }); + + await spo.getSiteAdminPropertiesByUrl('https://contoso.sharepoint.com/sites/sales', false, logger, true); + + assert.deepStrictEqual(postStub.firstCall.args[0].data, { url: 'https://contoso.sharepoint.com/sites/sales', includeDetail: false }); + }); + + it(`Gets site properties with included details as admin using provided url`, async () => { + const siteId = 'b2307a39-e878-458b-bc90-03bc578531d6'; + const siteProperties = { SiteId: siteId }; + sinon.stub(spo, 'getSpoAdminUrl').resolves('https://contoso-admin.sharepoint.com'); + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://contoso-admin.sharepoint.com/_api/SPO.Tenant/GetSitePropertiesByUrl`) { + return siteProperties; + }; + + throw 'Invalid request'; + }); + + await spo.getSiteAdminPropertiesByUrl('https://contoso.sharepoint.com/sites/sales', true, logger, true); + + assert.deepStrictEqual(postStub.firstCall.args[0].data, { url: 'https://contoso.sharepoint.com/sites/sales', includeDetail: true }); + }); }); \ No newline at end of file diff --git a/src/utils/spo.ts b/src/utils/spo.ts index 5950a65adff..5b31e1829bd 100644 --- a/src/utils/spo.ts +++ b/src/utils/spo.ts @@ -132,6 +132,124 @@ export const settings = { pollingInterval: 3_000 }; +interface TenantSiteProperties { + AllowDownloadingNonWebViewableFiles: boolean; + AllowEditing: boolean; + AllowSelfServiceUpgrade: boolean; + AnonymousLinkExpirationInDays: number; + ApplyToExistingDocumentLibraries: boolean; + ApplyToNewDocumentLibraries: boolean; + ArchivedBy: string; + ArchivedTime: string; + ArchiveStatus: string; + AuthContextStrength: any; + AuthenticationContextLimitedAccess: boolean; + AuthenticationContextName: any; + AverageResourceUsage: number; + BlockDownloadLinksFileType: number; + BlockDownloadMicrosoft365GroupIds: any; + BlockDownloadPolicy: boolean; + BlockDownloadPolicyFileTypeIds: any; + BlockGuestsAsSiteAdmin: number; + BonusDiskQuota: string; + ClearRestrictedAccessControl: boolean; + CommentsOnSitePagesDisabled: boolean; + CompatibilityLevel: number; + ConditionalAccessPolicy: number; + CurrentResourceUsage: number; + DefaultLinkPermission: number; + DefaultLinkToExistingAccess: boolean; + DefaultLinkToExistingAccessReset: boolean; + DefaultShareLinkRole: number; + DefaultShareLinkScope: number; + DefaultSharingLinkType: number; + DenyAddAndCustomizePages: number; + Description: string; + DisableAppViews: number; + DisableCompanyWideSharingLinks: number; + DisableFlows: number; + EnableAutoExpirationVersionTrim: boolean; + ExcludeBlockDownloadPolicySiteOwners: boolean; + ExcludeBlockDownloadSharePointGroups: any[]; + ExcludedBlockDownloadGroupIds: any[]; + ExpireVersionsAfterDays: number; + ExternalUserExpirationInDays: number; + GroupId: string; + GroupOwnerLoginName: string; + HasHolds: boolean; + HubSiteId: string; + IBMode: string; + IBSegments: any[]; + IBSegmentsToAdd: any; + IBSegmentsToRemove: any; + InheritVersionPolicyFromTenant: boolean; + IsGroupOwnerSiteAdmin: boolean; + IsHubSite: boolean; + IsTeamsChannelConnected: boolean; + IsTeamsConnected: boolean; + LastContentModifiedDate: string; + Lcid: string; + LimitedAccessFileType: number; + ListsShowHeaderAndNavigation: boolean; + LockIssue: any; + LockReason: number; + LockState: string; + LoopDefaultSharingLinkRole: number; + LoopDefaultSharingLinkScope: number; + MajorVersionLimit: number; + MajorWithMinorVersionsLimit: number; + MediaTranscription: number; + OverrideBlockUserInfoVisibility: number; + OverrideSharingCapability: boolean; + OverrideTenantAnonymousLinkExpirationPolicy: boolean; + OverrideTenantExternalUserExpirationPolicy: boolean; + Owner: string; + OwnerEmail: string; + OwnerLoginName: string; + OwnerName: string; + PWAEnabled: number; + ReadOnlyAccessPolicy: boolean; + ReadOnlyForBlockDownloadPolicy: boolean; + ReadOnlyForUnmanagedDevices: boolean; + RelatedGroupId: string; + RequestFilesLinkEnabled: boolean; + RequestFilesLinkExpirationInDays: number; + RestrictContentOrgWideSearch: boolean; + RestrictedAccessControl: boolean; + RestrictedAccessControlGroups: any[]; + RestrictedAccessControlGroupsToAdd: any; + RestrictedAccessControlGroupsToRemove: any; + RestrictedToRegion: number; + SandboxedCodeActivationCapability: number; + SensitivityLabel: string; + SensitivityLabel2: any; + SetOwnerWithoutUpdatingSecondaryAdmin: boolean; + SharingAllowedDomainList: string; + SharingBlockedDomainList: string; + SharingCapability: number; + SharingDomainRestrictionMode: number; + SharingLockDownCanBeCleared: boolean; + SharingLockDownEnabled: boolean; + ShowPeoplePickerSuggestionsForGuestUsers: boolean; + SiteDefinedSharingCapability: number; + SiteId: string; + SocialBarOnSitePagesDisabled: boolean; + Status: string; + StorageMaximumLevel: string; + StorageQuotaType: any; + StorageUsage: string; + StorageWarningLevel: string; + TeamsChannelType: number; + Template: string; + TimeZoneId: number; + Title: string; + TitleTranslations: Array<{ LCID: number; Value: string }>; + Url: string; + UserCodeMaximumLevel: number; + UserCodeWarningLevel: number; + WebsCount: number; +} + export const spo = { async getRequestDigest(siteUrl: string): Promise { const requestOptions: CliRequestOptions = { @@ -2054,5 +2172,37 @@ export const spo = { const responseContent = await request.get<{ LoginName: string }>(requestOptions); return responseContent?.LoginName; + }, + + /** + * Retrieves the site admin properties for a given site URL. + * @param adminUrl The SharePoint admin url. + * @param siteUrl URL of the site for which to retrieve properties. + * @param includeDetail Set to true to include detailed properties. + * @param logger The logger object. + * @param verbose Set for verbose logging. + * @returns Tenant Site properties. + */ + async getSiteAdminPropertiesByUrl(siteUrl: string, includeDetail: boolean, logger: Logger, verbose?: boolean): Promise { + if (verbose) { + await logger.logToStderr(`Getting site admin properties for URL: ${siteUrl}...`); + } + + const adminUrl: string = await spo.getSpoAdminUrl(logger, !!verbose); + + const requestOptions: CliRequestOptions = { + url: `${adminUrl}/_api/SPO.Tenant/GetSitePropertiesByUrl`, + headers: { + accept: 'application/json;odata=nometadata', + 'content-type': 'application/json;charset=utf-8' + }, + data: { + url: siteUrl, + includeDetail: includeDetail + }, + responseType: 'json' + }; + + return request.post(requestOptions); } }; \ No newline at end of file