Skip to content

Commit 121eed5

Browse files
committed
feat: add delegation frontend
Signed-off-by: Hamza <hamzamahjoubi221@gmail.com>
1 parent 1e74c52 commit 121eed5

6 files changed

Lines changed: 413 additions & 3 deletions

File tree

REUSE.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ SPDX-FileCopyrightText = "2018-2024 Google LLC, 2016-2024 Nextcloud GmbH and Nex
144144
SPDX-License-Identifier = "Apache-2.0"
145145

146146
[[annotations]]
147-
path = ["img/mail.png", "img/mail.svg", "img/mail-dark.svg", "img/important.svg", "img/star.png", "img/star.svg", "img/mail-notification.png", "img/mail-notification.svg", "img/text_snippet.svg", "img/format-pilcrow-arrow-right.svg", "img/format-pilcrow-arrow-left.svg"]
147+
path = ["img/mail.png", "img/mail.svg", "img/mail-dark.svg", "img/important.svg", "img/star.png", "img/star.svg", "img/mail-notification.png", "img/mail-notification.svg", "img/text_snippet.svg", "img/format-pilcrow-arrow-right.svg", "img/format-pilcrow-arrow-left.svg", "img/delegation.svg"]
148148
precedence = "aggregate"
149149
SPDX-FileCopyrightText = "2018-2026 Google LLC"
150150
SPDX-License-Identifier = "Apache-2.0"

img/delegation.svg

Lines changed: 1 addition & 0 deletions
Loading

src/components/AppSettingsMenu.vue

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,16 @@
3232
</template>
3333
{{ account.emailAddress }}
3434
</NcFormBoxButton>
35+
<NcFormBoxButton
36+
v-for="account in delegatedAccounts"
37+
:key="account.id"
38+
:aria-label="t('mail', 'Account settings')"
39+
@click="openAccountSettings(account.id)">
40+
<template #icon>
41+
<IconArrow :size="20" />
42+
</template>
43+
{{ t('mail', '{email} (delegated)', {email: account.emailAddress}) }}
44+
</NcFormBoxButton>
3545
<NcButton
3646
v-if="allowNewMailAccounts"
3747
variant="secondary"
@@ -409,7 +419,11 @@ export default {
409419
},
410420
411421
accountsWithEmail() {
412-
return this.getAccounts.filter((account) => account && account.emailAddress)
422+
return this.getAccounts.filter((account) => account && account.emailAddress && !account.isDelegated)
423+
},
424+
425+
delegatedAccounts() {
426+
return this.getAccounts.filter((account) => account && account.emailAddress && account.isDelegated)
413427
},
414428
415429
sortFavorites: {

src/components/DelegationModal.vue

Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
- SPDX-License-Identifier: AGPL-3.0-or-later
4+
-->
5+
6+
<template>
7+
<NcModal v-if="view === 'main'" size="normal" @close="$emit('close')">
8+
<div class="delegation-modal">
9+
<h2>{{ t('mail', 'Delegation') }}</h2>
10+
11+
<div class="delegation-modal__section">
12+
<p class="delegation-modal__description">
13+
{{ t('mail', 'Allow users to send, receive, and delete mail on your behalf') }}
14+
</p>
15+
16+
<NcListItem
17+
v-for="user in delegates"
18+
:key="user.userId"
19+
:name="user.userId">
20+
<template #icon>
21+
<NcAvatar
22+
disable-menu
23+
:size="34"
24+
:user="user.userId" />
25+
</template>
26+
<template #extra-actions>
27+
<NcButton
28+
:title="t('mail', 'Revoke access')"
29+
:aria-label="t('mail', 'Revoke access')"
30+
variant="tertiary-no-background"
31+
@click="confirmRevoke(user)">
32+
<template #icon>
33+
<IconClose :size="20" />
34+
</template>
35+
</NcButton>
36+
</template>
37+
</NcListItem>
38+
39+
<NcButton
40+
wide
41+
variant="secondary"
42+
@click="openAddDelegate">
43+
<template #icon>
44+
<IconPlus :size="20" />
45+
</template>
46+
{{ t('mail', 'Add delegate') }}
47+
</NcButton>
48+
</div>
49+
</div>
50+
</NcModal>
51+
52+
<NcDialog
53+
v-else-if="view === 'add'"
54+
class="add-delegates-dialog"
55+
:open="view === 'add'"
56+
:name="t('mail', 'Add delegate')"
57+
:buttons="addDelegateButtons"
58+
@closing="closeDialog">
59+
<NcSelectUsers
60+
v-model="selectedUser"
61+
class="add-delegates-dialog__select"
62+
:input-label="t('mail', 'Select a user')"
63+
:options="userSuggestions"
64+
:loading="searchLoading"
65+
:placeholder="t('mail', 'Select a user')"
66+
@search="onSearch" />
67+
<p class="add-delegates-dialog__description">
68+
{{ t('mail', 'They will be able to send, receive, and delete mail on your behalf') }}
69+
</p>
70+
</NcDialog>
71+
72+
<NcDialog
73+
v-else-if="view === 'revoke'"
74+
class="revoke-dialog"
75+
:open="view === 'revoke'"
76+
:name="t('mail', 'Revoke access?')"
77+
:buttons="revokeButtons"
78+
@closing="closeDialog">
79+
<p class="revoke-dialog__text">
80+
{{ revokeText }}
81+
</p>
82+
</NcDialog>
83+
</template>
84+
85+
<script>
86+
import IconCheck from '@mdi/svg/svg/check.svg?raw'
87+
import { getCurrentUser } from '@nextcloud/auth'
88+
import axios from '@nextcloud/axios'
89+
import { showError, showSuccess } from '@nextcloud/dialogs'
90+
import { generateOcsUrl } from '@nextcloud/router'
91+
import { ShareType } from '@nextcloud/sharing'
92+
import { NcAvatar, NcButton, NcDialog, NcListItem, NcModal, NcSelectUsers } from '@nextcloud/vue'
93+
import debounce from 'lodash/fp/debounce.js'
94+
import IconClose from 'vue-material-design-icons/Close.vue'
95+
import IconPlus from 'vue-material-design-icons/Plus.vue'
96+
import logger from '../logger.js'
97+
import { delegate, fetchDelegatedUsers, unDelegate } from '../service/DelegationService.js'
98+
99+
export default {
100+
name: 'DelegationModal',
101+
components: {
102+
NcAvatar,
103+
NcButton,
104+
NcDialog,
105+
NcListItem,
106+
NcModal,
107+
NcSelectUsers,
108+
IconClose,
109+
IconPlus,
110+
},
111+
112+
props: {
113+
account: {
114+
type: Object,
115+
required: true,
116+
},
117+
},
118+
119+
data() {
120+
return {
121+
view: 'main',
122+
delegates: [],
123+
revokeUser: null,
124+
selectedUser: null,
125+
userSuggestions: [],
126+
searchLoading: false,
127+
delegating: false,
128+
}
129+
},
130+
131+
computed: {
132+
addDelegateButtons() {
133+
return [
134+
{
135+
label: t('mail', 'Cancel'),
136+
type: 'tertiary',
137+
disabled: this.delegating,
138+
callback: () => { this.closeDialog() },
139+
},
140+
{
141+
label: t('mail', 'Delegate access'),
142+
type: 'primary',
143+
icon: IconCheck,
144+
disabled: !this.selectedUser || this.delegating,
145+
callback: () => { this.addDelegate() },
146+
},
147+
]
148+
},
149+
150+
revokeButtons() {
151+
return [
152+
{
153+
label: t('mail', 'Cancel'),
154+
type: 'tertiary',
155+
callback: () => { this.closeDialog() },
156+
},
157+
{
158+
label: t('mail', 'Revoke'),
159+
type: 'error',
160+
callback: () => { this.revokeDelegate() },
161+
},
162+
]
163+
},
164+
165+
revokeText() {
166+
if (!this.revokeUser) {
167+
return ''
168+
}
169+
return t('mail', '{userId} will no longer be able to act on your behalf', { userId: this.revokeUser.userId })
170+
},
171+
},
172+
173+
async mounted() {
174+
await this.fetchDelegates()
175+
},
176+
177+
methods: {
178+
async fetchDelegates() {
179+
try {
180+
this.delegates = await fetchDelegatedUsers(this.account.accountId)
181+
} catch (error) {
182+
logger.error('Could not fetch delegates', { error })
183+
showError(t('mail', 'Could not fetch delegates'))
184+
}
185+
},
186+
187+
openAddDelegate() {
188+
this.view = 'add'
189+
},
190+
191+
confirmRevoke(user) {
192+
this.revokeUser = user
193+
this.view = 'revoke'
194+
},
195+
196+
onSearch(query) {
197+
this.debounceGetSuggestions(query.trim())
198+
},
199+
200+
debounceGetSuggestions: debounce(300, function(...args) {
201+
this.getSuggestions(...args)
202+
}),
203+
204+
async getSuggestions(search) {
205+
if (!search) {
206+
this.userSuggestions = []
207+
return
208+
}
209+
210+
this.searchLoading = true
211+
try {
212+
const request = await axios.get(generateOcsUrl('apps/files_sharing/api/v1/sharees'), {
213+
params: {
214+
format: 'json',
215+
itemType: 'file',
216+
search,
217+
shareTypes: [ShareType.User],
218+
},
219+
})
220+
221+
const data = request.data.ocs.data
222+
const exact = request.data.ocs.data.exact
223+
224+
const rawSuggestions = exact.users.concat(data.users)
225+
const currentUserId = getCurrentUser().uid
226+
const delegateIds = this.delegates.map((d) => d.userId)
227+
228+
this.userSuggestions = rawSuggestions
229+
.map((result) => ({
230+
id: result.value.shareWith,
231+
displayName: result.name || result.label,
232+
subname: result.value.shareWith,
233+
user: result.value.shareWith,
234+
}))
235+
.filter((u) => u.id !== currentUserId && !delegateIds.includes(u.id))
236+
} catch (error) {
237+
logger.error('Error fetching user suggestions', { error })
238+
} finally {
239+
this.searchLoading = false
240+
}
241+
},
242+
243+
async addDelegate() {
244+
if (!this.selectedUser) {
245+
return
246+
}
247+
248+
this.delegating = true
249+
try {
250+
const delegation = await delegate(this.account.accountId, this.selectedUser.id)
251+
this.delegates.push(delegation)
252+
showSuccess(t('mail', 'Delegated access to {userId}', { userId: this.selectedUser.id }))
253+
this.delegating = false
254+
} catch (error) {
255+
logger.error('Could not delegate access', { error })
256+
showError(t('mail', 'Could not delegate access'))
257+
this.delegating = false
258+
}
259+
},
260+
261+
async revokeDelegate() {
262+
if (!this.revokeUser) {
263+
return
264+
}
265+
266+
try {
267+
await unDelegate(this.account.accountId, this.revokeUser.userId)
268+
this.delegates = this.delegates.filter((d) => d.userId !== this.revokeUser.userId)
269+
showSuccess(t('mail', 'Revoked access for {userId}', { userId: this.revokeUser.userId }))
270+
} catch (error) {
271+
logger.error('Could not revoke delegation', { error })
272+
showError(t('mail', 'Could not revoke delegation'))
273+
}
274+
},
275+
276+
closeDialog() {
277+
this.view = 'main'
278+
},
279+
},
280+
}
281+
</script>
282+
283+
<style lang="scss" scoped>
284+
.delegation-modal {
285+
padding: var(--default-grid-baseline) calc(var(--default-grid-baseline) * 3) calc(var(--default-grid-baseline) * 3);
286+
h2 {
287+
margin: 0;
288+
text-align: center;
289+
}
290+
&__section {
291+
margin-top: calc(var(--default-grid-baseline) * 3);
292+
}
293+
294+
&__description {
295+
color: var(--color-text-maxcontrast);
296+
margin-bottom: calc(var(--default-grid-baseline) * 2);
297+
}
298+
}
299+
300+
.add-delegates-dialog{
301+
&__description{
302+
color: var(--color-text-maxcontrast);
303+
padding: calc(var(--default-grid-baseline) * 2) 0;
304+
}
305+
&__select{
306+
width: 100%;
307+
}
308+
}
309+
310+
.revoke-dialog {
311+
&__text{
312+
padding-bottom: calc(var(--default-grid-baseline) * 2);
313+
}
314+
}
315+
</style>

0 commit comments

Comments
 (0)