From 89e0a7c6eb4cc8d82a489b760a63fc02f6d01a1f Mon Sep 17 00:00:00 2001 From: Jannik Stehle Date: Mon, 2 Sep 2024 13:59:58 +0200 Subject: [PATCH] feat: transparent shares --- .../InviteCollaboratorForm.vue | 6 +- .../SideBar/Shares/Collaborators/ListItem.vue | 6 +- .../components/SideBar/Shares/FileShares.vue | 18 +- .../SideBar/Shares/Links/DetailsAndEdit.vue | 17 +- .../SideBar/Shares/SpaceMembers.vue | 14 +- .../composables/extensions/useFileSideBars.ts | 25 +-- .../src/services/folder/loaderSpace.ts | 8 +- .../Shares/Collaborators/ListItem.spec.ts | 17 +- .../SideBar/Shares/FileShares.spec.ts | 13 +- .../Shares/Links/DetailsAndEdit.spec.ts | 39 ++-- .../SideBar/Shares/SpaceMembers.spec.ts | 27 +-- .../web-client/src/helpers/share/types.ts | 1 + .../web-client/src/helpers/space/functions.ts | 2 +- .../src/components/SideBar/FileSideBar.vue | 76 ++++--- .../SideBar/Spaces/Details/SpaceDetails.vue | 4 - .../src/composables/piniaStores/resources.ts | 18 ++ .../src/composables/piniaStores/spaces.ts | 74 +++---- .../web-pkg/src/composables/shares/index.ts | 1 + .../composables/shares/useCanListShares.ts | 43 ++++ .../components/sidebar/FileSideBar.spec.ts | 34 +-- .../Spaces/Details/SpaceDetails.spec.ts | 1 - .../composables/piniaStores/spaces.spec.ts | 82 +++----- .../shares/useCanListShares.spec.ts | 194 ++++++++++++++++++ packages/web-test-helpers/src/mocks/pinia.ts | 4 +- 24 files changed, 446 insertions(+), 278 deletions(-) create mode 100644 packages/web-pkg/src/composables/shares/useCanListShares.ts create mode 100644 packages/web-pkg/tests/unit/composables/shares/useCanListShares.spec.ts 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 758a7d8b4d0..51075bf00b0 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 @@ -229,7 +229,7 @@ export default defineComponent({ const clientService = useClientService() const { showMessage, showErrorMessage } = useMessages() const spacesStore = useSpacesStore() - const { upsertSpace, upsertSpaceMember } = spacesStore + const { upsertSpace } = spacesStore const capabilityStore = useCapabilityStore() const capabilityRefs = storeToRefs(capabilityStore) const configStore = useConfigStore() @@ -426,10 +426,6 @@ export default defineComponent({ ) upsertSpace(updatedSpace) - - addedShares.forEach((member) => { - upsertSpaceMember({ member }) - }) } if (results.length !== errors.length) { 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 6b39d359aa2..c1260941f22 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 @@ -213,7 +213,7 @@ export default defineComponent({ const sharesStore = useSharesStore() const { graphRoles } = storeToRefs(sharesStore) const { updateShare } = sharesStore - const { upsertSpace, upsertSpaceMember } = useSpacesStore() + const { upsertSpace } = useSpacesStore() const { user } = storeToRefs(userStore) @@ -260,7 +260,6 @@ export default defineComponent({ showMessage, showErrorMessage, upsertSpace, - upsertSpaceMember, isExternalShare, DateTime } @@ -413,7 +412,7 @@ export default defineComponent({ expirationDateTime?: string }) { try { - const share = await this.updateShare({ + await this.updateShare({ clientService: this.$clientService, space: this.space, resource: this.resource, @@ -426,7 +425,6 @@ export default defineComponent({ const space = await client.drives.getDrive(this.resource.id, this.graphRoles) this.upsertSpace(space) - this.upsertSpaceMember({ member: share }) } this.showMessage({ title: this.$gettext('Share successfully changed') }) 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 811250c4c30..c76b3e4920c 100644 --- a/packages/web-app-files/src/components/SideBar/Shares/FileShares.vue +++ b/packages/web-app-files/src/components/SideBar/Shares/FileShares.vue @@ -111,7 +111,7 @@ import { } from '@ownclouders/web-pkg' import { isLocationSharesActive } from '@ownclouders/web-pkg' import { textUtils } from '../../../helpers/textUtils' -import { ShareTypes } from '@ownclouders/web-client' +import { isShareSpaceResource, ShareTypes } from '@ownclouders/web-client' import InviteCollaboratorForm from './Collaborators/InviteCollaborator/InviteCollaboratorForm.vue' import CollaboratorListItem from './Collaborators/ListItem.vue' import { @@ -147,21 +147,29 @@ export default defineComponent({ const resourcesStore = useResourcesStore() const { removeResources, getAncestorById } = resourcesStore - const spacesStore = useSpacesStore() - const { spaceMembers } = storeToRefs(spacesStore) + const { getSpaceMembers } = useSpacesStore() const configStore = useConfigStore() const { options: configOptions } = storeToRefs(configStore) const sharesStore = useSharesStore() const { addShare, deleteShare } = sharesStore - const { collaboratorShares } = storeToRefs(sharesStore) const { user } = storeToRefs(userStore) const resource = inject>('resource') const space = inject>('space') + const collaboratorShares = computed(() => { + if (isProjectSpaceResource(unref(space))) { + // filter out project space members, they are listed separately (see down below) + return sharesStore.collaboratorShares.filter((c) => c.resourceId !== unref(space).id) + } + return sharesStore.collaboratorShares + }) + + const spaceMembers = computed(() => getSpaceMembers(unref(space))) + const sharesListCollapsed = ref(true) const toggleShareListCollapsed = () => { sharesListCollapsed.value = !unref(sharesListCollapsed) @@ -433,7 +441,7 @@ export default defineComponent({ return false } - if (isProjectSpaceResource(this.space)) { + if (isProjectSpaceResource(this.space) || isShareSpaceResource(this.space)) { return this.space.canShare({ user: this.user }) } diff --git a/packages/web-app-files/src/components/SideBar/Shares/Links/DetailsAndEdit.vue b/packages/web-app-files/src/components/SideBar/Shares/Links/DetailsAndEdit.vue index 952b2ee73ab..ff6267ad783 100644 --- a/packages/web-app-files/src/components/SideBar/Shares/Links/DetailsAndEdit.vue +++ b/packages/web-app-files/src/components/SideBar/Shares/Links/DetailsAndEdit.vue @@ -140,7 +140,7 @@ import { useModals, useResourcesStore } from '@ownclouders/web-pkg' -import { LinkShare, ShareTypes } from '@ownclouders/web-client' +import { LinkShare } from '@ownclouders/web-client' import { computed, defineComponent, inject, PropType, Ref, ref, unref } from 'vue' import { formatDateFromDateTime, formatRelativeDateFromDateTime } from '@ownclouders/web-pkg' import { Resource, SpaceResource } from '@ownclouders/web-client' @@ -291,10 +291,7 @@ export default defineComponent({ }) const sharedAncestor = computed(() => { - const ancestorPath = Object.keys(unref(ancestorMetaData)).find((key) => - unref(ancestorMetaData)[key].shareTypes.includes(ShareTypes.link.value) - ) - return ancestorPath ? unref(ancestorMetaData)[ancestorPath] : undefined + return resourcesStore.getAncestorById(props.linkShare.resourceId) }) const viaRouterParams = computed(() => { @@ -317,9 +314,13 @@ export default defineComponent({ return null } - return $gettext('Navigate to the parent (%{folderName})', { - folderName: basename(unref(sharedAncestor).path) - }) + let folderName = basename(unref(sharedAncestor).path) + if (!folderName) { + // no folder name means path is "/" -> parent is the space + folderName = unref(space).name + } + + return $gettext('Navigate to the parent (%{folderName})', { folderName }) }) return { 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 4f11b2668ca..9dce3658515 100644 --- a/packages/web-app-files/src/components/SideBar/Shares/SpaceMembers.vue +++ b/packages/web-app-files/src/components/SideBar/Shares/SpaceMembers.vue @@ -84,7 +84,7 @@ import { useSpacesStore, useUserStore } from '@ownclouders/web-pkg' -import { defineComponent, inject, ref, Ref } from 'vue' +import { computed, defineComponent, inject, ref, Ref, unref } from 'vue' import { shareSpaceAddMemberHelp } from '../../../helpers/contextualHelpers' import { ProjectSpaceResource, CollaboratorShare } from '@ownclouders/web-client' import { useClientService } from '@ownclouders/web-pkg' @@ -109,8 +109,7 @@ export default defineComponent({ const { deleteShare } = sharesStore const { graphRoles } = storeToRefs(sharesStore) const spacesStore = useSpacesStore() - const { upsertSpace, removeSpaceMember } = spacesStore - const { spaceMembers } = storeToRefs(spacesStore) + const { upsertSpace, getSpaceMembers } = spacesStore const capabilityStore = useCapabilityStore() const { filesPrivateLinks } = storeToRefs(capabilityStore) @@ -121,17 +120,20 @@ export default defineComponent({ const markInstance = ref() + const resource = inject>('resource') + + const spaceMembers = computed(() => getSpaceMembers(unref(resource))) + return { user, clientService, configStore, configOptions, - resource: inject>('resource'), + resource, dispatchModal, spaceMembers, deleteShare, upsertSpace, - removeSpaceMember, canShare, markInstance, filesPrivateLinks, @@ -233,8 +235,6 @@ export default defineComponent({ this.upsertSpace(space) } - this.removeSpaceMember({ member: share }) - this.showMessage({ title: this.$gettext('Share was removed successfully') }) diff --git a/packages/web-app-files/src/composables/extensions/useFileSideBars.ts b/packages/web-app-files/src/composables/extensions/useFileSideBars.ts index e255c12a705..68f369f8b52 100644 --- a/packages/web-app-files/src/composables/extensions/useFileSideBars.ts +++ b/packages/web-app-files/src/composables/extensions/useFileSideBars.ts @@ -13,7 +13,6 @@ import { SpaceDetailsMultiple, SpaceNoSelection, isLocationTrashActive, - isLocationPublicActive, isLocationSpacesActive, isLocationSharesActive, useRouter, @@ -21,13 +20,12 @@ import { useIsFilesAppActive, useGetMatchingSpace, useUserStore, - useCapabilityStore + useCapabilityStore, + useCanListShares } from '@ownclouders/web-pkg' import { isPersonalSpaceResource, isProjectSpaceResource, - isShareResource, - isShareSpaceResource, isSpaceResource, SpaceResource } from '@ownclouders/web-client' @@ -44,6 +42,7 @@ export const useSideBarPanels = (): SidebarPanelExtension s.root?.remoteItem?.id === space.id) + const matchingMountPoint = await spacesStore.getMountPointForSpace({ graphClient, space }) if (!matchingMountPoint) { return null } diff --git a/packages/web-app-files/tests/unit/components/SideBar/Shares/Collaborators/ListItem.spec.ts b/packages/web-app-files/tests/unit/components/SideBar/Shares/Collaborators/ListItem.spec.ts index dcb828fcbf9..60e9d442f4a 100644 --- a/packages/web-app-files/tests/unit/components/SideBar/Shares/Collaborators/ListItem.spec.ts +++ b/packages/web-app-files/tests/unit/components/SideBar/Shares/Collaborators/ListItem.spec.ts @@ -12,7 +12,7 @@ import { defaultComponentMocks, nextTicks } from 'web-test-helpers' -import { useMessages, useSharesStore, useSpacesStore } from '@ownclouders/web-pkg' +import { useMessages, useSharesStore } from '@ownclouders/web-pkg' import EditDropdown from '../../../../../../src/components/SideBar/Shares/Collaborators/EditDropdown.vue' import RoleDropdown from '../../../../../../src/components/SideBar/Shares/Collaborators/RoleDropdown.vue' import { mock } from 'vitest-mock-extended' @@ -155,21 +155,6 @@ describe('Collaborator ListItem component', () => { const sharesStore = useSharesStore() expect(sharesStore.updateShare).toHaveBeenCalled() }) - it('calls "upsertSpaceMember" for space resources', async () => { - const resource = mock({ driveType: 'project' }) - const { wrapper } = createWrapper({ - share: getShareMock({ shareType: ShareTypes.user.value }), - resource - }) - wrapper.findComponent('role-dropdown-stub').vm.$emit('optionChange', { - permissions: [GraphSharePermission.readBasic] - }) - - await nextTicks(4) - - const spacesStore = useSpacesStore() - expect(spacesStore.upsertSpaceMember).toHaveBeenCalled() - }) it('shows a message on error', async () => { const resource = mock({ driveType: 'project' }) vi.spyOn(console, 'error').mockImplementation(() => undefined) diff --git a/packages/web-app-files/tests/unit/components/SideBar/Shares/FileShares.spec.ts b/packages/web-app-files/tests/unit/components/SideBar/Shares/FileShares.spec.ts index 7dd49e7bbd7..d38546f6148 100644 --- a/packages/web-app-files/tests/unit/components/SideBar/Shares/FileShares.spec.ts +++ b/packages/web-app-files/tests/unit/components/SideBar/Shares/FileShares.spec.ts @@ -121,8 +121,8 @@ describe('FileShares', () => { const user = { id: '1' } as User const space = mock({ driveType: 'project', isMember: () => true }) const spaceMembers = [ - { sharedWith: { id: user.id } }, - { sharedWith: { id: '2' } } + { sharedWith: { id: user.id, displayName: '' }, resourceId: space.id, permissions: [] }, + { sharedWith: { id: '2', displayName: '' }, resourceId: space.id, permissions: [] } ] as CollaboratorShare[] const collaborator = getCollaborator() collaborator.sharedWith = { @@ -137,7 +137,9 @@ describe('FileShares', () => { it('does not load space members if a space is given but the current user not a member', () => { const user = { id: '1' } as User const space = mock({ driveType: 'project' }) - const spaceMembers = [{ sharedWith: { id: `${user}-2` } }] as CollaboratorShare[] + const spaceMembers = [ + { sharedWith: { id: `${user}-2`, displayName: '' }, resourceId: space.id, permissions: [] } + ] as CollaboratorShare[] const collaborator = getCollaborator() collaborator.sharedWith = { ...collaborator.sharedWith, @@ -183,6 +185,10 @@ function getWrapper({ files_sharing: { deny_access: false } } satisfies Partial + if (spaceMembers.length) { + collaborators = [...collaborators, ...spaceMembers] + } + return { wrapper: mountType(FileShares, { global: { @@ -191,7 +197,6 @@ function getWrapper({ piniaOptions: { stubActions: false, userState: { user }, - spacesState: { spaceMembers }, capabilityState: { capabilities }, configState: { options: { contextHelpers: true } diff --git a/packages/web-app-files/tests/unit/components/SideBar/Shares/Links/DetailsAndEdit.spec.ts b/packages/web-app-files/tests/unit/components/SideBar/Shares/Links/DetailsAndEdit.spec.ts index 41c922b433d..eb196c12509 100644 --- a/packages/web-app-files/tests/unit/components/SideBar/Shares/Links/DetailsAndEdit.spec.ts +++ b/packages/web-app-files/tests/unit/components/SideBar/Shares/Links/DetailsAndEdit.spec.ts @@ -11,8 +11,8 @@ import { useLinkTypes, LinkRoleDropdown, AncestorMetaDataValue, - AncestorMetaData, - useGetMatchingSpace + useGetMatchingSpace, + useResourcesStore } from '@ownclouders/web-pkg' import { SharingLinkType } from '@ownclouders/web-client/graph/generated' import { Resource } from '@ownclouders/web-client' @@ -71,16 +71,14 @@ describe('DetailsAndEdit component', () => { }) it('renders a button for indirect links', () => { - const linkShare = mock({ indirect: true }) - const ancestorMetaData = { - '/parent': mock({ - id: 'ancestorId', - shareTypes: [ShareTypes.link.value], - path: '/parent' - }) - } + const linkShare = mock({ indirect: true, resourceId: 'ancestorId' }) + const sharedAncestor = mock({ + id: 'ancestorId', + shareTypes: [ShareTypes.link.value], + path: '/parent' + }) - const { wrapper } = getShallowMountedWrapper({ linkShare, ancestorMetaData }) + const { wrapper } = getShallowMountedWrapper({ linkShare, sharedAncestor }) const viaButton = wrapper.findComponent('.oc-files-file-link-via') expect(viaButton.exists()).toBeTruthy() expect(viaButton.props('to').query.fileId).toEqual('ancestorId') @@ -104,12 +102,12 @@ function getShallowMountedWrapper({ linkShare = exampleLink, isModifiable = true, availableLinkTypes = [SharingLinkType.View], - ancestorMetaData = {} + sharedAncestor }: { linkShare?: LinkShare isModifiable?: boolean availableLinkTypes?: SharingLinkType[] - ancestorMetaData?: AncestorMetaData + sharedAncestor?: AncestorMetaDataValue } = {}) { vi.mocked(useLinkTypes).mockReturnValue( mock>({ @@ -124,6 +122,11 @@ function getShallowMountedWrapper({ }) ) + const plugins = defaultPlugins() + + const resourcesStore = useResourcesStore() + vi.mocked(resourcesStore).getAncestorById.mockReturnValue(sharedAncestor) + const mocks = defaultComponentMocks() return { wrapper: shallowMount(DetailsAndEdit, { @@ -137,15 +140,7 @@ function getShallowMountedWrapper({ mocks, renderStubDefaultSlot: true, stubs: { OcDatepicker: false, 'date-picker': true, OcButton: false }, - plugins: [ - ...defaultPlugins({ - piniaOptions: { - resourcesStore: { - ancestorMetaData - } - } - }) - ], + plugins, provide: { ...mocks, resource: mock({ path: '/', remoteItemPath: undefined }) } } }) diff --git a/packages/web-app-files/tests/unit/components/SideBar/Shares/SpaceMembers.spec.ts b/packages/web-app-files/tests/unit/components/SideBar/Shares/SpaceMembers.spec.ts index 60cff8a840b..da903d5fef1 100644 --- a/packages/web-app-files/tests/unit/components/SideBar/Shares/SpaceMembers.spec.ts +++ b/packages/web-app-files/tests/unit/components/SideBar/Shares/SpaceMembers.spec.ts @@ -15,7 +15,7 @@ import { RouteLocation } from 'web-test-helpers' import { User } from '@ownclouders/web-client/graph/generated' -import { useCanShare, useModals } from '@ownclouders/web-pkg' +import { useCanShare, useModals, useSpacesStore } from '@ownclouders/web-pkg' import ListItem from '../../../../../src/components/SideBar/Shares/Collaborators/ListItem.vue' vi.mock('@ownclouders/web-pkg', async (importOriginal) => ({ @@ -134,19 +134,22 @@ function getWrapper({ const mocks = defaultComponentMocks({ currentRoute: mock({ name: currentRouteName }) }) + + const plugins = defaultPlugins({ + piniaOptions: { + userState: { user }, + configState: { + options: { contextHelpers: true } + } + } + }) + + const spacesStore = useSpacesStore() + vi.mocked(spacesStore).getSpaceMembers.mockReturnValue(spaceMembers) + return mountType(SpaceMembers, { global: { - plugins: [ - ...defaultPlugins({ - piniaOptions: { - userState: { user }, - spacesState: { spaceMembers }, - configState: { - options: { contextHelpers: true } - } - } - }) - ], + plugins, mocks, provide: { ...mocks, diff --git a/packages/web-client/src/helpers/share/types.ts b/packages/web-client/src/helpers/share/types.ts index 66ea5a04f07..15f508fb9b0 100644 --- a/packages/web-client/src/helpers/share/types.ts +++ b/packages/web-client/src/helpers/share/types.ts @@ -11,6 +11,7 @@ export enum GraphSharePermission { readContent = 'libre.graph/driveItem/content/read', readChildren = 'libre.graph/driveItem/children/read', readDeleted = 'libre.graph/driveItem/deleted/read', + readPermissions = 'libre.graph/driveItem/permissions/read', updatePath = 'libre.graph/driveItem/path/update', updateDeleted = 'libre.graph/driveItem/deleted/update', updatePermissions = 'libre.graph/driveItem/permissions/update', diff --git a/packages/web-client/src/helpers/space/functions.ts b/packages/web-client/src/helpers/space/functions.ts index 0b600773ddd..9de33b63ba3 100644 --- a/packages/web-client/src/helpers/space/functions.ts +++ b/packages/web-client/src/helpers/space/functions.ts @@ -342,7 +342,7 @@ export function buildSpace( return s } -function getPermissionsForSpaceMember(space: SpaceResource, user: User) { +export function getPermissionsForSpaceMember(space: SpaceResource, user: User) { const permissions: string[] = [] // FIXME: user should always be given, adjust `can...` functions in SpaceResource diff --git a/packages/web-pkg/src/components/SideBar/FileSideBar.vue b/packages/web-pkg/src/components/SideBar/FileSideBar.vue index ebf5ae846df..2fa2786b8fe 100644 --- a/packages/web-pkg/src/components/SideBar/FileSideBar.vue +++ b/packages/web-pkg/src/components/SideBar/FileSideBar.vue @@ -52,7 +52,8 @@ import { useResourcesStore, useUserStore, useConfigStore, - useAppsStore + useAppsStore, + useCanListShares } from '../../composables' import { isProjectSpaceResource, @@ -63,7 +64,9 @@ import { isSpaceResource, isPersonalSpaceResource, isCollaboratorShare, - isLinkShare + isLinkShare, + isShareSpaceResource, + isIncomingShareResource } from '@ownclouders/web-client' import { storeToRefs } from 'pinia' import { useTask } from 'vue-concurrency' @@ -98,6 +101,7 @@ export default defineComponent({ const userStore = useUserStore() const configStore = useConfigStore() const appsStore = useAppsStore() + const { canListShares } = useCanListShares() const resourcesStore = useResourcesStore() const { currentFolder } = storeToRefs(resourcesStore) @@ -201,9 +205,20 @@ export default defineComponent({ const { collaboratorShares: collaboratorCache, linkShares: linkCache } = sharesStore const client = clientService.graphAuthenticated.permissions + let driveId = props.space?.id + if (isShareSpaceResource(props?.space)) { + const matchingMountPoint = yield spacesStore.getMountPointForSpace({ + graphClient: clientService.graphAuthenticated, + space: props.space + }) + if (matchingMountPoint) { + driveId = matchingMountPoint.root.remoteItem.rootId + } + } + // load direct shares const { shares, allowedRoles } = yield* call( - client.listPermissions(props.space?.id, resource.id, sharesStore.graphRoles) + client.listPermissions(driveId, resource.fileId, sharesStore.graphRoles) ) const loadedCollaboratorShares = shares.filter(isCollaboratorShare) @@ -216,7 +231,7 @@ export default defineComponent({ // load external share roles if (appsStore.isAppEnabled('open-cloud-mesh')) { const { allowedRoles } = yield* call( - client.listPermissions(props.space?.id, resource.id, sharesStore.graphRoles, { + client.listPermissions(driveId, resource.fileId, sharesStore.graphRoles, { filter: `@libre.graph.permissions.roles.allowedValues/rolePermissions/any(p:contains(p/condition, '@Subject.UserType=="Federated"'))`, select: [ListPermissionsSpaceRootSelectEnum.LibreGraphPermissionsRolesAllowedValues] }) @@ -246,27 +261,44 @@ export default defineComponent({ }) } - // load uncached indirect shares - const ancestorDataWithoutRoot = resourcesStore.ancestorMetaData - if (Object.keys(resourcesStore.ancestorMetaData).includes('/')) { - // filter out space roots because they don't have shares - // (expect for project spaces, but they are loaded separately) - delete ancestorDataWithoutRoot['/'] - } - + // gather all ancestors we need to load shares for (indirect shares, space members) const cachedIds = [...collaboratorCache, ...linkCache].map(({ resourceId }) => resourceId) - const ancestorIds = Object.values(ancestorDataWithoutRoot) + const ancestorIds = Object.values(resourcesStore.ancestorMetaData) + .filter(({ id, path }) => { + if (id === resource.id || cachedIds.includes(id)) { + // share already cached + return false + } + if (isIncomingShareResource(resource)) { + // incoming shares don't have ancestors because they are root elements themselves + return false + } + if (isPersonalSpaceResource(props.space)) { + // filter out personal space roots since they don't have shares + return path !== '/' + } + return true + }) .map(({ id }) => id) - .filter((id) => id !== resource.id && !cachedIds.includes(id)) + + if ( + unref(isFlatFileList) && + isProjectSpaceResource(props.space) && + !isProjectSpaceResource(resource) + ) { + // add project space to ancestors in flat file list where we don't have ancestors + // to display space members in the sidebar + ancestorIds.push(props.space.id) + } const queue = new PQueue({ concurrency: configStore.options.concurrentRequests.shares.list }) - const promises = ancestorIds.map((id) => { + const promises = [...new Set(ancestorIds)].map((id) => { return queue.add(() => clientService.graphAuthenticated.permissions - .listPermissions(props.space?.id, id, sharesStore.graphRoles) + .listPermissions(driveId, id, sharesStore.graphRoles) .then((result) => { const indirectShares = result.shares.map((s) => ({ ...s, indirect: true })) loadedCollaboratorShares.push(...indirectShares.filter(isCollaboratorShare)) @@ -278,16 +310,6 @@ export default defineComponent({ yield Promise.allSettled(promises) sharesStore.setCollaboratorShares(loadedCollaboratorShares) sharesStore.setLinkShares(loadedLinkShares) - - if (isProjectSpaceResource(resource)) { - spacesStore.setSpaceMembers(sharesStore.collaboratorShares) - } else if (isProjectSpaceResource(props.space)) { - yield spacesStore.loadSpaceMembers({ - graphClient: clientService.graphAuthenticated, - space: props.space - }) - } - sharesStore.setLoading(false) }).restartable() @@ -339,7 +361,7 @@ export default defineComponent({ } isMetaDataLoading.value = true - if (unref(userIsSpaceMember) && !unref(isTrashLocation)) { + if (canListShares({ space: props.space, resource })) { try { if (loadSharesTask.isRunning) { loadSharesTask.cancelAll() diff --git a/packages/web-pkg/src/components/SideBar/Spaces/Details/SpaceDetails.vue b/packages/web-pkg/src/components/SideBar/Spaces/Details/SpaceDetails.vue index d68c91d0f4b..9fb24363ca9 100644 --- a/packages/web-pkg/src/components/SideBar/Spaces/Details/SpaceDetails.vue +++ b/packages/web-pkg/src/components/SideBar/Spaces/Details/SpaceDetails.vue @@ -92,7 +92,6 @@ import { usePreviewService, useClientService, useUserStore, - useSpacesStore, useSharesStore, useResourcesStore, useResourceContents, @@ -127,14 +126,12 @@ export default defineComponent({ const userStore = useUserStore() const previewService = usePreviewService() const clientService = useClientService() - const spacesStore = useSpacesStore() const resourcesStore = useResourcesStore() const { resourceContentsText } = useResourceContents({ showSizeInformation: false }) const router = useRouter() const { current: currentLanguage } = useGettext() const sharesStore = useSharesStore() - const { spaceMembers } = storeToRefs(spacesStore) const resource = inject>('resource') const spaceImage = ref('') @@ -177,7 +174,6 @@ export default defineComponent({ linkShareCount, showWebDavDetails, user, - spaceMembers, resourceContentsText, showSize, size diff --git a/packages/web-pkg/src/composables/piniaStores/resources.ts b/packages/web-pkg/src/composables/piniaStores/resources.ts index 814eaad6e09..b66706faa70 100644 --- a/packages/web-pkg/src/composables/piniaStores/resources.ts +++ b/packages/web-pkg/src/composables/piniaStores/resources.ts @@ -287,6 +287,24 @@ export const useResourcesStore = defineStore('resources', () => { } return Promise.all(promises).then(() => { + if (!Object.keys(data).includes('/')) { + // add space as root element + const cachedRoot = unref(ancestorMetaData)['/'] + if (cachedRoot?.spaceId === space.id) { + data['/'] = cachedRoot + } else { + const { parentFolderId } = Object.values(data)[0] + const space = spacesStore.spaces.find(({ id }) => parentFolderId.startsWith(id)) + data['/'] = { + id: space.id, + shareTypes: space.shareTypes, + parentFolderId: space.id, + spaceId: space.id, + path: '/' + } + } + } + setAncestorMetaData(data) }) } diff --git a/packages/web-pkg/src/composables/piniaStores/spaces.ts b/packages/web-pkg/src/composables/piniaStores/spaces.ts index 3b264891cec..318a130579d 100644 --- a/packages/web-pkg/src/composables/piniaStores/spaces.ts +++ b/packages/web-pkg/src/composables/piniaStores/spaces.ts @@ -2,7 +2,7 @@ import { defineStore } from 'pinia' import { computed, ref, unref } from 'vue' import { buildShareSpaceResource, - isCollaboratorShare, + isMountPointSpaceResource, SpaceResource } from '@ownclouders/web-client' import { Graph } from '@ownclouders/web-client/graph' @@ -12,7 +12,7 @@ import { isPersonalSpaceResource, isProjectSpaceResource } from '@ownclouders/web-client' -import type { CollaboratorShare, ShareRole } from '@ownclouders/web-client' +import type { CollaboratorShare, MountPointSpaceResource, ShareRole } from '@ownclouders/web-client' import { useUserStore } from './user' import { ConfigStore, useConfigStore } from './config' import { useSharesStore } from './shares' @@ -75,7 +75,6 @@ export const useSpacesStore = defineStore('spaces', () => { const sharesStore = useSharesStore() const spaces = ref([]) - const spaceMembers = ref([]) const currentSpace = ref() const spacesInitialized = ref(false) const mountPointsInitialized = ref(false) @@ -101,8 +100,13 @@ export const useSpacesStore = defineStore('spaces', () => { currentSpace.value = space } - const setSpaceMembers = (members: CollaboratorShare[]) => { - spaceMembers.value = members + const getSpaceMembers = (space: SpaceResource) => { + // only project spaces have members + if (!isProjectSpaceResource(space)) { + return [] + } + const members = sharesStore.collaboratorShares.filter((c) => c.resourceId === space.id) + return sortSpaceMembers(members) } const addSpaces = (s: SpaceResource[]) => { @@ -117,6 +121,22 @@ export const useSpacesStore = defineStore('spaces', () => { return unref(spaces).find((s) => id == s.id) } + const getMountPointForSpace = async ({ + graphClient, + space + }: { + graphClient: Graph + space: SpaceResource + }): Promise => { + await loadMountPoints({ graphClient }) + + // even if the resource has been shared via multiple permissions (e.g. directly via user and a group) + // we only care about one matching mount point since the remote item contains all permissions + return unref(spaces).find( + (s) => isMountPointSpaceResource(s) && s.root?.remoteItem?.id === space.id + ) + } + const createShareSpace = ({ driveAliasPrefix, id, @@ -222,46 +242,11 @@ export const useSpacesStore = defineStore('spaces', () => { addSpaces(projectSpaces) } - const loadSpaceMembers = async ({ - graphClient, - space - }: { - graphClient: Graph - space: SpaceResource - }) => { - spaceMembers.value = [] - - const { shares } = await graphClient.permissions.listPermissions( - space.id, - space.id, - sharesStore.graphRoles - ) - - const spaceShares = shares.filter(isCollaboratorShare) - spaceMembers.value = sortSpaceMembers(spaceShares) - } - - const upsertSpaceMember = ({ member }: { member: CollaboratorShare }) => { - const existingMember = unref(spaceMembers).find(({ id }) => id === member.id) - if (existingMember) { - Object.assign(existingMember, member) - return - } - - unref(spaceMembers).push(member) - } - - const removeSpaceMember = ({ member }: { member: CollaboratorShare }) => { - const existingMember = unref(spaceMembers).find(({ id }) => id === member.id) - spaceMembers.value = unref(spaceMembers).filter(({ id }) => existingMember.id !== id) - } - return { spaces, spacesInitialized, mountPointsInitialized, spacesLoading, - spaceMembers, currentSpace, personalSpace, @@ -271,7 +256,8 @@ export const useSpacesStore = defineStore('spaces', () => { setMountPointsInitialized, setSpacesLoading, setCurrentSpace, - setSpaceMembers, + getSpaceMembers, + getMountPointForSpace, addSpaces, removeSpace, @@ -279,11 +265,7 @@ export const useSpacesStore = defineStore('spaces', () => { updateSpaceField, loadSpaces, loadMountPoints, - reloadProjectSpaces, - - loadSpaceMembers, - upsertSpaceMember, - removeSpaceMember + reloadProjectSpaces } }) diff --git a/packages/web-pkg/src/composables/shares/index.ts b/packages/web-pkg/src/composables/shares/index.ts index f063f1f192f..f980ae2c9d4 100644 --- a/packages/web-pkg/src/composables/shares/index.ts +++ b/packages/web-pkg/src/composables/shares/index.ts @@ -1 +1,2 @@ +export * from './useCanListShares' export * from './useCanShare' diff --git a/packages/web-pkg/src/composables/shares/useCanListShares.ts b/packages/web-pkg/src/composables/shares/useCanListShares.ts new file mode 100644 index 00000000000..4b345a68405 --- /dev/null +++ b/packages/web-pkg/src/composables/shares/useCanListShares.ts @@ -0,0 +1,43 @@ +import { + getPermissionsForSpaceMember, + GraphSharePermission, + isIncomingShareResource, + isPublicSpaceResource, + isTrashResource, + Resource, + SpaceResource +} from '@ownclouders/web-client' +import { useCapabilityStore, useUserStore } from '../piniaStores' +import { isShareSpaceResource } from '@ownclouders/web-client' +import { useGetMatchingSpace } from '../spaces' + +export const useCanListShares = () => { + const capabilityStore = useCapabilityStore() + const { isPersonalSpaceRoot } = useGetMatchingSpace() + const userStore = useUserStore() + + const canListShares = ({ space, resource }: { space: SpaceResource; resource: Resource }) => { + if (!capabilityStore.sharingApiEnabled) { + return false + } + if (isPublicSpaceResource(space)) { + return false + } + if (isPersonalSpaceRoot(resource)) { + return false + } + if (isTrashResource(resource)) { + return false + } + if (isIncomingShareResource(resource)) { + return resource.sharePermissions.includes(GraphSharePermission.readPermissions) + } + if (isShareSpaceResource(space)) { + const permissions = getPermissionsForSpaceMember(space, userStore.user) + return permissions.includes(GraphSharePermission.readPermissions) + } + return true + } + + return { canListShares } +} diff --git a/packages/web-pkg/tests/unit/components/sidebar/FileSideBar.spec.ts b/packages/web-pkg/tests/unit/components/sidebar/FileSideBar.spec.ts index 405d9110f31..d6167f52edc 100644 --- a/packages/web-pkg/tests/unit/components/sidebar/FileSideBar.spec.ts +++ b/packages/web-pkg/tests/unit/components/sidebar/FileSideBar.spec.ts @@ -13,8 +13,7 @@ import { useAppsStore, useExtensionRegistry, useResourcesStore, - useSharesStore, - useSpacesStore + useSharesStore } from '../../../../src/composables/piniaStores' import { AncestorMetaDataValue } from '../../../../src' @@ -141,37 +140,6 @@ describe('FileSideBar', () => { mocks.$clientService.graphAuthenticated.permissions.listPermissions ).toHaveBeenCalledTimes(2) }) - it('calls "setSpaceMembers" for space resources', async () => { - const resource = mock({ id: '1', driveType: 'project' }) - const { wrapper, mocks } = createWrapper() - - mocks.$clientService.graphAuthenticated.permissions.listPermissions.mockResolvedValue({ - shares: [], - allowedActions: [], - allowedRoles: [] - }) - - const { setSpaceMembers } = useSpacesStore() - await wrapper.vm.loadSharesTask.perform(resource) - - expect(setSpaceMembers).toHaveBeenCalled() - }) - it('calls "loadSpaceMembers" if current space is a project space', async () => { - const resource = mock() - const space = mock({ id: '1', driveType: 'project' }) - const { wrapper, mocks } = createWrapper({ space }) - - mocks.$clientService.graphAuthenticated.permissions.listPermissions.mockResolvedValue({ - shares: [], - allowedActions: [], - allowedRoles: [] - }) - - const { loadSpaceMembers } = useSpacesStore() - await wrapper.vm.loadSharesTask.perform(resource) - - expect(loadSpaceMembers).toHaveBeenCalled() - }) it('loads available external share roles if the ocm app is enabled', async () => { const resource = mock() const { wrapper, mocks } = createWrapper() diff --git a/packages/web-pkg/tests/unit/components/sidebar/Spaces/Details/SpaceDetails.spec.ts b/packages/web-pkg/tests/unit/components/sidebar/Spaces/Details/SpaceDetails.spec.ts index 8d56b65368f..bb75120cf76 100644 --- a/packages/web-pkg/tests/unit/components/sidebar/Spaces/Details/SpaceDetails.spec.ts +++ b/packages/web-pkg/tests/unit/components/sidebar/Spaces/Details/SpaceDetails.spec.ts @@ -83,7 +83,6 @@ function createWrapper({ spaceResource = spaceMock, props = {} } = {}) { ...defaultPlugins({ piniaOptions: { userState: { user: { id: '1', onPremisesSamAccountName: 'marie' } as User }, - spacesState: { spaceMembers: [spaceShare] }, sharesState: { collaboratorShares: [spaceShare] }, resourcesStore: { resources: [mock({ name: 'file1', type: 'file' })] } } diff --git a/packages/web-pkg/tests/unit/composables/piniaStores/spaces.spec.ts b/packages/web-pkg/tests/unit/composables/piniaStores/spaces.spec.ts index 16d8d3281e0..6b922f9d1f0 100644 --- a/packages/web-pkg/tests/unit/composables/piniaStores/spaces.spec.ts +++ b/packages/web-pkg/tests/unit/composables/piniaStores/spaces.spec.ts @@ -2,20 +2,12 @@ import { getComposableWrapper } from 'web-test-helpers' import { useSpacesStore, sortSpaceMembers, - useUserStore, useSharesStore } from '../../../../src/composables/piniaStores' import { createPinia, setActivePinia } from 'pinia' import { mock, mockDeep } from 'vitest-mock-extended' -import { - CollaboratorShare, - GraphSharePermission, - ShareRole, - SpaceResource -} from '@ownclouders/web-client' +import { CollaboratorShare, GraphSharePermission, SpaceResource } from '@ownclouders/web-client' import { Graph } from '@ownclouders/web-client/graph' -import { User } from '@ownclouders/web-client/graph/generated' -import { ClientService } from '../../../../src/services' describe('spaces', () => { beforeEach(() => { @@ -258,66 +250,50 @@ describe('spaces', () => { }) }) }) - describe('method "upsertSpaceMember"', () => { - it('correctly adds space members', () => { + describe('method "getSpaceMembers"', () => { + it('correctly returns members for project spaces', () => { getWrapper({ setup: (instance) => { - const member = mock({ id: '1' }) - - instance.upsertSpaceMember({ member }) + const space = mock({ id: '1', driveType: 'project' }) + const sharesStore = useSharesStore() + sharesStore.collaboratorShares = [mock({ resourceId: space.id })] + const members = instance.getSpaceMembers(space) - expect(instance.spaceMembers.length).toBe(1) - expect(instance.spaceMembers[0].id).toBe(member.id) + expect(members.length).toBe(1) } }) }) - it('correctly updates space members', () => { + it('does not return members for personal space', () => { getWrapper({ setup: (instance) => { - const member = mock({ id: '1', indirect: false }) - instance.upsertSpaceMember({ member }) - - expect(instance.spaceMembers.length).toBe(1) - expect(instance.spaceMembers[0].indirect).toBe(member.indirect) - - instance.upsertSpaceMember({ member: { ...member, indirect: true } }) + const space = mock({ id: '1', driveType: 'personal' }) + const sharesStore = useSharesStore() + sharesStore.collaboratorShares = [mock({ resourceId: space.id })] + const members = instance.getSpaceMembers(space) - expect(instance.spaceMembers.length).toBe(1) - expect(instance.spaceMembers[0].indirect).toBe(true) + expect(members.length).toBe(0) } }) }) }) - describe('method "loadSpaceMembers"', () => { - it('loads space members and sets them', () => { + describe('method "getMountPointForSpace"', () => { + it('returns a matching mount point', () => { getWrapper({ setup: async (instance) => { - const share = mock({ - id: '1', - permissions: [], - role: mock({ id: 'roleId' }) - }) - const clientService = mockDeep() - clientService.graphAuthenticated.permissions.listPermissions.mockResolvedValue({ - shares: [share], - allowedActions: [], - allowedRoles: [] - }) - - const userStore = useUserStore() - userStore.user = mock() - - const sharesStore = useSharesStore() - sharesStore.graphRoles = { roleId: mock({ id: 'roleId' }) } - - await instance.loadSpaceMembers({ - graphClient: clientService.graphAuthenticated, - space: mock() - }) + const graphClient = mockDeep() + const space = mock({ id: '1', driveType: 'project' }) + const mountpoints = [ + mock({ + id: '2', + driveType: 'mountpoint', + root: { remoteItem: { id: space.id } } + }) + ] + instance.spaces = mountpoints + instance.mountPointsInitialized = true + const mountPoint = await instance.getMountPointForSpace({ graphClient, space }) - expect(clientService.graphAuthenticated.permissions.listPermissions).toHaveBeenCalled() - expect(instance.spaceMembers.length).toBe(1) - expect(instance.spaceMembers[0].id).toBe(share.id) + expect(mountPoint).toEqual(mountpoints[0]) } }) }) diff --git a/packages/web-pkg/tests/unit/composables/shares/useCanListShares.spec.ts b/packages/web-pkg/tests/unit/composables/shares/useCanListShares.spec.ts new file mode 100644 index 00000000000..63d4977ebe1 --- /dev/null +++ b/packages/web-pkg/tests/unit/composables/shares/useCanListShares.spec.ts @@ -0,0 +1,194 @@ +import { getComposableWrapper, useGetMatchingSpaceMock } from 'web-test-helpers' +import { mock } from 'vitest-mock-extended' +import { + getPermissionsForSpaceMember, + GraphSharePermission, + IncomingShareResource, + PersonalSpaceResource, + PublicSpaceResource, + Resource, + ShareSpaceResource, + SpaceResource, + TrashResource +} from '@ownclouders/web-client' +import { useCanListShares } from '../../../../src/composables/shares' +import { useCapabilityStore } from '../../../../src/composables/piniaStores' +import { useGetMatchingSpace } from '../../../../src/composables/spaces/useGetMatchingSpace' +import { Identity } from '@ownclouders/web-client/graph/generated' + +vi.mock('../../../../src/composables/spaces/useGetMatchingSpace') +vi.mock('@ownclouders/web-client', async (importOriginal) => ({ + ...(await importOriginal()), + getPermissionsForSpaceMember: vi.fn() +})) + +describe('useCanListShares', () => { + describe('canListShares', () => { + it('returns true with sharing enabled and sufficient permissions', () => { + getWrapper({ + setup: ({ canListShares }) => { + const space = mock() + const resource = mock() + + const capabilityStore = useCapabilityStore() + vi.mocked(capabilityStore).sharingApiEnabled = true + + const canList = canListShares({ space, resource }) + expect(canList).toBeTruthy() + } + }) + }) + it('returns false when sharing not enabled', () => { + getWrapper({ + setup: ({ canListShares }) => { + const space = mock() + const resource = mock() + + const capabilityStore = useCapabilityStore() + vi.mocked(capabilityStore).sharingApiEnabled = false + + const canList = canListShares({ space, resource }) + expect(canList).toBeFalsy() + } + }) + }) + it('returns false in public spaces', () => { + getWrapper({ + setup: ({ canListShares }) => { + const space = mock({ driveType: 'public' }) + const resource = mock() + + const capabilityStore = useCapabilityStore() + vi.mocked(capabilityStore).sharingApiEnabled = true + + const canList = canListShares({ space, resource }) + expect(canList).toBeFalsy() + } + }) + }) + it('returns false for personal space root resources', () => { + getWrapper({ + setup: ({ canListShares }) => { + const space = mock() + const resource = mock() + + const capabilityStore = useCapabilityStore() + vi.mocked(capabilityStore).sharingApiEnabled = true + + const canList = canListShares({ space, resource }) + expect(canList).toBeFalsy() + }, + isPersonalSpaceRoot: true + }) + }) + it('returns false for trash resources', () => { + getWrapper({ + setup: ({ canListShares }) => { + const space = mock() + const resource = mock({ ddate: '2021-01-01T00:00:00Z' }) + + const capabilityStore = useCapabilityStore() + vi.mocked(capabilityStore).sharingApiEnabled = true + + const canList = canListShares({ space, resource }) + expect(canList).toBeFalsy() + } + }) + }) + describe('incoming share resources', () => { + it('returns true with sufficient permissions', () => { + getWrapper({ + setup: ({ canListShares }) => { + const space = mock() + const resource = mock({ + sharedWith: [mock()], + sharePermissions: [GraphSharePermission.readPermissions], + outgoing: false + }) + + const capabilityStore = useCapabilityStore() + vi.mocked(capabilityStore).sharingApiEnabled = true + + const canList = canListShares({ space, resource }) + expect(canList).toBeTruthy() + } + }) + }) + it('returns false with insufficient permissions', () => { + getWrapper({ + setup: ({ canListShares }) => { + const space = mock() + const resource = mock({ + sharedWith: [mock()], + sharePermissions: [], + outgoing: false + }) + + const capabilityStore = useCapabilityStore() + vi.mocked(capabilityStore).sharingApiEnabled = true + + const canList = canListShares({ space, resource }) + expect(canList).toBeFalsy() + } + }) + }) + }) + describe('share spaces', () => { + it('returns true with sufficient permissions', () => { + getWrapper({ + setup: ({ canListShares }) => { + const space = mock({ driveType: 'share' }) + const resource = mock() + + const capabilityStore = useCapabilityStore() + vi.mocked(capabilityStore).sharingApiEnabled = true + + const canList = canListShares({ space, resource }) + expect(canList).toBeTruthy() + }, + shareSpacePermissions: [GraphSharePermission.readPermissions] + }) + }) + it('returns false with insufficient permissions', () => { + getWrapper({ + setup: ({ canListShares }) => { + const space = mock({ driveType: 'share' }) + const resource = mock() + + const capabilityStore = useCapabilityStore() + vi.mocked(capabilityStore).sharingApiEnabled = true + + const canList = canListShares({ space, resource }) + expect(canList).toBeFalsy() + }, + shareSpacePermissions: [] + }) + }) + }) + }) +}) + +function getWrapper({ + setup, + isPersonalSpaceRoot = false, + shareSpacePermissions = [] +}: { + setup: (instance: ReturnType) => void + isPersonalSpaceRoot?: boolean + shareSpacePermissions?: GraphSharePermission[] +}) { + vi.mocked(useGetMatchingSpace).mockImplementation(() => + useGetMatchingSpaceMock({ + isPersonalSpaceRoot: () => isPersonalSpaceRoot + }) + ) + + vi.mocked(getPermissionsForSpaceMember).mockReturnValue(shareSpacePermissions) + + return { + wrapper: getComposableWrapper(() => { + const instance = useCanListShares() + setup(instance) + }) + } +} diff --git a/packages/web-test-helpers/src/mocks/pinia.ts b/packages/web-test-helpers/src/mocks/pinia.ts index f47503e2d36..4392cd75257 100644 --- a/packages/web-test-helpers/src/mocks/pinia.ts +++ b/packages/web-test-helpers/src/mocks/pinia.ts @@ -64,7 +64,7 @@ export type PiniaMockOptions = { graphRoles?: Record loading?: boolean } - spacesState?: { spaces?: SpaceResource[]; spaceMembers?: CollaboratorShare[] } + spacesState?: { spaces?: SpaceResource[] } userState?: { user?: User } capabilityState?: { capabilities?: Partial @@ -132,7 +132,7 @@ export function createMockStore({ }, resources: { resources: [], ...resourcesStore }, shares: { collaboratorShares: [], linkShares: [], ...sharesState }, - spaces: { spaces: [], spaceMembers: [], ...spacesState }, + spaces: { spaces: [], ...spacesState }, userSettings: { users: [], selectedUsers: [], ...userSettingsStore }, groupSettings: { groups: [], selectedGroups: [], ...groupSettingsStore }, spaceSettings: { spaces: [], selectedSpaces: [], ...spaceSettingsStore },