diff --git a/apps/files_sharing/lib/Listener/LoadSidebarListener.php b/apps/files_sharing/lib/Listener/LoadSidebarListener.php index e6a30a5688173..4768f2e0657ca 100644 --- a/apps/files_sharing/lib/Listener/LoadSidebarListener.php +++ b/apps/files_sharing/lib/Listener/LoadSidebarListener.php @@ -38,6 +38,10 @@ public function handle(Event $event): void { return; } Util::addScript(Application::APP_ID, 'files_sharing_tab', 'files'); + // Vue 3 bridge exposing the unified sharing dialog on OCA.Sharing for the + // (Vue 2) sidebar to trigger without bundling Vue 3. + Util::addStyle(Application::APP_ID, 'sharing-dialog'); + Util::addScript(Application::APP_ID, 'sharing-dialog', 'files'); $appConfig = Server::get(IAppConfig::class); $gsConfig = Server::get(IConfig::class); diff --git a/apps/files_sharing/src/components/AvatarStack.vue b/apps/files_sharing/src/components/AvatarStack.vue new file mode 100644 index 0000000000000..b9854c3a17844 --- /dev/null +++ b/apps/files_sharing/src/components/AvatarStack.vue @@ -0,0 +1,72 @@ + + + + + + + diff --git a/apps/files_sharing/src/components/SharingEntryLink.vue b/apps/files_sharing/src/components/SharingEntryLink.vue index e67b3447335e1..e2bb0e4e48f72 100644 --- a/apps/files_sharing/src/components/SharingEntryLink.vue +++ b/apps/files_sharing/src/components/SharingEntryLink.vue @@ -21,7 +21,7 @@ {{ subtitle }}

@@ -151,6 +151,7 @@ + + @@ -53,6 +56,13 @@ export default { type: Boolean, default: null, }, + + // Force the overflow menu even with a single action (keeps destructive + // actions in a menu instead of rendering them inline). + forceMenu: { + type: Boolean, + default: false, + }, }, computed: { diff --git a/apps/files_sharing/src/components/UnifiedShareEntry.spec.ts b/apps/files_sharing/src/components/UnifiedShareEntry.spec.ts new file mode 100644 index 0000000000000..b31f8e28a531f --- /dev/null +++ b/apps/files_sharing/src/components/UnifiedShareEntry.spec.ts @@ -0,0 +1,160 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import UnifiedShareEntry from './UnifiedShareEntry.vue' +import { openShareEditDialog } from '../services/SharingDialog.ts' +import { deleteShare, removeRecipient } from '../services/unifiedShares.ts' + +vi.mock('../services/SharingDialog.ts', () => ({ + openShareEditDialog: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('../services/unifiedShares.ts', () => ({ + deleteShare: vi.fn().mockResolvedValue(undefined), + removeRecipient: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('../services/logger.ts', () => ({ + default: { error: vi.fn(), debug: vi.fn() }, +})) + +// Confirm dialog: answers with the last button ("Delete") by default; set +// `confirmation.declined` to answer with the first one ("Cancel") instead. +const confirmation = vi.hoisted(() => ({ declined: false })) + +vi.mock('@nextcloud/dialogs', () => ({ + DialogBuilder: class { + buttons: { callback: () => void }[] = [] + setName() { + return this + } + + setText() { + return this + } + + setButtons(buttons: { callback: () => void }[]) { + this.buttons = buttons + return this + } + + build() { + const { buttons } = this + return { + show: async () => (confirmation.declined ? buttons.at(0) : buttons.at(-1))?.callback(), + } + } + }, +})) + +function recipient(value: string) { + return { + class: 'UserRecipient', + value, + instance: null, + display_name: value, + icon: null, + secret: { updatable: false }, + initiator: null, + permissions: [], + } +} + +function share(recipients = [recipient('bob')]) { + return { + id: '42', + state: 'active', + recipients, + permissions: [], + permission_preset: null, + owner: { user_id: 'alice', display_name: 'Alice', instance: null }, + } +} + +function mountEntry(data = share()) { + return mount(UnifiedShareEntry, { + propsData: { share: data, fileInfo: { node: { fileid: 1 } } }, + stubs: { + NcAvatar: true, + AvatarStack: true, + // The actions live in a menu that only renders its content once opened. + NcActions: { template: '
' }, + }, + }) +} + +/** Trigger a row action by its label, as clicking the menu entry would. */ +async function triggerAction(wrapper: ReturnType, label: string) { + const action = wrapper.findAllComponents({ name: 'NcActionButton' }) + .wrappers.find((button) => button.text().includes(label)) + expect(action, `the "${label}" action is rendered`).toBeDefined() + action!.vm.$emit('click') + await new Promise((resolve) => setTimeout(resolve)) +} + +beforeEach(() => { + vi.clearAllMocks() + confirmation.declined = false +}) + +describe('editing', () => { + it('refreshes the list once the dialog closes', async () => { + const wrapper = mountEntry() + await triggerAction(wrapper, 'Edit share') + expect(openShareEditDialog).toHaveBeenCalledWith('42', { fileid: 1 }) + expect(wrapper.emitted('refresh')).toHaveLength(1) + }) + + it('still refreshes when the dialog fails', async () => { + vi.mocked(openShareEditDialog).mockRejectedValueOnce(new Error('nope')) + const wrapper = mountEntry() + await triggerAction(wrapper, 'Edit share') + // The dialog writes straight to the backend, so it may have applied + // changes before it errored. + expect(wrapper.emitted('refresh')).toHaveLength(1) + }) +}) + +describe('deleting the share', () => { + it('deletes it and refreshes once confirmed', async () => { + const wrapper = mountEntry() + await triggerAction(wrapper, 'Delete share') + expect(deleteShare).toHaveBeenCalledWith('42') + expect(wrapper.emitted('refresh')).toHaveLength(1) + }) + + it('does not delete it when the confirmation is declined', async () => { + confirmation.declined = true + const wrapper = mountEntry() + await triggerAction(wrapper, 'Delete share') + expect(deleteShare).not.toHaveBeenCalled() + expect(wrapper.emitted('refresh')).toBeUndefined() + }) + + it('does not refresh when the deletion fails', async () => { + vi.mocked(deleteShare).mockRejectedValueOnce(new Error('nope')) + const wrapper = mountEntry() + await triggerAction(wrapper, 'Delete share') + expect(wrapper.emitted('refresh')).toBeUndefined() + }) +}) + +describe('removing a participant', () => { + it('removes the recipient of the row it was triggered on', async () => { + const wrapper = mountEntry(share([recipient('bob'), recipient('carol')])) + await triggerAction(wrapper, 'Remove participant') + expect(removeRecipient).toHaveBeenCalledWith('42', 'UserRecipient', 'bob', null) + expect(wrapper.emitted('refresh')).toHaveLength(1) + }) + + it('does not refresh when the removal fails', async () => { + vi.mocked(removeRecipient).mockRejectedValueOnce(new Error('nope')) + const wrapper = mountEntry(share([recipient('bob'), recipient('carol')])) + await triggerAction(wrapper, 'Remove participant') + expect(wrapper.emitted('refresh')).toBeUndefined() + }) +}) diff --git a/apps/files_sharing/src/components/UnifiedShareEntry.vue b/apps/files_sharing/src/components/UnifiedShareEntry.vue new file mode 100644 index 0000000000000..4b6aafc3069fa --- /dev/null +++ b/apps/files_sharing/src/components/UnifiedShareEntry.vue @@ -0,0 +1,222 @@ + + + + + + + diff --git a/apps/files_sharing/src/components/UnifiedShareList.vue b/apps/files_sharing/src/components/UnifiedShareList.vue new file mode 100644 index 0000000000000..9d3f80c7c691b --- /dev/null +++ b/apps/files_sharing/src/components/UnifiedShareList.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/apps/files_sharing/src/components/UnifiedShareListSkeleton.vue b/apps/files_sharing/src/components/UnifiedShareListSkeleton.vue new file mode 100644 index 0000000000000..bda92099c1022 --- /dev/null +++ b/apps/files_sharing/src/components/UnifiedShareListSkeleton.vue @@ -0,0 +1,85 @@ + + + + + + + diff --git a/apps/files_sharing/src/lib/unifiedSharing.spec.ts b/apps/files_sharing/src/lib/unifiedSharing.spec.ts new file mode 100644 index 0000000000000..bc502d531bc03 --- /dev/null +++ b/apps/files_sharing/src/lib/unifiedSharing.spec.ts @@ -0,0 +1,172 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { SharingPermission, SharingRecipient, SharingShare } from '../types/unifiedSharing.ts' + +import { describe, expect, test, vi } from 'vitest' +import { + isNoUserRecipient, + permissionLabel, + recipientPermissionLabel, + recipientSummary, + reshareSubtitle, + sharePermissionRank, + sortSharesByPermission, +} from './unifiedSharing.ts' +import { + RECIPIENT_TYPE_GROUP, + RECIPIENT_TYPE_USER, +} from './unifiedSharingConstants.ts' + +vi.mock('@nextcloud/l10n', () => ({ + translate: (_app: string, text: string) => text, + translatePlural: (_app: string, singular: string, plural: string, count: number) => (count === 1 ? singular : plural).replace('%n', String(count)), +})) + +vi.mock('@nextcloud/capabilities', () => ({ + getCapabilities: () => ({ + sharing: { + permission_presets: [ + { class: 'PresetView', display_name: 'Can view' }, + { class: 'PresetEdit', display_name: 'Can edit' }, + ], + }, + }), +})) + +function permission(cls: string, priority: number, enabled: boolean, presets: string[] = []): SharingPermission { + return { + class: cls, + source_class: null, + display_name: cls, + hint: null, + priority, + presets, + enabled, + } +} + +function recipient(cls: string, value: string, initiator: SharingRecipient['initiator'] = null, permissions: SharingPermission[] = []): SharingRecipient { + return { + class: cls, + value, + instance: null, + display_name: value, + icon: null, + secret: { updatable: false }, + initiator, + permissions, + } +} + +function share(overrides: Partial = {}): SharingShare { + return { + id: '1', + owner: { user_id: 'alice', instance: null, display_name: 'Alice', icon: { svg: '' } }, + last_updated: 0, + state: 'active', + sources: [], + recipients: [], + permissions: [], + permission_preset: null, + ...overrides, + } +} + +describe('sharePermissionRank', () => { + test('sums priorities of enabled permissions only', () => { + const s = share({ + permissions: [permission('read', 1, true), permission('write', 4, true), permission('share', 16, false)], + }) + expect(sharePermissionRank(s)).toBe(5) + }) +}) + +describe('sortSharesByPermission', () => { + test('orders by rank desc, then recipient count, then id', () => { + const low = share({ id: 'a', permissions: [permission('read', 1, true)] }) + const high = share({ id: 'b', permissions: [permission('read', 1, true), permission('write', 4, true)] }) + const highMoreRecipients = share({ + id: 'c', + permissions: [permission('read', 1, true), permission('write', 4, true)], + recipients: [recipient(RECIPIENT_TYPE_USER, 'bob'), recipient(RECIPIENT_TYPE_USER, 'carol')], + }) + const sorted = sortSharesByPermission([low, high, highMoreRecipients]) + expect(sorted.map((s) => s.id)).toEqual(['c', 'b', 'a']) + }) + + test('does not mutate the input', () => { + const input = [share({ id: 'a' }), share({ id: 'b' })] + sortSharesByPermission(input) + expect(input.map((s) => s.id)).toEqual(['a', 'b']) + }) +}) + +describe('isNoUserRecipient', () => { + test('true for non-user classes, false for users', () => { + expect(isNoUserRecipient(recipient(RECIPIENT_TYPE_GROUP, 'devs'))).toBe(true) + expect(isNoUserRecipient(recipient(RECIPIENT_TYPE_USER, 'bob'))).toBe(false) + }) +}) + +describe('recipientSummary', () => { + test('counts and pluralizes by category', () => { + const recipients = [ + recipient(RECIPIENT_TYPE_USER, 'bob'), + recipient(RECIPIENT_TYPE_GROUP, 'devs'), + recipient(RECIPIENT_TYPE_GROUP, 'ops'), + ] + expect(recipientSummary(recipients)).toBe('1 person, 2 groups') + }) +}) + +describe('permissionLabel', () => { + test('returns the preset display name', () => { + expect(permissionLabel(share({ permission_preset: 'PresetEdit' }))).toBe('Can edit') + }) + + test('returns Custom permissions when no preset matches', () => { + expect(permissionLabel(share({ permission_preset: null }))).toBe('Custom permissions') + expect(permissionLabel(share({ permission_preset: 'Unknown' }))).toBe('Custom permissions') + }) +}) + +describe('reshareSubtitle', () => { + test('counts recipients added by someone other than the owner', () => { + const initiator = { user_id: 'bob', instance: null, display_name: 'Bob', icon: { svg: '' } } + const s = share({ + recipients: [ + recipient(RECIPIENT_TYPE_USER, 'carol', initiator), + recipient(RECIPIENT_TYPE_USER, 'dave'), + ], + }) + expect(reshareSubtitle(s)).toBe('Reshared with 1 person') + }) + + test('empty when no reshares', () => { + expect(reshareSubtitle(share({ recipients: [recipient(RECIPIENT_TYPE_USER, 'bob')] }))).toBe('') + }) +}) + +describe('recipientPermissionLabel', () => { + const read = (enabled: boolean) => permission('read', 1, enabled, ['PresetView', 'PresetEdit']) + const write = (enabled: boolean) => permission('write', 4, enabled, ['PresetEdit']) + // Share grants both -> its own label would be "Can edit". + const base = share({ permissions: [read(true), write(true)] }) + + test('inherits the share permissions when the recipient has no overrides', () => { + expect(recipientPermissionLabel(base, recipient(RECIPIENT_TYPE_USER, 'bob'))).toBe('Can edit') + }) + + test('applies the recipient overrides on top of the share', () => { + const r = recipient(RECIPIENT_TYPE_USER, 'bob', null, [write(false)]) + expect(recipientPermissionLabel(base, r)).toBe('Can view') + }) + + test('falls back to Custom permissions when nothing matches', () => { + const r = recipient(RECIPIENT_TYPE_USER, 'bob', null, [read(false)]) + expect(recipientPermissionLabel(base, r)).toBe('Custom permissions') + }) +}) diff --git a/apps/files_sharing/src/lib/unifiedSharing.ts b/apps/files_sharing/src/lib/unifiedSharing.ts new file mode 100644 index 0000000000000..90e86a51c3b39 --- /dev/null +++ b/apps/files_sharing/src/lib/unifiedSharing.ts @@ -0,0 +1,168 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { SharingPermission, SharingRecipient, SharingShare } from '../types/unifiedSharing.ts' + +import { getCapabilities } from '@nextcloud/capabilities' +import { translatePlural as n, translate as t } from '@nextcloud/l10n' +import { + RECIPIENT_TYPE_EMAIL, + RECIPIENT_TYPE_GROUP, + RECIPIENT_TYPE_TEAM, + RECIPIENT_TYPE_TOKEN, + RECIPIENT_TYPE_USER, +} from './unifiedSharingConstants.ts' + +/** + * Rank a share by its highest granted permissions: the sum of priorities of its + * enabled permissions. Used to order the flat share list (highest first). + * + * @param share The share to rank + */ +export function sharePermissionRank(share: SharingShare): number { + return share.permissions + .filter((permission) => permission.enabled) + .reduce((sum, permission) => sum + permission.priority, 0) +} + +/** + * Sort shares by highest permissions first, then by recipient count, then by id + * for a stable order. + * + * @param shares The shares to sort (not mutated) + */ +export function sortSharesByPermission(shares: SharingShare[]): SharingShare[] { + return [...shares].sort((a, b) => { + const rank = sharePermissionRank(b) - sharePermissionRank(a) + if (rank !== 0) { + return rank + } + const count = b.recipients.length - a.recipients.length + if (count !== 0) { + return count + } + return a.id.localeCompare(b.id) + }) +} + +type CapabilitiesWithPresets = { + sharing?: { + permission_presets?: { class: string, display_name: string }[] + } +} + +/** + * Human-readable label for a share's permission preset, e.g. "Can edit". Falls + * back to "Custom permissions" when the enabled permissions match no preset. + * + * @param share The share + */ +export function permissionLabel(share: SharingShare): string { + if (share.permission_preset === null) { + return t('files_sharing', 'Custom permissions') + } + const capabilities = getCapabilities() as CapabilitiesWithPresets + const preset = (capabilities.sharing?.permission_presets ?? []).find((p) => p.class === share.permission_preset) + return preset?.display_name ?? t('files_sharing', 'Custom permissions') +} + +/** + * Human-readable label for a set of permissions: the preset whose member + * permissions are exactly the enabled ones, else "Custom permissions". + * + * @param permissions The permissions to label + */ +function labelForPermissions(permissions: SharingPermission[]): string { + const capabilities = getCapabilities() as CapabilitiesWithPresets + const enabled = new Set(permissions.filter((permission) => permission.enabled).map((permission) => permission.class)) + for (const preset of capabilities.sharing?.permission_presets ?? []) { + const members = permissions.filter((permission) => permission.presets.includes(preset.class)) + if (members.length > 0 && members.length === enabled.size && members.every((permission) => enabled.has(permission.class))) { + return preset.display_name + } + } + return t('files_sharing', 'Custom permissions') +} + +/** + * Human-readable permission label for a single recipient. + * + * A recipient's permissions are sparse overrides on top of the share's, so the + * effective state is the share's permissions with the recipient's applied. + * + * @param share The share the recipient belongs to + * @param recipient The recipient + */ +export function recipientPermissionLabel(share: SharingShare, recipient: SharingRecipient): string { + const overrides = new Map((recipient.permissions ?? []).map((permission) => [permission.class, permission])) + return labelForPermissions(share.permissions.map((permission) => ({ + ...permission, + enabled: overrides.get(permission.class)?.enabled ?? permission.enabled, + }))) +} + +/** + * Whether a recipient should render a non-user (initials) avatar. + * + * @param recipient The recipient + */ +export function isNoUserRecipient(recipient: SharingRecipient): boolean { + return recipient.class !== RECIPIENT_TYPE_USER +} + +/** + * Build a human-readable summary of a share's recipients, e.g. + * "1 person, 2 groups". Categories are listed in a stable order and only + * non-empty ones are included. + * + * @param recipients The share's recipients + */ +export function recipientSummary(recipients: SharingRecipient[]): string { + const counts: Record = {} + for (const recipient of recipients) { + counts[recipient.class] = (counts[recipient.class] ?? 0) + 1 + } + + const parts: string[] = [] + const push = (count: number, singular: string, plural: string) => { + if (count > 0) { + parts.push(n('files_sharing', singular, plural, count)) + } + } + + push(counts[RECIPIENT_TYPE_USER] ?? 0, '%n person', '%n people') + push(counts[RECIPIENT_TYPE_GROUP] ?? 0, '%n group', '%n groups') + push(counts[RECIPIENT_TYPE_TEAM] ?? 0, '%n team', '%n teams') + push(counts[RECIPIENT_TYPE_EMAIL] ?? 0, '%n email', '%n emails') + push(counts[RECIPIENT_TYPE_TOKEN] ?? 0, '%n link', '%n links') + + // Fallback for any unknown recipient class not covered above. + const known = new Set([ + RECIPIENT_TYPE_USER, + RECIPIENT_TYPE_GROUP, + RECIPIENT_TYPE_TEAM, + RECIPIENT_TYPE_EMAIL, + RECIPIENT_TYPE_TOKEN, + ]) + const otherCount = recipients.filter((r) => !known.has(r.class)).length + push(otherCount, '%n recipient', '%n recipients') + + return parts.join(t('files_sharing', ', ')) +} + +/** + * Best-effort "Reshared with N people" subtitle: counts recipients that were + * added by someone other than the share owner (i.e. via a reshare). Returns an + * empty string when there are none. + * + * @param share The share + */ +export function reshareSubtitle(share: SharingShare): string { + const reshared = share.recipients.filter((recipient) => recipient.initiator !== null && recipient.initiator.user_id !== share.owner.user_id).length + if (reshared === 0) { + return '' + } + return n('files_sharing', 'Reshared with %n person', 'Reshared with %n people', reshared) +} diff --git a/apps/files_sharing/src/lib/unifiedSharingConstants.ts b/apps/files_sharing/src/lib/unifiedSharingConstants.ts new file mode 100644 index 0000000000000..be8e2840f1df8 --- /dev/null +++ b/apps/files_sharing/src/lib/unifiedSharingConstants.ts @@ -0,0 +1,20 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Hardcoded backend class strings from the unified sharing API, mirrored from + * the dialog library's constants. They are validated against the server + * capabilities at runtime before use (see `unifiedShares.ts`). + */ + +/** Node (file/folder) source type. */ +export const SOURCE_TYPE_NODE = 'OCA\\Files\\Sharing\\Source\\NodeShareSourceType' + +/** Recipient type classes. */ +export const RECIPIENT_TYPE_USER = 'OC\\Core\\Sharing\\Recipient\\UserShareRecipientType' +export const RECIPIENT_TYPE_EMAIL = 'OC\\Core\\Sharing\\Recipient\\EmailShareRecipientType' +export const RECIPIENT_TYPE_GROUP = 'OC\\Core\\Sharing\\Recipient\\GroupShareRecipientType' +export const RECIPIENT_TYPE_TEAM = 'OC\\Core\\Sharing\\Recipient\\TeamShareRecipientType' +export const RECIPIENT_TYPE_TOKEN = 'OC\\Core\\Sharing\\Recipient\\TokenShareRecipientType' diff --git a/apps/files_sharing/src/services/ConfigService.ts b/apps/files_sharing/src/services/ConfigService.ts index edea1119e26a9..bf47a23c3fcd4 100644 --- a/apps/files_sharing/src/services/ConfigService.ts +++ b/apps/files_sharing/src/services/ConfigService.ts @@ -4,6 +4,7 @@ */ import { getCapabilities } from '@nextcloud/capabilities' import { loadState } from '@nextcloud/initial-state' +import { isSharingDialogAvailable } from './SharingDialog.ts' type PasswordPolicySettings = { enforceNonCommonPassword: boolean @@ -354,4 +355,13 @@ export default class Config { get showExternalSharing(): boolean { return loadState('files_sharing', 'showExternalSharing', true) } + + /** + * Whether the new unified sharing dialog replaces the legacy inline sharing UI. + * Derived from the server capabilities: when the unified sharing API is not + * advertised (capability empty), the legacy inputs and menus are used instead. + */ + get sharingDialogEnabled(): boolean { + return isSharingDialogAvailable() + } } diff --git a/apps/files_sharing/src/services/SharingDialog.ts b/apps/files_sharing/src/services/SharingDialog.ts new file mode 100644 index 0000000000000..3363a74230d23 --- /dev/null +++ b/apps/files_sharing/src/services/SharingDialog.ts @@ -0,0 +1,63 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Node } from '@nextcloud/files' + +import { getCapabilities } from '@nextcloud/capabilities' + +/** + * The unified sharing dialog is a Vue 3 component. It is registered on the + * global `OCA.Sharing` namespace by the Vue 3 bridge entry point + * (`sharing-dialog-bridge.ts`), so this Vue 2 frontend can trigger it without + * pulling Vue 3 into its bundle. + */ +type SharingDialogApi = { + openSharingDialog(node: Node): Promise + openShareEditDialog(shareId: string | number, node?: Node): Promise +} + +/** + * Get the sharing dialog API registered by the Vue 3 bridge, if loaded. + */ +function sharingDialogApi(): SharingDialogApi | undefined { + return (window.OCA?.Sharing as Partial | undefined) as SharingDialogApi | undefined +} + +/** + * Whether the server provides the unified sharing API this dialog talks to. + * Mirrors the library check so the sidebar can gate on it without importing + * the Vue 3 code. + */ +export function isSharingDialogAvailable(): boolean { + const capabilities = getCapabilities() as { sharing?: { api_versions?: unknown[] } } + return (capabilities.sharing?.api_versions?.length ?? 0) > 0 +} + +/** + * Open the unified sharing dialog to create a new share for a node. + * + * @param node The file or folder to share + */ +export function openShareCreateDialog(node: Node): Promise { + const api = sharingDialogApi() + if (!api?.openSharingDialog) { + return Promise.reject(new Error('The unified sharing dialog is not available')) + } + return api.openSharingDialog(node) +} + +/** + * Open the unified sharing dialog to edit an existing share. + * + * @param shareId The share id (mapped to the unified API by the legacy bridge) + * @param node The backing node, used for the dialog title + */ +export function openShareEditDialog(shareId: string | number, node?: Node): Promise { + const api = sharingDialogApi() + if (!api?.openShareEditDialog) { + return Promise.reject(new Error('The unified sharing dialog is not available')) + } + return api.openShareEditDialog(shareId, node) +} diff --git a/apps/files_sharing/src/services/unifiedShares.spec.ts b/apps/files_sharing/src/services/unifiedShares.spec.ts new file mode 100644 index 0000000000000..5a7d7811e0a8a --- /dev/null +++ b/apps/files_sharing/src/services/unifiedShares.spec.ts @@ -0,0 +1,77 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { SOURCE_TYPE_NODE } from '../lib/unifiedSharingConstants.ts' +import { deleteShare, getSharesForNode, removeRecipient } from './unifiedShares.ts' + +const axios = vi.hoisted(() => ({ get: vi.fn(), delete: vi.fn() })) +vi.mock('@nextcloud/axios', () => ({ default: axios })) + +const capabilities = vi.hoisted(() => ({ value: {} as unknown })) +vi.mock('@nextcloud/capabilities', () => ({ getCapabilities: () => capabilities.value })) + +vi.mock('@nextcloud/router', () => ({ + generateOcsUrl: (path: string, params?: Record) => 'ocs/' + path.replace(/\{(\w+)\}/g, (_, k) => params?.[k] ?? ''), +})) + +vi.mock('./logger.ts', () => ({ default: { warn: vi.fn(), error: vi.fn(), debug: vi.fn() } })) + +const ocs = (data: unknown[]) => ({ data: { ocs: { data } } }) +const node = { fileid: 42 } as never + +beforeEach(() => { + vi.resetAllMocks() + capabilities.value = { sharing: { source_types: [{ class: SOURCE_TYPE_NODE }] } } +}) + +describe('getSharesForNode', () => { + test('returns [] and does not call the API when the node source type is unavailable', async () => { + capabilities.value = { sharing: { source_types: [] } } + expect(await getSharesForNode(node)).toEqual([]) + expect(axios.get).not.toHaveBeenCalled() + }) + + test('unwraps ocs.data and filters by node', async () => { + axios.get.mockResolvedValueOnce(ocs([{ id: '1' }, { id: '2' }])) + const shares = await getSharesForNode(node) + expect(shares).toEqual([{ id: '1' }, { id: '2' }]) + expect(axios.get).toHaveBeenCalledTimes(1) + expect(axios.get.mock.calls[0][1].params).toMatchObject({ + filterSourceTypeClass: SOURCE_TYPE_NODE, + filterSourceTypeValue: '42', + filterState: 'active', + limit: 100, + }) + }) + + test('walks all pages until a short page', async () => { + const fullPage = Array.from({ length: 100 }, (_, i) => ({ id: String(i) })) + axios.get + .mockResolvedValueOnce(ocs(fullPage)) + .mockResolvedValueOnce(ocs([{ id: '100' }, { id: '101' }])) + const shares = await getSharesForNode(node) + expect(shares).toHaveLength(102) + expect(axios.get).toHaveBeenCalledTimes(2) + // Second page requests the id after the last of the first page. + expect(axios.get.mock.calls[1][1].params.lastShareID).toBe('99') + }) +}) + +describe('deleteShare', () => { + test('DELETEs the share by id', async () => { + await deleteShare('7') + expect(axios.delete).toHaveBeenCalledWith('ocs//apps/sharing/api/v1/share/7') + }) +}) + +describe('removeRecipient', () => { + test('DELETEs the recipient with the class/value/instance body', async () => { + await removeRecipient('7', 'RecipClass', 'bob', null) + expect(axios.delete).toHaveBeenCalledWith('ocs//apps/sharing/api/v1/share/7/recipient', { + data: { class: 'RecipClass', value: 'bob', instance: null }, + }) + }) +}) diff --git a/apps/files_sharing/src/services/unifiedShares.ts b/apps/files_sharing/src/services/unifiedShares.ts new file mode 100644 index 0000000000000..6e9be778c7a1a --- /dev/null +++ b/apps/files_sharing/src/services/unifiedShares.ts @@ -0,0 +1,96 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Node } from '@nextcloud/files' +import type { SharingShare } from '../types/unifiedSharing.ts' + +import axios from '@nextcloud/axios' +import { getCapabilities } from '@nextcloud/capabilities' +import { generateOcsUrl } from '@nextcloud/router' +import { SOURCE_TYPE_NODE } from '../lib/unifiedSharingConstants.ts' +import logger from './logger.ts' + +const PAGE_SIZE = 100 + +type Capabilities = { + sharing?: { + source_types?: { class: string }[] + } +} + +/** + * Whether the node source type is registered on the server. The hardcoded class + * string must match a capability entry, otherwise listing would silently return + * nothing. + */ +function isNodeSourceTypeAvailable(): boolean { + const capabilities = getCapabilities() as Capabilities + return (capabilities.sharing?.source_types ?? []).some((type) => type.class === SOURCE_TYPE_NODE) +} + +/** + * Fetch every active share whose source is the given node, from the unified + * sharing API. Results are paginated by the backend; this walks all pages. + * + * @param node The file or folder whose shares to list + */ +export async function getSharesForNode(node: Node): Promise { + if (!isNodeSourceTypeAvailable()) { + logger.warn('Node source type is not advertised by the sharing capability; cannot list unified shares') + return [] + } + + const url = generateOcsUrl('/apps/sharing/api/v1/shares') + const shares: SharingShare[] = [] + let lastShareID: string | undefined + + // Walk pages until the backend returns a short (final) page. + for (;;) { + const response = await axios.get(url, { + params: { + filterSourceTypeClass: SOURCE_TYPE_NODE, + filterSourceTypeValue: String(node.fileid), + filterState: 'active', + limit: PAGE_SIZE, + ...(lastShareID ? { lastShareID } : {}), + }, + }) + const page: SharingShare[] = response.data.ocs.data + shares.push(...page) + if (page.length < PAGE_SIZE) { + break + } + lastShareID = page[page.length - 1].id + } + + return shares +} + +/** + * Delete a share by id. + * + * @param id The share id + */ +export async function deleteShare(id: string): Promise { + await axios.delete(generateOcsUrl('/apps/sharing/api/v1/share/{id}', { id })) +} + +/** + * Remove a single recipient from a share. + * + * @param id The share id + * @param recipientClass The recipient type class + * @param recipientValue The recipient value + * @param instance The recipient's instance (federated recipients) + */ +export async function removeRecipient(id: string, recipientClass: string, recipientValue: string, instance?: string | null): Promise { + await axios.delete(generateOcsUrl('/apps/sharing/api/v1/share/{id}/recipient', { id }), { + data: { + class: recipientClass, + value: recipientValue, + instance: instance ?? null, + }, + }) +} diff --git a/apps/files_sharing/src/sharing-dialog-bridge.ts b/apps/files_sharing/src/sharing-dialog-bridge.ts new file mode 100644 index 0000000000000..2cd81c671b9d9 --- /dev/null +++ b/apps/files_sharing/src/sharing-dialog-bridge.ts @@ -0,0 +1,39 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Node } from '@nextcloud/files' + +import { getShare, isSharingDialogAvailable, openSharingDialog } from '@nextcloud/sharing/dialog' + +/** + * Bridge the Vue 3 unified sharing dialog into the global `OCA.Sharing` + * namespace so the (still Vue 2) files_sharing sidebar can trigger it without + * importing Vue 3 code into its bundle. The dialog spawns its own Vue 3 app, + * so it must be registered from this Vue 3 entry point where `vue` and + * `@nextcloud/vue` resolve to their Vue 3 versions. + * + * Once files_sharing is migrated to Vue 3 this bridge can be dropped and the + * library imported directly. + */ + +window.OCA ??= {} +window.OCA.Sharing ??= {} + +Object.assign(window.OCA.Sharing, { + openSharingDialog, + + /** + * Open the unified sharing dialog to edit an existing share. + * + * @param shareId The share id (mapped to the unified API by the legacy bridge) + * @param node The backing node, used for the dialog title + */ + async openShareEditDialog(shareId: string | number, node?: Node): Promise { + const share = await getShare(String(shareId)) + return share.showDialog(node) + }, + + isSharingDialogAvailable, +}) diff --git a/apps/files_sharing/src/types/unifiedSharing.ts b/apps/files_sharing/src/types/unifiedSharing.ts new file mode 100644 index 0000000000000..74a4d259c4eb1 --- /dev/null +++ b/apps/files_sharing/src/types/unifiedSharing.ts @@ -0,0 +1,78 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Types mirroring the unified sharing API (`@nextcloud/sharing/dialog`). + * + * The dialog library is Vue 3 and cannot be imported into this Vue 2 frontend, + * so the subset of the schema needed to list shares in the sidebar is kept here. + * Keep in sync with the library's `lib/dialog/types/api.ts`. + */ + +export interface SharingIconSVG { + svg: string +} + +export interface SharingIconURL { + light: string + dark: string +} + +export type SharingIcon = SharingIconSVG | SharingIconURL + +export interface SharingOwner { + user_id: string + instance: string | null + display_name: string + icon: SharingIcon +} + +export interface SharingSource { + class: string + value: string + display_name: string + icon: SharingIcon | null +} + +export interface SharingRecipientSecret { + updatable: boolean + value?: string + url?: string +} + +export interface SharingRecipient { + class: string + value: string + instance: string | null + display_name: string + icon: SharingIcon | null + secret: SharingRecipientSecret + initiator: SharingOwner | null + /** Per-recipient permissions, capped at the share-level ones */ + permissions: SharingPermission[] +} + +export interface SharingPermission { + class: string + source_class: string | null + display_name: string + hint: string | null + priority: number + presets: string[] + enabled: boolean +} + +export type SharingState = 'active' | 'draft' | 'deleted' + +export interface SharingShare { + id: string + owner: SharingOwner + last_updated: number + state: SharingState + sources: SharingSource[] + recipients: SharingRecipient[] + permissions: SharingPermission[] + permission_preset: string | null +} diff --git a/apps/files_sharing/src/views/SharingTab.spec.ts b/apps/files_sharing/src/views/SharingTab.spec.ts new file mode 100644 index 0000000000000..d609e3aff6846 --- /dev/null +++ b/apps/files_sharing/src/views/SharingTab.spec.ts @@ -0,0 +1,103 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// The component reads the theme at module scope, so it must exist before import. +vi.hoisted(() => { + window.OC = { ...window.OC, theme: { productName: 'Nextcloud' } } +}) + +import SharingTab from './SharingTab.vue' +import { openShareCreateDialog } from '../services/SharingDialog.ts' +import { getSharesForNode } from '../services/unifiedShares.ts' + +vi.mock('../services/SharingDialog.ts', () => ({ + openShareCreateDialog: vi.fn().mockResolvedValue(undefined), + isSharingDialogAvailable: vi.fn().mockReturnValue(true), +})) + +vi.mock('../services/unifiedShares.ts', () => ({ + getSharesForNode: vi.fn().mockResolvedValue([]), +})) + +vi.mock('../services/logger.ts', () => ({ + default: { error: vi.fn(), debug: vi.fn() }, +})) + +function buildContext(overrides: Record = {}) { + return { + fileInfo: { node: { fileid: 1 } }, + unifiedShares: [], + loading: false, + error: '', + getUnifiedShares: vi.fn().mockResolvedValue(undefined), + ...overrides, + } +} + +type Ctx = ReturnType + +/** + * Call a component method with a fake `this`, as the other specs here do. + * + * @param method The method name + * @param ctx The fake component context + * @param args Arguments passed to the method + */ +function call(method: string, ctx: Ctx, ...args: unknown[]) { + return SharingTab.methods[method].call(ctx, ...args) +} + +beforeEach(() => vi.clearAllMocks()) + +describe('openShareDialog', () => { + it('refreshes the list once the dialog closes', async () => { + const ctx = buildContext() + await call('openShareDialog', ctx) + expect(openShareCreateDialog).toHaveBeenCalledWith(ctx.fileInfo.node) + // Without the loading state, so the rendered rows stay mounted. + expect(ctx.getUnifiedShares).toHaveBeenCalledWith(false) + }) + + it('still refreshes when the dialog fails', async () => { + vi.mocked(openShareCreateDialog).mockRejectedValueOnce(new Error('nope')) + const ctx = buildContext() + await call('openShareDialog', ctx) + expect(ctx.getUnifiedShares).toHaveBeenCalledWith(false) + }) +}) + +describe('getUnifiedShares', () => { + it('loads the shares of the current node', async () => { + const shares = [{ id: '1' }] + vi.mocked(getSharesForNode).mockResolvedValueOnce(shares) + const ctx = buildContext({ getUnifiedShares: SharingTab.methods.getUnifiedShares }) + await SharingTab.methods.getUnifiedShares.call(ctx) + expect(getSharesForNode).toHaveBeenCalledWith(ctx.fileInfo.node) + expect(ctx.unifiedShares).toEqual(shares) + }) + + it('keeps the rows mounted while refreshing in place', async () => { + const ctx = buildContext() + let loadingWhileFetching = false + vi.mocked(getSharesForNode).mockImplementationOnce(async () => { + loadingWhileFetching = ctx.loading + return [] + }) + await SharingTab.methods.getUnifiedShares.call(ctx, false) + expect(loadingWhileFetching).toBe(false) + }) + + it('surfaces the backend error message', async () => { + vi.mocked(getSharesForNode).mockRejectedValueOnce({ + response: { data: { ocs: { meta: { message: 'Nope' } } } }, + }) + const ctx = buildContext() + await SharingTab.methods.getUnifiedShares.call(ctx) + expect(ctx.error).toBe('Nope') + expect(ctx.loading).toBe(false) + }) +}) diff --git a/apps/files_sharing/src/views/SharingTab.vue b/apps/files_sharing/src/views/SharingTab.vue index 0463a9969d28f..fa9710d6288fd 100644 --- a/apps/files_sharing/src/views/SharingTab.vue +++ b/apps/files_sharing/src/views/SharingTab.vue @@ -4,7 +4,7 @@ -->