diff options
Diffstat (limited to 'apps/files_sharing/src/components')
-rw-r--r-- | apps/files_sharing/src/components/SharingEntry.vue | 249 | ||||
-rw-r--r-- | apps/files_sharing/src/components/SharingEntryInternal.vue | 117 | ||||
-rw-r--r-- | apps/files_sharing/src/components/SharingEntryLink.vue | 769 | ||||
-rw-r--r-- | apps/files_sharing/src/components/SharingEntrySimple.vue | 97 | ||||
-rw-r--r-- | apps/files_sharing/src/components/SharingInput.vue | 444 |
5 files changed, 1676 insertions, 0 deletions
diff --git a/apps/files_sharing/src/components/SharingEntry.vue b/apps/files_sharing/src/components/SharingEntry.vue new file mode 100644 index 00000000000..857b57adbd0 --- /dev/null +++ b/apps/files_sharing/src/components/SharingEntry.vue @@ -0,0 +1,249 @@ +<!-- + - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.com> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> + +<template> + <li class="sharing-entry"> + <Avatar class="sharing-entry__avatar" + :user="share.shareWith" + :display-name="share.shareWithDisplayName" + :url="share.shareWithAvatar" /> + <div v-tooltip.auto="tooltip" class="sharing-entry__desc"> + <h5>{{ title }}</h5> + </div> + <Actions menu-align="right" class="sharing-entry__actions"> + <!-- edit permission --> + <ActionCheckbox + ref="canEdit" + :checked.sync="canEdit" + :value="permissionsEdit" + :disabled="saving"> + {{ t('files_sharing', 'Allow editing') }} + </ActionCheckbox> + + <!-- reshare permission --> + <ActionCheckbox + ref="canReshare" + :checked.sync="canReshare" + :value="permissionsShare" + :disabled="saving"> + {{ t('files_sharing', 'Can reshare') }} + </ActionCheckbox> + + <!-- expiration date --> + <ActionCheckbox :checked.sync="hasExpirationDate" + :disabled="config.isDefaultExpireDateEnforced || saving" + @uncheck="onExpirationDisable"> + {{ config.isDefaultExpireDateEnforced + ? t('files_sharing', 'Expiration date enforced') + : t('files_sharing', 'Set expiration date') }} + </ActionCheckbox> + <ActionInput v-if="hasExpirationDate" + ref="expireDate" + v-tooltip.auto="{ + content: errors.expireDate, + show: errors.expireDate, + trigger: 'manual' + }" + :class="{ error: errors.expireDate}" + :disabled="saving" + :first-day-of-week="firstDay" + :lang="lang" + :value="share.expireDate" + icon="icon-calendar-dark" + type="date" + :not-before="dateTomorrow" + :not-after="dateMaxEnforced" + @update:value="onExpirationChange"> + {{ t('files_sharing', 'Enter a date') }} + </ActionInput> + + <!-- note --> + <template v-if="canHaveNote"> + <ActionCheckbox + :checked.sync="hasNote" + :disabled="saving" + @uncheck="queueUpdate('note')"> + {{ t('files_sharing', 'Note to recipient') }} + </ActionCheckbox> + <ActionTextEditable v-if="hasNote" + ref="note" + v-tooltip.auto="{ + content: errors.note, + show: errors.note, + trigger: 'manual' + }" + :class="{ error: errors.note}" + :disabled="saving" + :value.sync="share.note" + icon="icon-edit" + @update:value="debounceQueueUpdate('note')" /> + </template> + + <ActionButton icon="icon-delete" :disabled="saving" @click.prevent="onDelete"> + {{ t('files_sharing', 'Unshare') }} + </ActionButton> + </Actions> + </li> +</template> + +<script> +import Avatar from 'nextcloud-vue/dist/Components/Avatar' +import Actions from 'nextcloud-vue/dist/Components/Actions' +import ActionButton from 'nextcloud-vue/dist/Components/ActionButton' +import ActionCheckbox from 'nextcloud-vue/dist/Components/ActionCheckbox' +import ActionInput from 'nextcloud-vue/dist/Components/ActionInput' +import ActionTextEditable from 'nextcloud-vue/dist/Components/ActionTextEditable' +import Tooltip from 'nextcloud-vue/dist/Directives/Tooltip' + +// eslint-disable-next-line no-unused-vars +import Share from '../models/Share' +import SharesMixin from '../mixins/SharesMixin' + +export default { + name: 'SharingEntry', + + components: { + Actions, + ActionButton, + ActionCheckbox, + ActionInput, + ActionTextEditable, + Avatar + }, + + directives: { + Tooltip + }, + + mixins: [SharesMixin], + + data() { + return { + permissionsEdit: OC.PERMISSION_UPDATE, + permissionsRead: OC.PERMISSION_READ, + permissionsShare: OC.PERMISSION_SHARE + } + }, + + computed: { + title() { + let title = this.share.shareWithDisplayName + if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_GROUP) { + title += ` (${t('files_sharing', 'group')})` + } else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_ROOM) { + title += ` (${t('files_sharing', 'conversation')})` + } else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_REMOTE) { + title += ` (${t('files_sharing', 'remote')})` + } else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP) { + title += ` (${t('files_sharing', 'remote group')})` + } else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_GUEST) { + title += ` (${t('files_sharing', 'guest')})` + } + return title + }, + + tooltip() { + if (this.share.owner !== this.share.uidFileOwner) { + const data = { + // todo: strong or italic? + // but the t function escape any html from the data :/ + user: this.share.shareWithDisplayName, + owner: this.share.owner + } + + if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_GROUP) { + return t('files_sharing', 'Shared with the group {user} by {owner}', data) + } else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_ROOM) { + return t('files_sharing', 'Shared with the conversation {user} by {owner}', data) + } + + return t('files_sharing', 'Shared with {user} by {owner}', data) + } + return null + }, + + canHaveNote() { + return this.share.type !== this.SHARE_TYPES.SHARE_TYPE_REMOTE + && this.share.type !== this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP + }, + + /** + * Can the sharee edit the shared file ? + */ + canEdit: { + get: function() { + return this.share.hasUpdatePermission + }, + set: function(checked) { + this.updatePermissions(checked, this.canReshare) + } + }, + + /** + * Can the sharee reshare the file ? + */ + canReshare: { + get: function() { + return this.share.hasSharePermission + }, + set: function(checked) { + this.updatePermissions(this.canEdit, checked) + } + } + + }, + + methods: { + updatePermissions(isEditChecked, isReshareChecked) { + // calc permissions if checked + const permissions = this.permissionsRead + | (isEditChecked ? this.permissionsEdit : 0) + | (isReshareChecked ? this.permissionsShare : 0) + + this.share.permissions = permissions + this.queueUpdate('permissions') + } + } + +} +</script> + +<style lang="scss" scoped> +.sharing-entry { + display: flex; + align-items: center; + height: 44px; + &__desc { + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 8px; + line-height: 1.2em; + p { + color: var(--color-text-maxcontrast); + } + } + &__actions { + margin-left: auto; + } +} +</style> diff --git a/apps/files_sharing/src/components/SharingEntryInternal.vue b/apps/files_sharing/src/components/SharingEntryInternal.vue new file mode 100644 index 00000000000..720c016b82e --- /dev/null +++ b/apps/files_sharing/src/components/SharingEntryInternal.vue @@ -0,0 +1,117 @@ + +<template> + <SharingEntrySimple + class="sharing-entry__internal" + :title="t('files_sharing', 'Internal link')" + :subtitle="internalLinkSubtitle"> + <template #avatar> + <div class="avatar-external icon-external-white" /> + </template> + + <ActionLink ref="copyButton" + :href="internalLink" + target="_blank" + :icon="copied && copySuccess ? 'icon-checkmark-color' : 'icon-clippy'" + @click.prevent="copyLink"> + {{ clipboardTooltip }} + </ActionLink> + </SharingEntrySimple> +</template> + +<script> +import { generateUrl } from '@nextcloud/router' +import ActionLink from 'nextcloud-vue/dist/Components/ActionLink' +import SharingEntrySimple from './SharingEntrySimple' + +export default { + name: 'SharingEntryInternal', + + components: { + ActionLink, + SharingEntrySimple + }, + + props: { + fileInfo: { + type: Object, + default: () => {}, + required: true + } + }, + + data() { + return { + copied: false, + copySuccess: false + } + }, + + computed: { + /** + * Get the internal link to this file id + * @returns {string} + */ + internalLink() { + return window.location.protocol + '//' + window.location.host + generateUrl('/f/') + this.fileInfo.id + }, + + /** + * Clipboard v-tooltip message + * @returns {string} + */ + clipboardTooltip() { + if (this.copied) { + return this.copySuccess + ? t('files_sharing', 'Link copied') + : t('files_sharing', 'Cannot copy, please copy the link manually') + } + return t('files_sharing', 'Copy to clipboard') + }, + + internalLinkSubtitle() { + if (this.fileInfo.type === 'dir') { + return t('files_sharing', 'Only works for users with access to this folder') + } + return t('files_sharing', 'Only works for users with access to this file') + } + }, + + methods: { + async copyLink() { + try { + await this.$copyText(this.internalLink) + // focus and show the tooltip + this.$refs.copyButton.$el.focus() + this.copySuccess = true + this.copied = true + } catch (error) { + this.copySuccess = false + this.copied = true + console.error(error) + } finally { + setTimeout(() => { + this.copySuccess = false + this.copied = false + }, 4000) + } + } + } +} +</script> + +<style lang="scss" scoped> +.sharing-entry__internal { + .avatar-external { + width: 32px; + height: 32px; + line-height: 32px; + font-size: 18px; + background-color: var(--color-text-maxcontrast); + border-radius: 50%; + flex-shrink: 0; + } + .icon-checkmark-color { + opacity: 1; + } +} +</style> diff --git a/apps/files_sharing/src/components/SharingEntryLink.vue b/apps/files_sharing/src/components/SharingEntryLink.vue new file mode 100644 index 00000000000..afeaee06bde --- /dev/null +++ b/apps/files_sharing/src/components/SharingEntryLink.vue @@ -0,0 +1,769 @@ +<!-- + - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.com> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> + +<template> + <li :class="{'sharing-entry--share': share}" class="sharing-entry sharing-entry__link"> + <Avatar :is-no-user="true" + :class="isEmailShareType ? 'icon-mail-white' : 'icon-public-white'" + class="sharing-entry__avatar" /> + <div class="sharing-entry__desc"> + <h5>{{ title }}</h5> + </div> + + <!-- clipboard --> + <Actions v-if="share && !isEmailShareType && share.token" + ref="copyButton" + class="sharing-entry__copy"> + <ActionLink :href="shareLink" + target="_blank" + :icon="copied && copySuccess ? 'icon-checkmark-color' : 'icon-clippy'" + @click.stop.prevent="copyLink"> + {{ clipboardTooltip }} + </ActionLink> + </Actions> + + <!-- pending actions --> + <Actions v-if="!loading && (pendingPassword || pendingExpirationDate)" + class="sharing-entry__actions" + menu-align="right" + :open.sync="open" + @close="onNewLinkShare"> + <!-- pending data menu --> + <ActionText v-if="errors.pending" + icon="icon-error" + :class="{ error: errors.pending}"> + {{ errors.pending }} + </ActionText> + <ActionText v-else icon="icon-info"> + {{ t('files_sharing', 'Please enter the following required information before creating the share') }} + </ActionText> + + <!-- password --> + <ActionText v-if="pendingPassword" icon="icon-password"> + {{ t('files_sharing', 'Password protection (enforced)') }} + </ActionText> + <ActionCheckbox v-else-if="config.enableLinkPasswordByDefault" + :checked.sync="isPasswordProtected" + :disabled="config.enforcePasswordForPublicLink || saving" + class="share-link-password-checkbox" + @uncheck="onPasswordDisable"> + {{ t('files_sharing', 'Password protection') }} + </ActionCheckbox> + <ActionInput v-if="pendingPassword || share.password" + v-tooltip.auto="{ + content: errors.password, + show: errors.password, + trigger: 'manual', + defaultContainer: '#app-sidebar' + }" + class="share-link-password" + :value.sync="share.password" + :disabled="saving" + :required="config.enableLinkPasswordByDefault || config.enforcePasswordForPublicLink" + :minlength="isPasswordPolicyEnabled && config.passwordPolicy.minLength" + icon="" + autocomplete="new-password" + @submit="onNewLinkShare"> + {{ t('files_sharing', 'Enter a password') }} + </ActionInput> + + <!-- expiration date --> + <ActionText v-if="pendingExpirationDate" icon="icon-calendar-dark"> + {{ t('files_sharing', 'Expiration date (enforced)') }} + </ActionText> + <ActionInput v-if="pendingExpirationDate" + v-model="share.expireDate" + v-tooltip.auto="{ + content: errors.expireDate, + show: errors.expireDate, + trigger: 'manual', + defaultContainer: '#app-sidebar' + }" + class="share-link-expire-date" + :disabled="saving" + :first-day-of-week="firstDay" + :lang="lang" + icon="" + type="date" + :not-before="dateTomorrow" + :not-after="dateMaxEnforced"> + <!-- let's not submit when picked, the user + might want to still edit or copy the password --> + {{ t('files_sharing', 'Enter a date') }} + </ActionInput> + + <ActionButton icon="icon-close" @click.prevent.stop="onCancel"> + {{ t('files_sharing', 'Cancel') }} + </ActionButton> + </Actions> + + <!-- actions --> + <Actions v-else-if="!loading" + class="sharing-entry__actions" + menu-align="right" + :open.sync="open" + @close="onPasswordSubmit"> + <template v-if="share"> + <template v-if="isShareOwner"> + <!-- folder --> + <template v-if="isFolder && fileHasCreatePermission && config.isPublicUploadEnabled"> + <ActionRadio :checked="share.permissions === publicUploadRValue" + :value="publicUploadRValue" + :name="randomId" + :disabled="saving" + @change="togglePermissions"> + {{ t('files_sharing', 'Read only') }} + </ActionRadio> + <ActionRadio :checked="share.permissions === publicUploadRWValue" + :value="publicUploadRWValue" + :disabled="saving" + :name="randomId" + @change="togglePermissions"> + {{ t('files_sharing', 'Allow upload and editing') }} + </ActionRadio> + <ActionRadio :checked="share.permissions === publicUploadWValue" + :value="publicUploadWValue" + :disabled="saving" + :name="randomId" + class="sharing-entry__action--public-upload" + @change="togglePermissions"> + {{ t('files_sharing', 'File drop (upload only)') }} + </ActionRadio> + </template> + + <!-- file --> + <ActionCheckbox v-else + :checked.sync="canUpdate" + :disabled="saving" + @change="queueUpdate('permissions')"> + {{ t('files_sharing', 'Allow editing') }} + </ActionCheckbox> + + <ActionCheckbox + :checked.sync="share.hideDownload" + :disabled="saving" + @change="queueUpdate('hideDownload')"> + {{ t('files_sharing', 'Hide download') }} + </ActionCheckbox> + + <!-- password --> + <ActionCheckbox :checked.sync="isPasswordProtected" + :disabled="config.enforcePasswordForPublicLink || saving" + class="share-link-password-checkbox" + @uncheck="onPasswordDisable"> + {{ config.enforcePasswordForPublicLink + ? t('files_sharing', 'Password protection (enforced)') + : t('files_sharing', 'Password protect') }} + </ActionCheckbox> + <ActionInput v-if="isPasswordProtected" + ref="password" + v-tooltip.auto="{ + content: errors.password, + show: errors.password, + trigger: 'manual', + defaultContainer: '#app-sidebar' + }" + class="share-link-password" + :class="{ error: errors.password}" + :disabled="saving" + :required="config.enforcePasswordForPublicLink" + :value="hasUnsavedPassword ? share.newPassword : '***************'" + icon="icon-password" + autocomplete="new-password" + :type="hasUnsavedPassword ? 'text': 'password'" + @update:value="onPasswordChange" + @submit="onPasswordSubmit"> + {{ t('files_sharing', 'Enter a password') }} + </ActionInput> + + <!-- expiration date --> + <ActionCheckbox :checked.sync="hasExpirationDate" + :disabled="config.isDefaultExpireDateEnforced || saving" + class="share-link-expire-date-checkbox" + @uncheck="onExpirationDisable"> + {{ config.isDefaultExpireDateEnforced + ? t('files_sharing', 'Expiration date (enforced)') + : t('files_sharing', 'Set expiration date') }} + </ActionCheckbox> + <ActionInput v-if="hasExpirationDate" + ref="expireDate" + v-tooltip.auto="{ + content: errors.expireDate, + show: errors.expireDate, + trigger: 'manual', + defaultContainer: '#app-sidebar' + }" + class="share-link-expire-date" + :class="{ error: errors.expireDate}" + :disabled="saving" + :first-day-of-week="firstDay" + :lang="lang" + :value="share.expireDate" + icon="icon-calendar-dark" + type="date" + :not-before="dateTomorrow" + :not-after="dateMaxEnforced" + @update:value="onExpirationChange"> + {{ t('files_sharing', 'Enter a date') }} + </ActionInput> + + <!-- note --> + <ActionCheckbox :checked.sync="hasNote" + :disabled="saving" + @uncheck="queueUpdate('note')"> + {{ t('files_sharing', 'Note to recipient') }} + </ActionCheckbox> + <ActionTextEditable v-if="hasNote" + ref="note" + v-tooltip.auto="{ + content: errors.note, + show: errors.note, + trigger: 'manual', + defaultContainer: '#app-sidebar' + }" + :class="{ error: errors.note}" + :disabled="saving" + :value.sync="share.note" + icon="icon-edit" + @update:value="debounceQueueUpdate('note')" /> + </template> + + <components :is="action" v-for="(action, index) in externalActions" :key="index" /> + + <ActionButton icon="icon-delete" :disabled="saving" @click.prevent="onDelete"> + {{ t('files_sharing', 'Delete share') }} + </ActionButton> + <ActionButton v-if="!isEmailShareType && canReshare" + class="new-share-link" + icon="icon-add" + @click.prevent.stop="onNewLinkShare"> + {{ t('files_sharing', 'Add another link') }} + </ActionButton> + </template> + + <!-- Create new share --> + <ActionButton v-else-if="canReshare" + class="new-share-link" + icon="icon-add" + @click.prevent.stop="onNewLinkShare"> + {{ t('files_sharing', 'Create a new share link') }} + </ActionButton> + </Actions> + + <!-- loading indicator to replace the menu --> + <div v-else class="icon-loading-small sharing-entry__loading" /> + </li> +</template> + +<script> +import { generateUrl } from '@nextcloud/router' +import axios from '@nextcloud/axios' + +import ActionButton from 'nextcloud-vue/dist/Components/ActionButton' +import ActionCheckbox from 'nextcloud-vue/dist/Components/ActionCheckbox' +import ActionRadio from 'nextcloud-vue/dist/Components/ActionRadio' +import ActionInput from 'nextcloud-vue/dist/Components/ActionInput' +import ActionText from 'nextcloud-vue/dist/Components/ActionText' +import ActionTextEditable from 'nextcloud-vue/dist/Components/ActionTextEditable' +import ActionLink from 'nextcloud-vue/dist/Components/ActionLink' +import Actions from 'nextcloud-vue/dist/Components/Actions' +import Avatar from 'nextcloud-vue/dist/Components/Avatar' +import Tooltip from 'nextcloud-vue/dist/Directives/Tooltip' + +import Share from '../models/Share' +import SharesMixin from '../mixins/SharesMixin' + +const passwordSet = 'abcdefgijkmnopqrstwxyzABCDEFGHJKLMNPQRSTWXYZ23456789' + +export default { + name: 'SharingEntryLink', + + components: { + Actions, + ActionButton, + ActionCheckbox, + ActionRadio, + ActionInput, + ActionLink, + ActionText, + ActionTextEditable, + Avatar + }, + + directives: { + Tooltip + }, + + mixins: [SharesMixin], + + props: { + canReshare: { + type: Boolean, + default: true + } + }, + + data() { + return { + copySuccess: true, + copied: false, + + publicUploadRWValue: OC.PERMISSION_UPDATE | OC.PERMISSION_CREATE | OC.PERMISSION_READ | OC.PERMISSION_DELETE, + publicUploadRValue: OC.PERMISSION_READ, + publicUploadWValue: OC.PERMISSION_CREATE, + + ExternalLinkActions: OCA.Sharing.ExternalLinkActions.state + } + }, + + computed: { + /** + * Generate a unique random id for this SharingEntryLink only + * This allows ActionRadios to have the same name prop + * but not to impact others SharingEntryLink + * @returns {string} + */ + randomId() { + return Math.random().toString(27).substr(2) + }, + + /** + * Link share label + * TODO: allow editing + * @returns {string} + */ + title() { + // if we have a valid existing share (not pending) + if (this.share && this.share.id) { + if (!this.isShareOwner && this.share.ownerDisplayName) { + return t('files_sharing', 'Shared via link by {initiator}', { + initiator: this.share.ownerDisplayName + }) + } + if (this.share.label && this.share.label.trim() !== '') { + return this.share.label + } + if (this.isEmailShareType) { + return this.share.shareWith + } + } + return t('files_sharing', 'Share link') + }, + + /** + * Is the current share password protected ? + * @returns {boolean} + */ + isPasswordProtected: { + get: function() { + return this.config.enforcePasswordForPublicLink + || !!this.share.password + }, + set: async function(enabled) { + // TODO: directly save after generation to make sure the share is always protected + this.share.password = enabled ? await this.generatePassword() : '' + this.share.newPassword = this.share.password + } + }, + + /** + * Is the current share an email share ? + * @returns {boolean} + */ + isEmailShareType() { + return this.share + ? this.share.type === this.SHARE_TYPES.SHARE_TYPE_EMAIL + : false + }, + + /** + * Pending data. + * If the share still doesn't have an id, it is not synced + * Therefore this is still not valid and requires user input + * @returns {boolean} + */ + pendingPassword() { + return this.config.enforcePasswordForPublicLink && this.share && !this.share.id + }, + pendingExpirationDate() { + return this.config.isDefaultExpireDateEnforced && this.share && !this.share.id + }, + + /** + * Can the recipient edit the file ? + * @returns {boolean} + */ + canUpdate: { + get: function() { + return this.share.hasUpdatePermission + }, + set: function(enabled) { + this.share.permissions = enabled + ? OC.PERMISSION_READ | OC.PERMISSION_UPDATE + : OC.PERMISSION_READ + } + }, + + // if newPassword exists, but is empty, it means + // the user deleted the original password + hasUnsavedPassword() { + return this.share.newPassword !== undefined + }, + + /** + * Is the current share a folder ? + * TODO: move to a proper FileInfo model? + * @returns {boolean} + */ + isFolder() { + return this.fileInfo.type === 'dir' + }, + + /** + * Does the current file/folder have create permissions + * TODO: move to a proper FileInfo model? + * @returns {boolean} + */ + fileHasCreatePermission() { + return !!(this.fileInfo.permissions & OC.PERMISSION_CREATE) + }, + + /** + * Return the public share link + * @returns {string} + */ + shareLink() { + return window.location.protocol + '//' + window.location.host + generateUrl('/s/') + this.share.token + }, + + /** + * Clipboard v-tooltip message + * @returns {string} + */ + clipboardTooltip() { + if (this.copied) { + return this.copySuccess + ? t('files_sharing', 'Link copied') + : t('files_sharing', 'Cannot copy, please copy the link manually') + } + return t('files_sharing', 'Copy to clipboard') + }, + + /** + * External aditionnal actions for the menu + * @returns {Array} + */ + externalActions() { + return this.ExternalLinkActions.actions + }, + + isPasswordPolicyEnabled() { + return typeof this.config.passwordPolicy === 'object' + } + }, + + methods: { + /** + * Create a new share link and append it to the list + */ + async onNewLinkShare() { + const shareDefaults = { + share_type: OC.Share.SHARE_TYPE_LINK + } + if (this.config.isDefaultExpireDateEnforced) { + // default is empty string if not set + // expiration is the share object key, not expireDate + shareDefaults.expiration = this.config.defaultExpirationDateString + } + if (this.config.enableLinkPasswordByDefault) { + shareDefaults.password = await this.generatePassword() + } + + // do not push yet if we need a password or an expiration date + if (this.config.enforcePasswordForPublicLink || this.config.isDefaultExpireDateEnforced) { + this.loading = true + // if a share already exists, pushing it + if (this.share && !this.share.id) { + if (this.checkShare(this.share)) { + await this.pushNewLinkShare(this.share, true) + return true + } else { + this.open = true + OC.Notification.showTemporary(t('files_sharing', 'Error, please enter proper password and/or expiration date')) + return false + } + } + + // ELSE, show the pending popovermenu + // if password enforced, pre-fill with random one + if (this.config.enforcePasswordForPublicLink) { + shareDefaults.password = await this.generatePassword() + } + + // create share & close menu + const share = new Share(shareDefaults) + const component = await new Promise(resolve => { + this.$emit('add:share', share, resolve) + }) + + // open the menu on the + // freshly created share component + this.open = false + this.loading = false + component.open = true + + // Nothing enforced, creating share directly + } else { + const share = new Share(shareDefaults) + await this.pushNewLinkShare(share) + } + }, + + /** + * Push a new link share to the server + * And update or append to the list + * accordingly + * + * @param {Share} share the new share + * @param {boolean} [update=false] do we update the current share ? + */ + async pushNewLinkShare(share, update) { + try { + this.loading = true + this.errors = {} + + const path = (this.fileInfo.path + '/' + this.fileInfo.name).replace('//', '/') + const newShare = await this.createShare({ + path, + shareType: OC.Share.SHARE_TYPE_LINK, + password: share.password, + expireDate: share.expireDate + // we do not allow setting the publicUpload + // before the share creation. + // Todo: We also need to fix the createShare method in + // lib/Controller/ShareAPIController.php to allow file drop + // (currently not supported on create, only update) + }) + + this.open = false + + console.debug('Link share created', newShare) + + // if share already exists, copy link directly on next tick + let component + if (update) { + component = await new Promise(resolve => { + this.$emit('update:share', newShare, resolve) + }) + } else { + // adding new share to the array and copying link to clipboard + // using promise so that we can copy link in the same click function + // and avoid firefox copy permissions issue + component = await new Promise(resolve => { + this.$emit('add:share', newShare, resolve) + }) + } + + // Execute the copy link method + // freshly created share component + // ! somehow does not works on firefox ! + component.copyLink() + + } catch ({ response }) { + const message = response.data.ocs.meta.message + if (message.match(/password/i)) { + this.onSyncError('password', message) + } else if (message.match(/date/i)) { + this.onSyncError('expireDate', message) + } else { + this.onSyncError('pending', message) + } + } finally { + this.loading = false + } + }, + + /** + * On permissions change + * @param {Event} event js event + */ + togglePermissions(event) { + const permissions = parseInt(event.target.value, 10) + this.share.permissions = permissions + this.queueUpdate('permissions') + }, + + /** + * Generate a valid policy password or + * request a valid password if password_policy + * is enabled + * + * @returns {string} a valid password + */ + async generatePassword() { + // password policy is enabled, let's request a pass + if (this.config.passwordPolicy.api && this.config.passwordPolicy.api.generate) { + try { + const request = await axios.get(this.config.passwordPolicy.api.generate) + if (request.data.ocs.data.password) { + return request.data.ocs.data.password + } + } catch (error) { + console.info('Error generating password from password_policy', error) + } + } + + // generate password of 10 length based on passwordSet + return Array(10).fill(0) + .reduce((prev, curr) => { + prev += passwordSet.charAt(Math.floor(Math.random() * passwordSet.length)) + return prev + }, '') + }, + + async copyLink() { + try { + await this.$copyText(this.shareLink) + // focus and show the tooltip + this.$refs.copyButton.$el.focus() + this.copySuccess = true + this.copied = true + } catch (error) { + this.copySuccess = false + this.copied = true + console.error(error) + } finally { + setTimeout(() => { + this.copySuccess = false + this.copied = false + }, 4000) + } + }, + + /** + * Update newPassword values + * of share. If password is set but not newPassword + * then the user did not changed the password + * If both co-exists, the password have changed and + * we show it in plain text. + * Then on submit (or menu close), we sync it. + * @param {string} password the changed password + */ + onPasswordChange(password) { + this.$set(this.share, 'newPassword', password) + }, + + /** + * Uncheck password protection + * We need this method because @update:checked + * is ran simultaneously as @uncheck, so + * so we cannot ensure data is up-to-date + */ + onPasswordDisable() { + this.share.password = '' + + // reset password state after sync + this.$delete(this.share, 'newPassword') + + // only update if valid share. + if (this.share.id) { + this.queueUpdate('password') + } + }, + + /** + * Menu have been closed or password has been submited. + * The only property that does not get + * synced automatically is the password + * So let's check if we have an unsaved + * password. + * expireDate is saved on datepicker pick + * or close. + */ + onPasswordSubmit() { + if (this.hasUnsavedPassword) { + this.share.password = this.share.newPassword + this.queueUpdate('password') + } + }, + + /** + * Cancel the share creation + * Used in the pending popover + */ + onCancel() { + // this.share already exists at this point, + // but is incomplete as not pushed to server + // YET. We can safely delete the share :) + this.$emit('remove:share', this.share) + } + } + +} +</script> + +<style lang="scss" scoped> +.sharing-entry { + display: flex; + align-items: center; + height: 44px; + &__desc { + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 8px; + line-height: 1.2em; + } + + &:not(.sharing-entry--share) &__actions { + .new-share-link { + border-top: 1px solid var(--color-border); + } + } + + .sharing-entry__action--public-upload { + border-bottom: 1px solid var(--color-border); + } + + &__loading { + width: 44px; + height: 44px; + margin: 0; + padding: 14px; + margin-left: auto; + } + + // put menus to the left + // but only the first one + .action-item { + margin-left: auto; + ~ .action-item, + ~ .sharing-entry__loading { + margin-left: 0; + } + } + + .icon-checkmark-color { + opacity: 1; + } +} +</style> diff --git a/apps/files_sharing/src/components/SharingEntrySimple.vue b/apps/files_sharing/src/components/SharingEntrySimple.vue new file mode 100644 index 00000000000..4538950a831 --- /dev/null +++ b/apps/files_sharing/src/components/SharingEntrySimple.vue @@ -0,0 +1,97 @@ +<!-- + - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.com> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> + +<template> + <li class="sharing-entry"> + <slot name="avatar" /> + <div v-tooltip="tooltip" class="sharing-entry__desc"> + <h5>{{ title }}</h5> + <p v-if="subtitle"> + {{ subtitle }} + </p> + </div> + <Actions v-if="$slots['default']" menu-align="right" class="sharing-entry__actions"> + <slot /> + </Actions> + </li> +</template> + +<script> +import Actions from 'nextcloud-vue/dist/Components/Actions' +import Tooltip from 'nextcloud-vue/dist/Directives/Tooltip' + +export default { + name: 'SharingEntrySimple', + + components: { + Actions + }, + + directives: { + Tooltip + }, + + props: { + title: { + type: String, + default: '', + required: true + }, + tooltip: { + type: String, + default: '' + }, + subtitle: { + type: String, + default: '' + } + } + +} +</script> + +<style lang="scss" scoped> +.sharing-entry { + display: flex; + align-items: center; + height: 44px; + &__desc { + padding: 8px; + line-height: 1.2em; + position: relative; + flex: 1 1; + min-width: 0; + h5 { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + max-width: inherit; + } + p { + color: var(--color-text-maxcontrast); + } + } + &__actions { + margin-left: auto !important; + } +} +</style> diff --git a/apps/files_sharing/src/components/SharingInput.vue b/apps/files_sharing/src/components/SharingInput.vue new file mode 100644 index 00000000000..df222eafe0c --- /dev/null +++ b/apps/files_sharing/src/components/SharingInput.vue @@ -0,0 +1,444 @@ +<!-- + - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.com> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> + +<template> + <Multiselect ref="multiselect" + class="sharing-input" + :disabled="!canReshare" + :hide-selected="true" + :internal-search="false" + :loading="loading" + :options="options" + :placeholder="inputPlaceholder" + :preselect-first="true" + :preserve-search="true" + :searchable="true" + :user-select="true" + @search-change="asyncFind" + @select="addShare"> + <template #noOptions> + {{ t('files_sharing', 'No recommendations. Start typing.') }} + </template> + <template #noResult> + {{ noResultText }} + </template> + </Multiselect> +</template> + +<script> +import { generateOcsUrl } from '@nextcloud/router' +import { getCurrentUser } from '@nextcloud/auth' +import axios from '@nextcloud/axios' +import debounce from 'debounce' +import Multiselect from 'nextcloud-vue/dist/Components/Multiselect' + +import Config from '../services/ConfigService' +import Share from '../models/Share' +import ShareRequests from '../mixins/ShareRequests' +import ShareTypes from '../mixins/ShareTypes' + +export default { + name: 'SharingInput', + + components: { + Multiselect + }, + + mixins: [ShareTypes, ShareRequests], + + props: { + shares: { + type: Array, + default: () => [], + required: true + }, + linkShares: { + type: Array, + default: () => [], + required: true + }, + fileInfo: { + type: Object, + default: () => {}, + required: true + }, + reshare: { + type: Share, + default: null + }, + canReshare: { + type: Boolean, + required: true + } + }, + + data() { + return { + config: new Config(), + loading: false, + query: '', + recommendations: [], + ShareSearch: OCA.Sharing.ShareSearch.state, + suggestions: [] + } + }, + + computed: { + /** + * Implement ShareSearch + * allows external appas to inject new + * results into the autocomplete dropdown + * Used for the guests app + * + * @returns {Array} + */ + externalResults() { + return this.ShareSearch.results + }, + inputPlaceholder() { + const allowRemoteSharing = this.config.isRemoteShareAllowed + const allowMailSharing = this.config.isMailShareAllowed + + if (!this.canReshare) { + return t('files_sharing', 'Resharing is not allowed') + } + if (!allowRemoteSharing && allowMailSharing) { + return t('files_sharing', 'Name or email address...') + } + if (allowRemoteSharing && !allowMailSharing) { + return t('files_sharing', 'Name or federated cloud ID...') + } + if (allowRemoteSharing && allowMailSharing) { + return t('files_sharing', 'Name, federated cloud ID or email address...') + } + + return t('files_sharing', 'Name...') + }, + + 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() { + this.getRecommendations() + }, + + methods: { + async asyncFind(query, id) { + // 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=false] search on lookup server + */ + async getSuggestions(search, lookup) { + this.loading = true + lookup = lookup || false + console.info(search, lookup) + + const 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 + } + }) + + if (request.data.ocs.meta.statuscode !== 100) { + console.error('Error fetching suggestions', request) + return + } + + const data = request.data.ocs.data + const exact = request.data.ocs.data.exact + data.exact = [] // removing exact from general results + + // flatten array of arrays + const rawExactSuggestions = Object.values(exact).reduce((arr, elem) => arr.concat(elem), []) + const rawSuggestions = Object.values(data).reduce((arr, elem) => arr.concat(elem), []) + + // remove invalid data and format to user-select layout + const exactSuggestions = this.filterOutExistingShares(rawExactSuggestions) + .map(share => this.formatForMultiselect(share)) + const suggestions = this.filterOutExistingShares(rawSuggestions) + .map(share => this.formatForMultiselect(share)) + + // lookup clickable entry + const lookupEntry = [] + if (data.lookupEnabled) { + lookupEntry.push({ + isNoUser: true, + displayName: t('files_sharing', 'Search globally'), + lookup: true + }) + } + + // if there is a condition specified, filter it + const externalResults = this.externalResults.filter(result => !result.condition || result.condition(this)) + + this.suggestions = exactSuggestions.concat(suggestions).concat(externalResults).concat(lookupEntry) + + this.loading = false + console.info('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 + + const request = await axios.get(generateOcsUrl('apps/files_sharing/api/v1') + 'sharees_recommended', { + params: { + format: 'json', + itemType: this.fileInfo.type + } + }) + + if (request.data.ocs.meta.statuscode !== 100) { + console.error('Error fetching recommendations', request) + return + } + + const exact = request.data.ocs.data.exact + + // flatten array of arrays + const rawRecommendations = Object.values(exact).reduce((arr, elem) => arr.concat(elem), []) + + // remove invalid data and format to user-select layout + this.recommendations = this.filterOutExistingShares(rawRecommendations) + .map(share => this.formatForMultiselect(share)) + + this.loading = false + console.info('recommendations', this.recommendations) + }, + + /** + * Filter out existing shares from + * the provided shares search results + * + * @param {Object[]} shares the array of shares object + * @returns {Object[]} + */ + filterOutExistingShares(shares) { + return shares.reduce((arr, share) => { + // only check proper objects + if (typeof share !== 'object') { + return arr + } + try { + // 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 === this.SHARE_TYPES.SHARE_TYPE_EMAIL) { + 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 + * @returns {string} the icon class + */ + shareTypeToIcon(type) { + switch (type) { + case this.SHARE_TYPES.SHARE_TYPE_GUEST: + // default is a user, other icons are here to differenciate + // themselves from it, so let's not display the user icon + // case this.SHARE_TYPES.SHARE_TYPE_REMOTE: + // case this.SHARE_TYPES.SHARE_TYPE_USER: + return 'icon-user' + case this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP: + case this.SHARE_TYPES.SHARE_TYPE_GROUP: + return 'icon-group' + case this.SHARE_TYPES.SHARE_TYPE_EMAIL: + return 'icon-mail' + case this.SHARE_TYPES.SHARE_TYPE_CIRCLE: + return 'icon-circle' + case this.SHARE_TYPES.SHARE_TYPE_ROOM: + return 'icon-room' + + default: + return '' + } + }, + + /** + * Format shares for the multiselect options + * @param {Object} result select entry item + * @returns {Object} + */ + formatForMultiselect(result) { + let desc + if ((result.value.shareType === this.SHARE_TYPES.SHARE_TYPE_REMOTE + || result.value.shareType === this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP + ) && result.value.server) { + desc = t('files_sharing', 'on {server}', { server: result.value.server }) + } else if (result.value.shareType === this.SHARE_TYPES.SHARE_TYPE_EMAIL) { + desc = result.value.shareWith + } + + return { + shareWith: result.value.shareWith, + shareType: result.value.shareType, + user: result.uuid || result.value.shareWith, + isNoUser: !result.uuid, + displayName: result.name || result.label, + desc, + icon: this.shareTypeToIcon(result.value.shareType) + } + }, + + /** + * Process the new share request + * @param {Object} value the multiselect option + */ + async addShare(value) { + if (value.lookup) { + return this.getSuggestions(this.query, true) + } + + // handle externalResults from OCA.Sharing.ShareSearch + if (value.handler) { + const share = await value.handler(this) + this.$emit('add:share', new Share(share)) + return true + } + + this.loading = true + try { + const path = (this.fileInfo.path + '/' + this.fileInfo.name).replace('//', '/') + const share = await this.createShare({ + path, + shareType: value.shareType, + shareWith: value.shareWith + }) + this.$emit('add:share', share) + + this.getRecommendations() + + } catch (response) { + // focus back if any error + const input = this.$refs.multiselect.$el.querySelector('input') + if (input) { + input.focus() + } + this.query = value.shareWith + } finally { + this.loading = false + } + } + } +} +</script> + +<style lang="scss"> +.sharing-input { + width: 100%; + margin: 10px 0; + + // properly style the lookup entry + .multiselect__option { + span[lookup] { + .avatardiv { + background-image: var(--icon-search-fff); + background-repeat: no-repeat; + background-position: center; + background-color: var(--color-text-maxcontrast) !important; + div { + display: none; + } + } + } + } +} +</style> |