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', () => {