diff --git a/apps/files_sharing/lib/Listener/LoadSidebarListener.php b/apps/files_sharing/lib/Listener/LoadSidebarListener.php
index e6a30a5688173..4768f2e0657ca 100644
--- a/apps/files_sharing/lib/Listener/LoadSidebarListener.php
+++ b/apps/files_sharing/lib/Listener/LoadSidebarListener.php
@@ -38,6 +38,10 @@ public function handle(Event $event): void {
return;
}
Util::addScript(Application::APP_ID, 'files_sharing_tab', 'files');
+ // Vue 3 bridge exposing the unified sharing dialog on OCA.Sharing for the
+ // (Vue 2) sidebar to trigger without bundling Vue 3.
+ Util::addStyle(Application::APP_ID, 'sharing-dialog');
+ Util::addScript(Application::APP_ID, 'sharing-dialog', 'files');
$appConfig = Server::get(IAppConfig::class);
$gsConfig = Server::get(IConfig::class);
diff --git a/apps/files_sharing/src/components/AvatarStack.vue b/apps/files_sharing/src/components/AvatarStack.vue
new file mode 100644
index 0000000000000..b9854c3a17844
--- /dev/null
+++ b/apps/files_sharing/src/components/AvatarStack.vue
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+ +{{ overflow }}
+
+
+
+
+
+
+
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.spec.ts b/apps/files_sharing/src/components/UnifiedShareEntry.spec.ts
new file mode 100644
index 0000000000000..b31f8e28a531f
--- /dev/null
+++ b/apps/files_sharing/src/components/UnifiedShareEntry.spec.ts
@@ -0,0 +1,160 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import UnifiedShareEntry from './UnifiedShareEntry.vue'
+import { openShareEditDialog } from '../services/SharingDialog.ts'
+import { deleteShare, removeRecipient } from '../services/unifiedShares.ts'
+
+vi.mock('../services/SharingDialog.ts', () => ({
+ openShareEditDialog: vi.fn().mockResolvedValue(undefined),
+}))
+
+vi.mock('../services/unifiedShares.ts', () => ({
+ deleteShare: vi.fn().mockResolvedValue(undefined),
+ removeRecipient: vi.fn().mockResolvedValue(undefined),
+}))
+
+vi.mock('../services/logger.ts', () => ({
+ default: { error: vi.fn(), debug: vi.fn() },
+}))
+
+// Confirm dialog: answers with the last button ("Delete") by default; set
+// `confirmation.declined` to answer with the first one ("Cancel") instead.
+const confirmation = vi.hoisted(() => ({ declined: false }))
+
+vi.mock('@nextcloud/dialogs', () => ({
+ DialogBuilder: class {
+ buttons: { callback: () => void }[] = []
+ setName() {
+ return this
+ }
+
+ setText() {
+ return this
+ }
+
+ setButtons(buttons: { callback: () => void }[]) {
+ this.buttons = buttons
+ return this
+ }
+
+ build() {
+ const { buttons } = this
+ return {
+ show: async () => (confirmation.declined ? buttons.at(0) : buttons.at(-1))?.callback(),
+ }
+ }
+ },
+}))
+
+function recipient(value: string) {
+ return {
+ class: 'UserRecipient',
+ value,
+ instance: null,
+ display_name: value,
+ icon: null,
+ secret: { updatable: false },
+ initiator: null,
+ permissions: [],
+ }
+}
+
+function share(recipients = [recipient('bob')]) {
+ return {
+ id: '42',
+ state: 'active',
+ recipients,
+ permissions: [],
+ permission_preset: null,
+ owner: { user_id: 'alice', display_name: 'Alice', instance: null },
+ }
+}
+
+function mountEntry(data = share()) {
+ return mount(UnifiedShareEntry, {
+ propsData: { share: data, fileInfo: { node: { fileid: 1 } } },
+ stubs: {
+ NcAvatar: true,
+ AvatarStack: true,
+ // The actions live in a menu that only renders its content once opened.
+ NcActions: { template: '
' },
+ },
+ })
+}
+
+/** Trigger a row action by its label, as clicking the menu entry would. */
+async function triggerAction(wrapper: ReturnType, label: string) {
+ const action = wrapper.findAllComponents({ name: 'NcActionButton' })
+ .wrappers.find((button) => button.text().includes(label))
+ expect(action, `the "${label}" action is rendered`).toBeDefined()
+ action!.vm.$emit('click')
+ await new Promise((resolve) => setTimeout(resolve))
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ confirmation.declined = false
+})
+
+describe('editing', () => {
+ it('refreshes the list once the dialog closes', async () => {
+ const wrapper = mountEntry()
+ await triggerAction(wrapper, 'Edit share')
+ expect(openShareEditDialog).toHaveBeenCalledWith('42', { fileid: 1 })
+ expect(wrapper.emitted('refresh')).toHaveLength(1)
+ })
+
+ it('still refreshes when the dialog fails', async () => {
+ vi.mocked(openShareEditDialog).mockRejectedValueOnce(new Error('nope'))
+ const wrapper = mountEntry()
+ await triggerAction(wrapper, 'Edit share')
+ // The dialog writes straight to the backend, so it may have applied
+ // changes before it errored.
+ expect(wrapper.emitted('refresh')).toHaveLength(1)
+ })
+})
+
+describe('deleting the share', () => {
+ it('deletes it and refreshes once confirmed', async () => {
+ const wrapper = mountEntry()
+ await triggerAction(wrapper, 'Delete share')
+ expect(deleteShare).toHaveBeenCalledWith('42')
+ expect(wrapper.emitted('refresh')).toHaveLength(1)
+ })
+
+ it('does not delete it when the confirmation is declined', async () => {
+ confirmation.declined = true
+ const wrapper = mountEntry()
+ await triggerAction(wrapper, 'Delete share')
+ expect(deleteShare).not.toHaveBeenCalled()
+ expect(wrapper.emitted('refresh')).toBeUndefined()
+ })
+
+ it('does not refresh when the deletion fails', async () => {
+ vi.mocked(deleteShare).mockRejectedValueOnce(new Error('nope'))
+ const wrapper = mountEntry()
+ await triggerAction(wrapper, 'Delete share')
+ expect(wrapper.emitted('refresh')).toBeUndefined()
+ })
+})
+
+describe('removing a participant', () => {
+ it('removes the recipient of the row it was triggered on', async () => {
+ const wrapper = mountEntry(share([recipient('bob'), recipient('carol')]))
+ await triggerAction(wrapper, 'Remove participant')
+ expect(removeRecipient).toHaveBeenCalledWith('42', 'UserRecipient', 'bob', null)
+ expect(wrapper.emitted('refresh')).toHaveLength(1)
+ })
+
+ it('does not refresh when the removal fails', async () => {
+ vi.mocked(removeRecipient).mockRejectedValueOnce(new Error('nope'))
+ const wrapper = mountEntry(share([recipient('bob'), recipient('carol')]))
+ await triggerAction(wrapper, 'Remove participant')
+ expect(wrapper.emitted('refresh')).toBeUndefined()
+ })
+})
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.spec.ts b/apps/files_sharing/src/lib/unifiedSharing.spec.ts
new file mode 100644
index 0000000000000..bc502d531bc03
--- /dev/null
+++ b/apps/files_sharing/src/lib/unifiedSharing.spec.ts
@@ -0,0 +1,172 @@
+/**
+ * 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 { describe, expect, test, vi } from 'vitest'
+import {
+ isNoUserRecipient,
+ permissionLabel,
+ recipientPermissionLabel,
+ recipientSummary,
+ reshareSubtitle,
+ sharePermissionRank,
+ sortSharesByPermission,
+} from './unifiedSharing.ts'
+import {
+ RECIPIENT_TYPE_GROUP,
+ RECIPIENT_TYPE_USER,
+} from './unifiedSharingConstants.ts'
+
+vi.mock('@nextcloud/l10n', () => ({
+ translate: (_app: string, text: string) => text,
+ translatePlural: (_app: string, singular: string, plural: string, count: number) => (count === 1 ? singular : plural).replace('%n', String(count)),
+}))
+
+vi.mock('@nextcloud/capabilities', () => ({
+ getCapabilities: () => ({
+ sharing: {
+ permission_presets: [
+ { class: 'PresetView', display_name: 'Can view' },
+ { class: 'PresetEdit', display_name: 'Can edit' },
+ ],
+ },
+ }),
+}))
+
+function permission(cls: string, priority: number, enabled: boolean, presets: string[] = []): SharingPermission {
+ return {
+ class: cls,
+ source_class: null,
+ display_name: cls,
+ hint: null,
+ priority,
+ presets,
+ enabled,
+ }
+}
+
+function recipient(cls: string, value: string, initiator: SharingRecipient['initiator'] = null, permissions: SharingPermission[] = []): SharingRecipient {
+ return {
+ class: cls,
+ value,
+ instance: null,
+ display_name: value,
+ icon: null,
+ secret: { updatable: false },
+ initiator,
+ permissions,
+ }
+}
+
+function share(overrides: Partial = {}): SharingShare {
+ return {
+ id: '1',
+ owner: { user_id: 'alice', instance: null, display_name: 'Alice', icon: { svg: '' } },
+ last_updated: 0,
+ state: 'active',
+ sources: [],
+ recipients: [],
+ permissions: [],
+ permission_preset: null,
+ ...overrides,
+ }
+}
+
+describe('sharePermissionRank', () => {
+ test('sums priorities of enabled permissions only', () => {
+ const s = share({
+ permissions: [permission('read', 1, true), permission('write', 4, true), permission('share', 16, false)],
+ })
+ expect(sharePermissionRank(s)).toBe(5)
+ })
+})
+
+describe('sortSharesByPermission', () => {
+ test('orders by rank desc, then recipient count, then id', () => {
+ const low = share({ id: 'a', permissions: [permission('read', 1, true)] })
+ const high = share({ id: 'b', permissions: [permission('read', 1, true), permission('write', 4, true)] })
+ const highMoreRecipients = share({
+ id: 'c',
+ permissions: [permission('read', 1, true), permission('write', 4, true)],
+ recipients: [recipient(RECIPIENT_TYPE_USER, 'bob'), recipient(RECIPIENT_TYPE_USER, 'carol')],
+ })
+ const sorted = sortSharesByPermission([low, high, highMoreRecipients])
+ expect(sorted.map((s) => s.id)).toEqual(['c', 'b', 'a'])
+ })
+
+ test('does not mutate the input', () => {
+ const input = [share({ id: 'a' }), share({ id: 'b' })]
+ sortSharesByPermission(input)
+ expect(input.map((s) => s.id)).toEqual(['a', 'b'])
+ })
+})
+
+describe('isNoUserRecipient', () => {
+ test('true for non-user classes, false for users', () => {
+ expect(isNoUserRecipient(recipient(RECIPIENT_TYPE_GROUP, 'devs'))).toBe(true)
+ expect(isNoUserRecipient(recipient(RECIPIENT_TYPE_USER, 'bob'))).toBe(false)
+ })
+})
+
+describe('recipientSummary', () => {
+ test('counts and pluralizes by category', () => {
+ const recipients = [
+ recipient(RECIPIENT_TYPE_USER, 'bob'),
+ recipient(RECIPIENT_TYPE_GROUP, 'devs'),
+ recipient(RECIPIENT_TYPE_GROUP, 'ops'),
+ ]
+ expect(recipientSummary(recipients)).toBe('1 person, 2 groups')
+ })
+})
+
+describe('permissionLabel', () => {
+ test('returns the preset display name', () => {
+ expect(permissionLabel(share({ permission_preset: 'PresetEdit' }))).toBe('Can edit')
+ })
+
+ test('returns Custom permissions when no preset matches', () => {
+ expect(permissionLabel(share({ permission_preset: null }))).toBe('Custom permissions')
+ expect(permissionLabel(share({ permission_preset: 'Unknown' }))).toBe('Custom permissions')
+ })
+})
+
+describe('reshareSubtitle', () => {
+ test('counts recipients added by someone other than the owner', () => {
+ const initiator = { user_id: 'bob', instance: null, display_name: 'Bob', icon: { svg: '' } }
+ const s = share({
+ recipients: [
+ recipient(RECIPIENT_TYPE_USER, 'carol', initiator),
+ recipient(RECIPIENT_TYPE_USER, 'dave'),
+ ],
+ })
+ expect(reshareSubtitle(s)).toBe('Reshared with 1 person')
+ })
+
+ test('empty when no reshares', () => {
+ expect(reshareSubtitle(share({ recipients: [recipient(RECIPIENT_TYPE_USER, 'bob')] }))).toBe('')
+ })
+})
+
+describe('recipientPermissionLabel', () => {
+ const read = (enabled: boolean) => permission('read', 1, enabled, ['PresetView', 'PresetEdit'])
+ const write = (enabled: boolean) => permission('write', 4, enabled, ['PresetEdit'])
+ // Share grants both -> its own label would be "Can edit".
+ const base = share({ permissions: [read(true), write(true)] })
+
+ test('inherits the share permissions when the recipient has no overrides', () => {
+ expect(recipientPermissionLabel(base, recipient(RECIPIENT_TYPE_USER, 'bob'))).toBe('Can edit')
+ })
+
+ test('applies the recipient overrides on top of the share', () => {
+ const r = recipient(RECIPIENT_TYPE_USER, 'bob', null, [write(false)])
+ expect(recipientPermissionLabel(base, r)).toBe('Can view')
+ })
+
+ test('falls back to Custom permissions when nothing matches', () => {
+ const r = recipient(RECIPIENT_TYPE_USER, 'bob', null, [read(false)])
+ expect(recipientPermissionLabel(base, r)).toBe('Custom permissions')
+ })
+})
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.spec.ts b/apps/files_sharing/src/services/unifiedShares.spec.ts
new file mode 100644
index 0000000000000..5a7d7811e0a8a
--- /dev/null
+++ b/apps/files_sharing/src/services/unifiedShares.spec.ts
@@ -0,0 +1,77 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { beforeEach, describe, expect, test, vi } from 'vitest'
+import { SOURCE_TYPE_NODE } from '../lib/unifiedSharingConstants.ts'
+import { deleteShare, getSharesForNode, removeRecipient } from './unifiedShares.ts'
+
+const axios = vi.hoisted(() => ({ get: vi.fn(), delete: vi.fn() }))
+vi.mock('@nextcloud/axios', () => ({ default: axios }))
+
+const capabilities = vi.hoisted(() => ({ value: {} as unknown }))
+vi.mock('@nextcloud/capabilities', () => ({ getCapabilities: () => capabilities.value }))
+
+vi.mock('@nextcloud/router', () => ({
+ generateOcsUrl: (path: string, params?: Record) => 'ocs/' + path.replace(/\{(\w+)\}/g, (_, k) => params?.[k] ?? ''),
+}))
+
+vi.mock('./logger.ts', () => ({ default: { warn: vi.fn(), error: vi.fn(), debug: vi.fn() } }))
+
+const ocs = (data: unknown[]) => ({ data: { ocs: { data } } })
+const node = { fileid: 42 } as never
+
+beforeEach(() => {
+ vi.resetAllMocks()
+ capabilities.value = { sharing: { source_types: [{ class: SOURCE_TYPE_NODE }] } }
+})
+
+describe('getSharesForNode', () => {
+ test('returns [] and does not call the API when the node source type is unavailable', async () => {
+ capabilities.value = { sharing: { source_types: [] } }
+ expect(await getSharesForNode(node)).toEqual([])
+ expect(axios.get).not.toHaveBeenCalled()
+ })
+
+ test('unwraps ocs.data and filters by node', async () => {
+ axios.get.mockResolvedValueOnce(ocs([{ id: '1' }, { id: '2' }]))
+ const shares = await getSharesForNode(node)
+ expect(shares).toEqual([{ id: '1' }, { id: '2' }])
+ expect(axios.get).toHaveBeenCalledTimes(1)
+ expect(axios.get.mock.calls[0][1].params).toMatchObject({
+ filterSourceTypeClass: SOURCE_TYPE_NODE,
+ filterSourceTypeValue: '42',
+ filterState: 'active',
+ limit: 100,
+ })
+ })
+
+ test('walks all pages until a short page', async () => {
+ const fullPage = Array.from({ length: 100 }, (_, i) => ({ id: String(i) }))
+ axios.get
+ .mockResolvedValueOnce(ocs(fullPage))
+ .mockResolvedValueOnce(ocs([{ id: '100' }, { id: '101' }]))
+ const shares = await getSharesForNode(node)
+ expect(shares).toHaveLength(102)
+ expect(axios.get).toHaveBeenCalledTimes(2)
+ // Second page requests the id after the last of the first page.
+ expect(axios.get.mock.calls[1][1].params.lastShareID).toBe('99')
+ })
+})
+
+describe('deleteShare', () => {
+ test('DELETEs the share by id', async () => {
+ await deleteShare('7')
+ expect(axios.delete).toHaveBeenCalledWith('ocs//apps/sharing/api/v1/share/7')
+ })
+})
+
+describe('removeRecipient', () => {
+ test('DELETEs the recipient with the class/value/instance body', async () => {
+ await removeRecipient('7', 'RecipClass', 'bob', null)
+ expect(axios.delete).toHaveBeenCalledWith('ocs//apps/sharing/api/v1/share/7/recipient', {
+ data: { class: 'RecipClass', value: 'bob', instance: null },
+ })
+ })
+})
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.spec.ts b/apps/files_sharing/src/views/SharingTab.spec.ts
new file mode 100644
index 0000000000000..d609e3aff6846
--- /dev/null
+++ b/apps/files_sharing/src/views/SharingTab.spec.ts
@@ -0,0 +1,103 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+// The component reads the theme at module scope, so it must exist before import.
+vi.hoisted(() => {
+ window.OC = { ...window.OC, theme: { productName: 'Nextcloud' } }
+})
+
+import SharingTab from './SharingTab.vue'
+import { openShareCreateDialog } from '../services/SharingDialog.ts'
+import { getSharesForNode } from '../services/unifiedShares.ts'
+
+vi.mock('../services/SharingDialog.ts', () => ({
+ openShareCreateDialog: vi.fn().mockResolvedValue(undefined),
+ isSharingDialogAvailable: vi.fn().mockReturnValue(true),
+}))
+
+vi.mock('../services/unifiedShares.ts', () => ({
+ getSharesForNode: vi.fn().mockResolvedValue([]),
+}))
+
+vi.mock('../services/logger.ts', () => ({
+ default: { error: vi.fn(), debug: vi.fn() },
+}))
+
+function buildContext(overrides: Record = {}) {
+ return {
+ fileInfo: { node: { fileid: 1 } },
+ unifiedShares: [],
+ loading: false,
+ error: '',
+ getUnifiedShares: vi.fn().mockResolvedValue(undefined),
+ ...overrides,
+ }
+}
+
+type Ctx = ReturnType
+
+/**
+ * Call a component method with a fake `this`, as the other specs here do.
+ *
+ * @param method The method name
+ * @param ctx The fake component context
+ * @param args Arguments passed to the method
+ */
+function call(method: string, ctx: Ctx, ...args: unknown[]) {
+ return SharingTab.methods[method].call(ctx, ...args)
+}
+
+beforeEach(() => vi.clearAllMocks())
+
+describe('openShareDialog', () => {
+ it('refreshes the list once the dialog closes', async () => {
+ const ctx = buildContext()
+ await call('openShareDialog', ctx)
+ expect(openShareCreateDialog).toHaveBeenCalledWith(ctx.fileInfo.node)
+ // Without the loading state, so the rendered rows stay mounted.
+ expect(ctx.getUnifiedShares).toHaveBeenCalledWith(false)
+ })
+
+ it('still refreshes when the dialog fails', async () => {
+ vi.mocked(openShareCreateDialog).mockRejectedValueOnce(new Error('nope'))
+ const ctx = buildContext()
+ await call('openShareDialog', ctx)
+ expect(ctx.getUnifiedShares).toHaveBeenCalledWith(false)
+ })
+})
+
+describe('getUnifiedShares', () => {
+ it('loads the shares of the current node', async () => {
+ const shares = [{ id: '1' }]
+ vi.mocked(getSharesForNode).mockResolvedValueOnce(shares)
+ const ctx = buildContext({ getUnifiedShares: SharingTab.methods.getUnifiedShares })
+ await SharingTab.methods.getUnifiedShares.call(ctx)
+ expect(getSharesForNode).toHaveBeenCalledWith(ctx.fileInfo.node)
+ expect(ctx.unifiedShares).toEqual(shares)
+ })
+
+ it('keeps the rows mounted while refreshing in place', async () => {
+ const ctx = buildContext()
+ let loadingWhileFetching = false
+ vi.mocked(getSharesForNode).mockImplementationOnce(async () => {
+ loadingWhileFetching = ctx.loading
+ return []
+ })
+ await SharingTab.methods.getUnifiedShares.call(ctx, false)
+ expect(loadingWhileFetching).toBe(false)
+ })
+
+ it('surfaces the backend error message', async () => {
+ vi.mocked(getSharesForNode).mockRejectedValueOnce({
+ response: { data: { ocs: { meta: { message: 'Nope' } } } },
+ })
+ const ctx = buildContext()
+ await SharingTab.methods.getUnifiedShares.call(ctx)
+ expect(ctx.error).toBe('Nope')
+ expect(ctx.loading).toBe(false)
+ })
+})
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') }}
+
+
+
+
+
+