From d1640256b9f958e533655bc28fbbca8f2e2d5912 Mon Sep 17 00:00:00 2001 From: Mathijs Verbeeck Date: Fri, 3 Apr 2026 10:15:28 +0200 Subject: [PATCH] Adds command `outlook calendar set`. Closes #7117 --- .../cmd/outlook/calendar/calendar-set.mdx | 178 ++++++++ docs/src/config/sidebars.ts | 5 + src/m365/outlook/commands.ts | 1 + .../commands/calendar/calendar-set.spec.ts | 426 ++++++++++++++++++ .../outlook/commands/calendar/calendar-set.ts | 168 +++++++ 5 files changed, 778 insertions(+) create mode 100644 docs/docs/cmd/outlook/calendar/calendar-set.mdx create mode 100644 src/m365/outlook/commands/calendar/calendar-set.spec.ts create mode 100644 src/m365/outlook/commands/calendar/calendar-set.ts diff --git a/docs/docs/cmd/outlook/calendar/calendar-set.mdx b/docs/docs/cmd/outlook/calendar/calendar-set.mdx new file mode 100644 index 00000000000..27da7114b78 --- /dev/null +++ b/docs/docs/cmd/outlook/calendar/calendar-set.mdx @@ -0,0 +1,178 @@ +import Global from '../../_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# outlook calendar set + +Updates a calendar for a user. + +## Usage + +```sh +m365 outlook calendar set [options] +``` + +## Options + +```md definition-list +`-i, --id ` +: ID of the calendar. + +`-n, --name [name]` +: New name of the calendar. + +`--userId [userId]` +: ID of the user. Specify either `userId` or `userName`, but not both. + +`--userName [userName]` +: UPN of the user. Specify either `userId` or `userName`, but not both. + +`--calendarGroupId [calendarGroupId]` +: ID of the calendar group where the calendar belongs. Specify either `calendarGroupId` or `calendarGroupName`, but not both. + +`--calendarGroupName [calendarGroupName]` +: Name of the calendar group where the calendar belongs. Specify either `calendarGroupId` or `calendarGroupName`, but not both. + +`--color [color]` +: The color of the calendar in the UI. Allowed values: `auto`, `lightBlue`, `lightGreen`, `lightOrange`, `lightGray`, `lightYellow`, `lightTeal`, `lightPink`, `lightBrown`, `lightRed`, `maxColor`. + +`--isDefault [isDefault]` +: Mark whether the calendar is the user's default calendar. Allowed values: `true`, `false`. +``` + + + +## Permissions + + + + + | Resource | Permissions | + |-----------------|-------------------------------------------------| + | Microsoft Graph | Calendars.ReadWrite, Calendars.ReadWrite.Shared | + + + + + | Resource | Permissions | + |-----------------|---------------------| + | Microsoft Graph | Calendars.ReadWrite | + + + + +:::note + +When using delegated permissions, specifying `userId` or `userName` for a different user requires the `Calendars.ReadWrite.Shared` scope. When the specified user matches the signed-in user, no shared scope is needed. + +::: + +## Examples + +Update the name of a calendar for the signed-in user. + +```sh +m365 outlook calendar set --id 'AAMkAGI2TQpZAAA=' --name 'Team planning' +``` + +Change the color of a specific calendar for a user by UPN. + +```sh +m365 outlook calendar set --id 'AAMkAGI2TQpZAAA=' --userName 'john.doe@contoso.com' --color 'lightGreen' +``` + +Mark a calendar as the default calendar for a user. + +```sh +m365 outlook calendar set --id 'AAMkAGI2TQpZAAA=' --userId 'b743445a-112c-4fda-9afd-05943f9c7b36' --isDefault true +``` + +Update a calendar within a specific calendar group by name. + +```sh +m365 outlook calendar set --id 'AAMkAGI2TQpZAAA=' --name 'Team planning' --calendarGroupName 'My Calendars' +``` + +## Response + + + + + ```json + { + "id": "AAMkAGI2TQpZAAA=", + "name": "Team planning", + "color": "auto", + "hexColor": "", + "isDefaultCalendar": false, + "changeKey": "DxYSthXJXEWwAQSYQnXvIgAAIxGttg==", + "canShare": true, + "canViewPrivateItems": true, + "canEdit": true, + "allowedOnlineMeetingProviders": [ + "teamsForBusiness" + ], + "defaultOnlineMeetingProvider": "teamsForBusiness", + "isTallyingResponses": true, + "isRemovable": false, + "owner": { + "name": "John Doe", + "address": "john.doe@contoso.com" + } + } + ``` + + + + + ```text + allowedOnlineMeetingProviders: ["teamsForBusiness"] + canEdit : true + canShare : true + canViewPrivateItems : true + changeKey : DxYSthXJXEWwAQSYQnXvIgAAIxGttg== + color : auto + defaultOnlineMeetingProvider : teamsForBusiness + hexColor : + id : AAMkAGI2TQpZAAA= + isDefaultCalendar : false + isRemovable : false + isTallyingResponses : true + name : Team planning + owner : {"name":"John Doe","address":"john.doe@contoso.com"} + ``` + + + + + ```csv + id,name,color,hexColor,isDefaultCalendar,changeKey,canShare,canViewPrivateItems,canEdit,defaultOnlineMeetingProvider,isTallyingResponses,isRemovable + AAMkAGI2TQpZAAA=,Team planning,auto,,false,DxYSthXJXEWwAQSYQnXvIgAAIxGttg==,true,true,true,teamsForBusiness,true,false + ``` + + + + + ```md + # outlook calendar set + + Date: 4/2/2026 + + Property | Value + ---------|------- + id | AAMkAGI2TQpZAAA= + name | Team planning + color | auto + hexColor | + isDefaultCalendar | false + changeKey | DxYSthXJXEWwAQSYQnXvIgAAIxGttg== + canShare | true + canViewPrivateItems | true + canEdit | true + defaultOnlineMeetingProvider | teamsForBusiness + isTallyingResponses | true + isRemovable | false + ``` + + + diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index e0f837d676a..0e22ca9320d 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -1318,6 +1318,11 @@ const sidebars: SidebarsConfig = { type: 'doc', label: 'calendar remove', id: 'cmd/outlook/calendar/calendar-remove' + }, + { + type: 'doc', + label: 'calendar set', + id: 'cmd/outlook/calendar/calendar-set' } ] }, diff --git a/src/m365/outlook/commands.ts b/src/m365/outlook/commands.ts index fb433f5842c..ccc5bf106a9 100644 --- a/src/m365/outlook/commands.ts +++ b/src/m365/outlook/commands.ts @@ -4,6 +4,7 @@ export default { CALENDAR_ADD: `${prefix} calendar add`, CALENDAR_GET: `${prefix} calendar get`, CALENDAR_REMOVE: `${prefix} calendar remove`, + CALENDAR_SET: `${prefix} calendar set`, CALENDARGROUP_LIST: `${prefix} calendargroup list`, EVENT_CANCEL: `${prefix} event cancel`, EVENT_LIST: `${prefix} event list`, diff --git a/src/m365/outlook/commands/calendar/calendar-set.spec.ts b/src/m365/outlook/commands/calendar/calendar-set.spec.ts new file mode 100644 index 00000000000..d64f13ae9d1 --- /dev/null +++ b/src/m365/outlook/commands/calendar/calendar-set.spec.ts @@ -0,0 +1,426 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import auth from '../../../../Auth.js'; +import { CommandError } from '../../../../Command.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; +import { Logger } from '../../../../cli/Logger.js'; +import { cli } from '../../../../cli/cli.js'; +import request from '../../../../request.js'; +import { telemetry } from '../../../../telemetry.js'; +import { accessToken } from '../../../../utils/accessToken.js'; +import { calendarGroup } from '../../../../utils/calendarGroup.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, { options } from './calendar-set.js'; + +describe(commands.CALENDAR_SET, () => { + const calendarId = 'AAMkAGI2TQpZAAA='; + const userId = 'b743445a-112c-4fda-9afd-05943f9c7b36'; + const userName = 'john.doe@contoso.com'; + const currentUserId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + const currentUserName = 'current.user@contoso.com'; + const calendarGroupId = 'AAMkAGI2TGuMAAA='; + const calendarGroupName = 'My Calendars'; + + const calendarResponse = { + "id": calendarId, + "name": "Team planning", + "color": "auto", + "hexColor": "", + "isDefaultCalendar": false, + "changeKey": "DxYSthXJXEWwAQSYQnXvIgAAIxGttg==", + "canShare": true, + "canViewPrivateItems": true, + "canEdit": true, + "allowedOnlineMeetingProviders": [ + "teamsForBusiness" + ], + "defaultOnlineMeetingProvider": "teamsForBusiness", + "isTallyingResponses": true, + "isRemovable": false, + "owner": { + "name": "John Doe", + "address": "john.doe@contoso.com" + } + }; + + let log: string[]; + let logger: Logger; + let commandInfo: CommandInfo; + let loggerLogSpy: sinon.SinonSpy; + 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(''); + auth.connection.active = true; + if (!auth.connection.accessTokens[auth.defaultResource]) { + auth.connection.accessTokens[auth.defaultResource] = { + expiresOn: 'abc', + accessToken: 'abc' + }; + } + 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'); + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(false); + sinon.stub(accessToken, 'getScopesFromAccessToken').returns([]); + sinon.stub(accessToken, 'getUserIdFromAccessToken').returns(currentUserId); + sinon.stub(accessToken, 'getUserNameFromAccessToken').returns(currentUserName); + }); + + afterEach(() => { + sinonUtil.restore([ + accessToken.isAppOnlyAccessToken, + accessToken.getScopesFromAccessToken, + accessToken.getUserIdFromAccessToken, + accessToken.getUserNameFromAccessToken, + request.patch, + calendarGroup.getUserCalendarGroupByName + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.CALENDAR_SET); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('passes validation with id and name', () => { + const actual = commandOptionsSchema.safeParse({ id: calendarId, name: 'Team planning' }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation with id and color', () => { + const actual = commandOptionsSchema.safeParse({ id: calendarId, color: 'lightGreen' }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation with id and isDefault', () => { + const actual = commandOptionsSchema.safeParse({ id: calendarId, isDefault: true }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation with id, name and userId', () => { + const actual = commandOptionsSchema.safeParse({ id: calendarId, name: 'Team planning', userId }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation with id, name and userName', () => { + const actual = commandOptionsSchema.safeParse({ id: calendarId, name: 'Team planning', userName }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation with id, name and calendarGroupId', () => { + const actual = commandOptionsSchema.safeParse({ id: calendarId, name: 'Team planning', calendarGroupId }); + assert.strictEqual(actual.success, true); + }); + + it('passes validation with id, name and calendarGroupName', () => { + const actual = commandOptionsSchema.safeParse({ id: calendarId, name: 'Team planning', calendarGroupName }); + assert.strictEqual(actual.success, true); + }); + + it('fails validation if id is not specified', () => { + const actual = commandOptionsSchema.safeParse({ name: 'Team planning' }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if no updatable property is provided', () => { + const actual = commandOptionsSchema.safeParse({ id: calendarId }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if both userId and userName are specified', () => { + const actual = commandOptionsSchema.safeParse({ id: calendarId, name: 'Team planning', userId, userName }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if both calendarGroupId and calendarGroupName are specified', () => { + const actual = commandOptionsSchema.safeParse({ id: calendarId, name: 'Team planning', calendarGroupId, calendarGroupName }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if userId is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ id: calendarId, name: 'Team planning', userId: 'foo' }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if userName is not a valid UPN', () => { + const actual = commandOptionsSchema.safeParse({ id: calendarId, name: 'Team planning', userName: 'foo' }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if color has an invalid value', () => { + const actual = commandOptionsSchema.safeParse({ id: calendarId, color: 'invalidColor' }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation with unknown options', () => { + const actual = commandOptionsSchema.safeParse({ id: calendarId, name: 'Team planning', unknownOption: 'value' }); + assert.notStrictEqual(actual.success, true); + }); + + it('updates the name of a calendar for the signed-in user', async () => { + sinon.stub(request, 'patch').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/me/calendars/${calendarId}` && JSON.stringify(opts.data) === JSON.stringify({ name: 'Team planning' })) { + return calendarResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ id: calendarId, name: 'Team planning' }) }); + assert(loggerLogSpy.calledOnceWith(calendarResponse)); + }); + + it('updates the color of a calendar for the signed-in user', async () => { + sinon.stub(request, 'patch').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/me/calendars/${calendarId}` && JSON.stringify(opts.data) === JSON.stringify({ color: 'lightGreen' })) { + return calendarResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ id: calendarId, color: 'lightGreen' }) }); + assert(loggerLogSpy.calledOnceWith(calendarResponse)); + }); + + it('marks a calendar as default for the signed-in user', async () => { + sinon.stub(request, 'patch').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/me/calendars/${calendarId}` && JSON.stringify(opts.data) === JSON.stringify({ isDefaultCalendar: true })) { + return calendarResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ id: calendarId, isDefault: true }) }); + assert(loggerLogSpy.calledOnceWith(calendarResponse)); + }); + + it('updates a calendar for the signed-in user (verbose)', async () => { + sinon.stub(request, 'patch').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/me/calendars/${calendarId}`) { + return calendarResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ id: calendarId, name: 'Team planning', verbose: true }) }); + assert(loggerLogSpy.calledOnceWith(calendarResponse)); + }); + + it('updates a calendar for a user specified by id using delegated permissions with shared scope', async () => { + sinonUtil.restore(accessToken.getScopesFromAccessToken); + sinon.stub(accessToken, 'getScopesFromAccessToken').returns(['Calendars.ReadWrite.Shared']); + + sinon.stub(request, 'patch').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/calendars/${calendarId}`) { + return calendarResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ id: calendarId, name: 'Team planning', userId }) }); + assert(loggerLogSpy.calledOnceWith(calendarResponse)); + }); + + it('updates a calendar for a user specified by UPN using delegated permissions with shared scope', async () => { + sinonUtil.restore(accessToken.getScopesFromAccessToken); + sinon.stub(accessToken, 'getScopesFromAccessToken').returns(['Calendars.ReadWrite.Shared']); + + sinon.stub(request, 'patch').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userName}')/calendars/${calendarId}`) { + return calendarResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ id: calendarId, name: 'Team planning', userName }) }); + assert(loggerLogSpy.calledOnceWith(calendarResponse)); + }); + + it('does not check shared scope when userId matches the signed-in user', async () => { + sinonUtil.restore(accessToken.getUserIdFromAccessToken); + sinon.stub(accessToken, 'getUserIdFromAccessToken').returns(userId); + + sinon.stub(request, 'patch').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/calendars/${calendarId}`) { + return calendarResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ id: calendarId, name: 'Team planning', userId }) }); + assert(loggerLogSpy.calledOnceWith(calendarResponse)); + }); + + it('does not check shared scope when userName matches the signed-in user', async () => { + sinonUtil.restore(accessToken.getUserNameFromAccessToken); + sinon.stub(accessToken, 'getUserNameFromAccessToken').returns(userName); + + sinon.stub(request, 'patch').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userName}')/calendars/${calendarId}`) { + return calendarResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ id: calendarId, name: 'Team planning', userName }) }); + assert(loggerLogSpy.calledOnceWith(calendarResponse)); + }); + + it('updates a calendar for a user specified by id using app-only permissions', async () => { + sinonUtil.restore(accessToken.isAppOnlyAccessToken); + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(true); + + sinon.stub(request, 'patch').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/calendars/${calendarId}`) { + return calendarResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ id: calendarId, name: 'Team planning', userId }) }); + assert(loggerLogSpy.calledOnceWith(calendarResponse)); + }); + + it('updates a calendar for a user specified by UPN using app-only permissions', async () => { + sinonUtil.restore(accessToken.isAppOnlyAccessToken); + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(true); + + sinon.stub(request, 'patch').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userName}')/calendars/${calendarId}`) { + return calendarResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ id: calendarId, name: 'Team planning', userName }) }); + assert(loggerLogSpy.calledOnceWith(calendarResponse)); + }); + + it('updates a calendar within a specific calendar group by id', async () => { + sinon.stub(request, 'patch').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/me/calendarGroups/${calendarGroupId}/calendars/${calendarId}`) { + return calendarResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ id: calendarId, name: 'Team planning', calendarGroupId }) }); + assert(loggerLogSpy.calledOnceWith(calendarResponse)); + }); + + it('updates a calendar within a specific calendar group by name', async () => { + sinon.stub(calendarGroup, 'getUserCalendarGroupByName').resolves({ id: calendarGroupId }); + + sinon.stub(request, 'patch').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/me/calendarGroups/${calendarGroupId}/calendars/${calendarId}`) { + return calendarResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ id: calendarId, name: 'Team planning', calendarGroupName }) }); + assert(loggerLogSpy.calledOnceWith(calendarResponse)); + }); + + it('updates a calendar within a specific calendar group by name for a specific user', async () => { + sinonUtil.restore(accessToken.getScopesFromAccessToken); + sinon.stub(accessToken, 'getScopesFromAccessToken').returns(['Calendars.ReadWrite.Shared']); + + sinon.stub(calendarGroup, 'getUserCalendarGroupByName').resolves({ id: calendarGroupId }); + + sinon.stub(request, 'patch').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/calendarGroups/${calendarGroupId}/calendars/${calendarId}`) { + return calendarResponse; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: commandOptionsSchema.parse({ id: calendarId, name: 'Team planning', calendarGroupName, userId }) }); + assert(loggerLogSpy.calledOnceWith(calendarResponse)); + }); + + it('throws error when running with app-only permissions without userId or userName', async () => { + sinonUtil.restore(accessToken.isAppOnlyAccessToken); + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(true); + + await assert.rejects( + command.action(logger, { options: commandOptionsSchema.parse({ id: calendarId, name: 'Team planning' }) }), + new CommandError('When running with application permissions either userId or userName is required.') + ); + }); + + it('throws error when using delegated permissions with other userId without shared scope', async () => { + await assert.rejects( + command.action(logger, { options: commandOptionsSchema.parse({ id: calendarId, name: 'Team planning', userId }) }), + new CommandError('To update calendars of other users, the Entra ID application used for authentication must have the Calendars.ReadWrite.Shared delegated permission assigned.') + ); + }); + + it('throws error when using delegated permissions with other userName without shared scope', async () => { + await assert.rejects( + command.action(logger, { options: commandOptionsSchema.parse({ id: calendarId, name: 'Team planning', userName }) }), + new CommandError('To update calendars of other users, the Entra ID application used for authentication must have the Calendars.ReadWrite.Shared delegated permission assigned.') + ); + }); + + it('throws error when calendar group name does not exist', async () => { + sinon.stub(calendarGroup, 'getUserCalendarGroupByName').rejects(new Error(`The specified calendar group '${calendarGroupName}' does not exist.`)); + + await assert.rejects( + command.action(logger, { options: commandOptionsSchema.parse({ id: calendarId, name: 'Team planning', calendarGroupName }) }), + new CommandError(`The specified calendar group '${calendarGroupName}' does not exist.`) + ); + }); + + it('correctly handles API OData error', async () => { + const errorMessage = 'Something went wrong'; + sinon.stub(request, 'patch').rejects({ error: { error: { message: errorMessage } } }); + + await assert.rejects( + command.action(logger, { options: commandOptionsSchema.parse({ id: calendarId, name: 'Team planning' }) }), + new CommandError(errorMessage) + ); + }); +}); diff --git a/src/m365/outlook/commands/calendar/calendar-set.ts b/src/m365/outlook/commands/calendar/calendar-set.ts new file mode 100644 index 00000000000..ff1d15f31a6 --- /dev/null +++ b/src/m365/outlook/commands/calendar/calendar-set.ts @@ -0,0 +1,168 @@ +import { Calendar } from '@microsoft/microsoft-graph-types'; +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; +import GraphCommand from '../../../base/GraphCommand.js'; +import { Logger } from '../../../../cli/Logger.js'; +import commands from '../../commands.js'; +import { validation } from '../../../../utils/validation.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { accessToken } from '../../../../utils/accessToken.js'; +import auth from '../../../../Auth.js'; +import { calendarGroup } from '../../../../utils/calendarGroup.js'; + +const calendarColors = ['auto', 'lightBlue', 'lightGreen', 'lightOrange', 'lightGray', 'lightYellow', 'lightTeal', 'lightPink', 'lightBrown', 'lightRed', 'maxColor'] as const; + +export const options = z.strictObject({ + ...globalOptionsZod.shape, + id: z.string().alias('i'), + name: z.string().optional().alias('n'), + userId: z.string().refine(id => validation.isValidGuid(id), { + error: e => `'${e.input}' is not a valid GUID.` + }).optional(), + userName: z.string().refine(name => validation.isValidUserPrincipalName(name), { + error: e => `'${e.input}' is not a valid UPN.` + }).optional(), + calendarGroupId: z.string().optional(), + calendarGroupName: z.string().optional(), + color: z.enum(calendarColors).optional(), + isDefault: z.boolean().optional() +}); + +declare type Options = z.infer; + +interface CommandArgs { + options: Options; +} + +class OutlookCalendarSetCommand extends GraphCommand { + public get name(): string { + return commands.CALENDAR_SET; + } + + public get description(): string { + return 'Updates a calendar for a user'; + } + + public get schema(): z.ZodType | undefined { + return options; + } + + public getRefinedSchema(schema: typeof options): z.ZodObject | undefined { + return schema + .refine(options => !(options.userId && options.userName), { + error: 'Specify either userId or userName, but not both.' + }) + .refine(options => !(options.calendarGroupId && options.calendarGroupName), { + error: 'Specify either calendarGroupId or calendarGroupName, but not both.' + }) + .refine(options => [options.name, options.color, options.isDefault].filter(o => o !== undefined).length > 0, { + error: 'Specify at least one of the following options: name, color, or isDefault.' + }); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + try { + const token = auth.connection.accessTokens[auth.defaultResource].accessToken; + const isAppOnlyAccessToken = accessToken.isAppOnlyAccessToken(token); + + let requestUrl: string; + + if (isAppOnlyAccessToken) { + if (!args.options.userId && !args.options.userName) { + throw 'When running with application permissions either userId or userName is required.'; + } + + const userIdentifier = args.options.userId ?? args.options.userName; + requestUrl = this.buildRequestUrl(userIdentifier!); + } + else { + if (args.options.userId || args.options.userName) { + const currentUserId = accessToken.getUserIdFromAccessToken(token); + const currentUserName = accessToken.getUserNameFromAccessToken(token); + const isOtherUser = (args.options.userId && args.options.userId !== currentUserId) || + (args.options.userName && args.options.userName.toLowerCase() !== currentUserName?.toLowerCase()); + + if (isOtherUser) { + const scopes = accessToken.getScopesFromAccessToken(token); + const hasSharedScope = scopes.some(s => s === 'Calendars.ReadWrite.Shared'); + + if (!hasSharedScope) { + throw 'To update calendars of other users, the Entra ID application used for authentication must have the Calendars.ReadWrite.Shared delegated permission assigned.'; + } + } + + const userIdentifier = args.options.userId ?? args.options.userName; + requestUrl = this.buildRequestUrl(userIdentifier!); + } + else { + requestUrl = this.buildRequestUrl(undefined); + } + } + + if (this.verbose) { + await logger.logToStderr(`Updating calendar '${args.options.id}'...`); + } + + let calendarGroupId = args.options.calendarGroupId; + + if (args.options.calendarGroupName) { + const userIdForGroup = args.options.userId ?? args.options.userName ?? accessToken.getUserIdFromAccessToken(token); + const calendarGroupResult = await calendarGroup.getUserCalendarGroupByName(userIdForGroup, args.options.calendarGroupName, 'id'); + calendarGroupId = calendarGroupResult.id!; + } + + const url = this.buildCalendarUrl(requestUrl, args.options.id, calendarGroupId); + + const requestOptions: CliRequestOptions = { + url, + headers: { + accept: 'application/json;odata.metadata=none' + }, + responseType: 'json', + data: this.createRequestBody(args) + }; + + const result = await request.patch(requestOptions); + await logger.log(result); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } + + private buildRequestUrl(userIdentifier: string | undefined): string { + if (userIdentifier) { + return `${this.resource}/v1.0/users('${userIdentifier}')`; + } + + return `${this.resource}/v1.0/me`; + } + + private buildCalendarUrl(baseUrl: string, calendarId: string, calendarGroupId: string | undefined): string { + if (calendarGroupId) { + return `${baseUrl}/calendarGroups/${calendarGroupId}/calendars/${calendarId}`; + } + + return `${baseUrl}/calendars/${calendarId}`; + } + + private createRequestBody(args: CommandArgs): any { + const data: any = {}; + + if (args.options.name !== undefined) { + data.name = args.options.name; + } + + if (args.options.color !== undefined) { + data.color = args.options.color; + } + + if (args.options.isDefault !== undefined) { + data.isDefaultCalendar = args.options.isDefault; + } + + return data; + } +} + +export default new OutlookCalendarSetCommand();