Skip to content

Commit ad38f46

Browse files
committed
fix(files_sharing): Hide incompatible actions in 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. Signed-off-by: nfebe <fenn25.fn@gmail.com>
1 parent 020c4e9 commit ad38f46

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)