diff --git a/docs/docs/cmd/pp/website/website-webrole-list.mdx b/docs/docs/cmd/pp/website/website-webrole-list.mdx new file mode 100644 index 00000000000..19cb337c598 --- /dev/null +++ b/docs/docs/cmd/pp/website/website-webrole-list.mdx @@ -0,0 +1,139 @@ +import Global from '../../_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# pp website webrole list + +Lists all webroles for the specified Power Pages website. + +## Usage + +```sh +m365 pp website webrole list [options] +``` + +## Options + +```md definition-list +`--websiteId [websiteId]` +: ID of the Power Pages website. Specify either `websiteId` or `websiteName` but not both. + +`--websiteName [websiteName]` +: The unique name (not the display name) of the Power Pages website. Specify either `websiteId` or `websiteName` but not both. + +`-e, --environmentName ` +: The name of the environment where the Power Pages websites are located. + +`--asAdmin` +: Run the command as admin and retrieve Power Pages websites for environments you do not have explicitly assigned permissions to. +``` + + + +## Permissions + + + + + | Resource | Permissions | + |--------------------|--------------------------| + | Power Platform API | PowerPages.Websites.Read | + | Dynamics CRM | user_impersonation | + + + + +## Examples + +List all webroles for the site by name. + +```sh +m365 pp website webrole list --websiteName "Contoso" --environmentName "Default-2ca3eaa5-140f-4175-8261-3272edf9f339" +``` + +List all webroles for the site by name as admin. + +```sh +m365 pp website webrole list --websiteName "Contoso" --environmentName "Default-2ca3eaa5-140f-4175-8261-3272edf9f339" --asAdmin +``` + +List all webroles for the site by id. + +```sh +m365 pp website webrole list --websiteId "2ca3eaa5-140f-4175-8261-3272edf9f339" --environmentName "Default-2ca3eaa5-140f-4175-8261-3272edf9f339" +``` + +List all webroles for the site by id as admin. + +```sh +m365 pp website webrole list --websiteId "2ca3eaa5-140f-4175-8261-3272edf9f339" --environmentName "Default-2ca3eaa5-140f-4175-8261-3272edf9f339" --asAdmin +``` + +## Response + + + + + ```json + [ + { + "mspp_webroleid": "a242a363-6077-4cb7-b2d1-1714502d129a", + "mspp_name": "Anonymous Users", + "mspp_description": "Role for anonymous users", + "mspp_key": null, + "mspp_authenticatedusersrole": false, + "mspp_anonymoususersrole": true, + "mspp_createdon": "2026-01-21T22:10:56Z", + "mspp_modifiedon": "2026-01-21T22:10:56Z", + "statecode": 0, + "statuscode": 1, + "_mspp_websiteid_value": "5eb107a6-5ac2-4e1c-a3b9-d5c21bbc10ce", + "_mspp_createdby_value": "b7aa2026-a8c1-f011-bbd2-000d3a66196e", + "_mspp_modifiedby_value": "b7aa2026-a8c1-f011-bbd2-000d3a66196e" + } + ] + ``` + + + + + ```text + webroleid name statuscode + ------------------------------------ --------------- ---------- + a242a363-6077-4cb7-b2d1-1714502d129a Anonymous Users 1 + ``` + + + + + ```csv + mspp_modifiedon,mspp_key,mspp_description,mspp_authenticatedusersrole,statecode,_mspp_createdby_value,statuscode,mspp_anonymoususersrole,mspp_webroleid,mspp_createdon,_mspp_modifiedby_value,mspp_name,_mspp_websiteid_value + 2026-01-21T22:10:56Z,,Role for anonymous users,0,0,b7aa2026-a8c1-f011-bbd2-000d3a66196e,1,1,a242a363-6077-4cb7-b2d1-1714502d129a,2026-01-21T22:10:56Z,b7aa2026-a8c1-f011-bbd2-000d3a66196e,Anonymous Users,5eb107a6-5ac2-4e1c-a3b9-d5c21bbc10ce + ``` + + + + + ```md + # pp website webrole list --websiteName "Contoso" --environmentName "Default-2ca3eaa5-140f-4175-8261-3272edf9f339" + + Date: 2/8/2026 + + Property | Value + ---------|------- + mspp\_modifiedon | 2026-01-21T22:10:53Z + mspp\_description | Role for anonymous users + mspp\_authenticatedusersrole | true + statecode | 0 + \_mspp\_createdby\_value | b7aa2026-a8c1-f011-bbd2-000d3a66196e + statuscode | 1 + mspp\_anonymoususersrole | false + mspp\_webroleid | cc3bf86b-204c-4a97-b0b6-f788c62ae5e8 + mspp\_createdon | 2026-01-21T22:10:53Z + \_mspp\_modifiedby\_value | b7aa2026-a8c1-f011-bbd2-000d3a66196e + mspp\_name | Authenticated Users + \_mspp\_websiteid\_value | 5eb107a6-5ac2-4e1c-a3b9-d5c21bbc10ce + ``` + + + diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index 55e13dca27c..5937958f92f 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -1996,6 +1996,11 @@ const sidebars: SidebarsConfig = { type: 'doc', label: 'website get', id: 'cmd/pp/website/website-get' + }, + { + type: 'doc', + label: 'website webrole list', + id: 'cmd/pp/website/website-webrole-list' } ] } diff --git a/src/m365/pp/commands.ts b/src/m365/pp/commands.ts index 148f15f3cde..f2f4136142c 100644 --- a/src/m365/pp/commands.ts +++ b/src/m365/pp/commands.ts @@ -28,5 +28,6 @@ export default { SOLUTION_PUBLISHER_REMOVE: `${prefix} solution publisher remove`, TENANT_SETTINGS_LIST: `${prefix} tenant settings list`, TENANT_SETTINGS_SET: `${prefix} tenant settings set`, - WEBSITE_GET: `${prefix} website get` + WEBSITE_GET: `${prefix} website get`, + WEBSITE_WEBROLE_LIST: `${prefix} website webrole list` }; \ No newline at end of file diff --git a/src/m365/pp/commands/website/Webrole.ts b/src/m365/pp/commands/website/Webrole.ts new file mode 100644 index 00000000000..3a1ee920453 --- /dev/null +++ b/src/m365/pp/commands/website/Webrole.ts @@ -0,0 +1,15 @@ +export interface Webrole { + mspp_webroleid: string; + mspp_name: string; + mspp_description: string | null; + mspp_key: string | null; + mspp_authenticatedusersrole: boolean; + mspp_anonymoususersrole: boolean; + mspp_createdon: string; + mspp_modifiedon: string; + statecode: number; + statuscode: number; + _mspp_websiteid_value: string; + _mspp_createdby_value: string; + _mspp_modifiedby_value: string; +} \ No newline at end of file diff --git a/src/m365/pp/commands/website/website-webrole-list.spec.ts b/src/m365/pp/commands/website/website-webrole-list.spec.ts new file mode 100644 index 00000000000..aac67af2707 --- /dev/null +++ b/src/m365/pp/commands/website/website-webrole-list.spec.ts @@ -0,0 +1,196 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import auth from '../../../../Auth.js'; +import { cli } from '../../../../cli/cli.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; +import { Logger } from '../../../../cli/Logger.js'; +import { CommandError } from '../../../../Command.js'; +import { telemetry } from '../../../../telemetry.js'; +import { accessToken } from '../../../../utils/accessToken.js'; +import { odata } from '../../../../utils/odata.js'; +import { pid } from '../../../../utils/pid.js'; +import { powerPlatform } from '../../../../utils/powerPlatform.js'; +import { session } from '../../../../utils/session.js'; +import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import commands from '../../commands.js'; +import command, { options } from './website-webrole-list.js'; + +const environment = 'Default-727dc1e9-3cd1-4d1f-8102-ab5c936e52f0'; +const powerPageResponse = { + "@odata.metadata": "https://api.powerplatform.com/powerpages/environments/Default-727dc1e9-3cd1-4d1f-8102-ab5c936e52f0/websites/$metadata#Websites", + "id": "4916bb2c-91e1-4716-91d5-b6171928fac9", + "name": "Site 1", + "createdOn": "2024-10-27T12:00:03", + "templateName": "DefaultPortalTemplate", + "websiteUrl": "https://site-0uaq9.powerappsportals.com", + "tenantId": "727dc1e9-3cd1-4d1f-8102-ab5c936e52f0", + "dataverseInstanceUrl": "https://org0cd4b2b9.crm4.dynamics.com/", + "environmentName": "Contoso (default)", + "environmentId": "Default-727dc1e9-3cd1-4d1f-8102-ab5c936e52f0", + "dataverseOrganizationId": "2d58aeac-74d4-4939-98d1-e05a70a655ba", + "selectedBaseLanguage": 1033, + "customHostNames": [], + "websiteRecordId": "5eb107a6-5ac2-4e1c-a3b9-d5c21bbc10ce", + "subdomain": "site-0uaq9", + "packageInstallStatus": "Installed", + "type": "Trial", + "trialExpiringInDays": 86, + "suspendedWebsiteDeletingInDays": 93, + "packageVersion": "9.6.9.39", + "isEarlyUpgradeEnabled": false, + "isCustomErrorEnabled": true, + "applicationUserAadAppId": "3f57aca7-5051-41b2-989d-26da8af7a53e", + "ownerId": "33469a62-c3af-4cfe-b893-854eceab96da", + "status": "OperationComplete", + "siteVisibility": "private", + "dataModel": "Enhanced" +}; + +const webroleResponse = [ + { + "mspp_webroleid": "a242a363-6077-4cb7-b2d1-1714502d129a", + "mspp_name": "Anonymous Users", + "mspp_description": null, + "mspp_key": null, + "mspp_authenticatedusersrole": false, + "mspp_anonymoususersrole": true, + "mspp_createdon": "2026-01-21T22:10:56Z", + "mspp_modifiedon": "2026-01-21T22:10:56Z", + "statecode": 0, + "statuscode": 1, + "_mspp_websiteid_value": "5eb107a6-5ac2-4e1c-a3b9-d5c21bbc10ce", + "_mspp_createdby_value": "b7aa2026-a8c1-f011-bbd2-000d3a66196e", + "_mspp_modifiedby_value": "b7aa2026-a8c1-f011-bbd2-000d3a66196e" + } +]; + +describe(commands.WEBSITE_WEBROLE_LIST, () => { + let log: any[]; + let logger: Logger; + let loggerLogSpy: sinon.SinonSpy; + let commandInfo: CommandInfo; + let commandOptionsSchema: typeof options; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').resolves(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + sinon.stub(accessToken, 'assertAccessTokenType').returns(); + auth.connection.active = true; + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse() as typeof options; + }); + + 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([ + powerPlatform.getWebsiteById, + powerPlatform.getWebsiteIdByUniqueName, + powerPlatform.getDynamicsInstanceApiUrl, + odata.getAllItems + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name.startsWith(commands.WEBSITE_WEBROLE_LIST), true); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('fails validation if websiteName and websiteId are used at the same time', () => { + const actual = commandOptionsSchema.safeParse({ + environmentName: environment, + websiteId: '4916bb2c-91e1-4716-91d5-b6171928fac9', + websiteName: 'Site 1' + }); + assert.strictEqual(actual.success, false); + }); + + it('passes validation with only websiteId', () => { + const actual = commandOptionsSchema.safeParse({ + environmentName: environment, + websiteId: '4916bb2c-91e1-4716-91d5-b6171928fac9' + }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation with only websiteName', () => { + const actual = commandOptionsSchema.safeParse({ + environmentName: environment, + websiteName: 'Site 1' + }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation if neither websiteId, websiteName are provided', () => { + const actual = commandOptionsSchema.safeParse({ + environmentName: environment + }); + assert.strictEqual(actual.success, false); + }); + + it('retrieves webroles for Power Pages site by id', async () => { + sinon.stub(powerPlatform, 'getWebsiteById').resolves(powerPageResponse); + sinon.stub(powerPlatform, 'getDynamicsInstanceApiUrl').resolves('https://org0cd4b2b9.crm4.dynamics.com'); + sinon.stub(odata, 'getAllItems').resolves(webroleResponse); + + await command.action(logger, { options: { verbose: true, environmentName: environment, websiteId: '4916bb2c-91e1-4716-91d5-b6171928fac9' } }); + assert(loggerLogSpy.calledWith(webroleResponse)); + }); + + it('retrieves webroles for Power Pages site by name', async () => { + sinon.stub(powerPlatform, 'getWebsiteIdByUniqueName').resolves('5eb107a6-5ac2-4e1c-a3b9-d5c21bbc10ce'); + sinon.stub(powerPlatform, 'getDynamicsInstanceApiUrl').resolves('https://org0cd4b2b9.crm4.dynamics.com'); + sinon.stub(odata, 'getAllItems').resolves(webroleResponse); + + await command.action(logger, { options: { verbose: true, environmentName: environment, websiteName: 'Site 1' } }); + assert(loggerLogSpy.calledWith(webroleResponse)); + }); + + it('outputs text friendly output when output is text', async () => { + sinon.stub(powerPlatform, 'getWebsiteById').resolves(powerPageResponse); + sinon.stub(powerPlatform, 'getDynamicsInstanceApiUrl').resolves('https://org0cd4b2b9.crm4.dynamics.com'); + sinon.stub(odata, 'getAllItems').resolves(webroleResponse); + + await command.action(logger, { options: { environmentName: environment, websiteId: '4916bb2c-91e1-4716-91d5-b6171928fac9', output: 'text' } }); + assert(loggerLogSpy.calledWith([ + { + webroleid: 'a242a363-6077-4cb7-b2d1-1714502d129a', + name: 'Anonymous Users', + statuscode: 1 + } + ])); + }); + + it('correctly handles API OData error', async () => { + sinon.stub(powerPlatform, 'getWebsiteById').resolves(powerPageResponse); + sinon.stub(powerPlatform, 'getDynamicsInstanceApiUrl').resolves('https://org0cd4b2b9.crm4.dynamics.com'); + sinon.stub(odata, 'getAllItems').rejects(new Error('An error has occurred')); + + await assert.rejects(command.action(logger, { options: { environmentName: environment, websiteId: '4916bb2c-91e1-4716-91d5-b6171928fac9' } }), + new CommandError('An error has occurred')); + }); +}); \ No newline at end of file diff --git a/src/m365/pp/commands/website/website-webrole-list.ts b/src/m365/pp/commands/website/website-webrole-list.ts new file mode 100644 index 00000000000..30c401bea03 --- /dev/null +++ b/src/m365/pp/commands/website/website-webrole-list.ts @@ -0,0 +1,88 @@ +import { Logger } from '../../../../cli/Logger.js'; +import { globalOptionsZod } from '../../../../Command.js'; +import { powerPlatform } from '../../../../utils/powerPlatform.js'; +import PowerPlatformCommand from '../../../base/PowerPlatformCommand.js'; +import commands from '../../commands.js'; +import { odata } from '../../../../utils/odata.js'; +import { z } from 'zod'; +import { cli } from '../../../../cli/cli.js'; +import { Webrole } from './Webrole.js'; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + websiteId: z.uuid().optional(), + websiteName: z.string().optional(), + environmentName: z.string().alias('e'), + asAdmin: z.boolean().optional() +}); +declare type Options = z.infer; + +interface CommandArgs { + options: Options; +} + +class PpWebSiteWebRoleListCommand extends PowerPlatformCommand { + public get name(): string { + return commands.WEBSITE_WEBROLE_LIST; + } + + public get description(): string { + return 'Lists all webroles for the specified Power Pages website.'; + } + + public get schema(): z.ZodType | undefined { + return options; + } + + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(options => [options.websiteId, options.websiteName].filter(x => x !== undefined).length === 1, { + error: `Specify either websiteId or websiteName, but not both.` + }); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + if (this.verbose) { + await logger.logToStderr(`Retrieving the webroles for '${args.options.websiteId || args.options.websiteName}'...`); + } + + try { + const dynamicsApiUrl = await powerPlatform.getDynamicsInstanceApiUrl(args.options.environmentName, args.options.asAdmin); + const websiteRecordId = await this.getWebsiteRecordId(args, dynamicsApiUrl); + const roles = await this.getWebsiteRoles(dynamicsApiUrl, websiteRecordId); + + if (!args.options.output || !cli.shouldTrimOutput(args.options.output)) { + await logger.log(roles); + } + else { + // converted to text friendly output + await logger.log(roles.map(i => { + return { + webroleid: i.mspp_webroleid, + name: i.mspp_name, + statuscode: i.statuscode + }; + })); + } + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } + + private async getWebsiteRecordId(args: CommandArgs, dynamicsApiUrl: string): Promise { + if (args.options.websiteId) { + const website = await powerPlatform.getWebsiteById(args.options.environmentName, args.options.websiteId); + return website.websiteRecordId; + } + return powerPlatform.getWebsiteIdByUniqueName(dynamicsApiUrl, args.options.websiteName!); + } + + private async getWebsiteRoles(dynamicsApiUrl: string, websiteId: string): Promise { + const requestUrl = `${dynamicsApiUrl}/api/data/v9.2/mspp_webroles?$filter=_mspp_websiteid_value eq ${websiteId}`; + const result = await odata.getAllItems(requestUrl); + return result; + } +} + +export default new PpWebSiteWebRoleListCommand(); \ No newline at end of file diff --git a/src/utils/powerPlatform.spec.ts b/src/utils/powerPlatform.spec.ts index e8badada1bb..8fb19b443e4 100644 --- a/src/utils/powerPlatform.spec.ts +++ b/src/utils/powerPlatform.spec.ts @@ -375,6 +375,33 @@ describe('utils/powerPlatform', () => { await assert.rejects(powerPlatform.getWebsiteByUrl(environment, 'https://site-0uaq9.powerappsportals.com'), Error(`The specified Power Page website with url 'https://site-0uaq9.powerappsportals.com' does not exist.`)); }); + + it('returns correct website record id by unique name', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if ((opts.url === `${envUrl}/api/data/v9.2/powerpagesites?$filter=name eq 'Site 1'`)) { + if ((opts.headers?.accept as string)?.indexOf('application/json') === 0) { + return { value: [{ powerpagesiteid: '5eb107a6-5ac2-4e1c-a3b9-d5c21bbc10ce' }] }; + } + } + + throw `Invalid request with opts ${JSON.stringify(opts)}`; + }); + + const actual = await powerPlatform.getWebsiteIdByUniqueName(envUrl, 'Site 1'); + assert.strictEqual(actual, '5eb107a6-5ac2-4e1c-a3b9-d5c21bbc10ce'); + }); + + it('handles no website found when using unique name', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if ((opts.url === `${envUrl}/api/data/v9.2/powerpagesites?$filter=name eq 'Site 1'`)) { + return { value: [] }; + } + + return 'Invalid request'; + }); + + await assert.rejects(powerPlatform.getWebsiteIdByUniqueName(envUrl, 'Site 1'), Error(`The specified website 'Site 1' does not exist.`)); + }); //#endregion //#region Cards diff --git a/src/utils/powerPlatform.ts b/src/utils/powerPlatform.ts index 37031fbdb76..e40912a85ef 100644 --- a/src/utils/powerPlatform.ts +++ b/src/utils/powerPlatform.ts @@ -108,6 +108,30 @@ export const powerPlatform = { return items[0]; }, + /** + * Get a website record ID by unique name + * Returns the powerpagesiteid of the website + * @param dynamicsApiUrl The dynamics api url of the environment + * @param websiteUniqueName The unique name of the Power Pages website + */ + async getWebsiteIdByUniqueName(dynamicsApiUrl: string, websiteUniqueName: string): Promise { + const requestOptions: CliRequestOptions = { + url: `${dynamicsApiUrl}/api/data/v9.2/powerpagesites?$filter=name eq '${websiteUniqueName}'`, + headers: { + accept: 'application/json;odata.metadata=none' + }, + responseType: 'json' + }; + + const result = await request.get<{ value: any[] }>(requestOptions); + + if (result.value.length === 0) { + throw Error(`The specified website '${websiteUniqueName}' does not exist.`); + } + + return result.value[0].powerpagesiteid; + }, + /** * Get a card by name * Returns a card object