From 4179bb9d6578cd1efcdda768c2bd49fbfb460109 Mon Sep 17 00:00:00 2001
From: skjnldsv
Date: Thu, 3 Sep 2026 15:25:30 +0200
Subject: [PATCH 1/5] feat(files_sharing): unified sharing sidebar and dialog
Replace the share editor in the files sidebar with the unified sharing UI: one
flat list of shares ordered by permission, where a share with several recipients
renders as an expandable group showing each recipient's own permission, and
destructive actions live in the overflow menu.
Creating and editing a share opens the Vue 3 dialog from `@nextcloud/sharing`.
The sidebar is still Vue 2, so the dialog is reached through an `OCA.Sharing`
bridge that the Vue 3 entry point registers; once files_sharing is Vue 3 the
bridge can go and the library be imported directly.
The new components are written as `
+
+
diff --git a/apps/files_sharing/src/components/SharingEntryLink.vue b/apps/files_sharing/src/components/SharingEntryLink.vue
index e67b3447335e1..e2bb0e4e48f72 100644
--- a/apps/files_sharing/src/components/SharingEntryLink.vue
+++ b/apps/files_sharing/src/components/SharingEntryLink.vue
@@ -21,7 +21,7 @@
{{ subtitle }}
@@ -151,6 +151,7 @@
@@ -159,6 +160,16 @@
{{ t('files_sharing', 'Customize link') }}
+
+
+
+
+ {{ t('files_sharing', 'Customize link') }}
+
+
+
@@ -53,6 +56,13 @@ export default {
type: Boolean,
default: null,
},
+
+ // Force the overflow menu even with a single action (keeps destructive
+ // actions in a menu instead of rendering them inline).
+ forceMenu: {
+ type: Boolean,
+ default: false,
+ },
},
computed: {
diff --git a/apps/files_sharing/src/components/UnifiedShareEntry.vue b/apps/files_sharing/src/components/UnifiedShareEntry.vue
new file mode 100644
index 0000000000000..4b6aafc3069fa
--- /dev/null
+++ b/apps/files_sharing/src/components/UnifiedShareEntry.vue
@@ -0,0 +1,222 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('files_sharing', 'Edit share') }}
+
+
+
+
+
+ {{ t('files_sharing', 'Delete share') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('files_sharing', 'Edit share') }}
+
+
+
+
+
+ {{ t('files_sharing', 'Delete share') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('files_sharing', 'Remove participant') }}
+
+
+
+
+
+
+
+
+
diff --git a/apps/files_sharing/src/components/UnifiedShareList.vue b/apps/files_sharing/src/components/UnifiedShareList.vue
new file mode 100644
index 0000000000000..9d3f80c7c691b
--- /dev/null
+++ b/apps/files_sharing/src/components/UnifiedShareList.vue
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/files_sharing/src/components/UnifiedShareListSkeleton.vue b/apps/files_sharing/src/components/UnifiedShareListSkeleton.vue
new file mode 100644
index 0000000000000..bda92099c1022
--- /dev/null
+++ b/apps/files_sharing/src/components/UnifiedShareListSkeleton.vue
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/files_sharing/src/lib/unifiedSharing.ts b/apps/files_sharing/src/lib/unifiedSharing.ts
new file mode 100644
index 0000000000000..90e86a51c3b39
--- /dev/null
+++ b/apps/files_sharing/src/lib/unifiedSharing.ts
@@ -0,0 +1,168 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { SharingPermission, SharingRecipient, SharingShare } from '../types/unifiedSharing.ts'
+
+import { getCapabilities } from '@nextcloud/capabilities'
+import { translatePlural as n, translate as t } from '@nextcloud/l10n'
+import {
+ RECIPIENT_TYPE_EMAIL,
+ RECIPIENT_TYPE_GROUP,
+ RECIPIENT_TYPE_TEAM,
+ RECIPIENT_TYPE_TOKEN,
+ RECIPIENT_TYPE_USER,
+} from './unifiedSharingConstants.ts'
+
+/**
+ * Rank a share by its highest granted permissions: the sum of priorities of its
+ * enabled permissions. Used to order the flat share list (highest first).
+ *
+ * @param share The share to rank
+ */
+export function sharePermissionRank(share: SharingShare): number {
+ return share.permissions
+ .filter((permission) => permission.enabled)
+ .reduce((sum, permission) => sum + permission.priority, 0)
+}
+
+/**
+ * Sort shares by highest permissions first, then by recipient count, then by id
+ * for a stable order.
+ *
+ * @param shares The shares to sort (not mutated)
+ */
+export function sortSharesByPermission(shares: SharingShare[]): SharingShare[] {
+ return [...shares].sort((a, b) => {
+ const rank = sharePermissionRank(b) - sharePermissionRank(a)
+ if (rank !== 0) {
+ return rank
+ }
+ const count = b.recipients.length - a.recipients.length
+ if (count !== 0) {
+ return count
+ }
+ return a.id.localeCompare(b.id)
+ })
+}
+
+type CapabilitiesWithPresets = {
+ sharing?: {
+ permission_presets?: { class: string, display_name: string }[]
+ }
+}
+
+/**
+ * Human-readable label for a share's permission preset, e.g. "Can edit". Falls
+ * back to "Custom permissions" when the enabled permissions match no preset.
+ *
+ * @param share The share
+ */
+export function permissionLabel(share: SharingShare): string {
+ if (share.permission_preset === null) {
+ return t('files_sharing', 'Custom permissions')
+ }
+ const capabilities = getCapabilities() as CapabilitiesWithPresets
+ const preset = (capabilities.sharing?.permission_presets ?? []).find((p) => p.class === share.permission_preset)
+ return preset?.display_name ?? t('files_sharing', 'Custom permissions')
+}
+
+/**
+ * Human-readable label for a set of permissions: the preset whose member
+ * permissions are exactly the enabled ones, else "Custom permissions".
+ *
+ * @param permissions The permissions to label
+ */
+function labelForPermissions(permissions: SharingPermission[]): string {
+ const capabilities = getCapabilities() as CapabilitiesWithPresets
+ const enabled = new Set(permissions.filter((permission) => permission.enabled).map((permission) => permission.class))
+ for (const preset of capabilities.sharing?.permission_presets ?? []) {
+ const members = permissions.filter((permission) => permission.presets.includes(preset.class))
+ if (members.length > 0 && members.length === enabled.size && members.every((permission) => enabled.has(permission.class))) {
+ return preset.display_name
+ }
+ }
+ return t('files_sharing', 'Custom permissions')
+}
+
+/**
+ * Human-readable permission label for a single recipient.
+ *
+ * A recipient's permissions are sparse overrides on top of the share's, so the
+ * effective state is the share's permissions with the recipient's applied.
+ *
+ * @param share The share the recipient belongs to
+ * @param recipient The recipient
+ */
+export function recipientPermissionLabel(share: SharingShare, recipient: SharingRecipient): string {
+ const overrides = new Map((recipient.permissions ?? []).map((permission) => [permission.class, permission]))
+ return labelForPermissions(share.permissions.map((permission) => ({
+ ...permission,
+ enabled: overrides.get(permission.class)?.enabled ?? permission.enabled,
+ })))
+}
+
+/**
+ * Whether a recipient should render a non-user (initials) avatar.
+ *
+ * @param recipient The recipient
+ */
+export function isNoUserRecipient(recipient: SharingRecipient): boolean {
+ return recipient.class !== RECIPIENT_TYPE_USER
+}
+
+/**
+ * Build a human-readable summary of a share's recipients, e.g.
+ * "1 person, 2 groups". Categories are listed in a stable order and only
+ * non-empty ones are included.
+ *
+ * @param recipients The share's recipients
+ */
+export function recipientSummary(recipients: SharingRecipient[]): string {
+ const counts: Record = {}
+ for (const recipient of recipients) {
+ counts[recipient.class] = (counts[recipient.class] ?? 0) + 1
+ }
+
+ const parts: string[] = []
+ const push = (count: number, singular: string, plural: string) => {
+ if (count > 0) {
+ parts.push(n('files_sharing', singular, plural, count))
+ }
+ }
+
+ push(counts[RECIPIENT_TYPE_USER] ?? 0, '%n person', '%n people')
+ push(counts[RECIPIENT_TYPE_GROUP] ?? 0, '%n group', '%n groups')
+ push(counts[RECIPIENT_TYPE_TEAM] ?? 0, '%n team', '%n teams')
+ push(counts[RECIPIENT_TYPE_EMAIL] ?? 0, '%n email', '%n emails')
+ push(counts[RECIPIENT_TYPE_TOKEN] ?? 0, '%n link', '%n links')
+
+ // Fallback for any unknown recipient class not covered above.
+ const known = new Set([
+ RECIPIENT_TYPE_USER,
+ RECIPIENT_TYPE_GROUP,
+ RECIPIENT_TYPE_TEAM,
+ RECIPIENT_TYPE_EMAIL,
+ RECIPIENT_TYPE_TOKEN,
+ ])
+ const otherCount = recipients.filter((r) => !known.has(r.class)).length
+ push(otherCount, '%n recipient', '%n recipients')
+
+ return parts.join(t('files_sharing', ', '))
+}
+
+/**
+ * Best-effort "Reshared with N people" subtitle: counts recipients that were
+ * added by someone other than the share owner (i.e. via a reshare). Returns an
+ * empty string when there are none.
+ *
+ * @param share The share
+ */
+export function reshareSubtitle(share: SharingShare): string {
+ const reshared = share.recipients.filter((recipient) => recipient.initiator !== null && recipient.initiator.user_id !== share.owner.user_id).length
+ if (reshared === 0) {
+ return ''
+ }
+ return n('files_sharing', 'Reshared with %n person', 'Reshared with %n people', reshared)
+}
diff --git a/apps/files_sharing/src/lib/unifiedSharingConstants.ts b/apps/files_sharing/src/lib/unifiedSharingConstants.ts
new file mode 100644
index 0000000000000..be8e2840f1df8
--- /dev/null
+++ b/apps/files_sharing/src/lib/unifiedSharingConstants.ts
@@ -0,0 +1,20 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * Hardcoded backend class strings from the unified sharing API, mirrored from
+ * the dialog library's constants. They are validated against the server
+ * capabilities at runtime before use (see `unifiedShares.ts`).
+ */
+
+/** Node (file/folder) source type. */
+export const SOURCE_TYPE_NODE = 'OCA\\Files\\Sharing\\Source\\NodeShareSourceType'
+
+/** Recipient type classes. */
+export const RECIPIENT_TYPE_USER = 'OC\\Core\\Sharing\\Recipient\\UserShareRecipientType'
+export const RECIPIENT_TYPE_EMAIL = 'OC\\Core\\Sharing\\Recipient\\EmailShareRecipientType'
+export const RECIPIENT_TYPE_GROUP = 'OC\\Core\\Sharing\\Recipient\\GroupShareRecipientType'
+export const RECIPIENT_TYPE_TEAM = 'OC\\Core\\Sharing\\Recipient\\TeamShareRecipientType'
+export const RECIPIENT_TYPE_TOKEN = 'OC\\Core\\Sharing\\Recipient\\TokenShareRecipientType'
diff --git a/apps/files_sharing/src/services/ConfigService.ts b/apps/files_sharing/src/services/ConfigService.ts
index edea1119e26a9..bf47a23c3fcd4 100644
--- a/apps/files_sharing/src/services/ConfigService.ts
+++ b/apps/files_sharing/src/services/ConfigService.ts
@@ -4,6 +4,7 @@
*/
import { getCapabilities } from '@nextcloud/capabilities'
import { loadState } from '@nextcloud/initial-state'
+import { isSharingDialogAvailable } from './SharingDialog.ts'
type PasswordPolicySettings = {
enforceNonCommonPassword: boolean
@@ -354,4 +355,13 @@ export default class Config {
get showExternalSharing(): boolean {
return loadState('files_sharing', 'showExternalSharing', true)
}
+
+ /**
+ * Whether the new unified sharing dialog replaces the legacy inline sharing UI.
+ * Derived from the server capabilities: when the unified sharing API is not
+ * advertised (capability empty), the legacy inputs and menus are used instead.
+ */
+ get sharingDialogEnabled(): boolean {
+ return isSharingDialogAvailable()
+ }
}
diff --git a/apps/files_sharing/src/services/SharingDialog.ts b/apps/files_sharing/src/services/SharingDialog.ts
new file mode 100644
index 0000000000000..3363a74230d23
--- /dev/null
+++ b/apps/files_sharing/src/services/SharingDialog.ts
@@ -0,0 +1,63 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { Node } from '@nextcloud/files'
+
+import { getCapabilities } from '@nextcloud/capabilities'
+
+/**
+ * The unified sharing dialog is a Vue 3 component. It is registered on the
+ * global `OCA.Sharing` namespace by the Vue 3 bridge entry point
+ * (`sharing-dialog-bridge.ts`), so this Vue 2 frontend can trigger it without
+ * pulling Vue 3 into its bundle.
+ */
+type SharingDialogApi = {
+ openSharingDialog(node: Node): Promise
+ openShareEditDialog(shareId: string | number, node?: Node): Promise
+}
+
+/**
+ * Get the sharing dialog API registered by the Vue 3 bridge, if loaded.
+ */
+function sharingDialogApi(): SharingDialogApi | undefined {
+ return (window.OCA?.Sharing as Partial | undefined) as SharingDialogApi | undefined
+}
+
+/**
+ * Whether the server provides the unified sharing API this dialog talks to.
+ * Mirrors the library check so the sidebar can gate on it without importing
+ * the Vue 3 code.
+ */
+export function isSharingDialogAvailable(): boolean {
+ const capabilities = getCapabilities() as { sharing?: { api_versions?: unknown[] } }
+ return (capabilities.sharing?.api_versions?.length ?? 0) > 0
+}
+
+/**
+ * Open the unified sharing dialog to create a new share for a node.
+ *
+ * @param node The file or folder to share
+ */
+export function openShareCreateDialog(node: Node): Promise {
+ const api = sharingDialogApi()
+ if (!api?.openSharingDialog) {
+ return Promise.reject(new Error('The unified sharing dialog is not available'))
+ }
+ return api.openSharingDialog(node)
+}
+
+/**
+ * Open the unified sharing dialog to edit an existing share.
+ *
+ * @param shareId The share id (mapped to the unified API by the legacy bridge)
+ * @param node The backing node, used for the dialog title
+ */
+export function openShareEditDialog(shareId: string | number, node?: Node): Promise {
+ const api = sharingDialogApi()
+ if (!api?.openShareEditDialog) {
+ return Promise.reject(new Error('The unified sharing dialog is not available'))
+ }
+ return api.openShareEditDialog(shareId, node)
+}
diff --git a/apps/files_sharing/src/services/unifiedShares.ts b/apps/files_sharing/src/services/unifiedShares.ts
new file mode 100644
index 0000000000000..6e9be778c7a1a
--- /dev/null
+++ b/apps/files_sharing/src/services/unifiedShares.ts
@@ -0,0 +1,96 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { Node } from '@nextcloud/files'
+import type { SharingShare } from '../types/unifiedSharing.ts'
+
+import axios from '@nextcloud/axios'
+import { getCapabilities } from '@nextcloud/capabilities'
+import { generateOcsUrl } from '@nextcloud/router'
+import { SOURCE_TYPE_NODE } from '../lib/unifiedSharingConstants.ts'
+import logger from './logger.ts'
+
+const PAGE_SIZE = 100
+
+type Capabilities = {
+ sharing?: {
+ source_types?: { class: string }[]
+ }
+}
+
+/**
+ * Whether the node source type is registered on the server. The hardcoded class
+ * string must match a capability entry, otherwise listing would silently return
+ * nothing.
+ */
+function isNodeSourceTypeAvailable(): boolean {
+ const capabilities = getCapabilities() as Capabilities
+ return (capabilities.sharing?.source_types ?? []).some((type) => type.class === SOURCE_TYPE_NODE)
+}
+
+/**
+ * Fetch every active share whose source is the given node, from the unified
+ * sharing API. Results are paginated by the backend; this walks all pages.
+ *
+ * @param node The file or folder whose shares to list
+ */
+export async function getSharesForNode(node: Node): Promise {
+ if (!isNodeSourceTypeAvailable()) {
+ logger.warn('Node source type is not advertised by the sharing capability; cannot list unified shares')
+ return []
+ }
+
+ const url = generateOcsUrl('/apps/sharing/api/v1/shares')
+ const shares: SharingShare[] = []
+ let lastShareID: string | undefined
+
+ // Walk pages until the backend returns a short (final) page.
+ for (;;) {
+ const response = await axios.get(url, {
+ params: {
+ filterSourceTypeClass: SOURCE_TYPE_NODE,
+ filterSourceTypeValue: String(node.fileid),
+ filterState: 'active',
+ limit: PAGE_SIZE,
+ ...(lastShareID ? { lastShareID } : {}),
+ },
+ })
+ const page: SharingShare[] = response.data.ocs.data
+ shares.push(...page)
+ if (page.length < PAGE_SIZE) {
+ break
+ }
+ lastShareID = page[page.length - 1].id
+ }
+
+ return shares
+}
+
+/**
+ * Delete a share by id.
+ *
+ * @param id The share id
+ */
+export async function deleteShare(id: string): Promise {
+ await axios.delete(generateOcsUrl('/apps/sharing/api/v1/share/{id}', { id }))
+}
+
+/**
+ * Remove a single recipient from a share.
+ *
+ * @param id The share id
+ * @param recipientClass The recipient type class
+ * @param recipientValue The recipient value
+ * @param instance The recipient's instance (federated recipients)
+ */
+export async function removeRecipient(id: string, recipientClass: string, recipientValue: string, instance?: string | null): Promise {
+ await axios.delete(generateOcsUrl('/apps/sharing/api/v1/share/{id}/recipient', { id }), {
+ data: {
+ class: recipientClass,
+ value: recipientValue,
+ instance: instance ?? null,
+ },
+ })
+}
diff --git a/apps/files_sharing/src/sharing-dialog-bridge.ts b/apps/files_sharing/src/sharing-dialog-bridge.ts
new file mode 100644
index 0000000000000..2cd81c671b9d9
--- /dev/null
+++ b/apps/files_sharing/src/sharing-dialog-bridge.ts
@@ -0,0 +1,39 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { Node } from '@nextcloud/files'
+
+import { getShare, isSharingDialogAvailable, openSharingDialog } from '@nextcloud/sharing/dialog'
+
+/**
+ * Bridge the Vue 3 unified sharing dialog into the global `OCA.Sharing`
+ * namespace so the (still Vue 2) files_sharing sidebar can trigger it without
+ * importing Vue 3 code into its bundle. The dialog spawns its own Vue 3 app,
+ * so it must be registered from this Vue 3 entry point where `vue` and
+ * `@nextcloud/vue` resolve to their Vue 3 versions.
+ *
+ * Once files_sharing is migrated to Vue 3 this bridge can be dropped and the
+ * library imported directly.
+ */
+
+window.OCA ??= {}
+window.OCA.Sharing ??= {}
+
+Object.assign(window.OCA.Sharing, {
+ openSharingDialog,
+
+ /**
+ * Open the unified sharing dialog to edit an existing share.
+ *
+ * @param shareId The share id (mapped to the unified API by the legacy bridge)
+ * @param node The backing node, used for the dialog title
+ */
+ async openShareEditDialog(shareId: string | number, node?: Node): Promise {
+ const share = await getShare(String(shareId))
+ return share.showDialog(node)
+ },
+
+ isSharingDialogAvailable,
+})
diff --git a/apps/files_sharing/src/types/unifiedSharing.ts b/apps/files_sharing/src/types/unifiedSharing.ts
new file mode 100644
index 0000000000000..74a4d259c4eb1
--- /dev/null
+++ b/apps/files_sharing/src/types/unifiedSharing.ts
@@ -0,0 +1,78 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * Types mirroring the unified sharing API (`@nextcloud/sharing/dialog`).
+ *
+ * The dialog library is Vue 3 and cannot be imported into this Vue 2 frontend,
+ * so the subset of the schema needed to list shares in the sidebar is kept here.
+ * Keep in sync with the library's `lib/dialog/types/api.ts`.
+ */
+
+export interface SharingIconSVG {
+ svg: string
+}
+
+export interface SharingIconURL {
+ light: string
+ dark: string
+}
+
+export type SharingIcon = SharingIconSVG | SharingIconURL
+
+export interface SharingOwner {
+ user_id: string
+ instance: string | null
+ display_name: string
+ icon: SharingIcon
+}
+
+export interface SharingSource {
+ class: string
+ value: string
+ display_name: string
+ icon: SharingIcon | null
+}
+
+export interface SharingRecipientSecret {
+ updatable: boolean
+ value?: string
+ url?: string
+}
+
+export interface SharingRecipient {
+ class: string
+ value: string
+ instance: string | null
+ display_name: string
+ icon: SharingIcon | null
+ secret: SharingRecipientSecret
+ initiator: SharingOwner | null
+ /** Per-recipient permissions, capped at the share-level ones */
+ permissions: SharingPermission[]
+}
+
+export interface SharingPermission {
+ class: string
+ source_class: string | null
+ display_name: string
+ hint: string | null
+ priority: number
+ presets: string[]
+ enabled: boolean
+}
+
+export type SharingState = 'active' | 'draft' | 'deleted'
+
+export interface SharingShare {
+ id: string
+ owner: SharingOwner
+ last_updated: number
+ state: SharingState
+ sources: SharingSource[]
+ recipients: SharingRecipient[]
+ permissions: SharingPermission[]
+ permission_preset: string | null
+}
diff --git a/apps/files_sharing/src/views/SharingTab.vue b/apps/files_sharing/src/views/SharingTab.vue
index 0463a9969d28f..fa9710d6288fd 100644
--- a/apps/files_sharing/src/views/SharingTab.vue
+++ b/apps/files_sharing/src/views/SharingTab.vue
@@ -4,7 +4,7 @@
-->
-
+
@@ -21,16 +21,41 @@
-
+
+
+
+
+
+ {{ t('files_sharing', 'Share') }}
+
+
+
+
+
+