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 @@ + + @@ -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 @@ + + + + + + + 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 @@ -->