diff --git a/packages/web-app-files/src/components/SideBar/Shares/Collaborators/InviteCollaborator/InviteCollaboratorForm.vue b/packages/web-app-files/src/components/SideBar/Shares/Collaborators/InviteCollaborator/InviteCollaboratorForm.vue index f589b3e243c..427cc682490 100644 --- a/packages/web-app-files/src/components/SideBar/Shares/Collaborators/InviteCollaborator/InviteCollaboratorForm.vue +++ b/packages/web-app-files/src/components/SideBar/Shares/Collaborators/InviteCollaborator/InviteCollaboratorForm.vue @@ -150,7 +150,10 @@ import { CollaboratorShare, ShareRole, ShareTypes, - call + call, + isSharingHierarchyConflictPendingError, + shareHierarchyForceRequestOptions, + type SharingHierarchyConflict } from '@ownclouders/web-client' import { useCapabilityStore, @@ -159,7 +162,8 @@ import { useSpacesStore, useConfigStore, useSharesStore, - useUserStore + useUserStore, + useSharingHierarchyConflictsConfirm } from '@ownclouders/web-pkg' import { computed, defineComponent, inject, ref, unref, watch, onMounted, nextTick, Ref } from 'vue' @@ -218,6 +222,8 @@ export default defineComponent({ const { addShare } = sharesStore const { collaboratorShares } = storeToRefs(sharesStore) + const confirmSharingHierarchyConflicts = useSharingHierarchyConflictsConfirm() + const searchQuery = ref('') const searchInProgress = ref(false) const autocompleteResults = ref([]) @@ -376,46 +382,96 @@ export default defineComponent({ const savePromises: Promise[] = [] const errors: { displayName: string; error: Error }[] = [] const addedShares: CollaboratorShare[] = [] + const pendingForceShares: { + displayName: string + conflict: SharingHierarchyConflict + addShareParams: Parameters[0] + }[] = [] + + const finishAddedShare = (share: CollaboratorShare) => { + addedShares.push(share) + + if (unref(notifyEnabled)) { + clientService.httpAuthenticated.get( + `/ocs/v1.php/apps/files_sharing/api/v1/shares/${share.id}/notify` + ) as any + } + } unref(selectedCollaborators).forEach(({ id, shareType, displayName }) => { const type = getRecipientType(shareType) + const addShareParams = { + clientService, + space: unref(space), + resource: unref(resource), + options: { + roles: [unref(selectedRole).id], + expirationDateTime: unref(expirationDate), + recipients: [ + { + objectId: id, + '@libre.graph.recipient.type': type + } + ] + } + } savePromises.push( saveQueue.add(async () => { try { const share = await addShare({ - clientService, - space: unref(space), - resource: unref(resource), - options: { - roles: [unref(selectedRole).id], - expirationDateTime: unref(expirationDate), - recipients: [ - { - objectId: id, - '@libre.graph.recipient.type': type - } - ] - } + ...addShareParams, + deferSharingHierarchyConflictConfirm: true }) - addedShares.push(share) - - if (unref(notifyEnabled)) { - clientService.httpAuthenticated.get( - `/ocs/v1.php/apps/files_sharing/api/v1/shares/${share.id}/notify` - ) as any - } + finishAddedShare(share) } catch (error) { + if (isSharingHierarchyConflictPendingError(error)) { + pendingForceShares.push({ + displayName, + conflict: error.conflict, + addShareParams + }) + return + } console.error(error) - errors.push({ displayName, error }) + errors.push({ displayName, error: error as Error }) throw error } }) ) }) - const results = await Promise.allSettled(savePromises) + await Promise.allSettled(savePromises) + + if (pendingForceShares.length > 0) { + const proceed = await confirmSharingHierarchyConflicts( + pendingForceShares.map(({ conflict }) => conflict) + ) + + if (!proceed) { + pendingForceShares.forEach(({ displayName }) => { + errors.push({ + displayName, + error: new Error($gettext('Sharing change cancelled')) + }) + }) + } else { + for (const { displayName, addShareParams } of pendingForceShares) { + try { + const share = await addShare({ + ...addShareParams, + graphRequestOptions: shareHierarchyForceRequestOptions() + }) + + finishAddedShare(share) + } catch (error) { + console.error(error) + errors.push({ displayName, error: error as Error }) + } + } + } + } if (isProjectSpaceResource(unref(resource))) { const updatedSpace = await clientService.graphAuthenticated.drives.getDrive( @@ -426,7 +482,7 @@ export default defineComponent({ upsertSpace(updatedSpace) } - if (results.length !== errors.length) { + if (addedShares.length > 0) { showMessage({ title: $gettext('Share was added successfully') }) } diff --git a/packages/web-app-files/src/components/SideBar/Shares/Collaborators/ListItem.vue b/packages/web-app-files/src/components/SideBar/Shares/Collaborators/ListItem.vue index 777a37ef37f..94bacc3f5b6 100644 --- a/packages/web-app-files/src/components/SideBar/Shares/Collaborators/ListItem.vue +++ b/packages/web-app-files/src/components/SideBar/Shares/Collaborators/ListItem.vue @@ -128,7 +128,12 @@ import { DateTime } from 'luxon' import EditDropdown from './EditDropdown.vue' import RoleDropdown from './RoleDropdown.vue' -import { CollaboratorShare, ShareRole, ShareTypes } from '@ownclouders/web-client' +import { + CollaboratorShare, + ShareRole, + ShareTypes, + isSharingHierarchyConflictCancelledError +} from '@ownclouders/web-client' import { queryItemAsString, useMessages, @@ -136,7 +141,8 @@ import { useSpacesStore, useUserStore, useSharesStore, - useConfigStore + useConfigStore, + useSharingHierarchyConflictConfirm } from '@ownclouders/web-pkg' import { Resource, extractDomSelector } from '@ownclouders/web-client' import { computed, defineComponent, inject, PropType, Ref, unref } from 'vue' @@ -205,6 +211,7 @@ export default defineComponent({ const sharesStore = useSharesStore() const { graphRoles } = storeToRefs(sharesStore) const { updateShare } = sharesStore + const confirmSharingHierarchyConflict = useSharingHierarchyConflictConfirm() const { upsertSpace } = useSpacesStore() const { user } = storeToRefs(userStore) @@ -235,7 +242,9 @@ export default defineComponent({ } const notifyShare = async () => { try { - const resp = await clientService.httpAuthenticated.get(`/ocs/v1.php/apps/files_sharing/api/v1/shares/${props.share.id}/notify`) as any + const resp = (await clientService.httpAuthenticated.get( + `/ocs/v1.php/apps/files_sharing/api/v1/shares/${props.share.id}/notify` + )) as any showMessage({ title: $gettext(`Reminder sent to ${resp.data.recipients[0]}`) }) @@ -257,6 +266,7 @@ export default defineComponent({ resource: inject>('resource'), space: inject>('space'), updateShare, + confirmSharingHierarchyConflict, user, clientService, cernFeatures, @@ -415,7 +425,8 @@ export default defineComponent({ space: this.space, resource: this.resource, collaboratorShare: this.share, - options: { roles: [role.id], expirationDateTime } + options: { roles: [role.id], expirationDateTime }, + confirmSharingHierarchyConflict: this.confirmSharingHierarchyConflict }) if (isProjectSpaceResource(this.resource)) { @@ -427,6 +438,9 @@ export default defineComponent({ this.showMessage({ title: this.$gettext('Share successfully changed') }) } catch (e) { + if (isSharingHierarchyConflictCancelledError(e)) { + return + } console.error(e) this.showErrorMessage({ title: this.$gettext('Error while editing the share.'), diff --git a/packages/web-app-files/src/components/SideBar/Shares/FileShares.vue b/packages/web-app-files/src/components/SideBar/Shares/FileShares.vue index 4eb6b4742d2..ec5a7d2617e 100644 --- a/packages/web-app-files/src/components/SideBar/Shares/FileShares.vue +++ b/packages/web-app-files/src/components/SideBar/Shares/FileShares.vue @@ -108,7 +108,8 @@ import { useConfigStore, useSharesStore, useResourcesStore, - useCanShare + useCanShare, + useSharingHierarchyConflictConfirm } from '@ownclouders/web-pkg' import { isLocationSharesActive } from '@ownclouders/web-pkg' import { textUtils } from '../../../helpers/textUtils' @@ -122,7 +123,8 @@ import { Resource, SpaceResource, CollaboratorShare, - isSpaceResource + isSpaceResource, + isSharingHierarchyConflictCancelledError } from '@ownclouders/web-client' import { getSharedAncestorRoute } from '@ownclouders/web-pkg' import CopyPrivateLink from '../../Shares/CopyPrivateLink.vue' @@ -153,6 +155,8 @@ export default defineComponent({ const sharesStore = useSharesStore() const { addShare, deleteShare } = sharesStore + const confirmSharingHierarchyConflict = useSharingHierarchyConflictConfirm() + const { user } = storeToRefs(userStore) const resource = inject>('resource') @@ -208,6 +212,7 @@ export default defineComponent({ return { addShare, deleteShare, + confirmSharingHierarchyConflict, user, resource, space, @@ -335,12 +340,16 @@ export default defineComponent({ clientService: this.$clientService, space: this.space, resource: this.resource, - options: {} + options: {}, + confirmSharingHierarchyConflict: this.confirmSharingHierarchyConflict }) this.showMessage({ title: this.$gettext('Access was denied successfully') }) } catch (e) { + if (isSharingHierarchyConflictCancelledError(e)) { + return + } console.error(e) this.showErrorMessage({ title: this.$gettext('Failed to deny access'), @@ -356,12 +365,16 @@ export default defineComponent({ collaboratorShare: isSpaceResource(this.resource) ? this.getDeniedSpaceMember(share) : this.getDeniedShare(share), - loadIndicators: false + loadIndicators: false, + confirmSharingHierarchyConflict: this.confirmSharingHierarchyConflict }) this.showMessage({ title: this.$gettext('Access was granted successfully') }) } catch (e) { + if (isSharingHierarchyConflictCancelledError(e)) { + return + } console.error(e) this.showErrorMessage({ title: this.$gettext('Failed to grant access'), @@ -388,7 +401,8 @@ export default defineComponent({ space: this.space, resource: this.resource, collaboratorShare, - loadIndicators + loadIndicators, + confirmSharingHierarchyConflict: this.confirmSharingHierarchyConflict }) this.showMessage({ @@ -398,6 +412,9 @@ export default defineComponent({ this.removeResources([{ id: lastShareId }] as Resource[]) } } catch (error) { + if (isSharingHierarchyConflictCancelledError(error)) { + return + } console.error(error) this.showErrorMessage({ title: this.$gettext('Failed to remove share'), diff --git a/packages/web-app-files/src/components/SideBar/Shares/SpaceMembers.vue b/packages/web-app-files/src/components/SideBar/Shares/SpaceMembers.vue index eb1d42983f7..569efe49016 100644 --- a/packages/web-app-files/src/components/SideBar/Shares/SpaceMembers.vue +++ b/packages/web-app-files/src/components/SideBar/Shares/SpaceMembers.vue @@ -88,11 +88,16 @@ import { useModals, useSharesStore, useSpacesStore, - useUserStore + useUserStore, + useSharingHierarchyConflictConfirm } from '@ownclouders/web-pkg' import { computed, defineComponent, inject, nextTick, ref, Ref, unref, useTemplateRef } from 'vue' import { shareSpaceAddMemberHelp } from '../../../helpers/contextualHelpers' -import { ProjectSpaceResource, CollaboratorShare } from '@ownclouders/web-client' +import { + ProjectSpaceResource, + CollaboratorShare, + isSharingHierarchyConflictCancelledError +} from '@ownclouders/web-client' import { useClientService } from '@ownclouders/web-pkg' import Fuse from 'fuse.js' import Mark from 'mark.js' @@ -120,6 +125,8 @@ export default defineComponent({ const spacesStore = useSpacesStore() const { upsertSpace, getSpaceMembers } = spacesStore + const confirmSharingHierarchyConflict = useSharingHierarchyConflictConfirm() + const configStore = useConfigStore() const { options: configOptions } = storeToRefs(configStore) @@ -142,6 +149,7 @@ export default defineComponent({ dispatchModal, spaceMembers, deleteShare, + confirmSharingHierarchyConflict, upsertSpace, canShare, markInstance, @@ -235,7 +243,8 @@ export default defineComponent({ clientService: this.clientService, space: this.resource, resource: this.resource, - collaboratorShare: share + collaboratorShare: share, + confirmSharingHierarchyConflict: this.confirmSharingHierarchyConflict }) if (!currentUserRemoved) { @@ -256,6 +265,9 @@ export default defineComponent({ await this.$router.push(createLocationSpaces('files-spaces-projects')) } } catch (error) { + if (isSharingHierarchyConflictCancelledError(error)) { + return + } console.error(error) this.showErrorMessage({ title: this.$gettext('Failed to remove share'), diff --git a/packages/web-client/src/graph/permissions/permissions.ts b/packages/web-client/src/graph/permissions/permissions.ts index e8f7307629d..2bca18b295d 100644 --- a/packages/web-client/src/graph/permissions/permissions.ts +++ b/packages/web-client/src/graph/permissions/permissions.ts @@ -34,7 +34,7 @@ export const PermissionsFactory = ({ itemId: string, permId: string, graphRoles: Record, - requestOptions: GraphRequestOptions + requestOptions: GraphRequestOptions = {} ): Promise { const { data: permission } = await drivesPermissionsApiFactory.getPermission( driveId, @@ -54,7 +54,13 @@ export const PermissionsFactory = ({ }) as T }, - async listPermissions(driveId, itemId, graphRoles, options, requestOptions) { + async listPermissions( + driveId, + itemId, + graphRoles, + options, + requestOptions: GraphRequestOptions = {} + ) { let responseData: CollectionOfPermissionsWithAllowedValues if (driveId === itemId) { @@ -101,7 +107,7 @@ export const PermissionsFactory = ({ permId: string, data: Permission, graphRoles: Record, - requestOptions: GraphRequestOptions + requestOptions: GraphRequestOptions = {} ): Promise { let permission: Permission @@ -137,7 +143,7 @@ export const PermissionsFactory = ({ }) as T }, - async deletePermission(driveId, itemId, permId, requestOptions) { + async deletePermission(driveId, itemId, permId, requestOptions: GraphRequestOptions = {}) { if (driveId === itemId) { await drivesRootApiFactory.deletePermissionSpaceRoot(driveId, permId, requestOptions) return @@ -146,7 +152,13 @@ export const PermissionsFactory = ({ await drivesPermissionsApiFactory.deletePermission(driveId, itemId, permId, requestOptions) }, - async createInvite(driveId, itemId, data, graphRoles, requestOptions) { + async createInvite( + driveId, + itemId, + data, + graphRoles, + requestOptions: GraphRequestOptions = {} + ) { let permission: Permission | undefined if (driveId === itemId) { diff --git a/packages/web-client/src/graph/sharing/conflict.ts b/packages/web-client/src/graph/sharing/conflict.ts new file mode 100644 index 00000000000..677abe9c0d7 --- /dev/null +++ b/packages/web-client/src/graph/sharing/conflict.ts @@ -0,0 +1,156 @@ +import axios from 'axios' + +/** HTTP header Reva ocgraph reads on invite/update (see `Force` in feat/share-consistency). */ +export const SHARE_HIERARCHY_FORCE_HEADER = 'Force' + +export const shareHierarchyForceRequestOptions = (): { headers: Record } => ({ + headers: { [SHARE_HIERARCHY_FORCE_HEADER]: 'true' } +}) + +export type SharingHierarchyConflictingShare = { + id?: string + resourceId?: string + path?: string + permissionType?: string +} + +export type SharingHierarchyConflict = { + kind: 'hierarchy_conflict' + errorType: string + message: string + canForce: boolean + conflictingShares?: SharingHierarchyConflictingShare[] + raw: unknown +} + +export type ConfirmSharingHierarchyConflict = ( + conflict: SharingHierarchyConflict +) => Promise + +export class SharingHierarchyConflictCancelledError extends Error { + readonly name = 'SharingHierarchyConflictCancelledError' + + constructor() { + super('Sharing hierarchy change cancelled') + } +} + +export function isSharingHierarchyConflictCancelledError(e: unknown): boolean { + return e instanceof SharingHierarchyConflictCancelledError +} + +/** Thrown when `deferSharingHierarchyConflictConfirm` is set and the caller should batch confirmations. */ +export class SharingHierarchyConflictPendingError extends Error { + readonly name = 'SharingHierarchyConflictPendingError' + + constructor(readonly conflict: SharingHierarchyConflict) { + super(conflict.message || conflict.errorType || 'Sharing hierarchy conflict') + } +} + +export function isSharingHierarchyConflictPendingError( + e: unknown +): e is SharingHierarchyConflictPendingError { + return e instanceof SharingHierarchyConflictPendingError +} + +function asRecord(v: unknown): Record | null { + if (!v || typeof v !== 'object' || Array.isArray(v)) { + return null + } + return v as Record +} + +function pickPayload(data: unknown): Record | null { + const root = asRecord(data) + if (!root) { + return null + } + if ('error' in root) { + return asRecord(root.error) + } + return root +} + +function parseConflictingShare(value: unknown): SharingHierarchyConflictingShare | null { + const record = asRecord(value) + if (!record) { + return null + } + + const share: SharingHierarchyConflictingShare = {} + if (typeof record.id === 'string') { + share.id = record.id + } + if (typeof record.resource_id === 'string') { + share.resourceId = record.resource_id + } + if (typeof record.path === 'string') { + share.path = record.path + } + if (typeof record.permission_type === 'string') { + share.permissionType = record.permission_type + } + + return Object.keys(share).length > 0 ? share : null +} + +export function parseSharingHierarchyConflictPayload( + status: number | undefined, + data: unknown +): SharingHierarchyConflict | null { + if (status !== 409) { + return null + } + + let parsed: unknown = data + if (typeof data === 'string') { + try { + parsed = JSON.parse(data) + } catch { + return null + } + } + + const payload = pickPayload(parsed) + if (!payload) { + return null + } + + const errorType = typeof payload.error_type === 'string' ? payload.error_type : '' + const message = typeof payload.message === 'string' ? payload.message : '' + if (!errorType) { + return null + } + + const canForce = payload.can_force === true + let conflictingShares: SharingHierarchyConflictingShare[] | undefined + if (Array.isArray(payload.conflicting_shares)) { + conflictingShares = payload.conflicting_shares + .map(parseConflictingShare) + .filter((share): share is SharingHierarchyConflictingShare => share !== null) + if (conflictingShares.length === 0) { + conflictingShares = undefined + } + } + + return { + kind: 'hierarchy_conflict', + errorType, + message, + canForce, + conflictingShares, + raw: parsed + } +} + +/** + * Parses a Graph/Axios error response for ADR-0005 hierarchy conflicts (HTTP 409 + JSON body). + * Matches Reva `pkg/sharehierarchy.HierarchyConflictError` on feat/share-consistency. + */ +export function parseSharingHierarchyConflict(error: unknown): SharingHierarchyConflict | null { + if (!axios.isAxiosError(error)) { + return null + } + return parseSharingHierarchyConflictPayload(error.response?.status, error.response?.data) +} diff --git a/packages/web-client/src/index.ts b/packages/web-client/src/index.ts index b8a4ada43a6..5f71d22dacd 100644 --- a/packages/web-client/src/index.ts +++ b/packages/web-client/src/index.ts @@ -6,6 +6,9 @@ import { WebDAV, webdav } from './webdav' export * from './errors' export * from './helpers' export * from './utils' +export * from './graph/sharing/conflict' + +export type { GraphRequestOptions } from './graph/types' export { graph, ocs, webdav } diff --git a/packages/web-client/tests/unit/graph/sharing/conflict.spec.ts b/packages/web-client/tests/unit/graph/sharing/conflict.spec.ts new file mode 100644 index 00000000000..f3cf78b412a --- /dev/null +++ b/packages/web-client/tests/unit/graph/sharing/conflict.spec.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest' +import { AxiosError } from 'axios' +import { + SHARE_HIERARCHY_FORCE_HEADER, + SharingHierarchyConflictPendingError, + isSharingHierarchyConflictPendingError, + parseSharingHierarchyConflict, + parseSharingHierarchyConflictPayload, + shareHierarchyForceRequestOptions +} from '../../../../src/graph/sharing/conflict' + +function axios409(data: unknown): AxiosError { + return new AxiosError( + 'Conflict', + 'ERR_BAD_REQUEST', + undefined, + {}, + { + status: 409, + statusText: 'Conflict', + data, + headers: {}, + config: {} as any + } + ) +} + +describe('shareHierarchyForceRequestOptions', () => { + it('uses the Reva Force header from feat/share-consistency', () => { + expect(shareHierarchyForceRequestOptions()).toEqual({ + headers: { [SHARE_HIERARCHY_FORCE_HEADER]: 'true' } + }) + expect(SHARE_HIERARCHY_FORCE_HEADER).toBe('Force') + }) +}) + +describe('parseSharingHierarchyConflictPayload', () => { + it.each([ + { + name: 'child conflict with conflicting shares', + data: { + error_type: 'child_conflict', + message: 'Child shares will be removed', + can_force: true, + conflicting_shares: [ + { + id: 'share-1', + resource_id: 'storage!item', + path: '/project/sub', + permission_type: 'read' + } + ] + }, + expected: { + kind: 'hierarchy_conflict', + errorType: 'child_conflict', + message: 'Child shares will be removed', + canForce: true, + conflictingShares: [ + { + id: 'share-1', + resourceId: 'storage!item', + path: '/project/sub', + permissionType: 'read' + } + ] + } + }, + { + name: 'parent conflict', + data: { + error_type: 'parent_conflict', + message: 'Already shared through parent', + can_force: false + }, + expected: { + kind: 'hierarchy_conflict', + errorType: 'parent_conflict', + message: 'Already shared through parent', + canForce: false, + conflictingShares: undefined + } + }, + { + name: 'omitted can_force', + data: { + error_type: 'parent_conflict', + message: 'Blocked' + }, + expected: { + kind: 'hierarchy_conflict', + errorType: 'parent_conflict', + message: 'Blocked', + canForce: false, + conflictingShares: undefined + } + } + ])('parses $name', ({ data, expected }) => { + const r = parseSharingHierarchyConflictPayload(409, data) + expect(r).toMatchObject(expected) + expect(r?.raw).toBeDefined() + }) + + it('returns null for non-409', () => { + expect( + parseSharingHierarchyConflictPayload(400, { + error_type: 'parent_conflict', + message: 'y' + }) + ).toBeNull() + }) + + it('returns null for legacy Gerard payload shape', () => { + expect( + parseSharingHierarchyConflictPayload(409, { + code: 'SHARING_HIERARCHY_CONFLICT', + message: 'legacy', + can_force: true + }) + ).toBeNull() + }) + + it('parses string JSON body', () => { + const raw = + '{"error_type":"child_conflict","message":"M","can_force":true,"conflicting_shares":[{"id":"1","path":"/a"}]}' + const r = parseSharingHierarchyConflictPayload(409, raw) + expect(r?.errorType).toBe('child_conflict') + expect(r?.canForce).toBe(true) + expect(r?.conflictingShares).toEqual([{ id: '1', path: '/a' }]) + }) +}) + +describe('parseSharingHierarchyConflict', () => { + it('returns null for non-axios errors', () => { + expect(parseSharingHierarchyConflict(new Error('x'))).toBeNull() + }) + + it('extracts conflict from axios 409', () => { + const err = axios409({ + error_type: 'child_conflict', + message: 'M', + can_force: true + }) + expect(parseSharingHierarchyConflict(err)?.message).toBe('M') + expect(parseSharingHierarchyConflict(err)?.errorType).toBe('child_conflict') + }) +}) + +describe('SharingHierarchyConflictPendingError', () => { + it('is detected by type guard', () => { + const conflict = { + kind: 'hierarchy_conflict' as const, + errorType: 'child_conflict', + message: 'warn', + canForce: true, + raw: {} + } + const err = new SharingHierarchyConflictPendingError(conflict) + expect(isSharingHierarchyConflictPendingError(err)).toBe(true) + expect(isSharingHierarchyConflictPendingError(new Error('x'))).toBe(false) + expect(err.conflict).toBe(conflict) + }) +}) diff --git a/packages/web-pkg/src/composables/piniaStores/shares/shares.ts b/packages/web-pkg/src/composables/piniaStores/shares/shares.ts index 184a7f73f85..09276f1d385 100644 --- a/packages/web-pkg/src/composables/piniaStores/shares/shares.ts +++ b/packages/web-pkg/src/composables/piniaStores/shares/shares.ts @@ -4,7 +4,13 @@ import { Share, ShareRole, ShareTypes, - isProjectSpaceResource + isProjectSpaceResource, + parseSharingHierarchyConflict, + shareHierarchyForceRequestOptions, + SharingHierarchyConflictCancelledError, + SharingHierarchyConflictPendingError, + type ConfirmSharingHierarchyConflict, + type GraphRequestOptions } from '@ownclouders/web-client' import { defineStore } from 'pinia' import { Ref, ref, unref } from 'vue' @@ -20,6 +26,49 @@ import { useResourcesStore } from '../resources' import { useThemeStore } from '../theme' import { Permission, UnifiedRoleDefinition } from '@ownclouders/web-client/graph/generated' +function mergeGraphRequestOptions( + base?: GraphRequestOptions, + extra?: GraphRequestOptions +): GraphRequestOptions { + return { + ...base, + ...extra, + headers: { ...base?.headers, ...extra?.headers } + } +} + +async function withShareHierarchyForceRetry( + graphRequestOptions: GraphRequestOptions | undefined, + confirmSharingHierarchyConflict: ConfirmSharingHierarchyConflict | undefined, + deferSharingHierarchyConflictConfirm: boolean | undefined, + request: (opts?: GraphRequestOptions) => Promise +): Promise { + try { + return await request(graphRequestOptions) + } catch (e) { + const conflict = parseSharingHierarchyConflict(e) + if (!conflict) { + throw e + } + if (!conflict.canForce) { + throw new Error(conflict.message || conflict.errorType || 'Conflict') + } + if (deferSharingHierarchyConflictConfirm) { + throw new SharingHierarchyConflictPendingError(conflict) + } + if (!confirmSharingHierarchyConflict) { + throw new Error(conflict.message || conflict.errorType || 'Conflict') + } + const proceed = await confirmSharingHierarchyConflict(conflict) + if (!proceed) { + throw new SharingHierarchyConflictCancelledError() + } + return await request( + mergeGraphRequestOptions(graphRequestOptions, shareHierarchyForceRequestOptions()) + ) + } +} + export const useSharesStore = defineStore('shares', () => { const resourcesStore = useResourcesStore() const { getRoleIcon: getThemeRoleIcon } = useThemeStore() @@ -146,9 +195,22 @@ export const useSharesStore = defineStore('shares', () => { } } - const addShare = async ({ clientService, space, resource, options }: AddShareOptions) => { + const addShare = async ({ + clientService, + space, + resource, + options, + graphRequestOptions, + confirmSharingHierarchyConflict, + deferSharingHierarchyConflictConfirm + }: AddShareOptions) => { const client = clientService.graphAuthenticated.permissions - const share = await client.createInvite(space.id, resource.id, options, unref(graphRoles)) + const share = await withShareHierarchyForceRetry( + graphRequestOptions, + confirmSharingHierarchyConflict, + deferSharingHierarchyConflictConfirm, + (opts) => client.createInvite(space.id, resource.id, options, unref(graphRoles), opts) + ) addCollaboratorShares([share]) updateFileShareTypes(resource.id) @@ -161,7 +223,9 @@ export const useSharesStore = defineStore('shares', () => { space, resource, collaboratorShare, - options + options, + graphRequestOptions, + confirmSharingHierarchyConflict }: UpdateShareOptions) => { const client = clientService.graphAuthenticated.permissions @@ -170,12 +234,19 @@ export const useSharesStore = defineStore('shares', () => { expirationDateTime: options.expirationDateTime } satisfies Permission - const share = await client.updatePermission( - space.id, - resource.id, - collaboratorShare.id, - payload, - unref(graphRoles) + const share = await withShareHierarchyForceRetry( + graphRequestOptions, + confirmSharingHierarchyConflict, + undefined, + (opts) => + client.updatePermission( + space.id, + resource.id, + collaboratorShare.id, + payload, + unref(graphRoles), + opts + ) ) upsertCollaboratorShare(share) @@ -187,11 +258,18 @@ export const useSharesStore = defineStore('shares', () => { space, resource, collaboratorShare, - loadIndicators = false + loadIndicators = false, + graphRequestOptions, + confirmSharingHierarchyConflict }: DeleteShareOptions) => { const client = clientService.graphAuthenticated.permissions - await client.deletePermission(space.id, resource.id, collaboratorShare.id) + await withShareHierarchyForceRetry( + graphRequestOptions, + confirmSharingHierarchyConflict, + undefined, + (opts) => client.deletePermission(space.id, resource.id, collaboratorShare.id, opts) + ) removeCollaboratorShare(collaboratorShare) updateFileShareTypes(resource.id) @@ -270,7 +348,7 @@ export const useSharesStore = defineStore('shares', () => { grantedToIdentities: [ { group: { - displayName: "", + displayName: '', id: linkShare.notifyUploadsExtraRecipients } } diff --git a/packages/web-pkg/src/composables/piniaStores/shares/types.ts b/packages/web-pkg/src/composables/piniaStores/shares/types.ts index c06599865e2..53a0e1c2544 100644 --- a/packages/web-pkg/src/composables/piniaStores/shares/types.ts +++ b/packages/web-pkg/src/composables/piniaStores/shares/types.ts @@ -1,13 +1,22 @@ -import { Resource } from '@ownclouders/web-client' -import { ClientService } from '../../../services' -import { CollaboratorShare, LinkShare, SpaceResource } from '@ownclouders/web-client' +import { + CollaboratorShare, + LinkShare, + Resource, + SpaceResource, + type ConfirmSharingHierarchyConflict, + type GraphRequestOptions +} from '@ownclouders/web-client' import { DriveItemCreateLink, DriveItemInvite } from '@ownclouders/web-client/graph/generated' - +import { ClientService } from '../../../services' export interface AddShareOptions { clientService: ClientService space: SpaceResource resource: Resource options: DriveItemInvite + graphRequestOptions?: GraphRequestOptions + confirmSharingHierarchyConflict?: ConfirmSharingHierarchyConflict + /** When true, 409 with `can_force` throws instead of opening a confirmation dialog (for batched invite UX). */ + deferSharingHierarchyConflictConfirm?: boolean } export interface UpdateShareOptions { @@ -16,6 +25,8 @@ export interface UpdateShareOptions { resource: Resource collaboratorShare: CollaboratorShare options: DriveItemInvite + graphRequestOptions?: GraphRequestOptions + confirmSharingHierarchyConflict?: ConfirmSharingHierarchyConflict } export interface DeleteShareOptions { @@ -24,6 +35,8 @@ export interface DeleteShareOptions { resource: Resource collaboratorShare: CollaboratorShare loadIndicators?: boolean + graphRequestOptions?: GraphRequestOptions + confirmSharingHierarchyConflict?: ConfirmSharingHierarchyConflict } export interface AddLinkOptions { diff --git a/packages/web-pkg/src/composables/shares/index.ts b/packages/web-pkg/src/composables/shares/index.ts index f980ae2c9d4..a0e0aa3e41b 100644 --- a/packages/web-pkg/src/composables/shares/index.ts +++ b/packages/web-pkg/src/composables/shares/index.ts @@ -1,2 +1,3 @@ export * from './useCanListShares' export * from './useCanShare' +export * from './useSharingHierarchyConflictConfirm' diff --git a/packages/web-pkg/src/composables/shares/useSharingHierarchyConflictConfirm.ts b/packages/web-pkg/src/composables/shares/useSharingHierarchyConflictConfirm.ts new file mode 100644 index 00000000000..50415465991 --- /dev/null +++ b/packages/web-pkg/src/composables/shares/useSharingHierarchyConflictConfirm.ts @@ -0,0 +1,126 @@ +import type { SharingHierarchyConflict } from '@ownclouders/web-client' +import { useGettext } from 'vue3-gettext' +import { useModals } from '../piniaStores/modals' + +function formatConflictingShareLine( + conflict: SharingHierarchyConflict, + $gettext: (msg: string, ctx?: Record) => string +): string { + if (!conflict.conflictingShares?.length) { + return '' + } + + const lines = conflict.conflictingShares.map((share) => { + const label = share.path || share.id || share.resourceId || $gettext('Unknown share') + const suffix = share.permissionType ? ` (${share.permissionType})` : '' + return `• ${label}${suffix}` + }) + + return `${$gettext('Affected shares:')}\n${lines.join('\n')}` +} + +export function formatSharingHierarchyConflictMessage( + conflict: SharingHierarchyConflict, + $gettext: (msg: string, ctx?: Record) => string = (msg) => msg +): string { + const parts = [conflict.message].filter(Boolean) + const shareLines = formatConflictingShareLine(conflict, $gettext) + if (shareLines) { + parts.push(shareLines) + } + return parts.join('\n\n') +} + +function formatSharingHierarchyConflictsMessage( + conflicts: SharingHierarchyConflict[], + $gettext: (msg: string, ctx?: Record) => string +): string { + if (conflicts.length === 1) { + return formatSharingHierarchyConflictMessage(conflicts[0], $gettext) + } + + const intro = $gettext( + 'The following sharing changes need your confirmation before they can be applied:' + ) + const items = conflicts + .map( + (conflict, index) => + `${index + 1}. ${formatSharingHierarchyConflictMessage(conflict, $gettext)}` + ) + .join('\n\n') + + return `${intro}\n\n${items}` +} + +function dispatchHierarchyConfirmModal( + modalsStore: ReturnType, + { + title, + message, + confirmText, + cancelText + }: { + title: string + message: string + confirmText: string + cancelText: string + } +): Promise { + return new Promise((resolve) => { + modalsStore.dispatchModal({ + variation: 'warning', + title, + message, + confirmText, + cancelText, + onCancel: () => resolve(false), + onConfirm: () => resolve(true) + }) + }) +} + +/** + * Shows a confirmation dialog when the server returns 409 with `can_force: true` for share mutations. + */ +export function useSharingHierarchyConflictConfirm(): ( + conflict: SharingHierarchyConflict +) => Promise { + const { $gettext } = useGettext() + const modalsStore = useModals() + + return (conflict: SharingHierarchyConflict) => + dispatchHierarchyConfirmModal(modalsStore, { + title: $gettext('Sharing conflict'), + message: formatSharingHierarchyConflictMessage(conflict, $gettext), + confirmText: $gettext('Proceed anyway'), + cancelText: $gettext('Cancel') + }) +} + +/** + * Shows one confirmation dialog for multiple deferred hierarchy conflicts (e.g. batch invite). + */ +export function useSharingHierarchyConflictsConfirm(): ( + conflicts: SharingHierarchyConflict[] +) => Promise { + const { $gettext } = useGettext() + const modalsStore = useModals() + const confirmOne = useSharingHierarchyConflictConfirm() + + return (conflicts: SharingHierarchyConflict[]) => { + if (conflicts.length === 0) { + return Promise.resolve(true) + } + + if (conflicts.length === 1) { + return confirmOne(conflicts[0]) + } + + return dispatchHierarchyConfirmModal(modalsStore, { + title: $gettext('Sharing conflicts'), + message: formatSharingHierarchyConflictsMessage(conflicts, $gettext), + confirmText: $gettext('Proceed with all'), + cancelText: $gettext('Cancel') + }) + } +} diff --git a/packages/web-pkg/tests/unit/composables/piniaStores/shares.spec.ts b/packages/web-pkg/tests/unit/composables/piniaStores/shares.spec.ts index 0606dc1c9ea..70b5e5d7e4d 100644 --- a/packages/web-pkg/tests/unit/composables/piniaStores/shares.spec.ts +++ b/packages/web-pkg/tests/unit/composables/piniaStores/shares.spec.ts @@ -1,4 +1,5 @@ import { createTestingPinia, getComposableWrapper } from '@ownclouders/web-test-helpers' +import { AxiosError } from 'axios' import { AddLinkOptions, AddShareOptions, @@ -11,10 +12,38 @@ import { } from '../../../../src/composables/piniaStores' import { mock, mockDeep } from 'vitest-mock-extended' import { ClientService } from '../../../../src/services' -import { CollaboratorShare, LinkShare, Resource } from '@ownclouders/web-client' +import { + CollaboratorShare, + LinkShare, + Resource, + SHARE_HIERARCHY_FORCE_HEADER, + SpaceResource +} from '@ownclouders/web-client' import { User } from '@ownclouders/web-client/graph/generated' describe('useSharesStore', () => { + const space = { id: 'space-1' } as SpaceResource + + function conflict409() { + return new AxiosError( + 'Conflict', + 'ERR_BAD_REQUEST', + undefined, + {}, + { + status: 409, + statusText: 'Conflict', + data: { + error_type: 'child_conflict', + message: 'x', + can_force: true + }, + headers: {}, + config: {} as any + } + ) + } + beforeEach(() => { createTestingPinia({ stubActions: false, @@ -36,13 +65,78 @@ describe('useSharesStore', () => { const userStore = useUserStore() userStore.user = user - await instance.addShare(mock({ clientService, resource })) + await instance.addShare(mock({ clientService, resource, space })) expect(clientService.graphAuthenticated.permissions.createInvite).toHaveBeenCalledTimes(1) expect(instance.collaboratorShares.length).toBe(1) } }) }) + + it('retries createInvite with force header when user confirms after 409', async () => { + const resource = { id: '1' } as Resource + const share = mock({ id: '1' }) + const user = { id: '1' } as User + + const clientService = mockDeep() + clientService.graphAuthenticated.permissions.createInvite + .mockRejectedValueOnce(conflict409()) + .mockResolvedValueOnce(share) + + const userStore = useUserStore() + userStore.user = user + + const instance = useSharesStore() + + await instance.addShare({ + clientService, + resource, + space, + options: {} as AddShareOptions['options'], + confirmSharingHierarchyConflict: () => Promise.resolve(true), + deferSharingHierarchyConflictConfirm: false + }) + + expect(clientService.graphAuthenticated.permissions.createInvite).toHaveBeenCalledTimes(2) + expect(clientService.graphAuthenticated.permissions.createInvite).toHaveBeenLastCalledWith( + space.id, + resource.id, + expect.anything(), + expect.anything(), + expect.objectContaining({ + headers: { [SHARE_HIERARCHY_FORCE_HEADER]: 'true' } + }) + ) + expect(instance.collaboratorShares.length).toBe(1) + }) + + it('throws pending error when deferSharingHierarchyConflictConfirm is set', async () => { + const resource = { id: '1' } as Resource + const user = { id: '1' } as User + + const clientService = mockDeep() + clientService.graphAuthenticated.permissions.createInvite.mockRejectedValueOnce(conflict409()) + + const userStore = useUserStore() + userStore.user = user + + const instance = useSharesStore() + + await expect( + instance.addShare({ + clientService, + resource, + space, + options: {} as AddShareOptions['options'], + deferSharingHierarchyConflictConfirm: true + }) + ).rejects.toMatchObject({ + name: 'SharingHierarchyConflictPendingError', + conflict: expect.objectContaining({ canForce: true }) + }) + + expect(clientService.graphAuthenticated.permissions.createInvite).toHaveBeenCalledTimes(1) + }) }) describe('updateShare', () => { it('updates a collaborator share', () => {