From 1eafe63a151dd2dbd21c7d7e617f85871910baa8 Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Thu, 6 Aug 2026 23:02:29 +0200 Subject: [PATCH] feat: allow attendees to add guests to an event Organizers get a setting to let attendees of an event invite further people. Attendees can then add guests even though the event is otherwise read-only for them, and remove them again until the event is saved. The server reports guests off the base instance only, so the option needs the dav attendee_guests capability and is hidden for recurring events. Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Daniel Kesselberg --- .../Editor/Invitees/InviteesList.vue | 33 ++++++- .../Editor/Invitees/InviteesListItem.vue | 19 ++++ src/mixins/EditorMixin.js | 36 ++++++++ src/mixins/PropertyMixin.js | 2 + src/models/event.js | 6 ++ src/store/calendarObjectInstance.js | 12 +++ src/views/EditFull.vue | 64 +++++++++++++ src/views/EditSimple.vue | 1 + .../unit/mixins/EditorMixin.test.js | 89 ++++++++++++++++++- tests/javascript/unit/models/event.test.js | 19 +++- 10 files changed, 276 insertions(+), 5 deletions(-) diff --git a/src/components/Editor/Invitees/InviteesList.vue b/src/components/Editor/Invitees/InviteesList.vue index abdaea0a80..ebed26d19d 100644 --- a/src/components/Editor/Invitees/InviteesList.vue +++ b/src/components/Editor/Invitees/InviteesList.vue @@ -50,7 +50,7 @@ @@ -67,6 +67,7 @@ :key="invitee.email" :attendee="invitee" :isReadOnly="isReadOnly" + :canRemove="isForwardedByMe(invitee)" :organizerDisplayName="organizerDisplayName" :members="invitee.members" :isViewedByOrganizer="isViewedByOrganizer" @@ -122,6 +123,15 @@ export default { required: true, }, + /** + * Whether the current user may add guests even though the list is + * otherwise read-only for them, because they are an attendee. + */ + canAddGuests: { + type: Boolean, + default: false, + }, + isSharedWithMe: { type: Boolean, required: true, @@ -165,6 +175,9 @@ export default { creatingTalkRoom: false, showFreeBusyModel: false, recentAttendees: [], + // Guests added in this session, so they can be taken off again before + // saving. Once saved, only the organizer can remove an attendee. + forwardedAttendees: [], } }, @@ -424,6 +437,23 @@ export default { member, }) this.recentAttendees.push(email) + if (this.canAddGuests) { + this.forwardedAttendees.push(email) + } + }, + + /** + * Whether the given attendee was added by the current user and can + * therefore still be removed again. + * + * @param {object} attendee The attendee to check + * @return {boolean} + */ + isForwardedByMe(attendee) { + // Not once the editor switched to viewing, because the guest reached + // the organizer by then and only they can remove an attendee + return this.canAddGuests + && this.forwardedAttendees.includes(removeMailtoPrefix(attendee.uri)) }, removeAttendee(attendee) { @@ -441,6 +471,7 @@ export default { attendee, }) this.recentAttendees = this.recentAttendees.filter((a) => a.uri !== attendee.email) + this.forwardedAttendees = this.forwardedAttendees.filter((email) => email !== removeMailtoPrefix(attendee.uri)) if (this.showFreeBusyModel && this.calendarObjectInstance.attendees.length === 0) { showWarning(this.$t('calendar', 'Please add at least one attendee to use the "Find a time" feature.')) diff --git a/src/components/Editor/Invitees/InviteesListItem.vue b/src/components/Editor/Invitees/InviteesListItem.vue index bd8e4570b6..adb3b975b0 100644 --- a/src/components/Editor/Invitees/InviteesListItem.vue +++ b/src/components/Editor/Invitees/InviteesListItem.vue @@ -46,6 +46,16 @@ :size="20" /> + + + [], diff --git a/src/mixins/EditorMixin.js b/src/mixins/EditorMixin.js index 1b1a4c9615..d63e46463b 100644 --- a/src/mixins/EditorMixin.js +++ b/src/mixins/EditorMixin.js @@ -4,6 +4,7 @@ */ import { showError, showSuccess } from '@nextcloud/dialogs' +import { loadState } from '@nextcloud/initial-state' import { translate as t } from '@nextcloud/l10n' import { generateUrl } from '@nextcloud/router' import { mapState, mapStores } from 'pinia' @@ -241,6 +242,41 @@ export default { const organizer = this.calendarObjectInstance.organizer return removeMailtoPrefix(organizer.uri) === principal }, + /** + * Returns whether the organizer let attendees invite guests to this event. + * + * X-NC-ALLOW-ATTENDEE-GUESTS has to be turned on explicitly, so it is also + * absent on invitations we could not report guests back to. + * + * @return {boolean} + */ + isAddingGuestsAllowed() { + // Our own server has to generate the reply carrying the guests + if (loadState('core', 'capabilities', {})?.dav?.attendee_guests !== true) { + return false + } + + // isViewedByAttendee is false on read-only calendars and for non-attendees, + // but true for an organizer who also attends their own event + return this.isViewedByAttendee + && this.isViewedByOrganizer !== true + && this.calendarObjectInstance?.allowAttendeeGuests === 'TRUE' + }, + /** + * Returns whether guests added here actually reach the organizer. + * + * The organizer only picks up guests of the base instance, so recurring + * events are left to them. + * + * @return {boolean} + */ + canAddGuests() { + // A recurrence exception is not the base instance, but it can not create + // one either, so both checks are needed + return this.isAddingGuestsAllowed + && this.calendarObjectInstance?.isMasterItem === true + && !this.canCreateRecurrenceException + }, /** * Returns the attendee property corresponding to the current user * diff --git a/src/mixins/PropertyMixin.js b/src/mixins/PropertyMixin.js index 2f927af790..8401e19132 100644 --- a/src/mixins/PropertyMixin.js +++ b/src/mixins/PropertyMixin.js @@ -10,6 +10,7 @@ * See inline for more documentation */ +import AccountMultiplePlusOutline from 'vue-material-design-icons/AccountMultiplePlusOutline.vue' import AccountPlusOutline from 'vue-material-design-icons/AccountPlusOutline.vue' import Bell from 'vue-material-design-icons/BellOutline.vue' import Briefcase from 'vue-material-design-icons/BriefcaseOutline.vue' @@ -22,6 +23,7 @@ import TextBoxOutline from 'vue-material-design-icons/TextBoxOutline.vue' export default { components: { + AccountMultiplePlusOutline, AccountPlusOutline, Briefcase, Check, diff --git a/src/models/event.js b/src/models/event.js index 521cd4453e..072da3e3b9 100644 --- a/src/models/event.js +++ b/src/models/event.js @@ -74,6 +74,8 @@ function getDefaultEventObject(props = {}) { attachments: [], // Invitation forwarding invitationForwarding: 'TRUE', + // Attendees inviting guests, off unless the organizer turned it on + allowAttendeeGuests: 'FALSE', ...props, } } @@ -179,6 +181,10 @@ function mapEventComponentToEventObject(eventComponent) { eventObject.invitationForwarding = eventComponent.getFirstPropertyFirstValue('X-NC-INVITATION-FORWARDING') } + if (eventComponent.hasProperty('X-NC-ALLOW-ATTENDEE-GUESTS')) { + eventObject.allowAttendeeGuests = eventComponent.getFirstPropertyFirstValue('X-NC-ALLOW-ATTENDEE-GUESTS') + } + return eventObject } diff --git a/src/store/calendarObjectInstance.js b/src/store/calendarObjectInstance.js index f73c7325df..011c61e049 100644 --- a/src/store/calendarObjectInstance.js +++ b/src/store/calendarObjectInstance.js @@ -397,6 +397,18 @@ export default defineStore('calendarObjectInstance', { calendarObjectInstance.invitationForwarding = invitationForwarding }, + /** + * Change whether attendees of an event may invite guests + * + * @param {object} data The destructuring object + * @param {object} data.calendarObjectInstance The calendarObjectInstance object + * @param {string} data.allowAttendeeGuests Allow attendee guests value + */ + changeAllowAttendeeGuests({ calendarObjectInstance, allowAttendeeGuests }) { + calendarObjectInstance.eventComponent.updatePropertyWithValue('X-NC-ALLOW-ATTENDEE-GUESTS', allowAttendeeGuests) + calendarObjectInstance.allowAttendeeGuests = allowAttendeeGuests + }, + /** * Change the customized color of an event * diff --git a/src/views/EditFull.vue b/src/views/EditFull.vue index 1f7ae44974..965dfef6d0 100644 --- a/src/views/EditFull.vue +++ b/src/views/EditFull.vue @@ -255,6 +255,12 @@ :propModel="propInvitationForwarding" :value="invitationForwarding" @update:value="updateInvitationForwarding" /> + @@ -318,6 +324,7 @@ :calendar="selectedCalendar" :calendarObjectInstance="calendarObjectInstance" :isReadOnly="isReadOnly || isViewedByOrganizer === false" + :canAddGuests="canAddGuests" :isSharedWithMe="isSharedWithMe" :showHeader="true" @updateDates="updateDates" /> @@ -345,6 +352,7 @@ import IconCancel from '@mdi/svg/svg/cancel.svg?raw' import IconDelete from '@mdi/svg/svg/delete.svg?raw' import { Parameter } from '@nextcloud/calendar-js' +import { loadState } from '@nextcloud/initial-state' import { translate as t } from '@nextcloud/l10n' import moment from '@nextcloud/moment' import { generateUrl } from '@nextcloud/router' @@ -476,6 +484,23 @@ export default { info: t('calendar', 'Choose "Only invited attendees can respond" to prevent attendees from forwarding the invitation to others.'), defaultValue: 'TRUE', }, + + propAllowAttendeeGuests: { + // TRANSLATORS Setting of an event, deciding whether the people invited to it may invite further people + readableName: t('calendar', 'Allow attendees to invite guests'), + icon: 'AccountMultiplePlusOutline', + options: [ + // TRANSLATORS A guest is someone invited by an attendee rather than by the organizer + { value: 'TRUE', label: t('calendar', 'Attendees can invite guests') }, + // TRANSLATORS "you" is the organizer of the event, who is reading this setting + { value: 'FALSE', label: t('calendar', 'Only you can invite attendees') }, + ], + + multiple: false, + // TRANSLATORS "you" is the organizer of the event, who is reading this setting + info: t('calendar', 'Attendees can add other people, who are then invited to the event just like the attendees you added yourself.'), + defaultValue: 'FALSE', + }, } }, @@ -508,6 +533,10 @@ export default { return this.calendarObjectInstance?.invitationForwarding ?? null }, + allowAttendeeGuests() { + return this.calendarObjectInstance?.allowAttendeeGuests ?? null + }, + subTitle() { if (!this.calendarObjectInstance) { return '' @@ -542,6 +571,29 @@ export default { showInvitationForwarding() { return isAfterVersion(34) }, + + showAllowAttendeeGuests() { + return loadState('core', 'capabilities', {})?.dav?.attendee_guests === true + && this.isViewedByOrganizer !== false + }, + + /** + * Guests are only reported to the organizer for the whole event, so they + * are not offered for a recurring one. + * + * @return {boolean} + */ + isRecurring() { + return this.calendarObjectInstance?.recurrenceRule?.frequency !== 'NONE' + }, + }, + + watch: { + isRecurring(isRecurring) { + if (isRecurring && this.allowAttendeeGuests === 'TRUE') { + this.updateAllowAttendeeGuests('FALSE') + } + }, }, mounted() { @@ -633,6 +685,18 @@ export default { }) }, + /** + * Updates whether attendees may invite guests + * + * @param {string} allowAttendeeGuests Allow attendee guests value + */ + updateAllowAttendeeGuests(allowAttendeeGuests) { + this.calendarObjectInstanceStore.changeAllowAttendeeGuests({ + calendarObjectInstance: this.calendarObjectInstance, + allowAttendeeGuests, + }) + }, + /** * Adds a category to the event * diff --git a/src/views/EditSimple.vue b/src/views/EditSimple.vue index ad3059d8c4..0eca0bcd02 100644 --- a/src/views/EditSimple.vue +++ b/src/views/EditSimple.vue @@ -208,6 +208,7 @@ :hideErrors="true" :showHeader="true" :isReadOnly="isReadOnlyOrViewing || isViewedByOrganizer === false" + :canAddGuests="canAddGuests && !isReadOnlyOrViewing" :isSharedWithMe="isSharedWithMe" :calendar="selectedCalendar" :calendarObjectInstance="calendarObjectInstance" /> diff --git a/tests/javascript/unit/mixins/EditorMixin.test.js b/tests/javascript/unit/mixins/EditorMixin.test.js index 43645cda2b..ff1985295f 100644 --- a/tests/javascript/unit/mixins/EditorMixin.test.js +++ b/tests/javascript/unit/mixins/EditorMixin.test.js @@ -3,8 +3,93 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { loadState } from '@nextcloud/initial-state' +import EditorMixin from '../../../../src/mixins/EditorMixin.js' + +vi.mock('@nextcloud/initial-state', () => ({ + loadState: vi.fn(), +})) + +/** + * Evaluate the canAddGuests computed against a stubbed component. + * + * @param {object} context Overrides for the computed properties it depends on + * @return {boolean} + */ +function canAddGuests(context = {}) { + const self = { + isViewedByAttendee: true, + isViewedByOrganizer: false, + canCreateRecurrenceException: false, + ...context, + calendarObjectInstance: { + allowAttendeeGuests: 'TRUE', + isMasterItem: true, + ...context.calendarObjectInstance, + }, + } + self.isAddingGuestsAllowed = EditorMixin.computed.isAddingGuestsAllowed.call(self) + + return EditorMixin.computed.canAddGuests.call(self) +} + describe('mixins/EditorMixin test suite', () => { - it('should be true', () => { - expect(true).toEqual(true) + beforeEach(() => { + loadState.mockReturnValue({ dav: { attendee_guests: true } }) + }) + + it('should allow an attendee to add a guest', () => { + expect(canAddGuests()).toEqual(true) + }) + + it('should not allow adding guests when the organizer turned it off', () => { + expect(canAddGuests({ + calendarObjectInstance: { allowAttendeeGuests: 'FALSE' }, + })).toEqual(false) + }) + + it('should not allow adding guests when the organizer never turned it on', () => { + // Which is also the case for every invitation from a remote organizer + expect(canAddGuests({ + calendarObjectInstance: { allowAttendeeGuests: undefined }, + })).toEqual(false) + }) + + it('should not allow adding guests for a user who is not an attendee', () => { + expect(canAddGuests({ + isViewedByAttendee: false, + })).toEqual(false) + }) + + it('should not allow adding guests for an organizer attending their own event', () => { + expect(canAddGuests({ + isViewedByOrganizer: true, + })).toEqual(false) + }) + + it('should not allow adding guests to a recurring event', () => { + expect(canAddGuests({ + canCreateRecurrenceException: true, + })).toEqual(false) + }) + + it('should not allow adding guests to a recurrence exception', () => { + // An exception cannot create another one, so canCreateRecurrenceException + // alone does not catch it + expect(canAddGuests({ + calendarObjectInstance: { isMasterItem: false }, + })).toEqual(false) + }) + + it('should not allow adding guests when the server does not support it', () => { + loadState.mockReturnValue({ dav: {} }) + + expect(canAddGuests()).toEqual(false) + }) + + it('should not allow adding guests when no capabilities are available', () => { + loadState.mockReturnValue({}) + + expect(canAddGuests()).toEqual(false) }) }) diff --git a/tests/javascript/unit/models/event.test.js b/tests/javascript/unit/models/event.test.js index 899eeaf3f2..5bd089822a 100644 --- a/tests/javascript/unit/models/event.test.js +++ b/tests/javascript/unit/models/event.test.js @@ -64,6 +64,7 @@ describe('Test suite: Event model (models/event.js)', () => { categories: [], attachments: [], invitationForwarding: 'TRUE', + allowAttendeeGuests: 'FALSE', }) expect(getDefaultRecurrenceRuleObject).toHaveBeenCalledTimes(1) @@ -107,6 +108,7 @@ describe('Test suite: Event model (models/event.js)', () => { categories: [], attachments: [], invitationForwarding: 'TRUE', + allowAttendeeGuests: 'FALSE', otherProp: 'foo', }) @@ -158,6 +160,7 @@ describe('Test suite: Event model (models/event.js)', () => { categories: [], attachments: [], invitationForwarding: 'TRUE', + allowAttendeeGuests: 'FALSE', }) expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2) @@ -227,6 +230,7 @@ describe('Test suite: Event model (models/event.js)', () => { categories: [], attachments: [], invitationForwarding: 'TRUE', + allowAttendeeGuests: 'FALSE', }) expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2) @@ -295,6 +299,7 @@ describe('Test suite: Event model (models/event.js)', () => { categories: [], attachments: [], invitationForwarding: 'TRUE', + allowAttendeeGuests: 'FALSE', }) const alarms = eventComponent.getAlarmList() @@ -350,6 +355,7 @@ describe('Test suite: Event model (models/event.js)', () => { categories: ['BUSINESS', 'HUMAN RESOURCES'], attachments: [], invitationForwarding: 'TRUE', + allowAttendeeGuests: 'FALSE', }) expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2) @@ -407,6 +413,7 @@ describe('Test suite: Event model (models/event.js)', () => { categories: [], attachments: [], invitationForwarding: 'TRUE', + allowAttendeeGuests: 'FALSE', }) expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2) @@ -467,6 +474,7 @@ describe('Test suite: Event model (models/event.js)', () => { categories: [], attachments: [], invitationForwarding: 'TRUE', + allowAttendeeGuests: 'FALSE', }) expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2) @@ -524,6 +532,7 @@ describe('Test suite: Event model (models/event.js)', () => { categories: [], attachments: [], invitationForwarding: 'TRUE', + allowAttendeeGuests: 'FALSE', }) expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2) @@ -578,6 +587,7 @@ describe('Test suite: Event model (models/event.js)', () => { categories: [], attachments: [], invitationForwarding: 'TRUE', + allowAttendeeGuests: 'FALSE', }) expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2) @@ -632,6 +642,7 @@ describe('Test suite: Event model (models/event.js)', () => { categories: [], attachments: [], invitationForwarding: 'TRUE', + allowAttendeeGuests: 'FALSE', }) expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2) @@ -690,6 +701,7 @@ describe('Test suite: Event model (models/event.js)', () => { categories: [], attachments: [], invitationForwarding: 'TRUE', + allowAttendeeGuests: 'FALSE', }) expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2) @@ -747,6 +759,7 @@ describe('Test suite: Event model (models/event.js)', () => { categories: [], attachments: [], invitationForwarding: 'TRUE', + allowAttendeeGuests: 'FALSE', }) expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2) @@ -802,6 +815,7 @@ describe('Test suite: Event model (models/event.js)', () => { categories: [], attachments: [], invitationForwarding: 'TRUE', + allowAttendeeGuests: 'FALSE', }) expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2) @@ -860,6 +874,7 @@ describe('Test suite: Event model (models/event.js)', () => { categories: [], attachments: [], invitationForwarding: 'TRUE', + allowAttendeeGuests: 'FALSE', }) expect(getDateFromDateTimeValue).toHaveBeenCalledTimes(2) @@ -875,13 +890,13 @@ describe('Test suite: Event model (models/event.js)', () => { expect(getDefaultRecurrenceRuleObject).toHaveBeenCalledTimes(1) }) - it('should default invitation forwarding to TRUE', () => { + it('should default adding guests to FALSE', () => { getDefaultRecurrenceRuleObject .mockReturnValueOnce({ defaultRecurrenceObject: true, }) - expect(getDefaultEventObject().invitationForwarding).toEqual('TRUE') + expect(getDefaultEventObject().allowAttendeeGuests).toEqual('FALSE') }) it('should map an event component custom invitation forwarding property', () => {