aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files_sharing/src/files_actions
diff options
context:
space:
mode:
Diffstat (limited to 'apps/files_sharing/src/files_actions')
-rw-r--r--apps/files_sharing/src/files_actions/acceptShareAction.spec.ts217
-rw-r--r--apps/files_sharing/src/files_actions/acceptShareAction.ts48
-rw-r--r--apps/files_sharing/src/files_actions/openInFilesAction.spec.ts78
-rw-r--r--apps/files_sharing/src/files_actions/openInFilesAction.ts50
-rw-r--r--apps/files_sharing/src/files_actions/rejectShareAction.spec.ts243
-rw-r--r--apps/files_sharing/src/files_actions/rejectShareAction.ts66
-rw-r--r--apps/files_sharing/src/files_actions/restoreShareAction.spec.ts191
-rw-r--r--apps/files_sharing/src/files_actions/restoreShareAction.ts47
-rw-r--r--apps/files_sharing/src/files_actions/sharingStatusAction.scss29
-rw-r--r--apps/files_sharing/src/files_actions/sharingStatusAction.ts144
10 files changed, 1113 insertions, 0 deletions
diff --git a/apps/files_sharing/src/files_actions/acceptShareAction.spec.ts b/apps/files_sharing/src/files_actions/acceptShareAction.spec.ts
new file mode 100644
index 00000000000..4003e0799ac
--- /dev/null
+++ b/apps/files_sharing/src/files_actions/acceptShareAction.spec.ts
@@ -0,0 +1,217 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'
+
+import { action } from './acceptShareAction'
+import { File, Permission, View, FileAction } from '@nextcloud/files'
+import { ShareType } from '@nextcloud/sharing'
+import * as eventBus from '@nextcloud/event-bus'
+import axios from '@nextcloud/axios'
+
+import '../main.ts'
+
+vi.mock('@nextcloud/axios')
+
+const view = {
+ id: 'files',
+ name: 'Files',
+} as View
+
+const pendingShareView = {
+ id: 'pendingshares',
+ name: 'Pending shares',
+} as View
+
+// Mock webroot variable
+beforeAll(() => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (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,
+ })
+
+ expect(action).toBeInstanceOf(FileAction)
+ expect(action.id).toBe('accept-share')
+ expect(action.displayName([file], pendingShareView)).toBe('Accept share')
+ expect(action.iconSvgInline([file], pendingShareView)).toMatch(/<svg.+<\/svg>/)
+ expect(action.default).toBeUndefined()
+ expect(action.order).toBe(1)
+ expect(action.inline).toBeDefined()
+ expect(action.inline!(file, pendingShareView)).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,
+ })
+ 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,
+ })
+
+ expect(action.displayName([file1, file2], pendingShareView)).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,
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file], pendingShareView)).toBe(true)
+ })
+
+ test('Disabled on wrong view', () => {
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([], view)).toBe(false)
+ })
+
+ test('Disabled without nodes', () => {
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([], pendingShareView)).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: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ attributes: {
+ id: 123,
+ share_type: ShareType.User,
+ },
+ })
+
+ const exec = await action.exec(file, pendingShareView, '/')
+
+ 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/123')
+
+ 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: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ attributes: {
+ id: 123,
+ remote: 3,
+ share_type: ShareType.User,
+ },
+ })
+
+ const exec = await action.exec(file, pendingShareView, '/')
+
+ 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/123')
+
+ 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: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ attributes: {
+ id: 123,
+ share_type: ShareType.User,
+ },
+ })
+
+ const file2 = new File({
+ id: 2,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ attributes: {
+ id: 456,
+ share_type: ShareType.User,
+ },
+ })
+
+ const exec = await action.execBatch!([file1, file2], pendingShareView, '/')
+
+ 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/123')
+ expect(axios.post).toHaveBeenNthCalledWith(2, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/pending/456')
+
+ 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: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ attributes: {
+ id: 123,
+ share_type: ShareType.User,
+ },
+ })
+
+ const exec = await action.exec(file, pendingShareView, '/')
+
+ 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/123')
+
+ 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
new file mode 100644
index 00000000000..f2177fdec1a
--- /dev/null
+++ b/apps/files_sharing/src/files_actions/acceptShareAction.ts
@@ -0,0 +1,48 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Node, View } from '@nextcloud/files'
+
+import { emit } from '@nextcloud/event-bus'
+import { generateOcsUrl } from '@nextcloud/router'
+import { registerFileAction, FileAction } from '@nextcloud/files'
+import { translatePlural as n } from '@nextcloud/l10n'
+import axios from '@nextcloud/axios'
+import CheckSvg from '@mdi/svg/svg/check.svg?raw'
+
+import { pendingSharesViewId } from '../files_views/shares'
+
+export const action = new FileAction({
+ id: 'accept-share',
+ displayName: (nodes: Node[]) => n('files_sharing', 'Accept share', 'Accept shares', nodes.length),
+ iconSvgInline: () => CheckSvg,
+
+ enabled: (nodes, view) => nodes.length > 0 && view.id === pendingSharesViewId,
+
+ async exec(node: Node) {
+ try {
+ const isRemote = !!node.attributes.remote
+ const url = generateOcsUrl('apps/files_sharing/api/v1/{shareBase}/pending/{id}', {
+ shareBase: isRemote ? 'remote_shares' : 'shares',
+ id: node.attributes.id,
+ })
+ await axios.post(url)
+
+ // Remove from current view
+ emit('files:node:deleted', node)
+
+ return true
+ } catch (error) {
+ return false
+ }
+ },
+ async execBatch(nodes: Node[], view: View, dir: string) {
+ return Promise.all(nodes.map(node => this.exec(node, view, dir)))
+ },
+
+ order: 1,
+ inline: () => true,
+})
+
+registerFileAction(action)
diff --git a/apps/files_sharing/src/files_actions/openInFilesAction.spec.ts b/apps/files_sharing/src/files_actions/openInFilesAction.spec.ts
new file mode 100644
index 00000000000..23c0938545c
--- /dev/null
+++ b/apps/files_sharing/src/files_actions/openInFilesAction.spec.ts
@@ -0,0 +1,78 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { File, Permission, View, DefaultType, FileAction } from '@nextcloud/files'
+import { describe, expect, test, vi } from 'vitest'
+import { deletedSharesViewId, pendingSharesViewId, sharedWithOthersViewId, sharedWithYouViewId, sharesViewId, sharingByLinksViewId } from '../files_views/shares'
+import { action } from './openInFilesAction'
+
+import '../main'
+
+const view = {
+ id: 'files',
+ name: 'Files',
+} as View
+
+const validViews = [
+ sharesViewId,
+ sharedWithYouViewId,
+ sharedWithOthersViewId,
+ sharingByLinksViewId,
+].map(id => ({ id, name: id })) as View[]
+
+const invalidViews = [
+ deletedSharesViewId,
+ pendingSharesViewId,
+].map(id => ({ id, name: id })) as View[]
+
+describe('Open in files action conditions tests', () => {
+ test('Default values', () => {
+ expect(action).toBeInstanceOf(FileAction)
+ expect(action.id).toBe('files_sharing:open-in-files')
+ expect(action.displayName([], validViews[0])).toBe('Open in Files')
+ expect(action.iconSvgInline([], validViews[0])).toBe('')
+ expect(action.default).toBe(DefaultType.HIDDEN)
+ expect(action.order).toBe(-1000)
+ expect(action.inline).toBeUndefined()
+ })
+})
+
+describe('Open in files action enabled tests', () => {
+ test('Enabled with on valid view', () => {
+ validViews.forEach(view => {
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([], view)).toBe(true)
+ })
+ })
+
+ test('Disabled on wrong view', () => {
+ invalidViews.forEach(view => {
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([], view)).toBe(false)
+ })
+ })
+})
+
+describe('Open in files action execute tests', () => {
+ test('Open in files', async () => {
+ const goToRouteMock = vi.fn()
+ // @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation
+ window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
+
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ root: '/files/admin',
+ permissions: Permission.READ,
+ })
+
+ const exec = await action.exec(file, view, '/')
+ // Silent action
+ expect(exec).toBe(null)
+ expect(goToRouteMock).toBeCalledTimes(1)
+ expect(goToRouteMock).toBeCalledWith(null, { fileid: '1', view: 'files' }, { dir: '/Foo', openfile: 'true' })
+ })
+})
diff --git a/apps/files_sharing/src/files_actions/openInFilesAction.ts b/apps/files_sharing/src/files_actions/openInFilesAction.ts
new file mode 100644
index 00000000000..133b4531bb5
--- /dev/null
+++ b/apps/files_sharing/src/files_actions/openInFilesAction.ts
@@ -0,0 +1,50 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { Node } from '@nextcloud/files'
+
+import { registerFileAction, FileAction, DefaultType, FileType } from '@nextcloud/files'
+import { translate as t } from '@nextcloud/l10n'
+import { sharesViewId, sharedWithYouViewId, sharedWithOthersViewId, sharingByLinksViewId } from '../files_views/shares'
+
+export const action = new FileAction({
+ id: 'files_sharing:open-in-files',
+ displayName: () => t('files_sharing', 'Open in Files'),
+ iconSvgInline: () => '',
+
+ enabled: (nodes, view) => [
+ sharesViewId,
+ sharedWithYouViewId,
+ sharedWithOthersViewId,
+ sharingByLinksViewId,
+ // Deleted and pending shares are not
+ // accessible in the files app.
+ ].includes(view.id),
+
+ async exec(node: Node) {
+ const isFolder = node.type === FileType.Folder
+
+ window.OCP.Files.Router.goToRoute(
+ null, // use default route
+ {
+ view: 'files',
+ fileid: String(node.fileid),
+ },
+ {
+ // If this node is a folder open the folder in files
+ dir: isFolder ? node.path : node.dirname,
+ // otherwise if this is a file, we should open it
+ openfile: isFolder ? undefined : 'true',
+ },
+ )
+ return null
+ },
+
+ // Before openFolderAction
+ order: -1000,
+ default: DefaultType.HIDDEN,
+})
+
+registerFileAction(action)
diff --git a/apps/files_sharing/src/files_actions/rejectShareAction.spec.ts b/apps/files_sharing/src/files_actions/rejectShareAction.spec.ts
new file mode 100644
index 00000000000..51ded69d1c5
--- /dev/null
+++ b/apps/files_sharing/src/files_actions/rejectShareAction.spec.ts
@@ -0,0 +1,243 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { File, Folder, Permission, View, FileAction } from '@nextcloud/files'
+import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'
+import { ShareType } from '@nextcloud/sharing'
+import * as eventBus from '@nextcloud/event-bus'
+import axios from '@nextcloud/axios'
+
+import { action } from './rejectShareAction'
+import '../main'
+
+vi.mock('@nextcloud/axios')
+
+const view = {
+ id: 'files',
+ name: 'Files',
+} as View
+
+const pendingShareView = {
+ id: 'pendingshares',
+ name: 'Pending shares',
+} as View
+
+// Mock webroot variable
+beforeAll(() => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (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,
+ })
+
+ expect(action).toBeInstanceOf(FileAction)
+ expect(action.id).toBe('reject-share')
+ expect(action.displayName([file], pendingShareView)).toBe('Reject share')
+ expect(action.iconSvgInline([file], pendingShareView)).toMatch(/<svg.+<\/svg>/)
+ expect(action.default).toBeUndefined()
+ expect(action.order).toBe(2)
+ expect(action.inline).toBeDefined()
+ expect(action.inline!(file, pendingShareView)).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,
+ })
+ 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,
+ })
+
+ expect(action.displayName([file1, file2], pendingShareView)).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,
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file], pendingShareView)).toBe(true)
+ })
+
+ test('Disabled on wrong view', () => {
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([], view)).toBe(false)
+ })
+
+ test('Disabled without nodes', () => {
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([], pendingShareView)).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,
+ },
+ })
+ 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,
+ },
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([folder1], pendingShareView)).toBe(true)
+ expect(action.enabled!([folder2], pendingShareView)).toBe(false)
+ expect(action.enabled!([folder1, folder2], pendingShareView)).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: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ attributes: {
+ id: 123,
+ share_type: ShareType.User,
+ },
+ })
+
+ const exec = await action.exec(file, pendingShareView, '/')
+
+ 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/123')
+
+ 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: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ attributes: {
+ id: 123,
+ remote: 3,
+ share_type: ShareType.User,
+ },
+ })
+
+ const exec = await action.exec(file, pendingShareView, '/')
+
+ 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/123')
+
+ 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: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ attributes: {
+ id: 123,
+ share_type: ShareType.User,
+ },
+ })
+
+ const file2 = new File({
+ id: 2,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ attributes: {
+ id: 456,
+ share_type: ShareType.User,
+ },
+ })
+
+ const exec = await action.execBatch!([file1, file2], pendingShareView, '/')
+
+ 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/123')
+ expect(axios.delete).toHaveBeenNthCalledWith(2, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/456')
+
+ 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: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ attributes: {
+ id: 123,
+ share_type: ShareType.User,
+ },
+ })
+
+ const exec = await action.exec(file, pendingShareView, '/')
+
+ 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/123')
+
+ 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
new file mode 100644
index 00000000000..22f77262ef2
--- /dev/null
+++ b/apps/files_sharing/src/files_actions/rejectShareAction.ts
@@ -0,0 +1,66 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Node, View } from '@nextcloud/files'
+
+import { emit } from '@nextcloud/event-bus'
+import { generateOcsUrl } from '@nextcloud/router'
+import { registerFileAction, FileAction } from '@nextcloud/files'
+import { translatePlural as n } from '@nextcloud/l10n'
+import { ShareType } from '@nextcloud/sharing'
+import { pendingSharesViewId } from '../files_views/shares'
+
+import axios from '@nextcloud/axios'
+import CloseSvg from '@mdi/svg/svg/close.svg?raw'
+
+export const action = new FileAction({
+ id: 'reject-share',
+ displayName: (nodes: Node[]) => 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(node: Node) {
+ try {
+ const isRemote = !!node.attributes.remote
+ const url = generateOcsUrl('apps/files_sharing/api/v1/{shareBase}/{id}', {
+ shareBase: isRemote ? 'remote_shares' : 'shares',
+ id: node.attributes.id,
+ })
+ await axios.delete(url)
+
+ // Remove from current view
+ emit('files:node:deleted', node)
+
+ return true
+ } catch (error) {
+ return false
+ }
+ },
+ async execBatch(nodes: Node[], view: View, dir: string) {
+ return Promise.all(nodes.map(node => this.exec(node, view, dir)))
+ },
+
+ order: 2,
+ inline: () => true,
+})
+
+registerFileAction(action)
diff --git a/apps/files_sharing/src/files_actions/restoreShareAction.spec.ts b/apps/files_sharing/src/files_actions/restoreShareAction.spec.ts
new file mode 100644
index 00000000000..015aa8aa95d
--- /dev/null
+++ b/apps/files_sharing/src/files_actions/restoreShareAction.spec.ts
@@ -0,0 +1,191 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { File, Permission, View, FileAction } from '@nextcloud/files'
+import { ShareType } from '@nextcloud/sharing'
+import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'
+
+import axios from '@nextcloud/axios'
+import * as eventBus from '@nextcloud/event-bus'
+import { action } from './restoreShareAction'
+import '../main.ts'
+
+vi.mock('@nextcloud/auth')
+vi.mock('@nextcloud/axios')
+
+const view = {
+ id: 'files',
+ name: 'Files',
+} as View
+
+const deletedShareView = {
+ id: 'deletedshares',
+ name: 'Deleted shares',
+} as View
+
+// Mock webroot variable
+beforeAll(() => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (window as any)._oc_webroot = ''
+})
+
+describe('Restore 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,
+ })
+
+ expect(action).toBeInstanceOf(FileAction)
+ expect(action.id).toBe('restore-share')
+ expect(action.displayName([file], deletedShareView)).toBe('Restore share')
+ expect(action.iconSvgInline([file], deletedShareView)).toMatch(/<svg.+<\/svg>/)
+ expect(action.default).toBeUndefined()
+ expect(action.order).toBe(1)
+ expect(action.inline).toBeDefined()
+ expect(action.inline!(file, deletedShareView)).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,
+ })
+ 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,
+ })
+
+ expect(action.displayName([file1, file2], deletedShareView)).toBe('Restore shares')
+ })
+})
+
+describe('Restore 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,
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file], deletedShareView)).toBe(true)
+ })
+
+ test('Disabled on wrong view', () => {
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([], view)).toBe(false)
+ })
+
+ test('Disabled without nodes', () => {
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([], deletedShareView)).toBe(false)
+ })
+})
+
+describe('Restore share action execute tests', () => {
+ beforeEach(() => { vi.resetAllMocks() })
+
+ test('Restore share action', async () => {
+ vi.spyOn(axios, 'post')
+ vi.spyOn(eventBus, 'emit')
+
+ 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.READ,
+ attributes: {
+ id: 123,
+ share_type: ShareType.User,
+ },
+ })
+
+ const exec = await action.exec(file, deletedShareView, '/')
+
+ expect(exec).toBe(true)
+ expect(axios.post).toBeCalledTimes(1)
+ expect(axios.post).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/deletedshares/123')
+
+ expect(eventBus.emit).toBeCalledTimes(1)
+ expect(eventBus.emit).toBeCalledWith('files:node:deleted', file)
+ })
+
+ test('Restore share action batch', async () => {
+ vi.spyOn(axios, 'post')
+ vi.spyOn(eventBus, 'emit')
+
+ const file1 = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ attributes: {
+ id: 123,
+ share_type: ShareType.User,
+ },
+ })
+
+ const file2 = new File({
+ id: 2,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ attributes: {
+ id: 456,
+ share_type: ShareType.User,
+ },
+ })
+
+ const exec = await action.execBatch!([file1, file2], deletedShareView, '/')
+
+ 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/deletedshares/123')
+ expect(axios.post).toHaveBeenNthCalledWith(2, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/deletedshares/456')
+
+ expect(eventBus.emit).toBeCalledTimes(2)
+ expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:node:deleted', file1)
+ expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:node:deleted', file2)
+ })
+
+ test('Restore fails', async () => {
+ vi.spyOn(axios, 'post')
+ .mockImplementation(() => { throw new Error('Mock error') })
+
+ 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.READ,
+ attributes: {
+ id: 123,
+ share_type: ShareType.User,
+ },
+ })
+
+ const exec = await action.exec(file, deletedShareView, '/')
+
+ expect(exec).toBe(false)
+ expect(axios.post).toBeCalledTimes(1)
+ expect(axios.post).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/deletedshares/123')
+
+ expect(eventBus.emit).toBeCalledTimes(0)
+ })
+})
diff --git a/apps/files_sharing/src/files_actions/restoreShareAction.ts b/apps/files_sharing/src/files_actions/restoreShareAction.ts
new file mode 100644
index 00000000000..2d51de387ee
--- /dev/null
+++ b/apps/files_sharing/src/files_actions/restoreShareAction.ts
@@ -0,0 +1,47 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Node, View } from '@nextcloud/files'
+
+import { emit } from '@nextcloud/event-bus'
+import { FileAction, registerFileAction } from '@nextcloud/files'
+import { generateOcsUrl } from '@nextcloud/router'
+import { translatePlural as n } from '@nextcloud/l10n'
+import ArrowULeftTopSvg from '@mdi/svg/svg/arrow-u-left-top.svg?raw'
+import axios from '@nextcloud/axios'
+
+import { deletedSharesViewId } from '../files_views/shares'
+
+export const action = new FileAction({
+ id: 'restore-share',
+ displayName: (nodes: Node[]) => n('files_sharing', 'Restore share', 'Restore shares', nodes.length),
+
+ iconSvgInline: () => ArrowULeftTopSvg,
+
+ enabled: (nodes, view) => nodes.length > 0 && view.id === deletedSharesViewId,
+
+ async exec(node: Node) {
+ try {
+ const url = generateOcsUrl('apps/files_sharing/api/v1/deletedshares/{id}', {
+ id: node.attributes.id,
+ })
+ await axios.post(url)
+
+ // Remove from current view
+ emit('files:node:deleted', node)
+
+ return true
+ } catch (error) {
+ return false
+ }
+ },
+ async execBatch(nodes: Node[], view: View, dir: string) {
+ return Promise.all(nodes.map(node => this.exec(node, view, dir)))
+ },
+
+ order: 1,
+ inline: () => true,
+})
+
+registerFileAction(action)
diff --git a/apps/files_sharing/src/files_actions/sharingStatusAction.scss b/apps/files_sharing/src/files_actions/sharingStatusAction.scss
new file mode 100644
index 00000000000..3a6690f40f1
--- /dev/null
+++ b/apps/files_sharing/src/files_actions/sharingStatusAction.scss
@@ -0,0 +1,29 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+ // Only when rendered inline, when not enough space, this is put in the menu
+.action-items > .files-list__row-action-sharing-status {
+ // put icon at the end of the button
+ direction: rtl;
+ // align icons with text-less inline actions
+ padding-inline-end: 0 !important;
+}
+
+svg.sharing-status__avatar {
+ height: 32px !important;
+ width: 32px !important;
+ max-height: 32px !important;
+ max-width: 32px !important;
+ border-radius: 32px;
+ overflow: hidden;
+}
+
+.files-list__row-action-sharing-status {
+ .button-vue__text {
+ color: var(--color-primary-element);
+ }
+ .button-vue__icon {
+ color: var(--color-primary-element);
+ }
+}
diff --git a/apps/files_sharing/src/files_actions/sharingStatusAction.ts b/apps/files_sharing/src/files_actions/sharingStatusAction.ts
new file mode 100644
index 00000000000..18fa46d2781
--- /dev/null
+++ b/apps/files_sharing/src/files_actions/sharingStatusAction.ts
@@ -0,0 +1,144 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { getCurrentUser } from '@nextcloud/auth'
+import { Node, View, registerFileAction, FileAction, Permission } from '@nextcloud/files'
+import { translate as t } from '@nextcloud/l10n'
+import { ShareType } from '@nextcloud/sharing'
+import { isPublicShare } from '@nextcloud/sharing/public'
+
+import AccountGroupSvg from '@mdi/svg/svg/account-group-outline.svg?raw'
+import AccountPlusSvg from '@mdi/svg/svg/account-plus-outline.svg?raw'
+import LinkSvg from '@mdi/svg/svg/link.svg?raw'
+import CircleSvg from '../../../../core/img/apps/circles.svg?raw'
+
+import { action as sidebarAction } from '../../../files/src/actions/sidebarAction'
+import { generateAvatarSvg } from '../utils/AccountIcon'
+
+import './sharingStatusAction.scss'
+
+const isExternal = (node: Node) => {
+ return node.attributes?.['is-federated'] ?? false
+}
+
+export const ACTION_SHARING_STATUS = 'sharing-status'
+export const action = new FileAction({
+ id: ACTION_SHARING_STATUS,
+ displayName(nodes: Node[]) {
+ const node = nodes[0]
+ const shareTypes = Object.values(node?.attributes?.['share-types'] || {}).flat() as number[]
+
+ if (shareTypes.length > 0
+ || (node.owner !== getCurrentUser()?.uid || isExternal(node))) {
+ return t('files_sharing', 'Shared')
+ }
+
+ return ''
+ },
+
+ title(nodes: Node[]) {
+ const node = nodes[0]
+
+ if (node.owner && (node.owner !== getCurrentUser()?.uid || isExternal(node))) {
+ const ownerDisplayName = node?.attributes?.['owner-display-name']
+ return t('files_sharing', 'Shared by {ownerDisplayName}', { ownerDisplayName })
+ }
+
+ const shareTypes = Object.values(node?.attributes?.['share-types'] || {}).flat() as number[]
+ if (shareTypes.length > 1) {
+ return t('files_sharing', 'Shared multiple times with different people')
+ }
+
+ const sharees = node.attributes.sharees?.sharee as { id: string, 'display-name': string, type: ShareType }[] | undefined
+ if (!sharees) {
+ // No sharees so just show the default message to create a new share
+ return t('files_sharing', 'Sharing options')
+ }
+
+ const sharee = [sharees].flat()[0] // the property is sometimes weirdly normalized, so we need to compensate
+ switch (sharee.type) {
+ case ShareType.User:
+ return t('files_sharing', 'Shared with {user}', { user: sharee['display-name'] })
+ case ShareType.Group:
+ return t('files_sharing', 'Shared with group {group}', { group: sharee['display-name'] ?? sharee.id })
+ default:
+ return t('files_sharing', 'Shared with others')
+ }
+ },
+
+ iconSvgInline(nodes: Node[]) {
+ const node = nodes[0]
+ const shareTypes = Object.values(node?.attributes?.['share-types'] || {}).flat() as number[]
+
+ // Mixed share types
+ if (Array.isArray(node.attributes?.['share-types']) && node.attributes?.['share-types'].length > 1) {
+ return AccountPlusSvg
+ }
+
+ // Link shares
+ if (shareTypes.includes(ShareType.Link)
+ || shareTypes.includes(ShareType.Email)) {
+ return LinkSvg
+ }
+
+ // Group shares
+ if (shareTypes.includes(ShareType.Group)
+ || shareTypes.includes(ShareType.RemoteGroup)) {
+ return AccountGroupSvg
+ }
+
+ // Circle shares
+ if (shareTypes.includes(ShareType.Team)) {
+ return CircleSvg
+ }
+
+ if (node.owner && (node.owner !== getCurrentUser()?.uid || isExternal(node))) {
+ return generateAvatarSvg(node.owner, isExternal(node))
+ }
+
+ return AccountPlusSvg
+ },
+
+ enabled(nodes: Node[]) {
+ if (nodes.length !== 1) {
+ return false
+ }
+
+ // Do not leak information about users to public shares
+ if (isPublicShare()) {
+ return false
+ }
+
+ const node = nodes[0]
+ const shareTypes = node.attributes?.['share-types']
+ const isMixed = Array.isArray(shareTypes) && shareTypes.length > 0
+
+ // If the node is shared multiple times with
+ // different share types to the current user
+ if (isMixed) {
+ return true
+ }
+
+ // If the node is shared by someone else
+ if (node.owner !== getCurrentUser()?.uid || isExternal(node)) {
+ return true
+ }
+
+ return (node.permissions & Permission.SHARE) !== 0
+ },
+
+ async exec(node: Node, view: View, dir: string) {
+ // You need read permissions to see the sidebar
+ if ((node.permissions & Permission.READ) !== 0) {
+ window.OCA?.Files?.Sidebar?.setActiveTab?.('sharing')
+ return sidebarAction.exec(node, view, dir)
+ }
+ return null
+ },
+
+ inline: () => true,
+
+})
+
+registerFileAction(action)