diff --git a/apps/files_sharing/src/files_actions/acceptShareAction.spec.ts b/apps/files_sharing/src/files_actions/acceptShareAction.spec.ts deleted file mode 100644 index 3956df517e9b9..0000000000000 --- a/apps/files_sharing/src/files_actions/acceptShareAction.spec.ts +++ /dev/null @@ -1,285 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { IFolder, IView } from '@nextcloud/files' - -import axios from '@nextcloud/axios' -import * as eventBus from '@nextcloud/event-bus' -import { File, Permission } from '@nextcloud/files' -import { ShareType } from '@nextcloud/sharing' -import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' -import { action } from './acceptShareAction.ts' - -import '../main.ts' - -vi.mock('@nextcloud/axios') - -const view = { - id: 'files', - name: 'Files', -} as IView - -const pendingShareView = { - id: 'pendingshares', - name: 'Pending shares', -} as IView - -// Mock webroot variable -beforeAll(() => { - (window as any)._oc_webroot = '' -}) - -describe('Accept share action conditions tests', () => { - test('Default values', () => { - const file = new File({ - id: 1, - source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', - owner: 'admin', - mime: 'text/plain', - permissions: Permission.ALL, - root: '/files/admin', - }) - - expect(action.id).toBe('accept-share') - expect(action.displayName({ - nodes: [file], - view: pendingShareView, - folder: {} as IFolder, - contents: [], - })).toBe('Accept share') - expect(action.iconSvgInline({ - nodes: [file], - view: pendingShareView, - folder: {} as IFolder, - contents: [], - })).toMatch(//) - expect(action.default).toBeUndefined() - expect(action.order).toBe(1) - expect(action.inline).toBeDefined() - expect(action.inline!({ - nodes: [file], - view: pendingShareView, - folder: {} as IFolder, - contents: [], - })).toBe(true) - }) - - test('Default values for multiple files', () => { - const file1 = new File({ - id: 1, - source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', - owner: 'admin', - mime: 'text/plain', - permissions: Permission.ALL, - root: '/files/admin', - }) - const file2 = new File({ - id: 2, - source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', - owner: 'admin', - mime: 'text/plain', - permissions: Permission.ALL, - root: '/files/admin', - }) - - expect(action.displayName({ - nodes: [file1, file2], - view: pendingShareView, - folder: {} as IFolder, - contents: [], - })).toBe('Accept shares') - }) -}) - -describe('Accept share action enabled tests', () => { - test('Enabled with on pending shares view', () => { - const file = new File({ - id: 1, - source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', - owner: 'admin', - mime: 'text/plain', - permissions: Permission.ALL, - root: '/files/admin', - }) - - expect(action.enabled).toBeDefined() - expect(action.enabled!({ - nodes: [file], - view: pendingShareView, - folder: {} as IFolder, - contents: [], - })).toBe(true) - }) - - test('Disabled on wrong view', () => { - expect(action.enabled).toBeDefined() - expect(action.enabled!({ - nodes: [], - view, - folder: {} as IFolder, - contents: [], - })).toBe(false) - }) - - test('Disabled without nodes', () => { - expect(action.enabled).toBeDefined() - expect(action.enabled!({ - nodes: [], - view: pendingShareView, - folder: {} as IFolder, - contents: [], - })).toBe(false) - }) -}) - -describe('Accept share action execute tests', () => { - beforeEach(() => { - vi.resetAllMocks() - }) - - test('Accept share action', async () => { - vi.spyOn(axios, 'post') - vi.spyOn(eventBus, 'emit') - - const file = new File({ - id: 123, - source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', - owner: 'admin', - mime: 'text/plain', - permissions: Permission.READ, - attributes: { - share_type: ShareType.User, - 'share-id': '1', - }, - root: '/files/admin', - }) - - const exec = await action.exec({ - nodes: [file], - view: pendingShareView, - folder: {} as IFolder, - contents: [], - }) - - expect(exec).toBe(true) - expect(axios.post).toBeCalledTimes(1) - expect(axios.post).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/pending/1') - - expect(eventBus.emit).toBeCalledTimes(1) - expect(eventBus.emit).toBeCalledWith('files:node:deleted', file) - }) - - test('Accept remote share action', async () => { - vi.spyOn(axios, 'post') - vi.spyOn(eventBus, 'emit') - - const file = new File({ - id: 123, - source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', - owner: 'admin', - mime: 'text/plain', - permissions: Permission.READ, - attributes: { - remote: 3, - share_type: ShareType.User, - 'share-id': '1', - }, - root: '/files/admin', - }) - - const exec = await action.exec({ - nodes: [file], - view: pendingShareView, - folder: {} as IFolder, - contents: [], - }) - - expect(exec).toBe(true) - expect(axios.post).toBeCalledTimes(1) - expect(axios.post).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/remote_shares/pending/1') - - expect(eventBus.emit).toBeCalledTimes(1) - expect(eventBus.emit).toBeCalledWith('files:node:deleted', file) - }) - - test('Accept share action batch', async () => { - vi.spyOn(axios, 'post') - vi.spyOn(eventBus, 'emit') - - const file1 = new File({ - id: 123, - source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt', - owner: 'admin', - mime: 'text/plain', - permissions: Permission.READ, - attributes: { - share_type: ShareType.User, - 'share-id': '1', - }, - root: '/files/admin', - }) - - const file2 = new File({ - id: 456, - source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt', - owner: 'admin', - mime: 'text/plain', - permissions: Permission.READ, - attributes: { - share_type: ShareType.User, - 'share-id': '2', - }, - root: '/files/admin', - }) - - const exec = await action.execBatch!({ - nodes: [file1, file2], - view: pendingShareView, - folder: {} as IFolder, - contents: [], - }) - - expect(exec).toStrictEqual([true, true]) - expect(axios.post).toBeCalledTimes(2) - expect(axios.post).toHaveBeenNthCalledWith(1, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/pending/1') - expect(axios.post).toHaveBeenNthCalledWith(2, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/pending/2') - - expect(eventBus.emit).toBeCalledTimes(2) - expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:node:deleted', file1) - expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:node:deleted', file2) - }) - - test('Accept fails', async () => { - vi.spyOn(axios, 'post').mockImplementation(() => { - throw new Error('Mock error') - }) - - const file = new File({ - id: 123, - source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', - owner: 'admin', - mime: 'text/plain', - permissions: Permission.READ, - attributes: { - share_type: ShareType.User, - 'share-id': '1', - }, - root: '/files/admin', - }) - - const exec = await action.exec({ - nodes: [file], - view: pendingShareView, - folder: {} as IFolder, - contents: [], - }) - - expect(exec).toBe(false) - expect(axios.post).toBeCalledTimes(1) - expect(axios.post).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/pending/1') - - expect(eventBus.emit).toBeCalledTimes(0) - }) -}) diff --git a/apps/files_sharing/src/files_actions/acceptShareAction.ts b/apps/files_sharing/src/files_actions/acceptShareAction.ts deleted file mode 100644 index 0368dec4bd6f3..0000000000000 --- a/apps/files_sharing/src/files_actions/acceptShareAction.ts +++ /dev/null @@ -1,51 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { IFileAction } from '@nextcloud/files' - -import CheckSvg from '@mdi/svg/svg/check.svg?raw' -import axios from '@nextcloud/axios' -import { emit } from '@nextcloud/event-bus' -import { translatePlural as n } from '@nextcloud/l10n' -import { generateOcsUrl } from '@nextcloud/router' -import { pendingSharesViewId } from '../files_views/shares.ts' - -export const action: IFileAction = { - id: 'accept-share', - displayName: ({ nodes }) => n('files_sharing', 'Accept share', 'Accept shares', nodes.length), - iconSvgInline: () => CheckSvg, - - enabled: ({ nodes, view }) => nodes.length > 0 && view.id === pendingSharesViewId, - - async exec({ nodes }) { - try { - const node = nodes[0] - const isRemote = !!node.attributes.remote - const url = generateOcsUrl('apps/files_sharing/api/v1/{shareBase}/pending/{id}', { - shareBase: isRemote ? 'remote_shares' : 'shares', - id: node.attributes['share-id'], - }) - await axios.post(url) - - // Remove from current view - emit('files:node:deleted', node) - - return true - } catch { - return false - } - }, - async execBatch({ nodes, view, folder, contents }) { - return Promise.all(nodes.map((node) => this.exec({ - nodes: [node], - view, - folder, - contents, - }))) - }, - - order: 1, - inline: () => true, -} diff --git a/apps/files_sharing/src/files_actions/openInFilesAction.spec.ts b/apps/files_sharing/src/files_actions/openInFilesAction.spec.ts index 82e7738e26ac4..97be27e443b9a 100644 --- a/apps/files_sharing/src/files_actions/openInFilesAction.spec.ts +++ b/apps/files_sharing/src/files_actions/openInFilesAction.spec.ts @@ -7,7 +7,7 @@ import type { IFolder, IView } from '@nextcloud/files' import { DefaultType, File, Permission } from '@nextcloud/files' import { describe, expect, test, vi } from 'vitest' -import { deletedSharesViewId, pendingSharesViewId, sharedWithOthersViewId, sharedWithYouViewId, sharesViewId, sharingByLinksViewId } from '../files_views/shares.ts' +import { deletedSharesViewId, sharedWithOthersViewId, sharedWithYouViewId, sharesViewId, sharingByLinksViewId } from '../files_views/shares.ts' import { action } from './openInFilesAction.ts' import '../main.ts' @@ -26,7 +26,6 @@ const validViews = [ const invalidViews = [ deletedSharesViewId, - pendingSharesViewId, ].map((id) => ({ id, name: id })) as IView[] describe('Open in files action conditions tests', () => { diff --git a/apps/files_sharing/src/files_actions/openInFilesAction.ts b/apps/files_sharing/src/files_actions/openInFilesAction.ts index 2ef957283a2d4..ba1ff650f2c16 100644 --- a/apps/files_sharing/src/files_actions/openInFilesAction.ts +++ b/apps/files_sharing/src/files_actions/openInFilesAction.ts @@ -19,7 +19,7 @@ export const action: IFileAction = { sharedWithYouViewId, sharedWithOthersViewId, sharingByLinksViewId, - // Deleted and pending shares are not + // Deleted shares are not // accessible in the files app. ].includes(view.id), diff --git a/apps/files_sharing/src/files_actions/rejectShareAction.spec.ts b/apps/files_sharing/src/files_actions/rejectShareAction.spec.ts deleted file mode 100644 index b3a3577d0577d..0000000000000 --- a/apps/files_sharing/src/files_actions/rejectShareAction.spec.ts +++ /dev/null @@ -1,329 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { IView } from '@nextcloud/files' - -import axios from '@nextcloud/axios' -import * as eventBus from '@nextcloud/event-bus' -import { File, Folder, Permission } from '@nextcloud/files' -import { ShareType } from '@nextcloud/sharing' -import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' -import { action } from './rejectShareAction.ts' - -import '../main.ts' - -vi.mock('@nextcloud/axios') - -const view = { - id: 'files', - name: 'Files', -} as IView - -const pendingShareView = { - id: 'pendingshares', - name: 'Pending shares', -} as IView - -// Mock webroot variable -beforeAll(() => { - (window as any)._oc_webroot = '' -}) - -describe('Reject share action conditions tests', () => { - test('Default values', () => { - const file = new File({ - id: 1, - source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', - owner: 'admin', - mime: 'text/plain', - permissions: Permission.ALL, - root: '/files/admin', - }) - - expect(action.id).toBe('reject-share') - expect(action.displayName({ - nodes: [file], - view: pendingShareView, - folder: {} as Folder, - contents: [], - })).toBe('Reject share') - expect(action.iconSvgInline({ - nodes: [file], - view: pendingShareView, - folder: {} as Folder, - contents: [], - })).toMatch(//) - expect(action.default).toBeUndefined() - expect(action.order).toBe(2) - expect(action.inline).toBeDefined() - expect(action.inline!({ - nodes: [file], - view: pendingShareView, - folder: {} as Folder, - contents: [], - })).toBe(true) - }) - - test('Default values for multiple files', () => { - const file1 = new File({ - id: 1, - source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', - owner: 'admin', - mime: 'text/plain', - permissions: Permission.ALL, - root: '/files/admin', - }) - const file2 = new File({ - id: 2, - source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', - owner: 'admin', - mime: 'text/plain', - permissions: Permission.ALL, - root: '/files/admin', - }) - - expect(action.displayName({ - nodes: [file1, file2], - view: pendingShareView, - folder: {} as Folder, - contents: [], - })).toBe('Reject shares') - }) -}) - -describe('Reject share action enabled tests', () => { - test('Enabled with on pending shares view', () => { - const file = new File({ - id: 1, - source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', - owner: 'admin', - mime: 'text/plain', - permissions: Permission.ALL, - root: '/files/admin', - }) - - expect(action.enabled).toBeDefined() - expect(action.enabled!({ - nodes: [file], - view: pendingShareView, - folder: {} as Folder, - contents: [], - })).toBe(true) - }) - - test('Disabled on wrong view', () => { - expect(action.enabled).toBeDefined() - expect(action.enabled!({ - nodes: [], - view, - folder: {} as Folder, - contents: [], - })).toBe(false) - }) - - test('Disabled without nodes', () => { - expect(action.enabled).toBeDefined() - expect(action.enabled!({ - nodes: [], - view: pendingShareView, - folder: {} as Folder, - contents: [], - })).toBe(false) - }) - - test('Disabled if some nodes are remote group shares', () => { - const folder1 = new Folder({ - id: 1, - source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/', - owner: 'admin', - permissions: Permission.READ, - attributes: { - share_type: ShareType.User, - }, - root: '/files/admin', - }) - const folder2 = new Folder({ - id: 2, - source: 'https://cloud.domain.com/remote.php/dav/files/admin/Bar/', - owner: 'admin', - permissions: Permission.READ, - attributes: { - remote_id: 1, - share_type: ShareType.RemoteGroup, - }, - root: '/files/admin', - }) - - expect(action.enabled).toBeDefined() - expect(action.enabled!({ - nodes: [folder1], - view: pendingShareView, - folder: {} as Folder, - contents: [], - })).toBe(true) - expect(action.enabled!({ - nodes: [folder2], - view: pendingShareView, - folder: {} as Folder, - contents: [], - })).toBe(false) - expect(action.enabled!({ - nodes: [folder1, folder2], - view: pendingShareView, - folder: {} as Folder, - contents: [], - })).toBe(false) - }) -}) - -describe('Reject share action execute tests', () => { - beforeEach(() => { - vi.resetAllMocks() - }) - - test('Reject share action', async () => { - vi.spyOn(axios, 'delete') - vi.spyOn(eventBus, 'emit') - - const file = new File({ - id: '123', - source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', - owner: 'admin', - mime: 'text/plain', - permissions: Permission.READ, - attributes: { - share_type: ShareType.User, - 'share-id': '1', - }, - root: '/files/admin', - }) - - const exec = await action.exec({ - nodes: [file], - view: pendingShareView, - folder: {} as Folder, - contents: [], - }) - - expect(exec).toBe(true) - expect(axios.delete).toBeCalledTimes(1) - expect(axios.delete).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/1') - - expect(eventBus.emit).toBeCalledTimes(1) - expect(eventBus.emit).toBeCalledWith('files:node:deleted', file) - }) - - test('Reject remote share action', async () => { - vi.spyOn(axios, 'delete') - vi.spyOn(eventBus, 'emit') - - const file = new File({ - id: '123', - source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', - owner: 'admin', - mime: 'text/plain', - permissions: Permission.READ, - attributes: { - remote: 3, - share_type: ShareType.User, - 'share-id': '1', - }, - root: '/files/admin', - }) - - const exec = await action.exec({ - nodes: [file], - view: pendingShareView, - folder: {} as Folder, - contents: [], - }) - - expect(exec).toBe(true) - expect(axios.delete).toBeCalledTimes(1) - expect(axios.delete).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/remote_shares/1') - - expect(eventBus.emit).toBeCalledTimes(1) - expect(eventBus.emit).toBeCalledWith('files:node:deleted', file) - }) - - test('Reject share action batch', async () => { - vi.spyOn(axios, 'delete') - vi.spyOn(eventBus, 'emit') - - const file1 = new File({ - id: '123', - source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt', - owner: 'admin', - mime: 'text/plain', - permissions: Permission.READ, - attributes: { - share_type: ShareType.User, - 'share-id': '1', - }, - root: '/files/admin', - }) - - const file2 = new File({ - id: '456', - source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt', - owner: 'admin', - mime: 'text/plain', - permissions: Permission.READ, - attributes: { - share_type: ShareType.User, - 'share-id': '2', - }, - root: '/files/admin', - }) - - const exec = await action.execBatch!({ - nodes: [file1, file2], - view: pendingShareView, - folder: {} as Folder, - contents: [], - }) - - expect(exec).toStrictEqual([true, true]) - expect(axios.delete).toBeCalledTimes(2) - expect(axios.delete).toHaveBeenNthCalledWith(1, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/1') - expect(axios.delete).toHaveBeenNthCalledWith(2, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/2') - - expect(eventBus.emit).toBeCalledTimes(2) - expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:node:deleted', file1) - expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:node:deleted', file2) - }) - - test('Reject fails', async () => { - vi.spyOn(axios, 'delete').mockImplementation(() => { - throw new Error('Mock error') - }) - - const file = new File({ - id: 123, - source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', - owner: 'admin', - mime: 'text/plain', - permissions: Permission.READ, - attributes: { - share_type: ShareType.User, - 'share-id': '1', - }, - root: '/files/admin', - }) - - const exec = await action.exec({ - nodes: [file], - view: pendingShareView, - folder: {} as Folder, - contents: [], - }) - - expect(exec).toBe(false) - expect(axios.delete).toBeCalledTimes(1) - expect(axios.delete).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/1') - - expect(eventBus.emit).toBeCalledTimes(0) - }) -}) diff --git a/apps/files_sharing/src/files_actions/rejectShareAction.ts b/apps/files_sharing/src/files_actions/rejectShareAction.ts deleted file mode 100644 index 5458e12baafee..0000000000000 --- a/apps/files_sharing/src/files_actions/rejectShareAction.ts +++ /dev/null @@ -1,74 +0,0 @@ -/*! - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import type { IFileAction } from '@nextcloud/files' - -import CloseSvg from '@mdi/svg/svg/close.svg?raw' -import axios from '@nextcloud/axios' -import { emit } from '@nextcloud/event-bus' -import { translatePlural as n } from '@nextcloud/l10n' -import { generateOcsUrl } from '@nextcloud/router' -import { ShareType } from '@nextcloud/sharing' -import { pendingSharesViewId } from '../files_views/shares.ts' - -export const action: IFileAction = { - id: 'reject-share', - displayName: ({ nodes }) => n('files_sharing', 'Reject share', 'Reject shares', nodes.length), - iconSvgInline: () => CloseSvg, - - enabled: ({ nodes, view }) => { - if (view.id !== pendingSharesViewId) { - return false - } - - if (nodes.length === 0) { - return false - } - - // disable rejecting group shares from the pending list because they anyway - // land back into that same list after rejecting them - if (nodes.some((node) => node.attributes.remote_id - && node.attributes.share_type === ShareType.RemoteGroup)) { - return false - } - - return true - }, - - async exec({ nodes }) { - try { - const node = nodes[0] - const isRemote = !!node.attributes.remote - const shareBase = isRemote ? 'remote_shares' : 'shares' - const id = node.attributes['share-id'] - let url: string - if (node.attributes.accepted === 0) { - url = generateOcsUrl('apps/files_sharing/api/v1/{shareBase}/pending/{id}', { - shareBase, - id, - }) - } else { - url = generateOcsUrl('apps/files_sharing/api/v1/{shareBase}/{id}', { - shareBase, - id, - }) - } - await axios.delete(url) - - // Remove from current view - emit('files:node:deleted', node) - - return true - } catch { - return false - } - }, - async execBatch({ nodes, view, folder, contents }) { - return Promise.all(nodes.map((node) => this.exec({ nodes: [node], view, folder, contents }))) - }, - - order: 2, - inline: () => true, -} diff --git a/apps/files_sharing/src/files_headers/pendingShares.spec.ts b/apps/files_sharing/src/files_headers/pendingShares.spec.ts new file mode 100644 index 0000000000000..840a17f957dae --- /dev/null +++ b/apps/files_sharing/src/files_headers/pendingShares.spec.ts @@ -0,0 +1,37 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { IView } from '@nextcloud/files' + +import { Folder } from '@nextcloud/files' +import { describe, expect, test } from 'vitest' +import { isPendingSharesEnabled } from './pendingShares.ts' + +/** + * Create a folder at the given path + * + * @param path The folder path + */ +function createFolder(path: string) { + return new Folder({ + id: 1, + source: `https://cloud.domain.com/remote.php/dav/files/admin${path}`, + owner: 'admin', + root: '/files/admin', + }) +} + +describe('Pending shares header', () => { + test.for([ + ['files', '/', true], + ['shareoverview', '/', true], + ['files', '/subfolder', false], + ['shareoverview', '/subfolder', false], + ['sharingin', '/', false], + ['favorites', '/', false], + ] as const)('view %s at %s is enabled: %s', ([id, path, expected]) => { + expect(isPendingSharesEnabled(createFolder(path), { id } as IView)).toBe(expected) + }) +}) diff --git a/apps/files_sharing/src/files_headers/pendingShares.ts b/apps/files_sharing/src/files_headers/pendingShares.ts new file mode 100644 index 0000000000000..5ee707bfdea4a --- /dev/null +++ b/apps/files_sharing/src/files_headers/pendingShares.ts @@ -0,0 +1,53 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { IFolder, IView } from '@nextcloud/files' +import type { ComponentPublicInstance, VueConstructor } from 'vue' + +import { registerFileListHeader } from '@nextcloud/files' +import Vue from 'vue' +import { sharesViewId } from '../files_views/shares.ts' + +type IFilesHeaderPendingShares = ComponentPublicInstance & { update: () => Promise } + +/** + * Whether to show pending shares: on top of "All files" and "Shares" + * + * @param folder The current folder + * @param view The current view + */ +export function isPendingSharesEnabled(folder: IFolder, view: IView): boolean { + return ['files', sharesViewId].includes(view.id) && folder.path === '/' +} + +/** + * Register the pending shares as a files list header + */ +export default function registerPendingShares() { + let FilesHeaderPendingShares: VueConstructor + let instance: IFilesHeaderPendingShares + + registerFileListHeader({ + id: 'pending-shares', + order: 1, + enabled: isPendingSharesEnabled, + updated: (folder: IFolder, view: IView) => { + if (instance && isPendingSharesEnabled(folder, view)) { + instance.update() + } + }, + render: async (el: HTMLElement, folder: IFolder, view: IView) => { + if (FilesHeaderPendingShares === undefined) { + const { default: component } = await import('../views/FilesHeaderPendingShares.vue') + FilesHeaderPendingShares = Vue.extend(component) + } + instance?.$destroy() + instance = new FilesHeaderPendingShares().$mount(el) as unknown as IFilesHeaderPendingShares + if (isPendingSharesEnabled(folder, view)) { + instance.update() + } + }, + }) +} diff --git a/apps/files_sharing/src/files_views/shares.spec.ts b/apps/files_sharing/src/files_views/shares.spec.ts index e6b5a25222ad6..44bf228d33dd2 100644 --- a/apps/files_sharing/src/files_views/shares.spec.ts +++ b/apps/files_sharing/src/files_views/shares.spec.ts @@ -37,12 +37,12 @@ describe('Sharing views definition', () => { const shareOverviewView = navigation.views.find((view) => view.id === 'shareoverview') as View const sharesChildViews = navigation.views.filter((view) => view.parent === 'shareoverview') as View[] - expect(navigation.register).toHaveBeenCalledTimes(7) + expect(navigation.register).toHaveBeenCalledTimes(6) // one main view and no children - expect(navigation.views.length).toBe(7) + expect(navigation.views.length).toBe(6) expect(shareOverviewView).toBeDefined() - expect(sharesChildViews.length).toBe(6) + expect(sharesChildViews.length).toBe(5) expect(shareOverviewView?.id).toBe('shareoverview') expect(shareOverviewView?.name).toBe('Shares') @@ -58,7 +58,6 @@ describe('Sharing views definition', () => { { id: 'sharinglinks', name: 'Shared by link' }, { id: 'filerequest', name: 'File requests' }, { id: 'deletedshares', name: 'Deleted shares' }, - { id: 'pendingshares', name: 'Pending shares' }, ] sharesChildViews.forEach((view, index) => { @@ -81,13 +80,13 @@ describe('Sharing views definition', () => { expect(navigation.views.length).toBe(0) registerSharingViews() - expect(navigation.register).toHaveBeenCalledTimes(6) - expect(navigation.views.length).toBe(6) + expect(navigation.register).toHaveBeenCalledTimes(5) + expect(navigation.views.length).toBe(5) const shareOverviewView = navigation.views.find((view) => view.id === 'shareoverview') as View const sharesChildViews = navigation.views.filter((view) => view.parent === 'shareoverview') as View[] expect(shareOverviewView).toBeDefined() - expect(sharesChildViews.length).toBe(5) + expect(sharesChildViews.length).toBe(4) expect(spy).toHaveBeenCalled() expect(spy).toHaveBeenCalledWith('files', 'storageStats', { quota: -1 }) @@ -105,11 +104,11 @@ describe('Sharing views definition', () => { expect(navigation.views.length).toBe(0) registerSharingViews() - expect(navigation.register).toHaveBeenCalledTimes(6) - expect(navigation.views.length).toBe(6) + expect(navigation.register).toHaveBeenCalledTimes(5) + expect(navigation.views.length).toBe(5) const sharesChildViews = navigation.views.filter((view) => view.parent === 'shareoverview') as View[] - expect(sharesChildViews.length).toBe(5) + expect(sharesChildViews.length).toBe(4) const sharingByLinksView = navigation.views.find((view) => view.id === 'sharinglinks') expect(sharingByLinksView).toBeUndefined() @@ -134,7 +133,7 @@ describe('Sharing views contents', () => { }) registerSharingViews() - expect(navigation.views.length).toBe(7) + expect(navigation.views.length).toBe(6) for (const view of navigation.views) { const content = await view.getContents('/', { signal: new AbortController().signal }) expect(content.contents).toStrictEqual([]) diff --git a/apps/files_sharing/src/files_views/shares.ts b/apps/files_sharing/src/files_views/shares.ts index dc901a9b2d7b2..d661bfebd0dd4 100644 --- a/apps/files_sharing/src/files_views/shares.ts +++ b/apps/files_sharing/src/files_views/shares.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import AccountClockSvg from '@mdi/svg/svg/account-clock-outline.svg?raw' import AccountGroupSvg from '@mdi/svg/svg/account-group-outline.svg?raw' import AccountSvg from '@mdi/svg/svg/account-outline.svg?raw' import AccountPlusSvg from '@mdi/svg/svg/account-plus-outline.svg?raw' @@ -22,7 +21,6 @@ export const sharedWithYouViewId = 'sharingin' export const sharedWithOthersViewId = 'sharingout' export const sharingByLinksViewId = 'sharinglinks' export const deletedSharesViewId = 'deletedshares' -export const pendingSharesViewId = 'pendingshares' export const fileRequestViewId = 'filerequest' export default () => { @@ -140,21 +138,4 @@ export default () => { getContents: () => getContents(false, false, false, true), })) - - Navigation.register(new View({ - id: pendingSharesViewId, - name: t('files_sharing', 'Pending shares'), - caption: t('files_sharing', 'List of unapproved shares.'), - - emptyTitle: t('files_sharing', 'No pending shares'), - emptyCaption: t('files_sharing', 'Shares you have received but not approved will show up here'), - - icon: AccountClockSvg, - order: 6, - parent: sharesViewId, - - columns: [], - - getContents: () => getContents(false, false, true, false), - })) } diff --git a/apps/files_sharing/src/init.ts b/apps/files_sharing/src/init.ts index 5ae97187d89e7..42de1472f7c60 100644 --- a/apps/files_sharing/src/init.ts +++ b/apps/files_sharing/src/init.ts @@ -5,13 +5,12 @@ import { addNewFileMenuEntry, registerFileAction } from '@nextcloud/files' import { registerDavProperty } from '@nextcloud/files/dav' -import { action as acceptShareAction } from './files_actions/acceptShareAction.ts' import { action as openInFilesAction } from './files_actions/openInFilesAction.ts' -import { action as rejectShareAction } from './files_actions/rejectShareAction.ts' import { action as restoreShareAction } from './files_actions/restoreShareAction.ts' import { action as sharingStatusAction } from './files_actions/sharingStatusAction.ts' import { registerAccountFilter } from './files_filters/AccountFilter.ts' import registerNoteToRecipient from './files_headers/noteToRecipient.ts' +import registerPendingShares from './files_headers/pendingShares.ts' import { entry as newFileRequest } from './files_newMenu/newFileRequest.ts' import registerSharingViews from './files_views/shares.ts' @@ -27,9 +26,7 @@ registerDavProperty('nc:share-attributes', { nc: 'http://nextcloud.org/ns' }) registerDavProperty('oc:share-types', { oc: 'http://owncloud.org/ns' }) registerDavProperty('ocs:share-permissions', { ocs: 'http://open-collaboration-services.org/ns' }) -registerFileAction(acceptShareAction) registerFileAction(openInFilesAction) -registerFileAction(rejectShareAction) registerFileAction(restoreShareAction) registerFileAction(sharingStatusAction) @@ -37,3 +34,6 @@ registerAccountFilter() // Add "note to recipient" message registerNoteToRecipient() + +// Add pending shares on top of "All files" and "Shares" +registerPendingShares() diff --git a/apps/files_sharing/src/services/PendingSharesService.spec.ts b/apps/files_sharing/src/services/PendingSharesService.spec.ts new file mode 100644 index 0000000000000..94cd6b098cdd1 --- /dev/null +++ b/apps/files_sharing/src/services/PendingSharesService.spec.ts @@ -0,0 +1,87 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import axios from '@nextcloud/axios' +import { File } from '@nextcloud/files' +import { ShareType } from '@nextcloud/sharing' +import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' +import { acceptShare, canRejectShare, rejectShare } from './PendingSharesService.ts' +import { fetchNode } from '~/apps/files/src/services/WebdavClient.ts' + +vi.mock('@nextcloud/axios') +vi.mock('~/apps/files/src/services/WebdavClient.ts', () => ({ fetchNode: vi.fn() })) + +beforeAll(() => { + (window as any)._oc_webroot = '' +}) + +beforeEach(() => { + vi.restoreAllMocks() + vi.mocked(fetchNode).mockReset() + vi.spyOn(axios, 'post') + vi.spyOn(axios, 'delete') +}) + +/** + * Create a pending share node + * + * @param attributes The share attributes + */ +function createShare(attributes: Record) { + return new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + root: '/files/admin', + attributes: { 'share-id': 123, ...attributes }, + }) +} + +describe('Pending shares service', () => { + test('Remote group shares can not be rejected', () => { + expect(canRejectShare(createShare({}))).toBe(true) + expect(canRejectShare(createShare({ remote_id: '1', share_type: ShareType.RemoteGroup }))).toBe(false) + expect(canRejectShare(createShare({ remote_id: '1', share_type: ShareType.Remote }))).toBe(true) + }) + + test('Accept local share and fetch its mount point', async () => { + const node = createShare({}) + vi.spyOn(axios, 'get').mockResolvedValue({ data: { ocs: { data: [{ path: '/foobar (2).txt' }] } } }) + vi.mocked(fetchNode).mockResolvedValue(node) + + expect(await acceptShare(node)).toBe(node) + expect(axios.post).toHaveBeenCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/pending/123') + expect(axios.get).toHaveBeenCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/123') + expect(fetchNode).toHaveBeenCalledWith('/foobar (2).txt') + }) + + test('Accept remote share and fetch its mount point', async () => { + const node = createShare({ remote: 'https://remote.example' }) + vi.spyOn(axios, 'get').mockResolvedValue({ data: { ocs: { data: { mountpoint: '/foobar.txt' } } } }) + + await acceptShare(node) + expect(axios.post).toHaveBeenCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/remote_shares/pending/123') + expect(axios.get).toHaveBeenCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/remote_shares/123') + expect(fetchNode).toHaveBeenCalledWith('/foobar.txt') + }) + + test('Failed accept does not fetch the node', async () => { + vi.spyOn(axios, 'post').mockRejectedValue(new Error('Mock error')) + + await expect(acceptShare(createShare({}))).rejects.toThrow('Mock error') + expect(fetchNode).not.toHaveBeenCalled() + }) + + test('Reject pending remote share', async () => { + await rejectShare(createShare({ remote: 'https://remote.example', accepted: 0 })) + expect(axios.delete).toHaveBeenCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/remote_shares/pending/123') + }) + + test('Reject local share', async () => { + await rejectShare(createShare({})) + expect(axios.delete).toHaveBeenCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/123') + }) +}) diff --git a/apps/files_sharing/src/services/PendingSharesService.ts b/apps/files_sharing/src/services/PendingSharesService.ts new file mode 100644 index 0000000000000..9ee8d9c356fbc --- /dev/null +++ b/apps/files_sharing/src/services/PendingSharesService.ts @@ -0,0 +1,60 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { INode, Node } from '@nextcloud/files' + +import axios from '@nextcloud/axios' +import { generateOcsUrl } from '@nextcloud/router' +import { ShareType } from '@nextcloud/sharing' +import { fetchNode } from '~/apps/files/src/services/WebdavClient.ts' + +/** + * Get the OCS url parameters for a share node + * + * @param node The share node + */ +function getShareParams(node: INode) { + return { + shareBase: node.attributes.remote ? 'remote_shares' : 'shares', + id: node.attributes['share-id'], + } +} + +/** + * Whether a pending share can be rejected. + * Rejected remote group shares land back in the pending list, so do not offer it. + * + * @param node The pending share node + */ +export function canRejectShare(node: INode): boolean { + return !(node.attributes.remote_id && node.attributes.share_type === ShareType.RemoteGroup) +} + +/** + * Accept a pending share and return the node it is mounted as + * + * @param node The pending share node + */ +export async function acceptShare(node: INode): Promise { + const params = getShareParams(node) + await axios.post(generateOcsUrl('apps/files_sharing/api/v1/{shareBase}/pending/{id}', params)) + + // The mount point can change on accept, e.g. on name conflicts + const { data } = await axios.get(generateOcsUrl('apps/files_sharing/api/v1/{shareBase}/{id}', params)) + const path = node.attributes.remote ? data.ocs.data.mountpoint : data.ocs.data[0].path + return fetchNode(path) +} + +/** + * Reject a pending share + * + * @param node The pending share node + */ +export async function rejectShare(node: INode): Promise { + const url = node.attributes.accepted === 0 + ? 'apps/files_sharing/api/v1/{shareBase}/pending/{id}' + : 'apps/files_sharing/api/v1/{shareBase}/{id}' + await axios.delete(generateOcsUrl(url, getShareParams(node))) +} diff --git a/apps/files_sharing/src/views/FilesHeaderPendingShares.spec.ts b/apps/files_sharing/src/views/FilesHeaderPendingShares.spec.ts new file mode 100644 index 0000000000000..760d372f06206 --- /dev/null +++ b/apps/files_sharing/src/views/FilesHeaderPendingShares.spec.ts @@ -0,0 +1,70 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { File, Folder } from '@nextcloud/files' +import { mount } from '@vue/test-utils' +import { beforeAll, describe, expect, test, vi } from 'vitest' +import FilesHeaderPendingShares from './FilesHeaderPendingShares.vue' +import { getContents } from '../services/SharingService.ts' + +vi.mock('../services/SharingService.ts', () => ({ getContents: vi.fn() })) +vi.mock('../services/PendingSharesService.ts', () => ({ + acceptShare: vi.fn(), + canRejectShare: vi.fn(() => true), + rejectShare: vi.fn(), +})) + +beforeAll(() => { + window.OC = { ...window.OC, MimeType: { getIconUrl: () => '/icon.svg' } } +}) + +/** + * Mount the header with the given pending shares + * + * @param contents The pending share nodes + */ +async function mountWithShares(contents: (File | Folder)[]) { + vi.mocked(getContents).mockResolvedValue({ folder: {} as Folder, contents }) + const wrapper = mount(FilesHeaderPendingShares) + await (wrapper.vm as unknown as { update: () => Promise }).update() + await wrapper.vm.$nextTick() + return wrapper +} + +describe('Pending shares header', () => { + test('Nothing is rendered without pending shares', async () => { + const wrapper = await mountWithShares([]) + expect(wrapper.find('.pending-shares').exists()).toBe(false) + }) + + test('Display names with special characters are not escaped twice', async () => { + const wrapper = await mountWithShares([new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/Budget.md', + owner: 'foo', + mime: 'text/markdown', + root: '/files/admin', + attributes: { 'owner-display-name': 'Foo & Bar' }, + })]) + + expect(wrapper.find('.pending-shares__details').text()).toBe('Shared by Foo & Bar') + expect(wrapper.find('.pending-shares__name').text()).toBe('Budget.md') + expect(wrapper.find('.pending-shares__share button').text()).toBe('Accept file') + }) + + test('The accept button is labeled by its visible text only', async () => { + const wrapper = await mountWithShares([new Folder({ + id: 2, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/Team photos', + owner: 'foo', + root: '/files/admin', + attributes: { 'owner-display-name': 'Foo' }, + })]) + + const button = wrapper.find('.pending-shares__share button') + expect(button.text()).toBe('Accept folder') + expect(button.attributes('aria-label')).toBeUndefined() + }) +}) diff --git a/apps/files_sharing/src/views/FilesHeaderPendingShares.vue b/apps/files_sharing/src/views/FilesHeaderPendingShares.vue new file mode 100644 index 0000000000000..068530d04f631 --- /dev/null +++ b/apps/files_sharing/src/views/FilesHeaderPendingShares.vue @@ -0,0 +1,281 @@ + + + + + +