Skip to content

Commit d1059cd

Browse files
authored
Merge pull request #258 from nextcloud-libraries/feature/dialog-per-recipient-permissions
feat(dialog): multiple recipients with per-recipient permissions
2 parents 029f919 + bf24a43 commit d1059cd

32 files changed

Lines changed: 2341 additions & 131 deletions

‎.gitignore‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,8 @@ typings/
6363
.next
6464

6565
/dist
66+
67+
# Playwright
68+
test-results/
69+
playwright-report/
70+
playwright/.cache/

‎lib/dialog/SharingDialog.vue‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@
6262
:folderName="folderName"
6363
@settingsWarning="settingsHasWarning = $event"
6464
@settingsAvailable="settingsAvailable = $event"
65-
@submitted="onSubmitted" />
65+
@submitted="onSubmitted"
66+
@deleted="emit('close')" />
6667
</template>
6768

6869
<!-- Settings toggle -->
@@ -98,7 +99,7 @@ import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
9899
import ShareConfirmation from './components/ShareConfirmation.vue'
99100
import SharePanel from './components/SharePanel.vue'
100101
import { createShare } from './api/share.ts'
101-
import { SOURCE_TYPE_NODE } from './constants.ts'
102+
import { RECIPIENT_TYPE_TOKEN, SOURCE_TYPE_NODE } from './constants.ts'
102103
import { ShareDialogTab } from './types/ui.ts'
103104
import { t } from './utils/l10n.ts'
104105
import { logger } from './utils/logger.ts'
@@ -138,7 +139,11 @@ const dialogTitle = computed(() => {
138139
: t('Share')
139140
})
140141
141-
const shareDialogTab = ref<ShareDialogTab>(ShareDialogTab.InvitedPeople)
142+
// Open an existing share on the view matching its type. Getting this wrong is
143+
// destructive: the link view syncs the token recipient, so opening a link share
144+
// on the invited view would strip its link.
145+
const isExistingLinkShare = props.share?.recipients.some((recipient) => recipient.class === RECIPIENT_TYPE_TOKEN) ?? false
146+
const shareDialogTab = ref<ShareDialogTab>(isExistingLinkShare ? ShareDialogTab.Anyone : ShareDialogTab.InvitedPeople)
142147
const settingsHasWarning = ref(false)
143148
const settingsAvailable = ref(false)
144149
@@ -207,6 +212,9 @@ onMounted(async () => {
207212
// flex is used so the dialog still sizes to its content when it is short.
208213
:deep(.share-panel) {
209214
max-height: 50vh;
215+
// Keep a stable floor so the dialog does not jump around as the content
216+
// changes (switching tabs, adding recipients, revealing toggles).
217+
min-height: min(320px, 50vh);
210218
overflow-y: auto;
211219
// Match the dialog's inline padding at the bottom (its content has none),
212220
// so the form does not sit flush against the edge.

‎lib/dialog/api/share.spec.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ vi.mock('./sharing.ts', () => ({
2222
updateShareProperty: vi.fn(),
2323
updateSharePermission: vi.fn(),
2424
selectSharePermissionPreset: vi.fn(),
25+
updateShareRecipientPermission: vi.fn(),
2526
updateShareState: vi.fn(),
2627
searchRecipients: vi.fn(),
2728
deleteShare: vi.fn(),
@@ -119,6 +120,13 @@ describe('Share', () => {
119120
expect(mocked.updateSharePermission).toHaveBeenCalledWith('abc', 'C', false)
120121
})
121122

123+
it('setRecipientPermission forwards recipient identity, permission and flag', async () => {
124+
const instance = await makeShare()
125+
mocked.updateShareRecipientPermission.mockResolvedValue(share())
126+
await instance.setRecipientPermission('R', 'bob', 'C', true, 'inst')
127+
expect(mocked.updateShareRecipientPermission).toHaveBeenCalledWith('abc', 'R', 'bob', 'C', true, 'inst')
128+
})
129+
122130
it('addSource forwards the class and value', async () => {
123131
const instance = await makeShare()
124132
mocked.addShareSource.mockResolvedValue(share())

‎lib/dialog/api/share.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,20 @@ class Share {
183183
return this.#sync(await client.selectSharePermissionPreset(this.id, presetClass))
184184
}
185185

186+
/**
187+
* Enable or disable a single permission for one recipient. The backend caps
188+
* the recipient at the share-level permissions (the maximum).
189+
*
190+
* @param recipientClass The recipient type class
191+
* @param recipientValue The recipient value
192+
* @param permissionClass The permission type class
193+
* @param enabled The new enabled state
194+
* @param instance The recipient's instance (federated shares)
195+
*/
196+
async setRecipientPermission(recipientClass: string, recipientValue: string, permissionClass: string, enabled: boolean, instance?: string): Promise<this> {
197+
return this.#sync(await client.updateShareRecipientPermission(this.id, recipientClass, recipientValue, permissionClass, enabled, instance))
198+
}
199+
186200
/**
187201
* Set the share state (draft → active → deleted).
188202
*

‎lib/dialog/api/sharing.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,31 @@ export async function selectSharePermissionPreset(shareId: string, presetClass:
172172
return unwrapOcs<SharingShare>(response)
173173
}
174174

175+
/**
176+
* Update a single permission for one recipient of a share.
177+
*
178+
* The backend caps the recipient's permissions at the share-level permissions
179+
* (the maximum), which are themselves bounded by the sharer's own permissions
180+
* on a reshare, or the admin default.
181+
*
182+
* @param shareId
183+
* @param recipientClass
184+
* @param recipientValue
185+
* @param permissionClass
186+
* @param enabled
187+
* @param instance
188+
*/
189+
export async function updateShareRecipientPermission(shareId: string, recipientClass: string, recipientValue: string, permissionClass: string, enabled: boolean, instance?: string): Promise<SharingShare> {
190+
const response = await axios.put(sharingUrl(`/share/${shareId}/recipient/permission`), {
191+
recipientClass,
192+
recipientValue,
193+
recipientInstance: instance ?? null,
194+
permissionClass,
195+
enabled,
196+
})
197+
return unwrapOcs<SharingShare>(response)
198+
}
199+
175200
/**
176201
* Update the state of a share (draft → active → deleted).
177202
*
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: GPL-3.0-or-later
4+
*/
5+
6+
import type { EditablePermission } from './PermissionEditor.vue'
7+
8+
import { shallowMount } from '@vue/test-utils'
9+
import { describe, expect, it, vi } from 'vitest'
10+
import PermissionEditor from './PermissionEditor.vue'
11+
12+
vi.mock('../utils/l10n.ts', () => ({ t: (s: string) => s }))
13+
14+
function permission(cls: string, enabled: boolean, available: boolean): EditablePermission {
15+
return { class: cls, source_class: null, display_name: cls, hint: null, priority: 0, presets: [], enabled, available }
16+
}
17+
18+
function mountEditor(props: Record<string, unknown> = {}) {
19+
return shallowMount(PermissionEditor, {
20+
props: {
21+
presetOptions: [{ value: 'View', label: 'Can view' }, { value: 'custom', label: 'Can…' }],
22+
selectedPreset: { value: 'custom', label: 'Can…' },
23+
showPermissions: true,
24+
permissions: [permission('read', true, true), permission('write', false, false)],
25+
...props,
26+
},
27+
global: { renderStubDefaultSlot: true },
28+
})
29+
}
30+
31+
describe('PermissionEditor', () => {
32+
it('shows the notice only when provided', () => {
33+
expect(mountEditor({ notice: null }).findComponent({ name: 'NcNoteCard' }).exists()).toBe(false)
34+
expect(mountEditor({ notice: 'Capped' }).text()).toContain('Capped')
35+
})
36+
37+
it('emits presetChange from the select', () => {
38+
const wrapper = mountEditor()
39+
wrapper.findComponent({ name: 'NcSelect' }).vm.$emit('update:modelValue', { value: 'View', label: 'Can view' })
40+
expect(wrapper.emitted('presetChange')![0]).toEqual([{ value: 'View', label: 'Can view' }])
41+
})
42+
43+
it('renders a toggle per permission and disables those over the cap', () => {
44+
const wrapper = mountEditor()
45+
const toggles = wrapper.findAllComponents({ name: 'NcFormBoxSwitch' })
46+
expect(toggles).toHaveLength(2)
47+
expect(toggles[0].props('disabled')).toBe(false)
48+
expect(toggles[1].props('disabled')).toBe(true)
49+
})
50+
51+
it('hides toggles when not in custom mode', () => {
52+
const wrapper = mountEditor({ showPermissions: false })
53+
expect(wrapper.findComponent({ name: 'NcFormBoxSwitch' }).exists()).toBe(false)
54+
})
55+
56+
it('emits permissionToggle from a switch', () => {
57+
const wrapper = mountEditor()
58+
wrapper.findAllComponents({ name: 'NcFormBoxSwitch' })[0].vm.$emit('update:modelValue', false)
59+
const emitted = wrapper.emitted('permissionToggle')![0]
60+
expect((emitted[0] as { class: string }).class).toBe('read')
61+
expect(emitted[1]).toBe(false)
62+
})
63+
})
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
<!--
2+
SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
SPDX-License-Identifier: GPL-3.0-or-later
4+
-->
5+
<template>
6+
<div class="permission-editor">
7+
<!-- Cap notice: shown when some permissions are not grantable -->
8+
<NcNoteCard v-if="notice" type="info">
9+
{{ notice }}
10+
</NcNoteCard>
11+
12+
<NcSelect
13+
:modelValue="selectedPreset"
14+
:clearable="false"
15+
:searchable="false"
16+
:inputLabel="presetLabel"
17+
:hideLabel="hideLabel"
18+
:options="presetOptions"
19+
class="permission-editor__preset"
20+
:placeholder="t('Custom permissions')"
21+
@update:modelValue="(option) => emit('presetChange', option)" />
22+
23+
<!-- Fine-grained toggles, shown while the custom entry is selected -->
24+
<Transition name="expand">
25+
<div v-if="showPermissions" ref="permissionsEl" class="permission-editor__permissions">
26+
<div class="permission-editor__permissions-inner">
27+
<NcFormBox>
28+
<NcFormBoxSwitch
29+
v-for="permission in permissions"
30+
:key="permission.class"
31+
:label="permission.display_name"
32+
:description="permission.hint ?? undefined"
33+
:disabled="permission.available === false"
34+
:error="permissionErrors[permission.class]"
35+
:modelValue="permission.enabled"
36+
@update:modelValue="(enabled) => emit('permissionToggle', permission, enabled)" />
37+
</NcFormBox>
38+
</div>
39+
</div>
40+
</Transition>
41+
42+
<NcNoteCard v-if="presetError" type="error">
43+
{{ presetError }}
44+
</NcNoteCard>
45+
</div>
46+
</template>
47+
48+
<script setup lang="ts">
49+
import type { PresetOption } from '../composables/useRecipientPermissions.ts'
50+
import type { SharingPermission } from '../types/api.ts'
51+
52+
import { nextTick, ref, watch } from 'vue'
53+
import NcFormBox from '@nextcloud/vue/components/NcFormBox'
54+
import NcFormBoxSwitch from '@nextcloud/vue/components/NcFormBoxSwitch'
55+
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
56+
import NcSelect from '@nextcloud/vue/components/NcSelect'
57+
import { t } from '../utils/l10n.ts'
58+
59+
/** A permission toggle; `available === false` disables it (over the cap). */
60+
export type EditablePermission = SharingPermission & { available?: boolean }
61+
62+
const props = withDefaults(defineProps<{
63+
/** Preset dropdown options */
64+
presetOptions: PresetOption[]
65+
/** The currently selected preset option */
66+
selectedPreset: PresetOption | null
67+
/** Whether the fine-grained toggles are shown (custom preset) */
68+
showPermissions: boolean
69+
/** The permission toggles */
70+
permissions: EditablePermission[]
71+
/** Backend errors per permission class */
72+
permissionErrors?: Record<string, string>
73+
/** Backend error for the preset selection */
74+
presetError?: string | null
75+
/** Info notice shown above the editor (e.g. the permission cap) */
76+
notice?: string | null
77+
/** Label for the preset select */
78+
presetLabel?: string
79+
/** Hide the preset select label */
80+
hideLabel?: boolean
81+
}>(), {
82+
permissionErrors: () => ({}),
83+
presetError: null,
84+
notice: null,
85+
presetLabel: undefined,
86+
hideLabel: false,
87+
})
88+
89+
const emit = defineEmits<{
90+
(e: 'presetChange', option: PresetOption | null): void
91+
(e: 'permissionToggle', permission: SharingPermission, enabled: boolean): void
92+
}>()
93+
94+
const permissionsEl = ref<HTMLElement | null>(null)
95+
96+
// Reveal the toggles once they appear, they are below the fold in the dialog.
97+
watch(() => props.showPermissions, async (shown) => {
98+
if (!shown) {
99+
return
100+
}
101+
await nextTick()
102+
permissionsEl.value?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
103+
})
104+
</script>
105+
106+
<style scoped lang="scss">
107+
.permission-editor {
108+
display: flex;
109+
flex-direction: column;
110+
gap: calc(var(--default-grid-baseline) * 3);
111+
112+
&__permissions {
113+
display: grid;
114+
grid-template-rows: 1fr;
115+
}
116+
117+
&__permissions-inner {
118+
overflow: hidden;
119+
}
120+
}
121+
122+
.expand-enter-active,
123+
.expand-leave-active {
124+
transition: grid-template-rows 0.2s ease-in-out;
125+
}
126+
127+
.expand-enter-from,
128+
.expand-leave-to {
129+
grid-template-rows: 0fr;
130+
}
131+
132+
@media (prefers-reduced-motion: reduce) {
133+
.expand-enter-active,
134+
.expand-leave-active {
135+
transition: none;
136+
}
137+
}
138+
</style>
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<!--
2+
SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
SPDX-License-Identifier: GPL-3.0-or-later
4+
-->
5+
<template>
6+
<ul v-if="recipients.length > 0" class="recipient-list">
7+
<RecipientRow
8+
v-for="recipient in recipients"
9+
:key="recipient.class + recipient.value + (recipient.instance ?? '')"
10+
:share="share"
11+
:recipient="recipient" />
12+
</ul>
13+
</template>
14+
15+
<script setup lang="ts">
16+
import type { Share } from '../api/share.ts'
17+
18+
import { computed } from 'vue'
19+
import RecipientRow from './RecipientRow.vue'
20+
import { RECIPIENT_TYPE_TOKEN } from '../constants.ts'
21+
22+
const props = defineProps<{
23+
/** The share being edited */
24+
share: Share
25+
}>()
26+
27+
// Link (token) recipients are managed by the "Anyone" tab, not listed here.
28+
const recipients = computed(() => props.share.recipients.filter((recipient) => recipient.class !== RECIPIENT_TYPE_TOKEN))
29+
</script>
30+
31+
<style scoped lang="scss">
32+
.recipient-list {
33+
display: flex;
34+
flex-direction: column;
35+
gap: calc(var(--default-grid-baseline) * 3);
36+
padding-inline-start: calc(var(--default-grid-baseline) * 2);
37+
}
38+
</style>

0 commit comments

Comments
 (0)