nextcloud-server/apps/files_sharing/src/components/SharingInput.vue

554 lines
14 KiB
Vue

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

<!--
- SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="sharing-search">
<label class="hidden-visually" :for="shareInputId">
{{ isExternal
? t('files_sharing', 'Enter external recipients')
: t('files_sharing', 'Search for internal recipients') }}
</label>
<NcSelect
ref="select"
v-model="value"
:input-id="shareInputId"
class="sharing-search__input"
:disabled="!canReshare"
:loading="loading"
:filterable="false"
:placeholder="inputPlaceholder"
:clear-search-on-blur="() => false"
:user-select="true"
:options="options"
:label-outside="true"
@search="asyncFind"
@option:selected="onSelected">
<template #no-options="{ search }">
{{ search ? noResultText : placeholder }}
</template>
</NcSelect>
</div>
</template>
<script>
import { getCurrentUser } from '@nextcloud/auth'
import axios from '@nextcloud/axios'
import { getCapabilities } from '@nextcloud/capabilities'
import { generateOcsUrl } from '@nextcloud/router'
import { ShareType } from '@nextcloud/sharing'
import debounce from 'debounce'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import ShareDetails from '../mixins/ShareDetails.js'
import ShareRequests from '../mixins/ShareRequests.js'
import Share from '../models/Share.ts'
import Config from '../services/ConfigService.ts'
import logger from '../services/logger.ts'
export default {
name: 'SharingInput',
components: {
NcSelect,
},
mixins: [ShareRequests, ShareDetails],
props: {
shares: {
type: Array,
required: true,
},
linkShares: {
type: Array,
required: true,
},
fileInfo: {
type: Object,
required: true,
},
reshare: {
type: Share,
default: null,
},
canReshare: {
type: Boolean,
required: true,
},
isExternal: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: '',
},
},
setup() {
return {
shareInputId: `share-input-${Math.random().toString(36).slice(2, 7)}`,
}
},
data() {
return {
config: new Config(),
loading: false,
query: '',
recommendations: [],
ShareSearch: OCA.Sharing.ShareSearch.state,
suggestions: [],
value: null,
}
},
computed: {
/**
* Implement ShareSearch
* allows external appas to inject new
* results into the autocomplete dropdown
* Used for the guests app
*
* @return {Array}
*/
externalResults() {
return this.ShareSearch.results
},
inputPlaceholder() {
const allowRemoteSharing = this.config.isRemoteShareAllowed
if (!this.canReshare) {
return t('files_sharing', 'Resharing is not allowed')
}
if (this.placeholder) {
return this.placeholder
}
// We can always search with email addresses for users too
if (!allowRemoteSharing) {
return t('files_sharing', 'Name or email …')
}
return t('files_sharing', 'Name, email, or Federated Cloud ID …')
},
isValidQuery() {
return this.query && this.query.trim() !== '' && this.query.length > this.config.minSearchStringLength
},
options() {
if (this.isValidQuery) {
return this.suggestions
}
return this.recommendations
},
noResultText() {
if (this.loading) {
return t('files_sharing', 'Searching …')
}
return t('files_sharing', 'No elements found.')
},
},
mounted() {
if (!this.isExternal) {
// We can only recommend users, groups etc for internal shares
this.getRecommendations()
}
},
methods: {
onSelected(option) {
this.value = null // Reset selected option
this.openSharingDetails(option)
},
async asyncFind(query) {
// save current query to check if we display
// recommendations or search results
this.query = query.trim()
if (this.isValidQuery) {
// start loading now to have proper ux feedback
// during the debounce
this.loading = true
await this.debounceGetSuggestions(query)
}
},
/**
* Get suggestions
*
* @param {string} search the search query
* @param {boolean} [lookup] search on lookup server
*/
async getSuggestions(search, lookup = false) {
this.loading = true
if (getCapabilities().files_sharing.sharee.query_lookup_default === true) {
lookup = true
}
const remoteTypes = [ShareType.Remote, ShareType.RemoteGroup]
const shareType = []
const showFederatedAsInternal = this.config.showFederatedSharesAsInternal
|| this.config.showFederatedSharesToTrustedServersAsInternal
// For internal users, add remote types if config says to show them as internal
const shouldAddRemoteTypes = (!this.isExternal && showFederatedAsInternal)
// For external users, add them if config *doesn't* say to show them as internal
|| (this.isExternal && !showFederatedAsInternal)
// Edge case: federated-to-trusted is a separate "add" trigger for external users
|| (this.isExternal && this.config.showFederatedSharesToTrustedServersAsInternal)
if (this.isExternal) {
if (getCapabilities().files_sharing.public.enabled === true) {
shareType.push(ShareType.Email)
}
} else {
shareType.push(
ShareType.User,
ShareType.Group,
ShareType.Team,
ShareType.Room,
ShareType.Guest,
ShareType.Deck,
ShareType.ScienceMesh,
)
}
if (shouldAddRemoteTypes) {
shareType.push(...remoteTypes)
}
let request = null
try {
request = await axios.get(generateOcsUrl('apps/files_sharing/api/v1/sharees'), {
params: {
format: 'json',
itemType: this.fileInfo.type === 'dir' ? 'folder' : 'file',
search,
lookup,
perPage: this.config.maxAutocompleteResults,
shareType,
},
})
} catch (error) {
logger.error('Error fetching suggestions', { error })
return
}
const { exact, ...data } = request.data.ocs.data
// flatten array of arrays
const rawExactSuggestions = Object.values(exact).flat()
const rawSuggestions = Object.values(data).flat()
// remove invalid data and format to user-select layout
const exactSuggestions = this.filterOutExistingShares(rawExactSuggestions)
.filter((result) => this.filterByTrustedServer(result))
.map((share) => this.formatForMultiselect(share))
// sort by type so we can get user&groups first...
.sort((a, b) => a.shareType - b.shareType)
const suggestions = this.filterOutExistingShares(rawSuggestions)
.filter((result) => this.filterByTrustedServer(result))
.map((share) => this.formatForMultiselect(share))
// sort by type so we can get user&groups first...
.sort((a, b) => a.shareType - b.shareType)
// lookup clickable entry
// show if enabled and not already requested
const lookupEntry = []
if (data.lookupEnabled && !lookup) {
lookupEntry.push({
id: 'global-lookup',
isNoUser: true,
displayName: t('files_sharing', 'Search everywhere'),
lookup: true,
})
}
// if there is a condition specified, filter it
const externalResults = this.externalResults.filter((result) => !result.condition || result.condition(this))
const allSuggestions = exactSuggestions.concat(suggestions).concat(externalResults).concat(lookupEntry)
// Count occurrences of display names in order to provide a distinguishable description if needed
const nameCounts = allSuggestions.reduce((nameCounts, result) => {
if (!result.displayName) {
return nameCounts
}
if (!nameCounts[result.displayName]) {
nameCounts[result.displayName] = 0
}
nameCounts[result.displayName]++
return nameCounts
}, {})
this.suggestions = allSuggestions.map((item) => {
// Make sure that items with duplicate displayName get the shareWith applied as a description
if (nameCounts[item.displayName] > 1 && !item.desc) {
return { ...item, desc: item.shareWithDisplayNameUnique }
}
return item
})
this.loading = false
logger.debug('sharing suggestions', { suggestions: this.suggestions })
},
/**
* Debounce getSuggestions
*
* @param {...*} args the arguments
*/
debounceGetSuggestions: debounce(function(...args) {
this.getSuggestions(...args)
}, 300),
/**
* Get the sharing recommendations
*/
async getRecommendations() {
this.loading = true
let request = null
try {
request = await axios.get(generateOcsUrl('apps/files_sharing/api/v1/sharees_recommended'), {
params: {
format: 'json',
itemType: this.fileInfo.type,
},
})
} catch (error) {
logger.error('Error fetching recommendations', { error })
return
}
// Add external results from the OCA.Sharing.ShareSearch api
const externalResults = this.externalResults.filter((result) => !result.condition || result.condition(this))
// flatten array of arrays
const rawRecommendations = Object.values(request.data.ocs.data.exact)
.reduce((arr, elem) => arr.concat(elem), [])
// remove invalid data and format to user-select layout
this.recommendations = this.filterOutExistingShares(rawRecommendations)
.filter((result) => this.filterByTrustedServer(result))
.map((share) => this.formatForMultiselect(share))
.concat(externalResults)
this.loading = false
logger.debug('sharing recommendations', { recommendations: this.recommendations })
},
/**
* Filter out existing shares from
* the provided shares search results
*
* @param {object[]} shares the array of shares object
* @return {object[]}
*/
filterOutExistingShares(shares) {
return shares.reduce((arr, share) => {
// only check proper objects
if (typeof share !== 'object') {
return arr
}
try {
if (share.value.shareType === ShareType.User) {
// filter out current user
if (share.value.shareWith === getCurrentUser().uid) {
return arr
}
// filter out the owner of the share
if (this.reshare && share.value.shareWith === this.reshare.owner) {
return arr
}
}
// filter out existing mail shares
if (share.value.shareType === ShareType.Email) {
// When sharing internally, we don't want to suggest email addresses
// that the user previously created shares to
if (!this.isExternal) {
return arr
}
const emails = this.linkShares.map((elem) => elem.shareWith)
if (emails.indexOf(share.value.shareWith.trim()) !== -1) {
return arr
}
} else { // filter out existing shares
// creating an object of uid => type
const sharesObj = this.shares.reduce((obj, elem) => {
obj[elem.shareWith] = elem.type
return obj
}, {})
// if shareWith is the same and the share type too, ignore it
const key = share.value.shareWith.trim()
if (key in sharesObj
&& sharesObj[key] === share.value.shareType) {
return arr
}
}
// ALL GOOD
// let's add the suggestion
arr.push(share)
} catch {
return arr
}
return arr
}, [])
},
/**
* Get the icon based on the share type
*
* @param {number} type the share type
* @return {string} the icon class
*/
shareTypeToIcon(type) {
switch (type) {
case ShareType.Guest:
// default is a user, other icons are here to differentiate
// themselves from it, so let's not display the user icon
// case ShareType.Remote:
// case ShareType.User:
return {
icon: 'icon-user',
iconTitle: t('files_sharing', 'Guest'),
}
case ShareType.RemoteGroup:
case ShareType.Group:
return {
icon: 'icon-group',
iconTitle: t('files_sharing', 'Group'),
}
case ShareType.Email:
return {
icon: 'icon-mail',
iconTitle: t('files_sharing', 'Email'),
}
case ShareType.Team:
return {
icon: 'icon-teams',
iconTitle: t('files_sharing', 'Team'),
}
case ShareType.Room:
return {
icon: 'icon-room',
iconTitle: t('files_sharing', 'Talk conversation'),
}
case ShareType.Deck:
return {
icon: 'icon-deck',
iconTitle: t('files_sharing', 'Deck board'),
}
case ShareType.Sciencemesh:
return {
icon: 'icon-sciencemesh',
iconTitle: t('files_sharing', 'ScienceMesh'),
}
default:
return {}
}
},
/**
* Filter suggestion results based on trusted server configuration
*
* @param {object} result The raw suggestion result from API
* @return {boolean} Whether to include this result in suggestions
*/
filterByTrustedServer(result) {
const isRemoteEntity = result.value.shareType === ShareType.Remote || result.value.shareType === ShareType.RemoteGroup
if (isRemoteEntity && this.config.showFederatedSharesToTrustedServersAsInternal && !this.isExternal) {
return result.value.isTrustedServer === true
}
return true
},
/**
* Format shares for the multiselect options
*
* @param {object} result select entry item
* @return {object}
*/
formatForMultiselect(result) {
let subname
let displayName = result.name || result.label
if (result.value.shareType === ShareType.User && this.config.shouldAlwaysShowUnique) {
subname = result.shareWithDisplayNameUnique ?? ''
} else if (result.value.shareType === ShareType.Email) {
subname = result.value.shareWith
} else if (result.value.shareType === ShareType.Remote || result.value.shareType === ShareType.RemoteGroup) {
if (this.config.showFederatedSharesAsInternal) {
subname = result.extra?.email?.value ?? ''
displayName = result.extra?.name?.value ?? displayName
} else if (result.value.server) {
subname = t('files_sharing', 'on {server}', { server: result.value.server })
}
} else {
subname = result.shareWithDescription ?? ''
}
return {
shareWith: result.value.shareWith,
shareType: result.value.shareType,
user: result.uuid || result.value.shareWith,
isNoUser: result.value.shareType !== ShareType.User,
displayName,
subname,
shareWithDisplayNameUnique: result.shareWithDisplayNameUnique || '',
...this.shareTypeToIcon(result.value.shareType),
}
},
},
}
</script>
<style lang="scss">
.sharing-search {
display: flex;
flex-direction: column;
margin-bottom: 4px;
label[for="sharing-search-input"] {
margin-bottom: 2px;
}
&__input {
width: 100%;
margin: 10px 0;
}
}
.vs__dropdown-menu {
// properly style the lookup entry
span[lookup] {
.avatardiv {
background-image: var(--icon-search-white);
background-repeat: no-repeat;
background-position: center;
background-color: var(--color-text-maxcontrast) !important;
.avatardiv__initials-wrapper {
display: none;
}
}
}
}
</style>