diff --git a/__fixtures__/collaborators.js b/__fixtures__/collaborators.js index 51cd46f0cce..415c1a3ebd6 100644 --- a/__fixtures__/collaborators.js +++ b/__fixtures__/collaborators.js @@ -1,6 +1,7 @@ import { peopleRoleEditorFile, - peopleRoleViewerFile + peopleRoleViewerFile, + spaceRoleEditor } from '../packages/web-app-files/src/helpers/share' export default [ @@ -142,5 +143,25 @@ export default [ role: peopleRoleViewerFile, path: "/Neuer Ordner-'singe'", key: "collaborator-51a8aafe-cd40-4d0a-8566-87a1149b7fea" + }, + { + shareType: 7, + id: "f5c28709-b921-4ec8-b39a-4c243709b514", + collaborator: { + name: "einstein", + displayName: "Albert Einstein", + additionalInfo: "einstein@example.org" + }, + owner: { + name: "admin", + displayName: "Admin", + additionalInfo: "admin@example.org" + }, + fileOwner: { + name: "admin", + displayName: "Admin", + additionalInfo: "admin@example.org" + }, + role: spaceRoleEditor } ] diff --git a/changelog/unreleased/enhancement-spaces-people-sharing b/changelog/unreleased/enhancement-spaces-people-sharing new file mode 100644 index 00000000000..8901c33f485 --- /dev/null +++ b/changelog/unreleased/enhancement-spaces-people-sharing @@ -0,0 +1,11 @@ +Enhancement: Implement people sharing for spaces + +Spaces can now be shared with other people. This change specifically includes: + +* listing all members who have access to a space (possible for all space members) +* adding members to a space and giving them dedicated roles (possible for managers only) +* editing the role of members (possible for managers only) +* removing members from a space (possible for managers only) + +https://github.com/owncloud/web/pull/6455 +https://github.com/owncloud/web/issues/6283 diff --git a/packages/web-app-files/src/components/SideBar/Details/SpaceDetails.vue b/packages/web-app-files/src/components/SideBar/Details/SpaceDetails.vue index 0ae941e8674..7c6d177fc70 100644 --- a/packages/web-app-files/src/components/SideBar/Details/SpaceDetails.vue +++ b/packages/web-app-files/src/components/SideBar/Details/SpaceDetails.vue @@ -12,10 +12,32 @@ class="space-default-image oc-px-m oc-py-m" /> -
- - - +
+
+ + + + + + + +
+
+ + +
@@ -30,7 +52,7 @@ @@ -47,11 +69,10 @@ import { ref } from '@vue/composition-api' import Mixins from '../../../mixins' import MixinResources from '../../../mixins/resources' -import { mapGetters } from 'vuex' +import { mapActions, mapGetters } from 'vuex' import { useTask } from 'vue-concurrency' import { buildWebDavSpacesPath } from '../../../helpers/resources' -import { useStore } from 'web-pkg/src/composables' -import { clientService } from 'web-pkg/src/services' +import { spaceRoleManager } from '../../../helpers/share' import SpaceQuota from '../../SpaceQuota.vue' export default { @@ -63,13 +84,7 @@ export default { return $gettext('Details') }, setup() { - const store = useStore() const spaceImage = ref('') - const owners = ref([]) - const graphClient = clientService.graphAuthenticated( - store.getters.configuration.server, - store.getters.getToken - ) const loadImageTask = useTask(function* (signal, ref) { if (!ref.space?.spaceImageData) { @@ -89,24 +104,14 @@ export default { spaceImage.value = Buffer.from(fileContents).toString('base64') }) - const loadOwnersTask = useTask(function* (signal, ref) { - const promises = [] - for (const userId of ref.ownerUserIds) { - promises.push(graphClient.users.getUser(userId)) - } - - if (promises.length > 0) { - yield Promise.all(promises).then((resolvedData) => { - resolvedData.forEach((response) => { - owners.value.push(response.data) - }) - }) - } - }) - - return { loadImageTask, loadOwnersTask, spaceImage, owners } + return { loadImageTask, spaceImage } }, computed: { + ...mapGetters('Files', [ + 'highlightedFile', + 'currentFileOutgoingCollaborators', + 'currentFileOutgoingLinks' + ]), ...mapGetters(['user']), space() { @@ -118,94 +123,69 @@ export default { lastModifyDate() { return this.formDateFromISO(this.space.mdate) }, - ownerUserIds() { - const permissions = this.space.spacePermissions?.filter((permission) => - permission.roles.includes('manager') - ) - if (!permissions.length) { - return [] - } - - const userIds = permissions.reduce((acc, item) => { - const ids = item.grantedTo.map((user) => user.user.id) - acc = acc.concat(ids) - return acc - }, []) - - return [...new Set(userIds)] - }, ownerUsernames() { const userId = this.user?.id - return this.owners - .map((owner) => { - if (owner.onPremisesSamAccountName === userId) { + return this.currentFileOutgoingCollaborators + .filter((share) => share.role.name === spaceRoleManager.name) + .map((share) => { + if (share.collaborator.name === userId) { return this.$gettextInterpolate(this.$gettext('%{displayName} (me)'), { - displayName: owner.displayName + displayName: share.collaborator.displayName }) } - return owner.displayName + return share.collaborator.displayName }) .join(', ') }, - hasPeopleShares() { - return false // @TODO + hasMemberShares() { + return this.memberShareCount > 1 }, hasLinkShares() { - return false // @TODO + return this.linkShareCount > 0 }, - peopleShareCount() { - return 0 // @TODO + memberShareCount() { + return this.currentFileOutgoingCollaborators.length }, linkShareCount() { - return 0 // @TODO + return this.currentFileOutgoingLinks.length }, - shareLabel() { - let peopleString, linksString - - if (this.hasPeopleShares) { - peopleString = this.$gettextInterpolate( - this.$ngettext( - 'This space has been shared with %{peopleShareCount} person.', - 'This space has been shared with %{peopleShareCount} people.', - this.peopleShareCount - ), - { - peopleShareCount: this.peopleShareCount - } - ) - } - - if (this.hasLinkShares) { - linksString = this.$gettextInterpolate( - this.$ngettext( - '%{linkShareCount} link giving access.', - '%{linkShareCount} links giving access.', - this.linkShareCount - ), - { - linkShareCount: this.linkShareCount - } - ) - } - - if (peopleString && linksString) { - return `${peopleString} ${linksString}` - } - - if (peopleString) { - return peopleString - } - - if (linksString) { - return linksString - } - - return '' + memberShareLabel() { + return this.$gettextInterpolate( + this.$ngettext( + 'This space has %{memberShareCount} member.', + 'This space has %{memberShareCount} members.', + this.memberShareCount + ), + { + memberShareCount: this.memberShareCount + } + ) + }, + linkShareLabel() { + return this.$gettextInterpolate( + this.$ngettext( + '%{linkShareCount} link giving access.', + '%{linkShareCount} links giving access.', + this.linkShareCount + ), + { + linkShareCount: this.linkShareCount + } + ) } }, mounted() { this.loadImageTask.perform(this) - this.loadOwnersTask.perform(this) + }, + methods: { + ...mapActions('Files/sidebar', { + setSidebarPanel: 'setActivePanel', + closeSidebar: 'close' + }), + + expandMemberPanel() { + this.setSidebarPanel('space-share-item') + } } } 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 b93c1aabe9a..a8039aaf835 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 @@ -1,11 +1,13 @@ + + diff --git a/packages/web-app-files/src/fileSideBars.js b/packages/web-app-files/src/fileSideBars.js index 63ab3881037..6587dcd31f8 100644 --- a/packages/web-app-files/src/fileSideBars.js +++ b/packages/web-app-files/src/fileSideBars.js @@ -7,6 +7,7 @@ import FileLinks from './components/SideBar/Links/FileLinks.vue' import NoSelection from './components/SideBar/NoSelection.vue' import SpaceActions from './components/SideBar/Actions/SpaceActions.vue' import SpaceDetails from './components/SideBar/Details/SpaceDetails.vue' +import SpaceShares from './components/SideBar/Shares/SpaceShares.vue' import { isLocationCommonActive, isLocationSpacesActive } from './router' export default [ @@ -112,5 +113,14 @@ export default [ get enabled() { return highlightedFile?.type === 'space' } + }), + ({ highlightedFile }) => ({ + app: 'space-share-item', + component: SpaceShares, + icon: 'group', + iconFillType: 'line', + get enabled() { + return highlightedFile?.type === 'space' + } }) ] diff --git a/packages/web-app-files/src/helpers/resources.js b/packages/web-app-files/src/helpers/resources.js index d4845efb797..cd1d2520e61 100644 --- a/packages/web-app-files/src/helpers/resources.js +++ b/packages/web-app-files/src/helpers/resources.js @@ -4,7 +4,15 @@ import { DateTime } from 'luxon' import { getIndicators } from './statusIndicators' import { $gettext } from '../gettext' import { DavPermission, DavProperty } from 'web-pkg/src/constants' -import { PeopleShareRoles, SharePermissions, ShareStatus, ShareTypes } from './share' +import { + PeopleShareRoles, + SharePermissions, + ShareStatus, + ShareTypes, + spaceRoleEditor, + spaceRoleManager, + spaceRoleViewer +} from './share' function _getFileExtension(name) { const extension = path.extname(name) @@ -324,9 +332,44 @@ export function buildShare(s, file, allowSharePermission) { if (parseInt(s.share_type) === ShareTypes.link.value) { return _buildLink(s) } + if (parseInt(s.share_type) === ShareTypes.space.value) { + return buildSpaceShare(s, file) + } + return buildCollaboratorShare(s, file, allowSharePermission) } +export function buildSpaceShare(s, spaceId) { + let permissions, role + + switch (s.role) { + case spaceRoleManager.name: + permissions = spaceRoleManager.bitmask(true) + role = spaceRoleManager + break + case spaceRoleEditor.name: + permissions = spaceRoleEditor.bitmask(true) + role = spaceRoleEditor + break + case spaceRoleViewer.name: + permissions = spaceRoleViewer.bitmask(true) + role = spaceRoleViewer + break + } + + return { + shareType: ShareTypes.space.value, + id: spaceId, + collaborator: { + name: s.onPremisesSamAccountName, + displayName: s.displayName, + additionalInfo: null + }, + permissions, + role + } +} + function _buildLink(link) { let description = '' diff --git a/packages/web-app-files/src/helpers/share/role.ts b/packages/web-app-files/src/helpers/share/role.ts index bd55034878e..a464a9c0338 100644 --- a/packages/web-app-files/src/helpers/share/role.ts +++ b/packages/web-app-files/src/helpers/share/role.ts @@ -98,6 +98,20 @@ export class PeopleShareRole extends ShareRole { } } +export class SpaceShareRole extends ShareRole { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public description(allowSharing: boolean): string { + switch (this.name) { + case spaceRoleViewer.name: + return $gettext('Download and preview') + case spaceRoleEditor.name: + return $gettext('Upload, edit, delete, download and preview') + case spaceRoleManager.name: + return $gettext('Upload, edit, delete, download, preview and share') + } + } +} + export class LinkShareRole extends ShareRole { public description(allowSharing: boolean): string { return linkRoleDescriptions[this.bitmask(allowSharing)] @@ -193,6 +207,45 @@ export const linkRoleUploaderFolder = new LinkShareRole( $gettext('uploader'), [SharePermissions.create] ) +export const spaceRoleViewer = new SpaceShareRole( + 'viewer', + false, + $gettext('Viewer'), + $gettext('viewer'), + [SharePermissions.read] +) +export const spaceRoleEditor = new SpaceShareRole( + 'editor', + false, + $gettext('Editor'), + $gettext('editor'), + [SharePermissions.read, SharePermissions.update, SharePermissions.create, SharePermissions.delete] +) +export const spaceRoleManager = new SpaceShareRole( + 'manager', + false, + $gettext('Manager'), + $gettext('manager'), + [ + SharePermissions.read, + SharePermissions.update, + SharePermissions.create, + SharePermissions.delete, + SharePermissions.share + ] +) + +export abstract class SpacePeopleShareRoles { + static readonly all = [spaceRoleViewer, spaceRoleEditor, spaceRoleManager] + + static list(): ShareRole[] { + return this.all + } + + static getByBitmask(bitmask: number): ShareRole { + return this.all.find((r) => r.bitmask(true) === bitmask) + } +} export abstract class PeopleShareRoles { static readonly all = [ diff --git a/packages/web-app-files/src/helpers/share/type.ts b/packages/web-app-files/src/helpers/share/type.ts index 34114f95628..47c928d7489 100644 --- a/packages/web-app-files/src/helpers/share/type.ts +++ b/packages/web-app-files/src/helpers/share/type.ts @@ -35,12 +35,13 @@ export abstract class ShareTypes { static readonly link = new ShareType('link', 3, $gettext('Link')) static readonly guest = new ShareType('guest', 4, $gettext('Guest')) static readonly remote = new ShareType('remote', 6, $gettext('Federated')) + static readonly space = new ShareType('space', 7, $gettext('User')) - static readonly individuals = [this.user, this.guest, this.remote] + static readonly individuals = [this.user, this.guest, this.remote, this.space] static readonly collectives = [this.group] static readonly unauthenticated = [this.link] - static readonly authenticated = [this.user, this.group, this.guest, this.remote] - static readonly all = [this.user, this.group, this.link, this.guest, this.remote] + static readonly authenticated = [this.user, this.group, this.guest, this.remote, this.space] + static readonly all = [this.user, this.group, this.link, this.guest, this.remote, this.space] static isIndividual(type: ShareType): boolean { return this.individuals.includes(type) diff --git a/packages/web-app-files/src/store/actions.js b/packages/web-app-files/src/store/actions.js index 37d0160a99a..6a74405c891 100644 --- a/packages/web-app-files/src/store/actions.js +++ b/packages/web-app-files/src/store/actions.js @@ -2,12 +2,17 @@ import PQueue from 'p-queue' import { getParentPaths } from '../helpers/path' import { dirname } from 'path' -import { buildResource, buildShare, buildCollaboratorShare } from '../helpers/resources' +import { + buildResource, + buildShare, + buildCollaboratorShare, + buildSpaceShare +} from '../helpers/resources' import { $gettext, $gettextInterpolate } from '../gettext' import { loadPreview } from '../helpers/resource' import { avatarUrl } from '../helpers/user' import { has } from 'lodash-es' -import { ShareTypes } from '../helpers/share' +import { ShareTypes, SpacePeopleShareRoles } from '../helpers/share' export default { updateFileProgress({ commit }, progress) { @@ -144,11 +149,47 @@ export default { value: computeShareTypes(state.currentFileOutgoingShares) }) }, - loadCurrentFileOutgoingShares(context, { client, path }) { + loadCurrentFileOutgoingShares(context, { client, path, space }) { context.commit('CURRENT_FILE_OUTGOING_SHARES_SET', []) context.commit('CURRENT_FILE_OUTGOING_SHARES_ERROR', null) context.commit('CURRENT_FILE_OUTGOING_SHARES_LOADING', true) + if (space) { + const promises = [] + const spaceShares = [] + + for (const permission of space.spacePermissions) { + for (const { + user: { id } + } of permission.grantedTo) { + promises.push( + client.users.getUser(id).then((resolved) => { + spaceShares.push( + buildSpaceShare( + { + ...resolved.data, + role: permission.roles[0] + }, + space.id + ) + ) + }) + ) + } + } + + return Promise.all(promises) + .then(() => { + context.commit('CURRENT_FILE_OUTGOING_SHARES_SET', spaceShares) + context.dispatch('updateCurrentFileShareTypes') + context.commit('CURRENT_FILE_OUTGOING_SHARES_LOADING', false) + }) + .catch((error) => { + context.commit('CURRENT_FILE_OUTGOING_SHARES_ERROR', error.message) + context.commit('CURRENT_FILE_OUTGOING_SHARES_LOADING', false) + }) + } + // see https://owncloud.dev/owncloud-sdk/Shares.html client.shares .getShares(path, { reshares: true }) @@ -211,6 +252,29 @@ export default { }) } + if (share.shareType === ShareTypes.space.value) { + return new Promise((resolve, reject) => { + client.shares + .shareSpaceWithUser('', share.collaborator.name, share.id, { + permissions + }) + .then(() => { + const role = SpacePeopleShareRoles.getByBitmask(permissions) + const shareObj = { + role: role.name, + onPremisesSamAccountName: share.collaborator.name, + displayName: share.collaborator.displayName + } + const updatedShare = buildSpaceShare(shareObj, share.id) + commit('CURRENT_FILE_OUTGOING_SHARES_UPDATE', updatedShare) + resolve(updatedShare) + }) + .catch((e) => { + reject(e) + }) + }) + } + return new Promise((resolve, reject) => { client.shares .updateShare(share.id, params) @@ -228,7 +292,10 @@ export default { }) }) }, - addShare(context, { client, path, shareWith, shareType, permissions, expirationDate }) { + addShare( + context, + { client, path, shareWith, shareType, permissions, expirationDate, spaceId, displayName } + ) { if (shareType === ShareTypes.group.value) { client.shares .shareFileWithGroup(path, shareWith, { @@ -261,6 +328,41 @@ export default { return } + if (shareType === ShareTypes.space.value) { + client.shares + .shareSpaceWithUser(path, shareWith, spaceId, { + permissions + }) + .then(() => { + const role = SpacePeopleShareRoles.getByBitmask(permissions) + const shareObj = { + role: role.name, + onPremisesSamAccountName: shareWith, + displayName + } + + context.commit('CURRENT_FILE_OUTGOING_SHARES_ADD', buildSpaceShare(shareObj, spaceId)) + context.commit('CURRENT_FILE_OUTGOING_SHARES_LOADING', true) + + // FIXME + return Promise.all([]).then(() => { + context.commit('CURRENT_FILE_OUTGOING_SHARES_LOADING', false) + }) + }) + .catch((e) => { + context.dispatch( + 'showMessage', + { + title: $gettext('Error while sharing.'), + desc: e, + status: 'danger' + }, + { root: true } + ) + }) + return + } + const remoteShare = shareType === ShareTypes.remote.value client.shares .shareFileWithUser(path, shareWith, { @@ -293,12 +395,27 @@ export default { }) }, deleteShare(context, { client, share, resource }) { + const additionalParams = {} + if (share.shareType === ShareTypes.space.value) { + additionalParams.shareWith = share.collaborator.name + } + client.shares - .deleteShare(share.id) + .deleteShare(share.id, additionalParams) .then(() => { context.commit('CURRENT_FILE_OUTGOING_SHARES_REMOVE', share) - context.dispatch('updateCurrentFileShareTypes') - context.dispatch('loadIndicators', { client, currentFolder: resource.path }) + + if (share.shareType !== ShareTypes.space.value) { + context.dispatch('updateCurrentFileShareTypes') + context.dispatch('loadIndicators', { client, currentFolder: resource.path }) + } else { + context.commit('CURRENT_FILE_OUTGOING_SHARES_LOADING', true) + + // FIXME + return Promise.all([]).then(() => { + context.commit('CURRENT_FILE_OUTGOING_SHARES_LOADING', false) + }) + } }) .catch((e) => { console.error(e) diff --git a/packages/web-app-files/src/store/mutations.js b/packages/web-app-files/src/store/mutations.js index 42929b18445..5c03d85323a 100644 --- a/packages/web-app-files/src/store/mutations.js +++ b/packages/web-app-files/src/store/mutations.js @@ -4,6 +4,7 @@ import { DateTime } from 'luxon' import { set, has } from 'lodash-es' import { getIndicators } from '../helpers/statusIndicators' import { renameResource } from '../helpers/resources' +import { ShareTypes } from '../helpers/share' export default { UPDATE_FILE_PROGRESS(state, file) { @@ -121,14 +122,28 @@ export default { state.currentFileOutgoingShares.push(share) }, CURRENT_FILE_OUTGOING_SHARES_REMOVE(state, share) { + if (share.shareType === ShareTypes.space.value) { + state.currentFileOutgoingShares = state.currentFileOutgoingShares.filter( + (s) => share.id === s.id && share.collaborator.name !== s.collaborator.name + ) + return + } state.currentFileOutgoingShares = state.currentFileOutgoingShares.filter( (s) => share.id !== s.id ) }, CURRENT_FILE_OUTGOING_SHARES_UPDATE(state, share) { - const fileIndex = state.currentFileOutgoingShares.findIndex((s) => { - return s.id === share.id - }) + let fileIndex + if (share.shareType === ShareTypes.space.value) { + fileIndex = state.currentFileOutgoingShares.findIndex((s) => { + return share.id === s.id && share.collaborator.name === s.collaborator.name + }) + } else { + fileIndex = state.currentFileOutgoingShares.findIndex((s) => { + return s.id === share.id + }) + } + if (fileIndex >= 0) { Vue.set(state.currentFileOutgoingShares, fileIndex, share) } else { diff --git a/packages/web-app-files/src/views/spaces/Project.vue b/packages/web-app-files/src/views/spaces/Project.vue index 5698327309b..28563b5a2e9 100644 --- a/packages/web-app-files/src/views/spaces/Project.vue +++ b/packages/web-app-files/src/views/spaces/Project.vue @@ -10,68 +10,78 @@ :space="space" > -
-
-
- -
+
+
+
-
-
-

{{ space.name }}

+
+
+
+

{{ space.name }}

+ + + + + +
    +
  • + + + {{ action.label() }} + +
  • +
+
+
- + + - - -
    -
  • - - - {{ action.label() }} - -
  • -
-

{{ space.description }}

@@ -261,9 +271,18 @@ export default { }) }) + const loadSharesTask = useTask(function* (signal, ref) { + yield ref.loadCurrentFileOutgoingShares({ + client: graphClient, + path: ref.space.id, + space: ref.space + }) + }) + return { space, loadResourcesTask, + loadSharesTask, resourceTargetLocation: createLocationSpaces('files-spaces-project'), paginatedResources, paginationPages, @@ -290,7 +309,8 @@ export default { 'selectedFiles', 'currentFolder', 'totalFilesCount', - 'totalFilesSize' + 'totalFilesSize', + 'currentFileOutgoingCollaborators' ]), selected: { @@ -319,6 +339,17 @@ export default { displayThumbnails() { return !this.configuration.options.disablePreviews }, + peopleCountString() { + const translated = this.$ngettext( + '%{count} member', + '%{count} members', + this.currentFileOutgoingCollaborators.length + ) + + return this.$gettextInterpolate(translated, { + count: this.currentFileOutgoingCollaborators.length + }) + }, quotaModalIsOpen() { return this.$data.$_editQuota_modalOpen }, @@ -388,6 +419,7 @@ export default { }, async mounted() { await this.loadResourcesTask.perform(this, false, this.$route.params.item || '') + this.loadSharesTask.perform(this) if (this.markdownResizeObserver) { this.markdownResizeObserver.unobserve(this.$refs.markdownContainer) @@ -413,7 +445,11 @@ export default { } }, methods: { - ...mapActions('Files', ['loadIndicators', 'loadPreview']), + ...mapActions('Files', ['loadIndicators', 'loadPreview', 'loadCurrentFileOutgoingShares']), + ...mapActions('Files/sidebar', { + openSidebarWithPanel: 'openWithPanel', + closeSidebar: 'close' + }), ...mapMutations('Files', [ 'SET_CURRENT_FOLDER', 'LOAD_FILES', @@ -485,6 +521,11 @@ export default { closeQuotaModal() { this.$_editQuota_closeModal() }, + async openSidebarSharePanel() { + await this.closeSidebar() + this.SET_FILE_SELECTION([this.space]) + this.openSidebarWithPanel('space-share-item') + }, closeReadmeContentModal() { this.$_editReadmeContent_closeModal() } @@ -496,9 +537,9 @@ export default { .space-overview { &-image { border-radius: 10px; - max-height: 250px; - object-fit: cover; width: 100%; + aspect-ratio: 16 / 9; + object-fit: cover; } &-image.expanded { @@ -510,6 +551,10 @@ export default { font-size: 1.5rem; } + &-people-count { + white-space: nowrap; + } + .markdown-container.collapsed { max-height: 150px; overflow: hidden; diff --git a/packages/web-app-files/src/views/spaces/Projects.vue b/packages/web-app-files/src/views/spaces/Projects.vue index f0114c0a4c3..2e2edc7ce28 100644 --- a/packages/web-app-files/src/views/spaces/Projects.vue +++ b/packages/web-app-files/src/views/spaces/Projects.vue @@ -52,7 +52,8 @@ oc-grid-row-large oc-text-center oc-child-width-1-3@m - oc-child-width-1-5@l + oc-child-width-1-4@l + oc-child-width-1-5@xl " >
  • @@ -97,40 +98,52 @@
  • - - - - -
      -
    • - + + + +
    +
    + + + + +
      +
    • - - {{ action.label() }} - -
    • -
    -
    + + + {{ action.label() }} + + + + +

    'ABSOLUTE_TIME') const formDateFromHTTP = jest.fn().mockImplementation(() => 'ABSOLUTE_TIME') const refreshShareDetailsTree = jest.fn() @@ -38,7 +51,7 @@ beforeEach(() => { }) describe('Details SideBar Panel', () => { - it('displayes the details side panel', () => { + it('displays the details side panel', () => { const wrapper = createWrapper(spaceMock) expect(wrapper).toMatchSnapshot() }) @@ -76,7 +89,9 @@ function createWrapper(spaceResource) { getters: { highlightedFile: function () { return spaceResource - } + }, + currentFileOutgoingCollaborators: () => [spaceShare], + currentFileOutgoingLinks: () => [] } } } diff --git a/packages/web-app-files/tests/unit/components/SideBar/Details/__snapshots__/SpaceDetails.spec.js.snap b/packages/web-app-files/tests/unit/components/SideBar/Details/__snapshots__/SpaceDetails.spec.js.snap index a63d31fd829..8324e5f0acd 100644 --- a/packages/web-app-files/tests/unit/components/SideBar/Details/__snapshots__/SpaceDetails.spec.js.snap +++ b/packages/web-app-files/tests/unit/components/SideBar/Details/__snapshots__/SpaceDetails.spec.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Details SideBar Panel displayes the details side panel 1`] = ` +exports[`Details SideBar Panel displays the details side panel 1`] = `

    @@ -15,7 +15,7 @@ exports[`Details SideBar Panel displayes the details side panel 1`] = `
    - + diff --git a/packages/web-app-files/tests/unit/components/SideBar/Shares/Collaborators/ListItem.spec.js b/packages/web-app-files/tests/unit/components/SideBar/Shares/Collaborators/ListItem.spec.js index de607e403cf..c57fc166737 100644 --- a/packages/web-app-files/tests/unit/components/SideBar/Shares/Collaborators/ListItem.spec.js +++ b/packages/web-app-files/tests/unit/components/SideBar/Shares/Collaborators/ListItem.spec.js @@ -4,6 +4,7 @@ import ListItem from '../../../../../../src/components/SideBar/Shares/Collaborat import stubs from '../../../../../../../../tests/unit/stubs' import GetTextPlugin from 'vue-gettext' import { peopleRoleViewerFolder, ShareTypes } from '../../../../../../src/helpers/share' +import Users from '@/__fixtures__/users' const localVue = createLocalVue() localVue.use(Vuex) @@ -39,15 +40,16 @@ describe('Collaborator ListItem component', () => { }) }) describe('non-user share types', () => { - it.each(ShareTypes.all.filter((shareType) => shareType !== ShareTypes.user))( - 'should display an oc-avatar-item for any non-user share types', - (shareType) => { - const wrapper = createWrapper({ shareType: shareType.value }) - expect(wrapper.find(selectors.userAvatarImage).exists()).toBeFalsy() - expect(wrapper.find(selectors.notUserAvatar).exists()).toBeTruthy() - expect(wrapper.find(selectors.notUserAvatar).attributes().name).toEqual(shareType.key) - } - ) + it.each( + ShareTypes.all.filter( + (shareType) => ![ShareTypes.user, ShareTypes.space].includes(shareType) + ) + )('should display an oc-avatar-item for any non-user share types', (shareType) => { + const wrapper = createWrapper({ shareType: shareType.value }) + expect(wrapper.find(selectors.userAvatarImage).exists()).toBeFalsy() + expect(wrapper.find(selectors.notUserAvatar).exists()).toBeTruthy() + expect(wrapper.find(selectors.notUserAvatar).attributes().name).toEqual(shareType.key) + }) }) }) describe('share info', () => { @@ -102,6 +104,9 @@ function createWrapper({ } = {}) { return mount(ListItem, { store: new Vuex.Store({ + state: { + user: Users.alice + }, getters: { isOcis: () => false }, diff --git a/packages/web-app-files/tests/unit/components/SideBar/Shares/FileShares.spec.js b/packages/web-app-files/tests/unit/components/SideBar/Shares/FileShares.spec.js index 75e27c1f3b5..4bd7b562703 100644 --- a/packages/web-app-files/tests/unit/components/SideBar/Shares/FileShares.spec.js +++ b/packages/web-app-files/tests/unit/components/SideBar/Shares/FileShares.spec.js @@ -14,7 +14,7 @@ localVue.use(GetTextPlugin, { silent: true }) -const user = Users.admin +const user = Users.alice const collaborators = [Collaborators[0], Collaborators[1]] const selectors = { diff --git a/packages/web-app-files/tests/unit/components/SideBar/Shares/InviteCollaborator/__snapshots__/RecipientContainer.spec.js.snap b/packages/web-app-files/tests/unit/components/SideBar/Shares/InviteCollaborator/__snapshots__/RecipientContainer.spec.js.snap index 3f1c1c3aa8d..d84bb63e827 100644 --- a/packages/web-app-files/tests/unit/components/SideBar/Shares/InviteCollaborator/__snapshots__/RecipientContainer.spec.js.snap +++ b/packages/web-app-files/tests/unit/components/SideBar/Shares/InviteCollaborator/__snapshots__/RecipientContainer.spec.js.snap @@ -1,11 +1,10 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`InviteCollaborator RecipientContainer displays an avatar image if capability is present 1`] = ` - +

    Albert Einstein

    -
    +
    `; exports[`InviteCollaborator RecipientContainer renders a recipient with a deselect button different recipients for different shareTypes 1`] = `

    Albert Einstein

    `; @@ -15,3 +14,5 @@ exports[`InviteCollaborator RecipientContainer renders a recipient with a desele exports[`InviteCollaborator RecipientContainer renders a recipient with a deselect button different recipients for different shareTypes 3`] = `

    guest-user

    `; exports[`InviteCollaborator RecipientContainer renders a recipient with a deselect button different recipients for different shareTypes 4`] = `

    remote-user

    `; + +exports[`InviteCollaborator RecipientContainer renders a recipient with a deselect button different recipients for different shareTypes 5`] = `

    Albert Einstein

    `; diff --git a/packages/web-app-files/tests/unit/components/SideBar/Shares/SpaceShares.spec.js b/packages/web-app-files/tests/unit/components/SideBar/Shares/SpaceShares.spec.js new file mode 100644 index 00000000000..2578bceb8f0 --- /dev/null +++ b/packages/web-app-files/tests/unit/components/SideBar/Shares/SpaceShares.spec.js @@ -0,0 +1,246 @@ +import SpaceShares from '@files/src/components/SideBar/Shares/SpaceShares.vue' +import { createLocalVue, mount, shallowMount } from '@vue/test-utils' +import GetTextPlugin from 'vue-gettext' +import Vuex from 'vuex' +import DesignSystem from 'owncloud-design-system' +import Users from '@/__fixtures__/users' +import { ShareTypes, spaceRoleManager, spaceRoleViewer } from '../../../../../src/helpers/share' +import VueCompositionAPI from '@vue/composition-api/dist/vue-composition-api' + +const localVue = createLocalVue() +localVue.use(DesignSystem) +localVue.use(Vuex) +localVue.use(VueCompositionAPI) +localVue.use(GetTextPlugin, { + translations: 'does-not-matter.json', + silent: true +}) + +const user = Users.alice +const spaceMock = { + type: 'space', + name: ' space', + id: '1', + mdate: 'Wed, 21 Oct 2015 07:28:00 GMT', + spaceQuota: { + used: 100, + total: 1000 + } +} + +const outgoingShares = [ + { + id: '1', + shareType: ShareTypes.space.value, + collaborator: { + name: user.id, + displayName: user.displayname + }, + role: { + name: spaceRoleManager.name + } + }, + { + id: '2', + shareType: ShareTypes.space.value, + collaborator: { + onPremisesSamAccountName: 'Einstein', + displayName: 'einstein' + }, + role: { + name: spaceRoleViewer.name + } + }, + { + id: '3', + shareType: ShareTypes.space.value, + collaborator: { + onPremisesSamAccountName: 'Marie', + displayName: 'marie' + }, + role: { + name: spaceRoleManager.name + } + } +] + +describe('SpaceShares', () => { + it('renders loading spinner while loading', () => { + const wrapper = getShallowMountedWrapper( + { + user + }, + true + ) + expect(wrapper).toMatchSnapshot() + }) + describe('if currentUser can share', () => { + it('initially renders add people dialog', () => { + const wrapper = getShallowMountedWrapper({ + user, + outgoingCollaborators: [outgoingShares[0]] + }) + expect(wrapper).toMatchSnapshot() + }) + }) + + describe('if currentUser can not share', () => { + it('other shares are listed, but creating/editing shares is not possible', () => { + const wrapper = getShallowMountedWrapper({ + user, + outgoingCollaborators: [outgoingShares[1]] + }) + expect(wrapper).toMatchSnapshot() + }) + }) + + describe('if currentUser is manager', () => { + it('allows role edit of the current user if another user is manager', () => { + const wrapper = getShallowMountedWrapper({ + user, + outgoingCollaborators: outgoingShares + }) + expect(wrapper).toMatchSnapshot() + }) + it('does not allow role edit of the current user if they are the only manager', () => { + const wrapper = getShallowMountedWrapper({ + user, + outgoingCollaborators: [outgoingShares[0], outgoingShares[1]] + }) + expect(wrapper).toMatchSnapshot() + }) + }) + + describe('if currentUser can delete', () => { + it('reacts on delete events by collaborator list items', async () => { + const wrapper = getMountedWrapper({ + user, + outgoingCollaborators: outgoingShares + }) + + const spyOnCollaboratorDelete = jest.spyOn(wrapper.vm, 'deleteShare') + wrapper + .find(`div[data-testid="collaborator-user-item-${outgoingShares[0].collaborator.name}"]`) + .vm.$emit('onDelete') + await wrapper.vm.$nextTick() + expect(spyOnCollaboratorDelete).toHaveBeenCalledTimes(1) + }) + }) +}) + +const storeOptions = (data, isInLoadingState) => { + const { user, outgoingCollaborators = [] } = data + + return { + state: { + user + }, + modules: { + Files: { + namespaced: true, + getters: { + highlightedFile: () => spaceMock, + currentFileOutgoingCollaborators: () => outgoingCollaborators, + currentFileOutgoingSharesLoading: () => isInLoadingState, + sharesTreeLoading: () => false + }, + actions: { + loadCurrentFileOutgoingShares: jest.fn(), + deleteShare: jest.fn() + }, + mutations: { + SET_HIGHLIGHTED_FILE(state, file) { + state.highlightedFile = spaceMock + } + } + } + }, + getters: { + isOcis: () => true, + user: () => user, + capabilities: () => { + return { + files_sharing: { + user: { + expire_date: { + enabled: true, + days: 10 + } + }, + group: { + expire_date: { + enabled: true, + days: 10 + } + } + } + } + } + } + } +} + +function getShallowMountedWrapper(data, loading = false) { + return shallowMount(getComponent(loading), { + localVue, + store: createStore(data, loading), + stubs: { + 'oc-button': true, + 'oc-icon': true, + 'oc-spinner': true + }, + provide: { + displayedItem: { + value: spaceMock + } + } + }) +} + +function getMountedWrapper(data, loading = false) { + return mount(getComponent(loading), { + localVue, + store: createStore(data, loading), + stubs: { + 'oc-button': true, + 'oc-icon': true, + 'oc-spinner': true, + 'avatar-image': true, + 'role-dropdown': true, + 'edit-dropdown': true + }, + mocks: { + $router: { + go: jest.fn(), + push: jest.fn(), + currentRoute: { + name: 'some-route', + query: { page: 1 } + }, + resolve: (r) => ({ href: r.name }) + } + }, + provide: { + displayedItem: { + value: spaceMock + } + } + }) +} + +function createStore(data, loading) { + return new Vuex.Store(storeOptions(data, loading)) +} + +function getComponent(loading) { + return { + ...SpaceShares, + setup: () => ({ + graphClient: {}, + loadSharesTask: { + isRunning: loading, + perform: jest.fn() + } + }) + } +} diff --git a/packages/web-app-files/tests/unit/components/SideBar/Shares/__snapshots__/SpaceShares.spec.js.snap b/packages/web-app-files/tests/unit/components/SideBar/Shares/__snapshots__/SpaceShares.spec.js.snap new file mode 100644 index 00000000000..b4ee8d187b7 --- /dev/null +++ b/packages/web-app-files/tests/unit/components/SideBar/Shares/__snapshots__/SpaceShares.spec.js.snap @@ -0,0 +1,60 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SpaceShares if currentUser can not share other shares are listed, but creating/editing shares is not possible 1`] = ` +
    + +
      +
    • + +
    • +
    +
    +`; + +exports[`SpaceShares if currentUser can share initially renders add people dialog 1`] = ` +
    + +
      +
    • + +
    • +
    +
    +`; + +exports[`SpaceShares if currentUser is manager allows role edit of the current user if another user is manager 1`] = ` +
    + +
      +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    +
    +`; + +exports[`SpaceShares if currentUser is manager does not allow role edit of the current user if they are the only manager 1`] = ` +
    + +
      +
    • + +
    • +
    • + +
    • +
    +
    +`; + +exports[`SpaceShares renders loading spinner while loading 1`] = ` +
    + +
    +`; diff --git a/packages/web-app-files/tests/unit/store/actions.spec.js b/packages/web-app-files/tests/unit/store/actions.spec.js new file mode 100644 index 00000000000..355a9efbeed --- /dev/null +++ b/packages/web-app-files/tests/unit/store/actions.spec.js @@ -0,0 +1,146 @@ +import actions from '../../../src/store/actions' +import { ShareTypes, spaceRoleManager } from '../../../src/helpers/share' + +const stateMock = { + commit: jest.fn(), + dispatch: jest.fn(), + getters: { + highlightedFile: { isFolder: false } + }, + rootGetters: { + isOcis: true + } +} + +const clientMock = { + users: { + getUser: () => { + return Promise.resolve({}) + } + }, + shares: { + getShares: () => { + return Promise.resolve([{ shareInfo: { share_type: ShareTypes.user.value, permissions: 1 } }]) + }, + updateShare: () => { + return Promise.resolve({ shareInfo: { share_type: ShareTypes.user.value, permissions: 1 } }) + }, + deleteShare: () => { + return Promise.resolve({}) + }, + shareSpaceWithUser: () => { + return Promise.resolve({}) + }, + shareFileWithUser: () => { + return Promise.resolve({ shareInfo: { share_type: ShareTypes.user.value, permissions: 1 } }) + } + } +} + +const shareMock = { + id: '1', + shareType: ShareTypes.user.value +} + +const spaceMock = { + type: 'space', + name: ' space', + id: '1', + mdate: 'Wed, 21 Oct 2015 07:28:00 GMT', + spacePermissions: [{ grantedTo: [{ user: { id: 1 } }], roles: ['manager'] }], + spaceQuota: { + used: 100, + total: 1000 + } +} + +const spaceShareMock = { + id: '1', + shareType: ShareTypes.space.value, + collaborator: { + onPremisesSamAccountName: 'Alice', + displayName: 'alice' + }, + role: { + name: spaceRoleManager.name + } +} + +describe('vuex store actions', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + describe('loadCurrentFileOutgoingShares', () => { + it.each([ + { space: spaceMock, expectedCommitCalls: 5, expectedDispatchCalls: 1 }, + { space: null, expectedCommitCalls: 5, expectedDispatchCalls: 1 } + ])('succeeds using action %s', async (dataSet) => { + const commitSpy = jest.spyOn(stateMock, 'commit') + const dispatchSpy = jest.spyOn(stateMock, 'dispatch') + + await actions.loadCurrentFileOutgoingShares(stateMock, { + client: clientMock, + path: 'path', + space: dataSet.space + }) + + expect(commitSpy).toBeCalledTimes(dataSet.expectedCommitCalls) + expect(dispatchSpy).toBeCalledTimes(dataSet.expectedDispatchCalls) + }) + }) + + describe('changeShare', () => { + it.each([{ share: spaceShareMock }, { share: shareMock }])( + 'succeeds using action %s', + async (dataSet) => { + const commitSpy = jest.spyOn(stateMock, 'commit') + + await actions.changeShare(stateMock, { + client: clientMock, + share: dataSet.share, + permissions: 1, + expirationDate: null + }) + + expect(commitSpy).toBeCalledTimes(1) + } + ) + }) + + describe('addShare', () => { + it.each([ + { shareType: spaceShareMock.shareType, spaceId: spaceMock.id, expectedCommitCalls: 2 }, + { shareType: shareMock.shareType, spaceId: null, expectedCommitCalls: 1 } + ])('succeeds using action %s', async (dataSet) => { + const commitSpy = jest.spyOn(stateMock, 'commit') + + await actions.addShare(stateMock, { + client: clientMock, + shareType: dataSet.shareType, + spaceId: dataSet.spaceId, + permissions: 1, + expirationDate: null + }) + + expect(commitSpy).toBeCalledTimes(dataSet.expectedCommitCalls) + }) + }) + + describe('deleteShare', () => { + it.each([ + { share: spaceShareMock, spaceId: spaceMock.id, expectedCommitCalls: 2 }, + { share: shareMock, spaceId: null, expectedCommitCalls: 1 } + ])('succeeds using action %s', async (dataSet) => { + const commitSpy = jest.spyOn(stateMock, 'commit') + + await actions.deleteShare(stateMock, { + client: clientMock, + share: dataSet.share, + resource: {} + }) + + expect(commitSpy).toBeCalledTimes(dataSet.expectedCommitCalls) + }) + }) +}) diff --git a/packages/web-app-files/tests/unit/store/mutations.spec.js b/packages/web-app-files/tests/unit/store/mutations.spec.js index beb13c7ab31..d0d7c92bcdb 100644 --- a/packages/web-app-files/tests/unit/store/mutations.spec.js +++ b/packages/web-app-files/tests/unit/store/mutations.spec.js @@ -1,5 +1,6 @@ import mutations from '../../../src/store/mutations' import { cloneDeep } from 'lodash-es' +import { ShareTypes } from '../../../src/helpers/share' const stateFixture = { files: [ @@ -83,4 +84,49 @@ describe('vuex store mutations', () => { expect(state.areHiddenFilesShown).toEqual(false) }) + + describe('CURRENT_FILE_OUTGOING_SHARES_REMOVE', () => { + it('removes an outgoing user share', () => { + const shareToRemove = { id: 1, shareType: ShareTypes.user.value } + const state = { currentFileOutgoingShares: [shareToRemove] } + mutations.CURRENT_FILE_OUTGOING_SHARES_REMOVE(state, shareToRemove) + + expect(state.currentFileOutgoingShares.length).toEqual(0) + }) + it('removes an outgoing space share', () => { + const shareToRemove = { + id: 1, + shareType: ShareTypes.space.value, + collaborator: { name: 'admin' } + } + const state = { currentFileOutgoingShares: [shareToRemove] } + mutations.CURRENT_FILE_OUTGOING_SHARES_REMOVE(state, shareToRemove) + + expect(state.currentFileOutgoingShares.length).toEqual(0) + }) + }) + + describe('CURRENT_FILE_OUTGOING_SHARES_UPDATE', () => { + it('updates an outgoing user share', () => { + const share = { id: 1, shareType: ShareTypes.user.value, permissions: 1 } + const state = { currentFileOutgoingShares: [share] } + const updatedShare = { ...share, permissions: 31 } + mutations.CURRENT_FILE_OUTGOING_SHARES_UPDATE(state, updatedShare) + + expect(state.currentFileOutgoingShares[0]).toEqual(updatedShare) + }) + it('updates an outgoing space share', () => { + const share = { + id: 1, + shareType: ShareTypes.space.value, + permissions: 1, + collaborator: { name: 'admin' } + } + const state = { currentFileOutgoingShares: [share] } + const updatedShare = { ...share, permissions: 31 } + mutations.CURRENT_FILE_OUTGOING_SHARES_UPDATE(state, updatedShare) + + expect(state.currentFileOutgoingShares[0]).toEqual(updatedShare) + }) + }) }) diff --git a/packages/web-app-files/tests/unit/views/spaces/Project.spec.js b/packages/web-app-files/tests/unit/views/spaces/Project.spec.js index 585964f6583..c8bfa13aa4b 100644 --- a/packages/web-app-files/tests/unit/views/spaces/Project.spec.js +++ b/packages/web-app-files/tests/unit/views/spaces/Project.spec.js @@ -6,6 +6,7 @@ import Files from '@/__fixtures__/files' import mockAxios from 'jest-mock-axios' import SpaceProject from '../../../../src/views/spaces/Project.vue' import Vuex from 'vuex' +import { ShareTypes, spaceRoleManager } from '../../../../src/helpers/share' localVue.use(GetTextPlugin, { translations: 'does-not-matter.json', @@ -58,6 +59,18 @@ const spaceMocks = { } } +const spaceShare = { + id: '1', + shareType: ShareTypes.space.value, + collaborator: { + onPremisesSamAccountName: 'Alice', + displayName: 'alice' + }, + role: { + name: spaceRoleManager.name + } +} + describe('Spaces project view', () => { it('should not show anything if space can not be found', async () => { mockAxios.request.mockImplementationOnce(() => { @@ -230,14 +243,16 @@ function getMountedWrapper(spaceResources = [], spaceItem = null, imageContent = LOAD_FILES: jest.fn() }, actions: { - loadIndicators: jest.fn() + loadIndicators: jest.fn(), + loadCurrentFileOutgoingShares: jest.fn() }, getters: { activeFiles: () => spaceResources, totalFilesCount: () => ({ files: spaceResources.length, folders: 0 }), selectedFiles: () => [], totalFilesSize: () => 10, - pages: () => 1 + pages: () => 1, + currentFileOutgoingCollaborators: () => [spaceShare] } } } diff --git a/packages/web-app-files/tests/unit/views/spaces/__snapshots__/Project.spec.js.snap b/packages/web-app-files/tests/unit/views/spaces/__snapshots__/Project.spec.js.snap index d2ec3a7e51a..48b24954bea 100644 --- a/packages/web-app-files/tests/unit/views/spaces/__snapshots__/Project.spec.js.snap +++ b/packages/web-app-files/tests/unit/views/spaces/__snapshots__/Project.spec.js.snap @@ -5,56 +5,59 @@ exports[`Spaces project view space image should show if given 1`] = `
    -
    -
    -
    -
    -
    -
    -

    space

    - - +
    +
    +
    +
    +
    +

    space

    + + + + +
      +
    • + + + Rename + +
    • +
    • + + + Change subtitle + +
    • +
    • + + + Edit description + +
    • +
    • + + + Upload new space image + +
    • +
    • + + + Disable + +
    • +
    • + + + Details + +
    • +
    +
    +
    + + 1 member - -
      -
    • - - - Rename - -
    • -
    • - - - Change subtitle - -
    • -
    • - - - Edit description - -
    • -
    • - - - Upload new space image - -
    • -
    • - - - Disable - -
    • -
    • - - - Details - -
    • -
    -
    @@ -75,56 +78,59 @@ exports[`Spaces project view space readme should show if given 1`] = `
    -
    -
    -
    -
    -
    -
    -

    space

    - - +
    +
    +
    +
    +
    +

    space

    + + + + +
      +
    • + + + Rename + +
    • +
    • + + + Change subtitle + +
    • +
    • + + + Edit description + +
    • +
    • + + + Upload new space image + +
    • +
    • + + + Disable + +
    • +
    • + + + Details + +
    • +
    +
    +
    + + 1 member - -
      -
    • - - - Rename - -
    • -
    • - - - Change subtitle - -
    • -
    • - - - Edit description - -
    • -
    • - - - Upload new space image - -
    • -
    • - - - Disable - -
    • -
    • - - - Details - -
    • -
    -
    diff --git a/packages/web-app-files/tests/unit/views/spaces/__snapshots__/Projects.spec.js.snap b/packages/web-app-files/tests/unit/views/spaces/__snapshots__/Projects.spec.js.snap index 1838eab175c..e21f892a1b6 100644 --- a/packages/web-app-files/tests/unit/views/spaces/__snapshots__/Projects.spec.js.snap +++ b/packages/web-app-files/tests/unit/views/spaces/__snapshots__/Projects.spec.js.snap @@ -13,7 +13,8 @@ exports[`Spaces component should list spaces 1`] = ` oc-grid-row-large oc-text-center oc-child-width-1-3@m - oc-child-width-1-5@l + oc-child-width-1-4@l + oc-child-width-1-5@xl ">
  • @@ -23,26 +24,29 @@ exports[`Spaces component should list spaces 1`] = `
    -
    -
    -
    -
      -
    • -
    • -
    • -
    • -
    • -
    +
    +
    +
    +
    +
    +
      +
    • +
    • +
    • +
    • +
    • +
    +
  • - +
    Manageralice
    Quota