Skip to content

Commit 6cc45f1

Browse files
committed
fix(files_sharing): Hide incompatible actions in pending and deleted share views
Pending and deleted share entries are not mounted into the user's filesystem, so generic file operations like delete or download cannot succeed and produce a misleading "file is not available" error. These views now expose only the actions that actually apply: accept and reject for pending shares; restore for deleted shares. All other views continue to show every registered action.
1 parent 020c4e9 commit 6cc45f1

6 files changed

Lines changed: 102 additions & 0 deletions

File tree

apps/files/src/components/FileEntryMixin.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { extname, relative } from 'path'
1818
import Vue, { computed, defineComponent } from 'vue'
1919
import { action as sidebarAction } from '../actions/sidebarAction.ts'
2020
import { onDropInternalFiles } from '../services/DropService.ts'
21+
import { isActionAllowedInView } from '../services/ViewActionsRegistry.ts'
2122
import { getDragAndDropPreview } from '../utils/dragUtils.ts'
2223
import { hashCode } from '../utils/hashUtils.ts'
2324
import { logger } from '../utils/logger.ts'
@@ -233,6 +234,7 @@ export default defineComponent({
233234
}
234235

235236
return this.actions
237+
.filter((action: IFileAction) => isActionAllowedInView(this.activeView?.id, action.id))
236238
.filter((action: IFileAction) => {
237239
if (!action.enabled) {
238240
return true

apps/files/src/components/FilesListTableHeaderActions.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ import { useActionsMenuStore } from '../store/actionsmenu.ts'
9393
import { useActiveStore } from '../store/active.ts'
9494
import { useFilesStore } from '../store/files.ts'
9595
import { useSelectionStore } from '../store/selection.ts'
96+
import { isActionAllowedInView } from '../services/ViewActionsRegistry.ts'
9697
import { logger } from '../utils/logger.ts'
9798
9899
export const FILE_LIST_HEAD_FIRST_BATCH_ACTION_ID = 'files-list-head-first-batch-action'
@@ -167,6 +168,8 @@ export default defineComponent({
167168
computed: {
168169
enabledFileActions(): IFileAction[] {
169170
return this.actions
171+
// Actions can be restricted per-view (e.g. pending shares only allow accept/reject)
172+
.filter((action) => isActionAllowedInView(this.currentView?.id, action.id))
170173
// We don't handle renderInline actions in this component
171174
.filter((action) => !action.renderInline)
172175
// We don't handle actions that are not visible
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*!
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { describe, expect, it } from 'vitest'
7+
import { isActionAllowedInView, restrictViewActions } from './ViewActionsRegistry.ts'
8+
9+
describe('ViewActionsRegistry', () => {
10+
it('allows any action in views that have not been restricted', () => {
11+
expect(isActionAllowedInView('unrestricted-view', 'delete')).toBe(true)
12+
expect(isActionAllowedInView('unrestricted-view', 'download')).toBe(true)
13+
})
14+
15+
it('allows any action when no view is active', () => {
16+
expect(isActionAllowedInView(undefined, 'delete')).toBe(true)
17+
})
18+
19+
it('only allows actions in the allowlist for restricted views', () => {
20+
restrictViewActions('pendingshares', ['accept-share', 'reject-share'])
21+
22+
expect(isActionAllowedInView('pendingshares', 'accept-share')).toBe(true)
23+
expect(isActionAllowedInView('pendingshares', 'reject-share')).toBe(true)
24+
expect(isActionAllowedInView('pendingshares', 'delete')).toBe(false)
25+
expect(isActionAllowedInView('pendingshares', 'download')).toBe(false)
26+
expect(isActionAllowedInView('pendingshares', 'sharing-status')).toBe(false)
27+
})
28+
29+
it('does not affect siblings when one view is restricted', () => {
30+
restrictViewActions('deletedshares', ['restore-share'])
31+
32+
expect(isActionAllowedInView('deletedshares', 'restore-share')).toBe(true)
33+
expect(isActionAllowedInView('deletedshares', 'delete')).toBe(false)
34+
// A sibling view that has not been restricted is still wide open.
35+
expect(isActionAllowedInView('files', 'delete')).toBe(true)
36+
})
37+
38+
it('replaces the allowlist when called twice for the same view', () => {
39+
restrictViewActions('view-a', ['action-1'])
40+
restrictViewActions('view-a', ['action-2'])
41+
42+
expect(isActionAllowedInView('view-a', 'action-1')).toBe(false)
43+
expect(isActionAllowedInView('view-a', 'action-2')).toBe(true)
44+
})
45+
})
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
const allowlists = new Map<string, ReadonlySet<string>>()
7+
8+
/**
9+
* Restrict the file actions exposed in a given view to the listed action IDs.
10+
*
11+
* Useful for views that list metadata rather than mounted filesystem entries
12+
* (pending shares, deleted shares), where generic file operations cannot
13+
* succeed and produce a misleading "file is not available" error.
14+
*
15+
* Default (view never registered here): all actions are candidates.
16+
* The action's own `enabled()` predicate still applies on top.
17+
*
18+
* @param viewId - The view to restrict
19+
* @param actionIds - The IDs of the only actions allowed in that view
20+
*/
21+
export function restrictViewActions(viewId: string, actionIds: readonly string[]): void {
22+
allowlists.set(viewId, new Set(actionIds))
23+
}
24+
25+
/**
26+
* Check if the given action is allowed in the given view.
27+
*
28+
* @param viewId - The view ID, or undefined when no view is active
29+
* @param actionId - The action ID
30+
*/
31+
export function isActionAllowedInView(viewId: string | undefined, actionId: string): boolean {
32+
if (viewId === undefined) {
33+
return true
34+
}
35+
const allowlist = allowlists.get(viewId)
36+
return allowlist === undefined || allowlist.has(actionId)
37+
}

apps/files/src/utils/actionUtils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { showError, showSuccess } from '@nextcloud/dialogs'
88
import { NodeStatus } from '@nextcloud/files'
99
import { t } from '@nextcloud/l10n'
1010
import Vue from 'vue'
11+
import { isActionAllowedInView } from '../services/ViewActionsRegistry.ts'
1112
import { useActiveStore } from '../store/active.ts'
1213
import { logger } from '../utils/logger.ts'
1314

@@ -41,6 +42,11 @@ export async function executeAction(action: IFileAction) {
4142
contents,
4243
} as ActionContextSingle
4344

45+
if (!isActionAllowedInView(currentView.id, action.id)) {
46+
logger.debug('Action is not allowed in the current view', { action, view: currentView })
47+
return
48+
}
49+
4450
if (!action.enabled!(context)) {
4551
logger.debug('Action is not not available for the current context', { action, node: currentNode, view: currentView })
4652
return

apps/files_sharing/src/files_views/shares.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { getNavigation, View } from '@nextcloud/files'
1414
import { loadState } from '@nextcloud/initial-state'
1515
import { t } from '@nextcloud/l10n'
1616
import { ShareType } from '@nextcloud/sharing'
17+
import { restrictViewActions } from '../../../files/src/services/ViewActionsRegistry.ts'
1718
import { getContents, isFileRequest } from '../services/SharingService.ts'
1819

1920
export const sharesViewId = 'shareoverview'
@@ -137,6 +138,11 @@ export default () => {
137138
getContents: () => getContents(false, false, false, true),
138139
}))
139140

141+
// Deleted shares are unmounted: only "restore" can succeed.
142+
// Generic file actions would call DAV against an unreachable path
143+
// and surface a misleading "file is not available" error.
144+
restrictViewActions(deletedSharesViewId, ['restore-share'])
145+
140146
Navigation.register(new View({
141147
id: pendingSharesViewId,
142148
name: t('files_sharing', 'Pending shares'),
@@ -153,4 +159,7 @@ export default () => {
153159

154160
getContents: () => getContents(false, false, true, false),
155161
}))
162+
163+
// Pending shares are not yet mounted: only "accept" / "reject" can succeed.
164+
restrictViewActions(pendingSharesViewId, ['accept-share', 'reject-share'])
156165
}

0 commit comments

Comments
 (0)