Skip to content

Commit e8b2495

Browse files
committed
feat: add readonly delegation
Signed-off-by: Grigory Vodyanov <scratchx@gmx.com>
1 parent b06a543 commit e8b2495

2 files changed

Lines changed: 106 additions & 45 deletions

File tree

src/components/AppNavigation/Settings/SettingsDelegationSection.vue

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@
2222
v-for="delegate in delegationStore.delegates"
2323
:key="delegate.url"
2424
:name="delegate.displayname || delegate.principalId"
25-
:subname="delegate.emailAddress"
2625
:compact="true">
26+
<template #subname>
27+
{{ delegateSubname(delegate) }}
28+
</template>
2729
<template #actions>
2830
<NcActionButton
2931
:aria-label="t('calendar', 'Revoke access')"
@@ -98,18 +100,24 @@
98100
</template>
99101
</NcSelect>
100102
<p class="delegation-add__description">
101-
{{ t('calendar', 'They will be able to manage your calendar events and invitations on your behalf.') }}
103+
{{ t('calendar', 'Choose whether they can view and edit events, or only view them.') }}
102104
</p>
103105
</div>
104106
<template #actions>
105107
<NcButton variant="secondary" @click="showAddDelegate = false">
106108
{{ t('calendar', 'Cancel') }}
107109
</NcButton>
110+
<NcButton
111+
variant="secondary"
112+
:disabled="!selectedUser || addLoading"
113+
@click="confirmAddDelegate('read')">
114+
{{ t('calendar', 'Add as viewer') }}
115+
</NcButton>
108116
<NcButton
109117
variant="primary"
110118
:disabled="!selectedUser || addLoading"
111-
@click="confirmAddDelegate">
112-
{{ t('calendar', 'Add') }}
119+
@click="confirmAddDelegate('write')">
120+
{{ t('calendar', 'Add as editor') }}
113121
</NcButton>
114122
</template>
115123
</NcDialog>
@@ -229,6 +237,23 @@ export default {
229237
},
230238
231239
methods: {
240+
/**
241+
* Returns a descriptive subname for a delegate list item
242+
* showing their permission level and optionally their email.
243+
*
244+
* @param {object} delegate The delegate principal object
245+
* @return {string}
246+
*/
247+
delegateSubname(delegate) {
248+
const permissionLabel = delegate.permission === 'read'
249+
? this.t('calendar', 'Viewer')
250+
: this.t('calendar', 'Editor')
251+
if (delegate.emailAddress) {
252+
return `${permissionLabel} · ${delegate.emailAddress}`
253+
}
254+
return permissionLabel
255+
},
256+
232257
/**
233258
* Open the revoke confirmation dialog for a specific delegate.
234259
*
@@ -289,17 +314,22 @@ export default {
289314
290315
/**
291316
* Execute the add-delegate operation.
317+
*
318+
* @param {'write'|'read'} permission The permission level to grant
292319
*/
293-
async confirmAddDelegate() {
320+
async confirmAddDelegate(permission) {
294321
if (!this.selectedUser) {
295322
return
296323
}
297324
this.addLoading = true
298325
const user = this.selectedUser
299326
this.showAddDelegate = false
300327
try {
301-
await this.delegationStore.addDelegate({ principalUrl: user.url })
302-
showSuccess(this.t('calendar', '{name} can now act on your behalf.', { name: user.displayname || user.principalId }))
328+
await this.delegationStore.addDelegate({ principalUrl: user.url, permission })
329+
const label = permission === 'read'
330+
? this.t('calendar', '{name} can now view your calendars.', { name: user.displayname || user.principalId })
331+
: this.t('calendar', '{name} can now act on your behalf.', { name: user.displayname || user.principalId })
332+
showSuccess(label)
303333
} catch (error) {
304334
logger.error('Failed to add delegate', { error })
305335
showError(this.t('calendar', 'Could not add delegate.'))
@@ -352,7 +382,7 @@ export default {
352382
if (currentUser && result.principalUrl === currentUser.url) {
353383
return false
354384
}
355-
// Exclude already-existing delegates
385+
// Exclude already-existing delegates (both editors and viewers)
356386
if (existingDelegateUrls.includes(result.principalUrl)) {
357387
return false
358388
}

src/store/delegation.js

Lines changed: 68 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -16,21 +16,20 @@ export default defineStore('delegation', {
1616
state: () => {
1717
return {
1818
/**
19-
* List of principal objects that the current user has delegated to
20-
* (members of the current user's calendar-proxy-write group).
19+
* List of principal objects that the current user has delegated to.
20+
* Each entry has the principal fields plus a `permission` field ('write'|'read').
2121
*
2222
* @type {object[]}
2323
*/
2424
delegates: [],
2525

2626
/**
27-
* Principal URLs of users who have granted the current user proxy access.
28-
* Stored as full absolute principal URLs so calendar-home discovery can
29-
* be performed without reconstructing paths from user IDs.
27+
* Users who have granted the current user proxy access, with permission level.
28+
* Each entry: { principalUrl: string, permission: 'write'|'read' }
3029
*
31-
* @type {string[]}
30+
* @type {Array<{principalUrl: string, permission: 'write'|'read'}>}
3231
*/
33-
delegatorPrincipalUrls: [],
32+
delegators: [],
3433
}
3534
},
3635

@@ -41,13 +40,13 @@ export default defineStore('delegation', {
4140
* @param {object} state The store state
4241
* @return {boolean}
4342
*/
44-
hasDelegatedCalendars: (state) => state.delegatorPrincipalUrls.length > 0,
43+
hasDelegatedCalendars: (state) => state.delegators.length > 0,
4544
},
4645

4746
actions: {
4847
/**
49-
* Fetch the current user's delegates (members of their calendar-proxy-write group)
50-
* and resolve their principal details.
48+
* Fetch the current user's delegates (members of their calendar-proxy-write
49+
* and calendar-proxy-read groups) and resolve their principal details.
5150
*
5251
* @return {Promise<void>}
5352
*/
@@ -58,33 +57,53 @@ export default defineStore('delegation', {
5857
return
5958
}
6059

61-
const proxyWriteGroupUrl = currentUser.url.replace(/\/?$/, '') + '/calendar-proxy-write'
60+
const baseUrl = currentUser.url.replace(/\/?$/, '')
61+
const proxyWriteGroupUrl = baseUrl + '/calendar-proxy-write'
62+
const proxyReadGroupUrl = baseUrl + '/calendar-proxy-read'
6263

63-
let memberUrls
64+
let writeUrls = []
65+
let readUrls = []
6466
try {
65-
memberUrls = await getClient().getDelegateUrls(proxyWriteGroupUrl)
67+
[writeUrls, readUrls] = await Promise.all([
68+
getClient().getDelegateUrls(proxyWriteGroupUrl),
69+
getClient().getDelegateUrls(proxyReadGroupUrl),
70+
])
6671
} catch (error) {
6772
logger.error('Could not fetch delegate URLs', { error })
6873
return
6974
}
7075

7176
const delegates = []
72-
for (const url of memberUrls) {
77+
78+
for (const url of writeUrls) {
7379
try {
7480
const dav = await findPrincipalByUrl(url)
7581
if (dav) {
76-
delegates.push(mapDavToPrincipal(dav))
82+
delegates.push({ ...mapDavToPrincipal(dav), permission: 'write' })
7783
}
7884
} catch (error) {
79-
logger.error('Could not resolve delegate principal', { url, error })
85+
logger.error('Could not resolve write delegate principal', { url, error })
8086
}
8187
}
88+
89+
for (const url of readUrls) {
90+
try {
91+
const dav = await findPrincipalByUrl(url)
92+
if (dav) {
93+
delegates.push({ ...mapDavToPrincipal(dav), permission: 'read' })
94+
}
95+
} catch (error) {
96+
logger.error('Could not resolve read delegate principal', { url, error })
97+
}
98+
}
99+
82100
this.delegates = delegates
83101
logger.debug('Fetched delegates', { delegates: this.delegates })
84102
},
85103

86104
/**
87-
* Fetch the principal URLs of users who have granted the current user proxy access.
105+
* Fetch the principal URLs and permission level of users who have granted
106+
* the current user proxy access (both read and write).
88107
*
89108
* @return {Promise<void>}
90109
*/
@@ -96,34 +115,38 @@ export default defineStore('delegation', {
96115
}
97116

98117
try {
99-
this.delegatorPrincipalUrls = await getClient().getDelegatorPrincipalUrls(currentUser.url)
100-
logger.debug('Fetched delegators', { delegatorPrincipalUrls: this.delegatorPrincipalUrls })
118+
this.delegators = await getClient().getDelegatorsWithPermission(currentUser.url)
119+
logger.debug('Fetched delegators', { delegators: this.delegators })
101120
} catch (error) {
102-
logger.error('Could not fetch delegator principal URLs', { error })
121+
logger.error('Could not fetch delegator information', { error })
103122
}
104123
},
105124

106125
/**
107-
* Add a user as a delegate (add them to the current user's calendar-proxy-write group).
126+
* Add a user as a delegate with the given permission level.
108127
*
109128
* @param {object} data The destructuring object
110129
* @param {string} data.principalUrl Absolute URL of the principal to add
130+
* @param {'write'|'read'} data.permission The permission level to grant
111131
* @return {Promise<void>}
112132
*/
113-
async addDelegate({ principalUrl }) {
133+
async addDelegate({ principalUrl, permission = 'write' }) {
114134
const principalsStore = usePrincipalsStore()
115135
const currentUser = principalsStore.getCurrentUserPrincipal
116136
if (!currentUser?.url) {
117137
return
118138
}
119139

120-
const proxyWriteGroupUrl = currentUser.url.replace(/\/?$/, '') + '/calendar-proxy-write'
121-
await getClient().addDelegate(proxyWriteGroupUrl, principalUrl)
140+
const baseUrl = currentUser.url.replace(/\/?$/, '')
141+
const proxyGroupUrl = permission === 'read'
142+
? baseUrl + '/calendar-proxy-read'
143+
: baseUrl + '/calendar-proxy-write'
144+
await getClient().addDelegate(proxyGroupUrl, principalUrl)
122145
await this.fetchDelegates()
123146
},
124147

125148
/**
126-
* Remove a delegate (remove them from the current user's calendar-proxy-write group).
149+
* Remove a delegate from whichever proxy group(s) they are in.
127150
*
128151
* @param {object} data The destructuring object
129152
* @param {string} data.principalUrl Absolute URL of the principal to remove
@@ -136,8 +159,15 @@ export default defineStore('delegation', {
136159
return
137160
}
138161

139-
const proxyWriteGroupUrl = currentUser.url.replace(/\/?$/, '') + '/calendar-proxy-write'
140-
await getClient().removeDelegate(proxyWriteGroupUrl, principalUrl)
162+
const baseUrl = currentUser.url.replace(/\/?$/, '')
163+
// Find the delegate's current permission so we remove from the right group.
164+
const existing = this.delegates.find((d) => d.url === principalUrl)
165+
const permission = existing?.permission ?? 'write'
166+
167+
const proxyGroupUrl = permission === 'read'
168+
? baseUrl + '/calendar-proxy-read'
169+
: baseUrl + '/calendar-proxy-write'
170+
await getClient().removeDelegate(proxyGroupUrl, principalUrl)
141171
this.delegates = this.delegates.filter((d) => d.url !== principalUrl)
142172
},
143173

@@ -147,24 +177,21 @@ export default defineStore('delegation', {
147177
* The calendars are tagged with isDelegated=true so CalendarList can show them
148178
* in their own section.
149179
*
150-
* Calendar home URLs are discovered via CalDAV PROPFIND on each delegator's
151-
* principal (RFC 4791 §6.2.1) rather than being constructed from user IDs,
152-
* which ensures correctness regardless of server path conventions.
180+
* Read-only delegators' calendars are additionally marked readOnly=true so they
181+
* are excluded from the calendar picker (which only lists writable calendars).
153182
*
154183
* @return {Promise<void>}
155184
*/
156185
async fetchDelegatedCalendars() {
157-
if (!this.delegatorPrincipalUrls.length) {
186+
if (!this.delegators.length) {
158187
return
159188
}
160189

161190
const principalsStore = usePrincipalsStore()
162191
const calendarsStore = useCalendarsStore()
163192
const currentUser = principalsStore.getCurrentUserPrincipal
164193

165-
for (const delegatorPrincipalUrl of this.delegatorPrincipalUrls) {
166-
// Discover the delegator's calendar home URL via CalDAV principal PROPFIND.
167-
// This follows RFC 4791 §6.2.1 and avoids hard-coding any URL path convention.
194+
for (const { principalUrl: delegatorPrincipalUrl, permission } of this.delegators) {
168195
const calendarHomeUrl = await getClient().getCalendarHomeUrlForPrincipal(delegatorPrincipalUrl)
169196
if (!calendarHomeUrl) {
170197
logger.warn('Could not determine calendar home URL for delegator', { delegatorPrincipalUrl })
@@ -176,16 +203,20 @@ export default defineStore('delegation', {
176203
const rawCalendars = await findCalendarsAtUrl(calendarHomeUrl)
177204
const mappedCalendars = rawCalendars
178205
.map((cal) => mapDavCollectionToCalendar(cal, currentUser))
179-
.map((cal) => ({ ...cal, isDelegated: true }))
206+
.map((cal) => ({
207+
...cal,
208+
isDelegated: true,
209+
// Read-only proxy access: prevent editing and hide from calendar picker
210+
...(permission === 'read' ? { readOnly: true } : {}),
211+
}))
180212

181213
for (const calendar of mappedCalendars) {
182-
// Only add if not already present
183214
if (!calendarsStore.getCalendarById(calendar.id)) {
184215
calendarsStore.addCalendarMutation({ calendar })
185216
}
186217
}
187218

188-
logger.debug('Fetched delegated calendars from', { calendarHomeUrl, count: mappedCalendars.length })
219+
logger.debug('Fetched delegated calendars from', { calendarHomeUrl, permission, count: mappedCalendars.length })
189220
} catch (error) {
190221
logger.error('Could not fetch calendars for delegator', { delegatorPrincipalUrl, error })
191222
showError(t('calendar', 'Could not load delegated calendars. Make sure the server supports calendar delegation.'))

0 commit comments

Comments
 (0)