nextcloud/apps/files_sharing/src/views/SharingTab.vue
Ferdinand Thiessen ee962f3a37 feat(files_sharing): provide web-components based sidebar API
This fixes apps providing vue components, which is invalid and does not
always work - and never work with Vue 3.

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
2025-09-18 15:45:20 +02:00

654 lines
19 KiB
Vue

<!--
- 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: hasExternalSections }">
<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="hasExternalSections && !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 -->
<SidebarTabExternalSection v-for="section in sortedExternalSections"
:key="section.id"
:section="section"
:node="fileInfo.node /* TODO: Fix once we have proper Node API */"
class="sharingTab__additionalContent" />
<!-- legacy sections: TODO: Remove as soon as possible -->
<SidebarTabExternalSectionLegacy v-for="(section, index) in legacySections"
:key="index"
:file-info="fileInfo"
:section-callback="section"
class="sharingTab__additionalContent" />
<!-- 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 { getSidebarSections } from '@nextcloud/sharing/ui'
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 SidebarTabExternalSection from '../components/SidebarTabExternal/SidebarTabExternalSection.vue'
import SidebarTabExternalSectionLegacy from '../components/SidebarTabExternal/SidebarTabExternalSectionLegacy.vue'
import ShareDetails from '../mixins/ShareDetails.js'
import logger from '../services/logger.ts'
const productName = window.OC.theme.productName
export default {
name: 'SharingTab',
components: {
InfoIcon,
NcAvatar,
NcButton,
NcCollectionList,
NcPopover,
SharingEntryInternal,
SharingEntrySimple,
SharingInherited,
SharingInput,
SharingLinkList,
SharingList,
SharingDetailsTab,
SidebarTabExternalSection,
SidebarTabExternalSectionLegacy,
},
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: [],
legacySections: OCA.Sharing.ShareTabSections.getSections(),
sections: getSidebarSections(),
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 {productName} accounts on other instances using their federated cloud ID.', { productName }),
additionalSharesHelpText: t('files_sharing', 'Shares from apps or other sources which are not included in internal or external shares.'),
}
},
computed: {
/**
* Are any sections registered by other apps.
*
* @return {boolean}
*/
hasExternalSections() {
return this.sections.length > 0 || this.legacySections.length > 0
},
sortedExternalSections() {
return this.sections
.filter((section) => section.enabled(this.fileInfo.node))
.sort((a, b) => a.order - b.order)
},
/**
* 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')
},
},
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: var(--default-clickable-area) 0;
}
}
.hint-body {
max-width: 300px;
padding: var(--border-radius-element);
}
</style>