diff options
Diffstat (limited to 'apps/files_sharing/src/views/SharingTab.vue')
-rw-r--r-- | apps/files_sharing/src/views/SharingTab.vue | 627 |
1 files changed, 627 insertions, 0 deletions
diff --git a/apps/files_sharing/src/views/SharingTab.vue b/apps/files_sharing/src/views/SharingTab.vue new file mode 100644 index 00000000000..2ed44a4b5ad --- /dev/null +++ b/apps/files_sharing/src/views/SharingTab.vue @@ -0,0 +1,627 @@ +<!-- + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <div class="sharingTab" :class="{ 'icon-loading': loading }"> + <!-- error message --> + <div v-if="error" class="emptycontent" :class="{ emptyContentWithSections: sections.length > 0 }"> + <div class="icon icon-error" /> + <h2>{{ error }}</h2> + </div> + + <!-- shares content --> + <div v-show="!showSharingDetailsView" + class="sharingTab__content"> + <!-- shared with me information --> + <ul v-if="isSharedWithMe"> + <SharingEntrySimple v-bind="sharedWithMe" class="sharing-entry__reshare"> + <template #avatar> + <NcAvatar :user="sharedWithMe.user" + :display-name="sharedWithMe.displayName" + class="sharing-entry__avatar" /> + </template> + </SharingEntrySimple> + </ul> + + <section> + <div class="section-header"> + <h4>{{ t('files_sharing', 'Internal shares') }}</h4> + <NcPopover popup-role="dialog"> + <template #trigger> + <NcButton class="hint-icon" + type="tertiary-no-background" + :aria-label="t('files_sharing', 'Internal shares explanation')"> + <template #icon> + <InfoIcon :size="20" /> + </template> + </NcButton> + </template> + <p class="hint-body"> + {{ internalSharesHelpText }} + </p> + </NcPopover> + </div> + <!-- add new share input --> + <SharingInput v-if="!loading" + :can-reshare="canReshare" + :file-info="fileInfo" + :link-shares="linkShares" + :reshare="reshare" + :shares="shares" + :placeholder="internalShareInputPlaceholder" + @open-sharing-details="toggleShareDetailsView" /> + + <!-- other shares list --> + <SharingList v-if="!loading" + ref="shareList" + :shares="shares" + :file-info="fileInfo" + @open-sharing-details="toggleShareDetailsView" /> + + <!-- inherited shares --> + <SharingInherited v-if="canReshare && !loading" :file-info="fileInfo" /> + + <!-- internal link copy --> + <SharingEntryInternal :file-info="fileInfo" /> + </section> + + <section> + <div class="section-header"> + <h4>{{ t('files_sharing', 'External shares') }}</h4> + <NcPopover popup-role="dialog"> + <template #trigger> + <NcButton class="hint-icon" + type="tertiary-no-background" + :aria-label="t('files_sharing', 'External shares explanation')"> + <template #icon> + <InfoIcon :size="20" /> + </template> + </NcButton> + </template> + <p class="hint-body"> + {{ externalSharesHelpText }} + </p> + </NcPopover> + </div> + <SharingInput v-if="!loading" + :can-reshare="canReshare" + :file-info="fileInfo" + :link-shares="linkShares" + :is-external="true" + :placeholder="externalShareInputPlaceholder" + :reshare="reshare" + :shares="shares" + @open-sharing-details="toggleShareDetailsView" /> + <!-- Non link external shares list --> + <SharingList v-if="!loading" + :shares="externalShares" + :file-info="fileInfo" + @open-sharing-details="toggleShareDetailsView" /> + <!-- link shares list --> + <SharingLinkList v-if="!loading && isLinkSharingAllowed" + ref="linkShareList" + :can-reshare="canReshare" + :file-info="fileInfo" + :shares="linkShares" + @open-sharing-details="toggleShareDetailsView" /> + </section> + + <section v-if="sections.length > 0 && !showSharingDetailsView"> + <div class="section-header"> + <h4>{{ t('files_sharing', 'Additional shares') }}</h4> + <NcPopover popup-role="dialog"> + <template #trigger> + <NcButton class="hint-icon" + type="tertiary-no-background" + :aria-label="t('files_sharing', 'Additional shares explanation')"> + <template #icon> + <InfoIcon :size="20" /> + </template> + </NcButton> + </template> + <p class="hint-body"> + {{ additionalSharesHelpText }} + </p> + </NcPopover> + </div> + <!-- additional entries, use it with cautious --> + <div v-for="(component, index) in sectionComponents" + :key="index" + class="sharingTab__additionalContent"> + <component :is="component" :file-info="fileInfo" /> + </div> + + <!-- projects (deprecated as of NC25 (replaced by related_resources) - see instance config "projects.enabled" ; ignore this / remove it / move into own section) --> + <div v-if="projectsEnabled" + v-show="!showSharingDetailsView && fileInfo" + class="sharingTab__additionalContent"> + <NcCollectionList :id="`${fileInfo.id}`" + type="file" + :name="fileInfo.name" /> + </div> + </section> + </div> + + <!-- share details --> + <SharingDetailsTab v-if="showSharingDetailsView" + :file-info="shareDetailsData.fileInfo" + :share="shareDetailsData.share" + @close-sharing-details="toggleShareDetailsView" + @add:share="addShare" + @remove:share="removeShare" /> + </div> +</template> + +<script> +import { getCurrentUser } from '@nextcloud/auth' +import { getCapabilities } from '@nextcloud/capabilities' +import { orderBy } from '@nextcloud/files' +import { loadState } from '@nextcloud/initial-state' +import { generateOcsUrl } from '@nextcloud/router' +import { ShareType } from '@nextcloud/sharing' + +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcCollectionList from '@nextcloud/vue/components/NcCollectionList' +import NcPopover from '@nextcloud/vue/components/NcPopover' +import InfoIcon from 'vue-material-design-icons/InformationOutline.vue' + +import axios from '@nextcloud/axios' +import moment from '@nextcloud/moment' + +import { shareWithTitle } from '../utils/SharedWithMe.js' + +import Config from '../services/ConfigService.ts' +import Share from '../models/Share.ts' +import SharingEntryInternal from '../components/SharingEntryInternal.vue' +import SharingEntrySimple from '../components/SharingEntrySimple.vue' +import SharingInput from '../components/SharingInput.vue' + +import SharingInherited from './SharingInherited.vue' +import SharingLinkList from './SharingLinkList.vue' +import SharingList from './SharingList.vue' +import SharingDetailsTab from './SharingDetailsTab.vue' + +import ShareDetails from '../mixins/ShareDetails.js' +import logger from '../services/logger.ts' + +export default { + name: 'SharingTab', + + components: { + InfoIcon, + NcAvatar, + NcButton, + NcCollectionList, + NcPopover, + SharingEntryInternal, + SharingEntrySimple, + SharingInherited, + SharingInput, + SharingLinkList, + SharingList, + SharingDetailsTab, + }, + mixins: [ShareDetails], + + data() { + return { + config: new Config(), + deleteEvent: null, + error: '', + expirationInterval: null, + loading: true, + + fileInfo: null, + + // reshare Share object + reshare: null, + sharedWithMe: {}, + shares: [], + linkShares: [], + externalShares: [], + + sections: OCA.Sharing.ShareTabSections.getSections(), + projectsEnabled: loadState('core', 'projects_enabled', false), + showSharingDetailsView: false, + shareDetailsData: {}, + returnFocusElement: null, + + internalSharesHelpText: t('files_sharing', 'Share files within your organization. Recipients who can already view the file can also use this link for easy access.'), + externalSharesHelpText: t('files_sharing', 'Share files with others outside your organization via public links and email addresses. You can also share to Nextcloud accounts on other instances using their federated cloud ID.'), + additionalSharesHelpText: t('files_sharing', 'Shares from apps or other sources which are not included in internal or external shares.'), + } + }, + + computed: { + /** + * Is this share shared with me? + * + * @return {boolean} + */ + isSharedWithMe() { + return !!this.sharedWithMe?.user + }, + + /** + * Is link sharing allowed for the current user? + * + * @return {boolean} + */ + isLinkSharingAllowed() { + const currentUser = getCurrentUser() + if (!currentUser) { + return false + } + + const capabilities = getCapabilities() + const publicSharing = capabilities.files_sharing?.public || {} + return publicSharing.enabled === true + }, + + canReshare() { + return !!(this.fileInfo.permissions & OC.PERMISSION_SHARE) + || !!(this.reshare && this.reshare.hasSharePermission && this.config.isResharingAllowed) + }, + + internalShareInputPlaceholder() { + return this.config.showFederatedSharesAsInternal && this.config.isFederationEnabled + // TRANSLATORS: Type as in with a keyboard + ? t('files_sharing', 'Type names, teams, federated cloud IDs') + // TRANSLATORS: Type as in with a keyboard + : t('files_sharing', 'Type names or teams') + }, + + externalShareInputPlaceholder() { + if (!this.isLinkSharingAllowed) { + // TRANSLATORS: Type as in with a keyboard + return this.config.isFederationEnabled ? t('files_sharing', 'Type a federated cloud ID') : '' + } + return !this.config.showFederatedSharesAsInternal && !this.config.isFederationEnabled + // TRANSLATORS: Type as in with a keyboard + ? t('files_sharing', 'Type an email') + // TRANSLATORS: Type as in with a keyboard + : t('files_sharing', 'Type an email or federated cloud ID') + }, + + sectionComponents() { + return this.sections.map((section) => section(undefined, this.fileInfo)) + }, + }, + methods: { + /** + * Update current fileInfo and fetch new data + * + * @param {object} fileInfo the current file FileInfo + */ + async update(fileInfo) { + this.fileInfo = fileInfo + this.resetState() + this.getShares() + }, + /** + * Get the existing shares infos + */ + async getShares() { + try { + this.loading = true + + // init params + const shareUrl = generateOcsUrl('apps/files_sharing/api/v1/shares') + const format = 'json' + // TODO: replace with proper getFUllpath implementation of our own FileInfo model + const path = (this.fileInfo.path + '/' + this.fileInfo.name).replace('//', '/') + + // fetch shares + const fetchShares = axios.get(shareUrl, { + params: { + format, + path, + reshares: true, + }, + }) + const fetchSharedWithMe = axios.get(shareUrl, { + params: { + format, + path, + shared_with_me: true, + }, + }) + + // wait for data + const [shares, sharedWithMe] = await Promise.all([fetchShares, fetchSharedWithMe]) + this.loading = false + + // process results + this.processSharedWithMe(sharedWithMe) + this.processShares(shares) + } catch (error) { + if (error?.response?.data?.ocs?.meta?.message) { + this.error = error.response.data.ocs.meta.message + } else { + this.error = t('files_sharing', 'Unable to load the shares list') + } + this.loading = false + console.error('Error loading the shares list', error) + } + }, + + /** + * Reset the current view to its default state + */ + resetState() { + clearInterval(this.expirationInterval) + this.loading = true + this.error = '' + this.sharedWithMe = {} + this.shares = [] + this.linkShares = [] + this.showSharingDetailsView = false + this.shareDetailsData = {} + }, + + /** + * Update sharedWithMe.subtitle with the appropriate + * expiration time left + * + * @param {Share} share the sharedWith Share object + */ + updateExpirationSubtitle(share) { + const expiration = moment(share.expireDate).unix() + this.$set(this.sharedWithMe, 'subtitle', t('files_sharing', 'Expires {relativetime}', { + relativetime: moment(expiration * 1000).fromNow(), + })) + + // share have expired + if (moment().unix() > expiration) { + clearInterval(this.expirationInterval) + // TODO: clear ui if share is expired + this.$set(this.sharedWithMe, 'subtitle', t('files_sharing', 'this share just expired.')) + } + }, + + /** + * Process the current shares data + * and init shares[] + * + * @param {object} share the share ocs api request data + * @param {object} share.data the request data + */ + processShares({ data }) { + if (data.ocs && data.ocs.data && data.ocs.data.length > 0) { + const shares = orderBy( + data.ocs.data.map(share => new Share(share)), + [ + // First order by the "share with" label + (share) => share.shareWithDisplayName, + // Then by the label + (share) => share.label, + // And last resort order by createdTime + (share) => share.createdTime, + ], + ) + + for (const share of shares) { + if ([ShareType.Link, ShareType.Email].includes(share.type)) { + this.linkShares.push(share) + } else if ([ShareType.Remote, ShareType.RemoteGroup].includes(share.type)) { + if (this.config.showFederatedSharesToTrustedServersAsInternal) { + if (share.isTrustedServer) { + this.shares.push(share) + } else { + this.externalShares.push(share) + } + } else if (this.config.showFederatedSharesAsInternal) { + this.shares.push(share) + } else { + this.externalShares.push(share) + } + } else { + this.shares.push(share) + } + } + + logger.debug(`Processed ${this.linkShares.length} link share(s)`) + logger.debug(`Processed ${this.shares.length} share(s)`) + logger.debug(`Processed ${this.externalShares.length} external share(s)`) + } + }, + + /** + * Process the sharedWithMe share data + * and init sharedWithMe + * + * @param {object} share the share ocs api request data + * @param {object} share.data the request data + */ + processSharedWithMe({ data }) { + if (data.ocs && data.ocs.data && data.ocs.data[0]) { + const share = new Share(data) + const title = shareWithTitle(share) + const displayName = share.ownerDisplayName + const user = share.owner + + this.sharedWithMe = { + displayName, + title, + user, + } + this.reshare = share + + // If we have an expiration date, use it as subtitle + // Refresh the status every 10s and clear if expired + if (share.expireDate && moment(share.expireDate).unix() > moment().unix()) { + // first update + this.updateExpirationSubtitle(share) + // interval update + this.expirationInterval = setInterval(this.updateExpirationSubtitle, 10000, share) + } + } else if (this.fileInfo && this.fileInfo.shareOwnerId !== undefined ? this.fileInfo.shareOwnerId !== getCurrentUser().uid : false) { + // Fallback to compare owner and current user. + this.sharedWithMe = { + displayName: this.fileInfo.shareOwner, + title: t( + 'files_sharing', + 'Shared with you by {owner}', + { owner: this.fileInfo.shareOwner }, + undefined, + { escape: false }, + ), + user: this.fileInfo.shareOwnerId, + } + } + }, + + /** + * Add a new share into the shares list + * and return the newly created share component + * + * @param {Share} share the share to add to the array + * @param {Function} [resolve] a function to run after the share is added and its component initialized + */ + addShare(share, resolve = () => { }) { + // only catching share type MAIL as link shares are added differently + // meaning: not from the ShareInput + if (share.type === ShareType.Email) { + this.linkShares.unshift(share) + } else if ([ShareType.Remote, ShareType.RemoteGroup].includes(share.type)) { + if (this.config.showFederatedSharesAsInternal) { + this.shares.unshift(share) + } if (this.config.showFederatedSharesToTrustedServersAsInternal) { + if (share.isTrustedServer) { + this.shares.unshift(share) + } + } else { + this.externalShares.unshift(share) + } + } else { + this.shares.unshift(share) + } + this.awaitForShare(share, resolve) + }, + /** + * Remove a share from the shares list + * + * @param {Share} share the share to remove + */ + removeShare(share) { + // Get reference for this.linkShares or this.shares + const shareList + = share.type === ShareType.Email + || share.type === ShareType.Link + ? this.linkShares + : this.shares + const index = shareList.findIndex(item => item.id === share.id) + if (index !== -1) { + shareList.splice(index, 1) + } + }, + /** + * Await for next tick and render after the list updated + * Then resolve with the matched vue component of the + * provided share object + * + * @param {Share} share newly created share + * @param {Function} resolve a function to execute after + */ + awaitForShare(share, resolve) { + this.$nextTick(() => { + let listComponent = this.$refs.shareList + // Only mail shares comes from the input, link shares + // are managed internally in the SharingLinkList component + if (share.type === ShareType.Email) { + listComponent = this.$refs.linkShareList + } + const newShare = listComponent.$children.find(component => component.share === share) + if (newShare) { + resolve(newShare) + } + }) + }, + + toggleShareDetailsView(eventData) { + if (!this.showSharingDetailsView) { + const isAction = Array.from(document.activeElement.classList) + .some(className => className.startsWith('action-')) + if (isAction) { + const menuId = document.activeElement.closest('[role="menu"]')?.id + this.returnFocusElement = document.querySelector(`[aria-controls="${menuId}"]`) + } else { + this.returnFocusElement = document.activeElement + } + } + + if (eventData) { + this.shareDetailsData = eventData + } + + this.showSharingDetailsView = !this.showSharingDetailsView + + if (!this.showSharingDetailsView) { + this.$nextTick(() => { // Wait for next tick as the element must be visible to be focused + this.returnFocusElement?.focus() + this.returnFocusElement = null + }) + } + }, + }, +} +</script> + +<style scoped lang="scss"> +.emptyContentWithSections { + margin: 1rem auto; +} + +.sharingTab { + position: relative; + height: 100%; + + &__content { + padding: 0 6px; + + section { + padding-bottom: 16px; + + .section-header { + margin-top: 2px; + margin-bottom: 2px; + display: flex; + align-items: center; + padding-bottom: 4px; + + h4 { + margin: 0; + font-size: 16px; + } + + .visually-hidden { + display: none; + } + + .hint-icon { + color: var(--color-primary-element); + } + + } + + } + + & > section:not(:last-child) { + border-bottom: 2px solid var(--color-border); + } + + } + + &__additionalContent { + margin: 44px 0; + } +} + +.hint-body { + max-width: 300px; + padding: var(--border-radius-element); +} +</style> |