diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 7cda2cdbd03..cc93a877e20 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -16,6 +16,7 @@ const dictionary = [ 'audit', 'bin', 'builder', + 'card', 'catalog', 'checklist', 'client', @@ -68,6 +69,7 @@ const dictionary = [ 'permission', 'place', 'policy', + 'profile', 'property', 'records', 'recycle', diff --git a/docs/docs/cmd/tenant/people/people-profilecardproperty-add.mdx b/docs/docs/cmd/tenant/people/people-profilecardproperty-add.mdx new file mode 100644 index 00000000000..4df13be755c --- /dev/null +++ b/docs/docs/cmd/tenant/people/people-profilecardproperty-add.mdx @@ -0,0 +1,171 @@ +import Global from '/docs/cmd/_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# tenant people profilecardproperty add + +Adds a custom attribute as a profile card property + +## Usage + +```sh +m365 tenant people profilecardproperty add [options] +``` + +## Options + +```md definition-list +`-n, --name ` +: The name of the property to add. Allowed values: `userPrincipalName`, `fax`, `streetAddress`, `postalCode`, `stateOrProvince`, `alias`, `extensionAttribute1`, `extensionAttribute2`, `extensionAttribute3`, `extensionAttribute4`, `extensionAttribute5`, `extensionAttribute6`, `extensionAttribute7`, `extensionAttribute8`, `extensionAttribute9`, `extensionAttribute10`, `extensionAttribute11`, `extensionAttribute12`, `extensionAttribute13`, `extensionAttribute14`, `extensionAttribute15` + +`-d, --displayName [displayName]` +: The display name of a property, only use together with extension attributes. +``` + + + +## Remarks + +:::info + +To use this command you must be either **Tenant Administrator** or **Global Administrator**. + +::: + +:::info + +You can specify localized values for the `displayName` as well. These can be entered by suffixing the displayName option with a language code: `--displayName-nl-NL "Kostencentrum" --displayName-de "Kostenstelle"`. + +::: + +## Examples + +Add the UPN as a profile property to the profile cards + +```sh +m365 tenant people profilecardproperty add --name userPrincipalName +``` + +Add a custom extension attribute Cost Center as a profile property to the profile cards + +```sh +m365 tenant people profilecardproperty add --name extensionAttribute1 --displayName 'Cost Center' +``` + +Add a custom extension attribute Cost Center as a profile property to the profile cards with a dutch localization + +```sh +m365 tenant people profilecardproperty add --name extensionAttribute1 --displayName 'Cost Center' --displayName-nl-NL 'Kostencentrum' +``` + +## Response + +### Standard response + + + + + ```json + { + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#admin/people/profileCardProperties/$entity", + "directoryPropertyName": "userPrincipalName", + "annotations": [] + } + ``` + + + + + ```text + @odata.context : https://graph.microsoft.com/v1.0/$metadata#admin/people/profileCardProperties/$entity + annotations : [] + directoryPropertyName: userPrincipalName + ``` + + + + + ```csv + @odata.context,directoryPropertyName + https://graph.microsoft.com/v1.0/$metadata#admin/people/profileCardProperties/$entity,userPrincipalName + ``` + + + + + ```md + # tenant people profilecardproperty add --name 'userPrincipalName' + + Date: 11/2/2023 + + Property | Value + ---------|------- + @odata.context | https://graph.microsoft.com/v1.0/$metadata#admin/people/profileCardProperties/$entity + directoryPropertyName | userPrincipalName + ``` + + + + +### Response with an extensionAttribute + +When we make use of one of the extensionAttributes, the response will differ. + + + + + ```json + { + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#admin/people/profileCardProperties/$entity", + "directoryPropertyName": "customAttribute1", + "annotations": [ + { + "displayName": "Cost center", + "localizations": [ + { + "languageTag": "nl-NL", + "displayName": "Kostenplaats" + } + ] + } + ] + } + ``` + + + + + ```text + @odata.context : https://graph.microsoft.com/v1.0/$metadata#admin/people/profileCardProperties/$entity + annotations : [{"displayName":"Cost center","localizations":[{"languageTag":"nl-NL","displayName":"Kostenplaats"}]}] + directoryPropertyName: customAttribute1 + ``` + + + + + ```csv + @odata.context,directoryPropertyName + https://graph.microsoft.com/v1.0/$metadata#admin/people/profileCardProperties/$entity,customAttribute1 + ``` + + + + + ```md + # tenant people profilecardproperty add --name 'extensionAttribute1' --displayName 'Cost center' --displayName-nl-NL 'Kostenplaats' + + Date: 11/2/2023 + + Property | Value + ---------|------- + @odata.context | https://graph.microsoft.com/v1.0/$metadata#admin/people/profileCardProperties/$entity + directoryPropertyName | customAttribute1 + ``` + + + + +## More information + +- https://learn.microsoft.com/en-us/graph/add-properties-profilecard \ No newline at end of file diff --git a/docs/src/config/sidebars.js b/docs/src/config/sidebars.js index 0ec3edb862e..f6f226ea5d9 100644 --- a/docs/src/config/sidebars.js +++ b/docs/src/config/sidebars.js @@ -673,6 +673,15 @@ const sidebars = { } ] }, + { + "people": [ + { + type: 'doc', + label: 'people profilecardproperty add', + id: 'cmd/tenant/people/people-profilecardproperty-add' + } + ] + }, { report: [ { diff --git a/src/m365/tenant/commands.ts b/src/m365/tenant/commands.ts index 85a06726e6c..0aaa80c96b1 100644 --- a/src/m365/tenant/commands.ts +++ b/src/m365/tenant/commands.ts @@ -3,6 +3,7 @@ const prefix: string = 'tenant'; export default { ID_GET: `${prefix} id get`, INFO_GET: `${prefix} info get`, + PEOPLE_PROFILECARDPROPERTY_ADD: `${prefix} people profilecardproperty add`, REPORT_ACTIVEUSERCOUNTS: `${prefix} report activeusercounts`, REPORT_ACTIVEUSERDETAIL: `${prefix} report activeuserdetail`, REPORT_OFFICE365ACTIVATIONCOUNTS: `${prefix} report office365activationcounts`, diff --git a/src/m365/tenant/commands/people/people-profilecardproperty-add.spec.ts b/src/m365/tenant/commands/people/people-profilecardproperty-add.spec.ts new file mode 100644 index 00000000000..5ec21b2e814 --- /dev/null +++ b/src/m365/tenant/commands/people/people-profilecardproperty-add.spec.ts @@ -0,0 +1,240 @@ +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 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 './people-profilecardproperty-add.js'; + +describe(commands.PEOPLE_PROFILECARDPROPERTY_ADD, () => { + + //#region Mocked Responses + const propertyResponse = { + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#admin/people/profileCardProperties/$entity", + "directoryPropertyName": "userPrincipalName", + "annotations": [] + }; + + const extensionAttributePropertyResponse = { + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#admin/people/profileCardProperties/$entity", + "directoryPropertyName": "userPrincipalName", + "annotations": [] + }; + //#endregion + + let log: string[]; + let logger: Logger; + let loggerLogSpy: sinon.SinonSpy; + let commandInfo: CommandInfo; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').returns(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + auth.service.connected = true; + 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.post + ]); + }); + + after(() => { + sinon.restore(); + auth.service.connected = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name.startsWith(commands.PEOPLE_PROFILECARDPROPERTY_ADD), true); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('fails validation if the name is not a valid value.', async () => { + const actual = await command.validate({ options: { name: 'invalid' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if the name is extensionAttribute1 and the displayName option is not used.', async () => { + const actual = await command.validate({ options: { name: 'extensionAttribute1' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if a localization property has an invalid name.', async () => { + const actual = await command.validate({ options: { name: 'extensionAttribute1', displayName: 'Cost center', 'invalid-nl-NL': 'Kostenplaats' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if a the localization option is used for a non-extension attribute.', async () => { + const actual = await command.validate({ options: { name: 'userPrincipalName', 'displayName-nl-NL': 'Kostenplaats' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if the displayName option is used for a non-extension attribute.', async () => { + const actual = await command.validate({ options: { name: 'userPrincipalName', displayName: 'Cost center' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('passes validation if the name is set to userPrincipalName.', async () => { + const actual = await command.validate({ options: { name: 'userPrincipalName' } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation if the name is extensionAttribute1 and the displayName option is used.', async () => { + const actual = await command.validate({ options: { name: 'extensionAttribute1', displayName: 'Cost center' } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation if a correct localization option is used.', async () => { + const actual = await command.validate({ options: { name: 'extensionAttribute1', displayName: 'Cost center', 'displayName-nl-NL': 'Kostenplaats' } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('correctly adds profile card property for userPrincipalName', async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/admin/people/profileCardProperties`) { + return propertyResponse; + } + + throw `Invalid request ${opts.url}`; + }); + + await command.action(logger, { options: { name: 'userPrincipalName' } }); + assert(loggerLogSpy.calledOnceWithExactly(propertyResponse)); + }); + + it('correctly adds profile card property for userPrincipalName (debug)', async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/admin/people/profileCardProperties`) { + return propertyResponse; + } + + throw `Invalid request ${opts.url}`; + }); + + await command.action(logger, { options: { name: 'userPrincipalName', debug: true } }); + assert(loggerLogSpy.calledOnceWithExactly(propertyResponse)); + }); + + it('correctly adds profile card property for fax', async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/admin/people/profileCardProperties`) { + return propertyResponse; + } + + throw `Invalid request ${opts.url}`; + }); + + await command.action(logger, { options: { name: 'fax' } }); + assert(loggerLogSpy.calledOnceWithExactly(propertyResponse)); + }); + + it('correctly adds profile card property for stateOrProvince', async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/admin/people/profileCardProperties`) { + return propertyResponse; + } + + throw `Invalid request ${opts.url}`; + }); + + await command.action(logger, { options: { name: 'stateOrProvince' } }); + assert(loggerLogSpy.calledOnceWithExactly(propertyResponse)); + }); + + it('correctly adds profile card property for alias', async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/admin/people/profileCardProperties`) { + return propertyResponse; + } + + throw `Invalid request ${opts.url}`; + }); + + await command.action(logger, { options: { name: 'alias' } }); + assert(loggerLogSpy.calledOnceWithExactly(propertyResponse)); + }); + + it('correctly adds profile card property for an extensionAttribute', async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/admin/people/profileCardProperties`) { + return extensionAttributePropertyResponse; + } + + throw `Invalid request ${opts.url}`; + }); + + await command.action(logger, { options: { name: 'extensionAttribute1', displayName: 'Cost center' } }); + assert(loggerLogSpy.calledOnceWithExactly(propertyResponse)); + }); + + it('correctly adds profile card property for an extensionAttribute with a localization', async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/admin/people/profileCardProperties`) { + return extensionAttributePropertyResponse; + } + + throw `Invalid request ${opts.url}`; + }); + + await command.action(logger, { options: { name: 'extensionAttribute1', displayName: 'Cost center', 'displayName-nl-NL': 'Kostenplaats' } }); + assert(loggerLogSpy.calledOnceWithExactly(propertyResponse)); + }); + + it('fails when the addition conflicts with an existing property', async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/admin/people/profileCardProperties`) { + throw { + "error": { + "code": "409", + "message": "Conflicts with existing entry", + "innerError": { + "peopleAdminErrorCode": "PeopleAdminItemConflict", + "peopleAdminRequestId": "36d1ea9e-83f8-49c9-7ebc-6f6c24ca03cc", + "peopleAdminClientRequestId": "174cf6d3-6cde-46a8-b4f3-5d4d07354ac2", + "date": "2023-11-02T15:22:36", + "request-id": "174cf6d3-6cde-46a8-b4f3-5d4d07354ac2", + "client-request-id": "174cf6d3-6cde-46a8-b4f3-5d4d07354ac2" + } + } + }; + } + + throw `Invalid request ${opts.url}`; + }); + + await assert.rejects(command.action(logger, { + options: { + name: 'userPrincipalName' + } + }), new CommandError(`Conflicts with existing entry`)); + }); +}); \ No newline at end of file diff --git a/src/m365/tenant/commands/people/people-profilecardproperty-add.ts b/src/m365/tenant/commands/people/people-profilecardproperty-add.ts new file mode 100644 index 00000000000..40f1a56e474 --- /dev/null +++ b/src/m365/tenant/commands/people/people-profilecardproperty-add.ts @@ -0,0 +1,177 @@ +import { Logger } from '../../../../cli/Logger.js'; +import GlobalOptions from '../../../../GlobalOptions.js'; +import request from '../../../../request.js'; +import GraphCommand from '../../../base/GraphCommand.js'; +import commands from '../../commands.js'; + +interface CommandArgs { + options: Options; +} + +interface Options extends GlobalOptions { + name: string; + displayName?: string; + localizations?: string; +} + +class TenantPeopleProfileCardPropertyAddCommand extends GraphCommand { + private static readonly availableNames: string[] = ['userPrincipalName', 'fax', 'streetAddress', 'postalCode', 'stateOrProvince', 'alias', 'extensionAttribute1', 'extensionAttribute2', 'extensionAttribute3', 'extensionAttribute4', 'extensionAttribute5', 'extensionAttribute6', 'extensionAttribute7', 'extensionAttribute8', 'extensionAttribute9', 'extensionAttribute10', 'extensionAttribute11', 'extensionAttribute12', 'extensionAttribute13', 'extensionAttribute14', 'extensionAttribute15']; + + public get name(): string { + return commands.PEOPLE_PROFILECARDPROPERTY_ADD; + } + + public get description(): string { + return 'Adds a custom attribute as a profile card property'; + } + + constructor() { + super(); + + this.#initTelemetry(); + this.#initOptions(); + this.#initOptionSets(); + this.#initValidators(); + } + + #initTelemetry(): void { + this.telemetry.push((args: CommandArgs) => { + Object.assign(this.telemetryProperties, { + name: args.options.name, + displayName: typeof args.options.domainName !== 'undefined', + localizations: typeof args.options.localizations !== 'undefined' + }); + }); + } + + #initOptions(): void { + this.options.unshift( + { + option: '-n, --name ', + autocomplete: TenantPeopleProfileCardPropertyAddCommand.availableNames + }, + { + option: '-d, --displayName [displayName]' + } + ); + } + + #initOptionSets(): void { + this.optionSets.push({ options: ['name', 'displayName'], runsWhen: (args) => args.options.name.toLowerCase().startsWith("extensionattribute") && args.options.displayName === undefined }); + } + + #initValidators(): void { + this.validators.push( + async (args: CommandArgs) => { + if (TenantPeopleProfileCardPropertyAddCommand.availableNames.every(n => n.toLowerCase() !== args.options.name.toLowerCase())) { + return `${args.options.name} is not a valid value for name. Allowed values are ${TenantPeopleProfileCardPropertyAddCommand.availableNames.join(', ')}`; + } + + if (args.options.name.toLowerCase().startsWith("extensionattribute") && args.options.displayName === undefined) { + return `The option 'displayName' is required when adding extensionAttributes as profile card properties`; + } + + if (!args.options.name.toLowerCase().startsWith("extensionattribute") && args.options.displayName !== undefined) { + return `The option 'displayName' can only be used when adding extensionAttributes as profile card properties`; + } + + const excludeOptions: string[] = ['name', 'displayName', 'debug', 'verbose', 'output']; + const unknownOptions = Object.keys(args.options).filter(key => excludeOptions.indexOf(key) === -1); + + if (!args.options.name.toLowerCase().startsWith('extensionattribute') && unknownOptions.length > 0) { + return `Unknown options like ${unknownOptions.join(', ')} are only supported with extensionAttributes`; + } + + if (args.options.name.toLowerCase().startsWith('extensionattribute')) { + const wronglyFormattedOptions = unknownOptions.filter(key => !key.toLowerCase().startsWith('displayname-')); + if (wronglyFormattedOptions.length > 0) { + return `Wrong option format detected for the following option(s): ${wronglyFormattedOptions.join(', ')}'. When adding localizations for extensionAttributes, use the format displayName-.`; + } + } + + return true; + } + ); + } + + public allowUnknownOptions(): boolean | undefined { + return true; + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + if (this.verbose) { + await logger.logToStderr(`Adding '${args.options.name}' as a profile card property...`); + } + + const requestOptions: any = { + url: `${this.resource}/v1.0/admin/people/profileCardProperties`, + headers: { + 'content-type': 'application/json' + }, + responseType: 'json', + data: { + directoryPropertyName: this.getPropertyName(args.options.name), + annotations: this.getAnnotations(args.options) + } + }; + + try { + const response: any = await request.post(requestOptions); + + await logger.log(response); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } + + // Converts the Entra ID property names to their Graph equivalents. + // For more information see https://learn.microsoft.com/en-us/graph/add-properties-profilecard. + private getPropertyName(name: string): string { + switch (name.toLowerCase()) { + case "fax": + return "faxNumber"; + case "stateorprovince": + return "state"; + case "alias": + return "mailNickname"; + default: + return name.toLowerCase().replace('extensionattribute', 'customAttribute'); + } + } + + private getAnnotations(options: Options): { displayName: string, localizations?: { languageTag: string, displayName: string }[] }[] { + if (!options.displayName) { + return []; + } + + return [ + { + displayName: options.displayName!, + localizations: this.getLocalizations(options) + } + ]; + } + + private getLocalizations(options: Options): { languageTag: string, displayName: string }[] { + const excludeOptions: string[] = ['name', 'displayName', 'debug', 'verbose', 'output']; + const unknownOptions = Object.keys(options).filter(key => excludeOptions.indexOf(key) === -1); + + if (unknownOptions.length === 0) { + return []; + } + + const localizations: { languageTag: string, displayName: string }[] = []; + + unknownOptions.forEach(key => { + localizations.push({ + languageTag: key.replace('displayName-', ''), + displayName: options[key] + }); + }); + + return localizations; + } +} + +export default new TenantPeopleProfileCardPropertyAddCommand(); \ No newline at end of file