diff --git a/src/components/AppNavigation/CalendarList.vue b/src/components/AppNavigation/CalendarList.vue index d88311a78e..9f1634778c 100644 --- a/src/components/AppNavigation/CalendarList.vue +++ b/src/components/AppNavigation/CalendarList.vue @@ -77,6 +77,14 @@ :calendar="calendar" /> + + + + + @@ -98,6 +106,7 @@ import CalendarListItemLoadingPlaceholder from './CalendarList/CalendarListItemL import CalendarListNew from './CalendarList/CalendarListNew.vue' import PublicCalendarListItem from './CalendarList/PublicCalendarListItem.vue' import useCalendarsStore from '../../store/calendars.js' +import useDelegationStore from '../../store/delegation.js' const limit = pLimit(1) @@ -129,12 +138,13 @@ export default { return { calendars: [], /** - * Calendars sorted by personal, shared, and deck + * Calendars sorted by personal, shared, deck, and delegated */ sortedCalendars: { personal: [], shared: [], deck: [], + delegated: [], }, disableDragging: false, @@ -143,7 +153,7 @@ export default { }, computed: { - ...mapStores(useCalendarsStore), + ...mapStores(useCalendarsStore, useDelegationStore), ...mapState(useCalendarsStore, { serverCalendars: 'sortedCalendarsSubscriptions', }), @@ -182,9 +192,15 @@ export default { personal: [], shared: [], deck: [], + delegated: [], } this.calendars.forEach((calendar) => { + if (calendar.isDelegated) { + this.sortedCalendars.delegated.push(calendar) + return + } + if (calendar.isSharedWithMe) { this.sortedCalendars.shared.push(calendar) return diff --git a/src/components/AppNavigation/CalendarList/CalendarListItem.vue b/src/components/AppNavigation/CalendarList/CalendarListItem.vue index b8cc504872..7f889436c3 100644 --- a/src/components/AppNavigation/CalendarList/CalendarListItem.vue +++ b/src/components/AppNavigation/CalendarList/CalendarListItem.vue @@ -26,16 +26,36 @@ + - + - + + + + + + + + + {{ ownerDisplayname }} + + + + @@ -143,7 +163,7 @@ export default { canBeShared() { // The backend falsely reports incoming editable shares as being shareable // Ref https://github.com/nextcloud/calendar/issues/5755 - if (this.calendar.isSharedWithMe) { + if (this.calendar.isSharedWithMe || this.calendar.isDelegated) { return false } @@ -165,7 +185,16 @@ export default { * @return {boolean} */ isSharedWithMe() { - return this.calendar.isSharedWithMe + return this.calendar.isSharedWithMe && !this.calendar.isDelegated + }, + + /** + * Is the calendar delegated to me by another user? + * + * @return {boolean} + */ + isDelegated() { + return !!this.calendar.isDelegated }, /** @@ -272,6 +301,7 @@ export default { * Open the calendar modal for this calendar item. */ showEditModal() { + console.log('getting here') this.calendarsStore.editCalendarModal = { calendarId: this.calendar.id } }, @@ -308,6 +338,16 @@ export default { height: 44px; } + // Size and position the delegated avatar in the counter slot to match icon buttons + .delegated-counter-avatar { + margin-inline-start: auto; + } + + // Vertically align the owner name with the avatar in the "Delegated to you by" row + :deep(.action-text__text) { + align-self: center ; + } + // Hide avatars if list item is hovered :deep(.app-navigation-entry:hover .app-navigation-entry__counter-wrapper) { display: none; diff --git a/src/components/AppNavigation/Settings.vue b/src/components/AppNavigation/Settings.vue index 7aef49bc80..32754403de 100644 --- a/src/components/AppNavigation/Settings.vue +++ b/src/components/AppNavigation/Settings.vue @@ -126,6 +126,11 @@ + + + @@ -158,6 +163,7 @@ import CogIcon from 'vue-material-design-icons/CogOutline.vue' import CalendarPicker from '../Shared/CalendarPicker.vue' import EventLegend from './Settings/EventLegend.vue' import SettingsAttachmentsFolder from './Settings/SettingsAttachmentsFolder.vue' +import SettingsDelegationSection from './Settings/SettingsDelegationSection.vue' import SettingsImportSection from './Settings/SettingsImportSection.vue' import SettingsTimezoneSelect from './Settings/SettingsTimezoneSelect.vue' import ShortcutOverview from './Settings/ShortcutOverview.vue' @@ -185,6 +191,7 @@ export default { SettingsImportSection, SettingsTimezoneSelect, SettingsAttachmentsFolder, + SettingsDelegationSection, ShortcutOverview, CogIcon, NcFormBox, diff --git a/src/components/AppNavigation/Settings/SettingsDelegationSection.vue b/src/components/AppNavigation/Settings/SettingsDelegationSection.vue new file mode 100644 index 0000000000..fcad3267b5 --- /dev/null +++ b/src/components/AppNavigation/Settings/SettingsDelegationSection.vue @@ -0,0 +1,459 @@ + + + + + + {{ t('calendar', 'Could not load delegates.') }} + + + + + + + + {{ delegateSubname(delegate) }} + + + + + + + {{ t('calendar', 'Revoke access') }} + + + + + + + {{ t('calendar', 'No delegates yet.') }} + + + + + + + {{ t('calendar', 'Add delegate') }} + + + + + + + + + + + + {{ t('calendar', 'No users found') }} + + + + + + {{ user.displayname }} + {{ user.emailAddress }} + + + + + + {{ t('calendar', 'Choose whether they can view and edit events, or only view them.') }} + + + + + {{ t('calendar', 'Cancel') }} + + + {{ t('calendar', 'Add as viewer') }} + + + {{ t('calendar', 'Add as editor') }} + + + + + + + + + diff --git a/src/components/Editor/CalendarPickerHeader.vue b/src/components/Editor/CalendarPickerHeader.vue index 494287d10d..f342592116 100644 --- a/src/components/Editor/CalendarPickerHeader.vue +++ b/src/components/Editor/CalendarPickerHeader.vue @@ -34,7 +34,18 @@ @click="$emit('update:value', calendar)"> + @@ -43,17 +54,29 @@ + @@ -142,6 +215,10 @@ export default { :deep(button) { align-items: center !important; } + + &__avatar { + margin: auto; + } } // Fix long calendar name ellipsis @@ -162,6 +239,12 @@ export default { } } + &__avatar { + flex-shrink: 0; + align-self: center; + margin-inline-start: var(--default-grid-baseline); + } + &__icon { display: flex; align-items: center; diff --git a/src/components/Shared/CalendarPicker.vue b/src/components/Shared/CalendarPicker.vue index 91faa0ceea..cebc317057 100644 --- a/src/components/Shared/CalendarPicker.vue +++ b/src/components/Shared/CalendarPicker.vue @@ -21,6 +21,7 @@ :color="getCalendarById(id).color" :displayName="getCalendarById(id).displayName" :isSharedWithMe="getCalendarById(id).isSharedWithMe" + :isDelegated="getCalendarById(id).isDelegated" :owner="getCalendarById(id).owner" /> @@ -28,6 +29,7 @@ :color="getCalendarById(id).color" :displayName="getCalendarById(id).displayName" :isSharedWithMe="getCalendarById(id).isSharedWithMe" + :isDelegated="getCalendarById(id).isDelegated" :owner="getCalendarById(id).owner" /> diff --git a/src/components/Shared/CalendarPickerOption.vue b/src/components/Shared/CalendarPickerOption.vue index 1c528f6fe8..72871f3b1e 100644 --- a/src/components/Shared/CalendarPickerOption.vue +++ b/src/components/Shared/CalendarPickerOption.vue @@ -14,7 +14,7 @@ } Raw cdav-library Calendar objects + */ +async function findCalendarsAtUrl(calendarHomeUrl) { + const calendarHome = getClient().getCalendarHomeForUrl(calendarHomeUrl) + return calendarHome.findAllCalendars() +} + export { advancedPrincipalPropertySearch, createCalendar, @@ -258,12 +269,14 @@ export { findAll, findAllCalendars, findAllDeletedCalendars, + findCalendarsAtUrl, findPrincipalByUrl, findPrincipalsInCollection, findPublicCalendarsByTokens, findSchedulingInbox, findSchedulingOutbox, getBirthdayCalendar, + getClient, getCurrentUserPrincipal, initializeClientForPublicView, initializeClientForUserView, diff --git a/src/store/delegation.js b/src/store/delegation.js new file mode 100644 index 0000000000..ff0cf6c66c --- /dev/null +++ b/src/store/delegation.js @@ -0,0 +1,227 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { showError } from '@nextcloud/dialogs' +import { translate as t } from '@nextcloud/l10n' +import { defineStore } from 'pinia' +import { mapDavCollectionToCalendar } from '../models/calendar.js' +import { mapDavToPrincipal } from '../models/principal.js' +import { findCalendarsAtUrl, findPrincipalByUrl, getClient } from '../services/caldavService.js' +import logger from '../utils/logger.js' +import useCalendarsStore from './calendars.js' +import usePrincipalsStore from './principals.js' + +export default defineStore('delegation', { + state: () => { + return { + /** + * List of principal objects that the current user has delegated to. + * Each entry has the principal fields plus a `permission` field ('write'|'read'). + * + * @type {object[]} + */ + delegates: [], + + /** + * Users who have granted the current user proxy access, with permission level. + * Each entry: { principalUrl: string, permission: 'write'|'read' } + * + * @type {Array<{principalUrl: string, permission: 'write'|'read'}>} + */ + delegators: [], + } + }, + + getters: { + /** + * Whether any delegated calendars exist (i.e. the current user is a delegate for someone). + * + * @param {object} state The store state + * @return {boolean} + */ + hasDelegatedCalendars: (state) => state.delegators.length > 0, + }, + + actions: { + /** + * Fetch the current user's delegates (members of their calendar-proxy-write + * and calendar-proxy-read groups) and resolve their principal details. + * + * @return {Promise} + */ + async fetchDelegates() { + const principalsStore = usePrincipalsStore() + const currentUser = principalsStore.getCurrentUserPrincipal + if (!currentUser?.url) { + return + } + + const baseUrl = currentUser.url.replace(/\/?$/, '') + const proxyWriteGroupUrl = baseUrl + '/calendar-proxy-write' + const proxyReadGroupUrl = baseUrl + '/calendar-proxy-read' + + let writeUrls = [] + let readUrls = [] + try { + [writeUrls, readUrls] = await Promise.all([ + getClient().getDelegateUrls(proxyWriteGroupUrl), + getClient().getDelegateUrls(proxyReadGroupUrl), + ]) + } catch (error) { + logger.error('Could not fetch delegate URLs', { error }) + return + } + + const delegates = [] + + for (const url of writeUrls) { + try { + const dav = await findPrincipalByUrl(url) + if (dav) { + delegates.push({ ...mapDavToPrincipal(dav), permission: 'write' }) + } + } catch (error) { + logger.error('Could not resolve write delegate principal', { url, error }) + } + } + + for (const url of readUrls) { + try { + const dav = await findPrincipalByUrl(url) + if (dav) { + delegates.push({ ...mapDavToPrincipal(dav), permission: 'read' }) + } + } catch (error) { + logger.error('Could not resolve read delegate principal', { url, error }) + } + } + + this.delegates = delegates + logger.debug('Fetched delegates', { delegates: this.delegates }) + }, + + /** + * Fetch the principal URLs and permission level of users who have granted + * the current user proxy access (both read and write). + * + * @return {Promise} + */ + async fetchDelegators() { + const principalsStore = usePrincipalsStore() + const currentUser = principalsStore.getCurrentUserPrincipal + if (!currentUser?.url) { + return + } + + try { + this.delegators = await getClient().getDelegatorsWithPermission(currentUser.url) + logger.debug('Fetched delegators', { delegators: this.delegators }) + } catch (error) { + logger.error('Could not fetch delegator information', { error }) + } + }, + + /** + * Add a user as a delegate with the given permission level. + * + * @param {object} data The destructuring object + * @param {string} data.principalUrl Absolute URL of the principal to add + * @param {'write'|'read'} data.permission The permission level to grant + * @return {Promise} + */ + async addDelegate({ principalUrl, permission = 'write' }) { + const principalsStore = usePrincipalsStore() + const currentUser = principalsStore.getCurrentUserPrincipal + if (!currentUser?.url) { + return + } + + const baseUrl = currentUser.url.replace(/\/?$/, '') + const proxyGroupUrl = permission === 'read' + ? baseUrl + '/calendar-proxy-read' + : baseUrl + '/calendar-proxy-write' + await getClient().addDelegate(proxyGroupUrl, principalUrl) + await this.fetchDelegates() + }, + + /** + * Remove a delegate from whichever proxy group(s) they are in. + * + * @param {object} data The destructuring object + * @param {string} data.principalUrl Absolute URL of the principal to remove + * @return {Promise} + */ + async removeDelegate({ principalUrl }) { + const principalsStore = usePrincipalsStore() + const currentUser = principalsStore.getCurrentUserPrincipal + if (!currentUser?.url) { + return + } + + const baseUrl = currentUser.url.replace(/\/?$/, '') + // Find the delegate's current permission so we remove from the right group. + const existing = this.delegates.find((d) => d.url === principalUrl) + const permission = existing?.permission ?? 'write' + + const proxyGroupUrl = permission === 'read' + ? baseUrl + '/calendar-proxy-read' + : baseUrl + '/calendar-proxy-write' + await getClient().removeDelegate(proxyGroupUrl, principalUrl) + this.delegates = this.delegates.filter((d) => d.url !== principalUrl) + }, + + /** + * Fetch all calendars from delegators' calendar homes and add them to the + * calendars store so they participate in normal event fetching and rendering. + * The calendars are tagged with isDelegated=true so CalendarList can show them + * in their own section. + * + * Read-only delegators' calendars are additionally marked readOnly=true so they + * are excluded from the calendar picker (which only lists writable calendars). + * + * @return {Promise} + */ + async fetchDelegatedCalendars() { + if (!this.delegators.length) { + return + } + + const principalsStore = usePrincipalsStore() + const calendarsStore = useCalendarsStore() + const currentUser = principalsStore.getCurrentUserPrincipal + + for (const { principalUrl: delegatorPrincipalUrl, permission } of this.delegators) { + const calendarHomeUrl = await getClient().getCalendarHomeUrlForPrincipal(delegatorPrincipalUrl) + if (!calendarHomeUrl) { + logger.warn('Could not determine calendar home URL for delegator', { delegatorPrincipalUrl }) + showError(t('calendar', 'Could not load delegated calendars. Make sure the server supports calendar delegation.')) + continue + } + + try { + const rawCalendars = await findCalendarsAtUrl(calendarHomeUrl) + const mappedCalendars = rawCalendars + .map((cal) => mapDavCollectionToCalendar(cal, currentUser)) + .map((cal) => ({ + ...cal, + isDelegated: true, + // Read-only proxy access: prevent editing and hide from calendar picker + ...(permission === 'read' ? { readOnly: true } : {}), + })) + + for (const calendar of mappedCalendars) { + if (!calendarsStore.getCalendarById(calendar.id)) { + calendarsStore.addCalendarMutation({ calendar }) + } + } + + logger.debug('Fetched delegated calendars from', { calendarHomeUrl, permission, count: mappedCalendars.length }) + } catch (error) { + logger.error('Could not fetch calendars for delegator', { delegatorPrincipalUrl, error }) + showError(t('calendar', 'Could not load delegated calendars. Make sure the server supports calendar delegation.')) + } + } + }, + }, +}) diff --git a/src/views/Calendar.vue b/src/views/Calendar.vue index 81ef0c440f..179d674a00 100644 --- a/src/views/Calendar.vue +++ b/src/views/Calendar.vue @@ -136,6 +136,7 @@ import { isNotifyPushAvailable, registerNotifyPushSyncListener } from '../servic import getTimezoneManager from '../services/timezoneDataProviderService.js' import useCalendarObjectsStore from '../store/calendarObjects.js' import useCalendarsStore from '../store/calendars.js' +import useDelegationStore from '../store/delegation.js' import useFetchedTimeRangesStore from '../store/fetchedTimeRanges.js' import usePrincipalsStore from '../store/principals.js' import useSettingsStore from '../store/settings.js' @@ -222,6 +223,7 @@ export default { usePrincipalsStore, useSettingsStore, useWidgetStore, + useDelegationStore, ), ...mapState(useSettingsStore, { @@ -386,6 +388,10 @@ export default { }) } + // Load delegation info: who has delegated their calendars to the current user + await this.delegationStore.fetchDelegators() + await this.delegationStore.fetchDelegatedCalendars() + this.loadingCalendars = false } }, diff --git a/tests/javascript/unit/models/calendar.test.js b/tests/javascript/unit/models/calendar.test.js index 164747461b..6e60b63f2c 100644 --- a/tests/javascript/unit/models/calendar.test.js +++ b/tests/javascript/unit/models/calendar.test.js @@ -32,6 +32,7 @@ describe('Test suite: Calendar model (models/calendar.js)', () => { url: '', readOnly: false, order: 0, + isDelegated: false, isSharedWithMe: false, canBeShared: false, canBePublished: false, @@ -67,6 +68,7 @@ describe('Test suite: Calendar model (models/calendar.js)', () => { url: '', readOnly: false, order: 0, + isDelegated: false, isSharedWithMe: false, canBeShared: false, canBePublished: false, @@ -117,6 +119,7 @@ describe('Test suite: Calendar model (models/calendar.js)', () => { supportsEvents: true, supportsJournals: false, supportsTasks: false, + isDelegated: false, isSharedWithMe: false, canCreateObject: false, canDeleteObject: false, @@ -169,6 +172,7 @@ describe('Test suite: Calendar model (models/calendar.js)', () => { supportsEvents: true, supportsJournals: false, supportsTasks: false, + isDelegated: false, isSharedWithMe: false, canCreateObject: false, canDeleteObject: false, @@ -219,6 +223,7 @@ describe('Test suite: Calendar model (models/calendar.js)', () => { supportsEvents: true, supportsJournals: false, supportsTasks: false, + isDelegated: false, isSharedWithMe: false, canCreateObject: false, canDeleteObject: false, @@ -269,6 +274,7 @@ describe('Test suite: Calendar model (models/calendar.js)', () => { supportsEvents: true, supportsJournals: false, supportsTasks: false, + isDelegated: false, isSharedWithMe: true, canCreateObject: false, canDeleteObject: false, @@ -319,6 +325,7 @@ describe('Test suite: Calendar model (models/calendar.js)', () => { supportsEvents: true, supportsJournals: false, supportsTasks: false, + isDelegated: false, isSharedWithMe: false, canCreateObject: false, canDeleteObject: false, @@ -369,6 +376,7 @@ describe('Test suite: Calendar model (models/calendar.js)', () => { supportsEvents: true, supportsJournals: false, supportsTasks: false, + isDelegated: false, isSharedWithMe: false, canCreateObject: false, canDeleteObject: false, @@ -419,6 +427,7 @@ describe('Test suite: Calendar model (models/calendar.js)', () => { supportsEvents: true, supportsJournals: false, supportsTasks: false, + isDelegated: false, isSharedWithMe: false, canCreateObject: false, canDeleteObject: false, @@ -469,6 +478,7 @@ describe('Test suite: Calendar model (models/calendar.js)', () => { supportsEvents: true, supportsJournals: false, supportsTasks: false, + isDelegated: false, isSharedWithMe: false, canCreateObject: false, canDeleteObject: false, @@ -575,6 +585,7 @@ describe('Test suite: Calendar model (models/calendar.js)', () => { supportsEvents: true, supportsJournals: false, supportsTasks: false, + isDelegated: false, isSharedWithMe: false, canCreateObject: false, canDeleteObject: false, @@ -697,6 +708,7 @@ describe('Test suite: Calendar model (models/calendar.js)', () => { supportsEvents: true, supportsJournals: false, supportsTasks: false, + isDelegated: false, isSharedWithMe: true, canCreateObject: false, canDeleteObject: false, @@ -748,6 +760,7 @@ describe('Test suite: Calendar model (models/calendar.js)', () => { supportsEvents: true, supportsJournals: false, supportsTasks: false, + isDelegated: false, isSharedWithMe: false, canCreateObject: false, canDeleteObject: false,
+ {{ t('calendar', 'No delegates yet.') }} +
+ {{ t('calendar', 'Choose whether they can view and edit events, or only view them.') }} +