diff options
Diffstat (limited to 'apps/files_sharing/src/components/SharingEntryLink.vue')
-rw-r--r-- | apps/files_sharing/src/components/SharingEntryLink.vue | 885 |
1 files changed, 480 insertions, 405 deletions
diff --git a/apps/files_sharing/src/components/SharingEntryLink.vue b/apps/files_sharing/src/components/SharingEntryLink.vue index ee7e8d4b930..6865af1b864 100644 --- a/apps/files_sharing/src/components/SharingEntryLink.vue +++ b/apps/files_sharing/src/components/SharingEntryLink.vue @@ -1,261 +1,163 @@ <!-- - - @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/>. - - - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <li :class="{'sharing-entry--share': share}" class="sharing-entry sharing-entry__link"> - <Avatar :is-no-user="true" + <li :class="{ 'sharing-entry--share': share }" + class="sharing-entry sharing-entry__link"> + <NcAvatar :is-no-user="true" :icon-class="isEmailShareType ? 'avatar-link-share icon-mail-white' : 'avatar-link-share icon-public-white'" class="sharing-entry__avatar" /> - <div class="sharing-entry__desc"> - <h5 :title="title"> - {{ title }} - </h5> - <p v-if="subtitle"> - {{ subtitle }} - </p> - </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> + <div class="sharing-entry__summary"> + <div class="sharing-entry__desc"> + <span class="sharing-entry__title" :title="title"> + {{ title }} + </span> + <p v-if="subtitle"> + {{ subtitle }} + </p> + <SharingEntryQuickShareSelect v-if="share && share.permissions !== undefined" + :share="share" + :file-info="fileInfo" + @open-sharing-details="openShareDetailsForCustomSettings(share)" /> + </div> + + <div class="sharing-entry__actions"> + <ShareExpiryTime v-if="share && share.expireDate" :share="share" /> + + <!-- clipboard --> + <div> + <NcActions v-if="share && (!isEmailShareType || isFileRequest) && share.token" ref="copyButton" class="sharing-entry__copy"> + <NcActionButton :aria-label="copyLinkTooltip" + :title="copyLinkTooltip" + :href="shareLink" + @click.prevent="copyLink"> + <template #icon> + <CheckIcon v-if="copied && copySuccess" + :size="20" + class="icon-checkmark-color" /> + <ClipboardIcon v-else :size="20" /> + </template> + </NcActionButton> + </NcActions> + </div> + </div> + </div> <!-- pending actions --> - <Actions v-if="!pending && (pendingPassword || pendingExpirationDate)" + <NcActions v-if="!pending && pendingDataIsMissing" class="sharing-entry__actions" + :aria-label="actionsTooltip" menu-align="right" :open.sync="open" - @close="onNewLinkShare"> + @close="onCancel"> <!-- pending data menu --> - <ActionText v-if="errors.pending" - icon="icon-error" - :class="{ error: errors.pending}"> + <NcActionText v-if="errors.pending" + class="error"> + <template #icon> + <ErrorIcon :size="20" /> + </template> {{ errors.pending }} - </ActionText> - <ActionText v-else icon="icon-info"> + </NcActionText> + <NcActionText v-else icon="icon-info"> {{ t('files_sharing', 'Please enter the following required information before creating the share') }} - </ActionText> + </NcActionText> <!-- password --> - <ActionText v-if="pendingPassword" icon="icon-password"> - {{ t('files_sharing', 'Password protection (enforced)') }} - </ActionText> - <ActionCheckbox v-else-if="config.enableLinkPasswordByDefault" + <NcActionCheckbox v-if="pendingPassword" :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' - }" + {{ config.enforcePasswordForPublicLink ? t('files_sharing', 'Password protection (enforced)') : t('files_sharing', 'Password protection') }} + </NcActionCheckbox> + + <NcActionInput v-if="pendingEnforcedPassword || isPasswordProtected" class="share-link-password" - :value.sync="share.password" + :label="t('files_sharing', 'Enter a password')" + :value.sync="share.newPassword" :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> + @submit="onNewLinkShare(true)"> + <template #icon> + <LockIcon :size="20" /> + </template> + </NcActionInput> + + <NcActionCheckbox v-if="pendingDefaultExpirationDate" + :checked.sync="defaultExpirationDateEnabled" + :disabled="pendingEnforcedExpirationDate || saving" + class="share-link-expiration-date-checkbox" + @update:model-value="onExpirationDateToggleUpdate"> + {{ config.isDefaultExpireDateEnforced ? t('files_sharing', 'Enable link expiration (enforced)') : t('files_sharing', 'Enable link expiration') }} + </NcActionCheckbox> <!-- 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' - }" + <NcActionInput v-if="(pendingDefaultExpirationDate || pendingEnforcedExpirationDate) && defaultExpirationDateEnabled" + data-cy-files-sharing-expiration-date-input class="share-link-expire-date" + :label="pendingEnforcedExpirationDate ? t('files_sharing', 'Enter expiration date (enforced)') : t('files_sharing', 'Enter expiration date')" :disabled="saving" - - :lang="lang" - icon="" + :is-native-picker="true" + :hide-label="true" + :value="new Date(share.expireDate)" type="date" - value-type="format" - :disabled-date="disabledDate"> - <!-- 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-checkmark" @click.prevent.stop="onNewLinkShare"> + :min="dateTomorrow" + :max="maxExpirationDateEnforced" + @update:model-value="onExpirationChange" + @change="expirationDateChanged"> + <template #icon> + <IconCalendarBlank :size="20" /> + </template> + </NcActionInput> + + <NcActionButton :disabled="pendingEnforcedPassword && !share.newPassword" + @click.prevent.stop="onNewLinkShare(true)"> + <template #icon> + <CheckIcon :size="20" /> + </template> {{ t('files_sharing', 'Create share') }} - </ActionButton> - <ActionButton icon="icon-close" @click.prevent.stop="onCancel"> + </NcActionButton> + <NcActionButton @click.prevent.stop="onCancel"> + <template #icon> + <CloseIcon :size="20" /> + </template> {{ t('files_sharing', 'Cancel') }} - </ActionButton> - </Actions> + </NcActionButton> + </NcActions> <!-- actions --> - <Actions v-else-if="!loading" + <NcActions v-else-if="!loading" class="sharing-entry__actions" + :aria-label="actionsTooltip" menu-align="right" :open.sync="open" @close="onMenuClose"> <template v-if="share"> <template v-if="share.canEdit && canReshare"> - <!-- Custom Label --> - <ActionInput ref="label" - v-tooltip.auto="{ - content: errors.label, - show: errors.label, - trigger: 'manual', - defaultContainer: '.app-sidebar' - }" - :class="{ error: errors.label }" - :disabled="saving" - :aria-label="t('files_sharing', 'Share label')" - :value="share.newLabel !== undefined ? share.newLabel : share.label" - icon="icon-edit" - maxlength="255" - @update:value="onLabelChange" - @submit="onLabelSubmit"> - {{ t('files_sharing', 'Share label') }} - </ActionInput> - - <SharePermissionsEditor :can-reshare="canReshare" - :share.sync="share" - :file-info="fileInfo" /> - - <ActionSeparator /> - - <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> - - <!-- password protected by Talk --> - <ActionCheckbox v-if="isPasswordProtectedByTalkAvailable" - :checked.sync="isPasswordProtectedByTalk" - :disabled="!canTogglePasswordProtectedByTalkAvailable || saving" - class="share-link-password-talk-checkbox" - @change="onPasswordProtectedByTalkChange"> - {{ t('files_sharing', 'Video verification') }} - </ActionCheckbox> - - <!-- 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" - :lang="lang" - :value="share.expireDate" - value-type="format" - icon="icon-calendar-dark" - type="date" - :disabled-date="disabledDate" - @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" - :placeholder="t('files_sharing', 'Enter a note for the share recipient')" - :value="share.newNote || share.note" - icon="icon-edit" - @update:value="onNoteChange" - @submit="onNoteSubmit" /> + <NcActionButton :disabled="saving" + :close-after-click="true" + @click.prevent="openSharingDetails"> + <template #icon> + <Tune :size="20" /> + </template> + {{ t('files_sharing', 'Customize link') }} + </NcActionButton> </template> - <ActionSeparator /> + <NcActionButton :close-after-click="true" + @click.prevent="showQRCode = true"> + <template #icon> + <IconQr :size="20" /> + </template> + {{ t('files_sharing', 'Generate QR code') }} + </NcActionButton> + + <NcActionSeparator /> <!-- external actions --> <ExternalShareAction v-for="action in externalLinkActions" @@ -266,104 +168,156 @@ :share="share" /> <!-- external legacy sharing via url (social...) --> - <ActionLink v-for="({icon, url, name}, index) in externalLegacyLinkActions" - :key="index" + <NcActionLink v-for="({ icon, url, name }, actionIndex) in externalLegacyLinkActions" + :key="actionIndex" :href="url(shareLink)" :icon="icon" target="_blank"> {{ name }} - </ActionLink> + </NcActionLink> - <ActionButton v-if="share.canDelete" - icon="icon-close" - :disabled="saving" - @click.prevent="onDelete"> - {{ t('files_sharing', 'Unshare') }} - </ActionButton> - <ActionButton v-if="!isEmailShareType && canReshare" + <NcActionButton v-if="!isEmailShareType && canReshare" class="new-share-link" - icon="icon-add" @click.prevent.stop="onNewLinkShare"> + <template #icon> + <PlusIcon :size="20" /> + </template> {{ t('files_sharing', 'Add another link') }} - </ActionButton> + </NcActionButton> + + <NcActionButton v-if="share.canDelete" + :disabled="saving" + @click.prevent="onDelete"> + <template #icon> + <CloseIcon :size="20" /> + </template> + {{ t('files_sharing', 'Unshare') }} + </NcActionButton> </template> <!-- Create new share --> - <ActionButton v-else-if="canReshare" + <NcActionButton v-else-if="canReshare" class="new-share-link" + :title="t('files_sharing', 'Create a new share link')" + :aria-label="t('files_sharing', 'Create a new share link')" :icon="loading ? 'icon-loading-small' : 'icon-add'" - @click.prevent.stop="onNewLinkShare"> - {{ t('files_sharing', 'Create a new share link') }} - </ActionButton> - </Actions> + @click.prevent.stop="onNewLinkShare" /> + </NcActions> <!-- loading indicator to replace the menu --> <div v-else class="icon-loading-small sharing-entry__loading" /> + + <!-- Modal to open whenever we have a QR code --> + <NcDialog v-if="showQRCode" + size="normal" + :open.sync="showQRCode" + :name="title" + :close-on-click-outside="true" + @close="showQRCode = false"> + <div class="qr-code-dialog"> + <VueQrcode tag="img" + :value="shareLink" + class="qr-code-dialog__img" /> + </div> + </NcDialog> </li> </template> <script> -import { generateUrl } from '@nextcloud/router' -import { Type as ShareTypes } from '@nextcloud/sharing' -import Vue from 'vue' - -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 ActionLink from '@nextcloud/vue/dist/Components/ActionLink' -import ActionText from '@nextcloud/vue/dist/Components/ActionText' -import ActionSeparator from '@nextcloud/vue/dist/Components/ActionSeparator' -import ActionTextEditable from '@nextcloud/vue/dist/Components/ActionTextEditable' -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 ExternalShareAction from './ExternalShareAction' -import SharePermissionsEditor from './SharePermissionsEditor' -import GeneratePassword from '../utils/GeneratePassword' -import Share from '../models/Share' -import SharesMixin from '../mixins/SharesMixin' +import { showError, showSuccess } from '@nextcloud/dialogs' +import { emit } from '@nextcloud/event-bus' +import { t } from '@nextcloud/l10n' +import moment from '@nextcloud/moment' +import { generateUrl, getBaseUrl } from '@nextcloud/router' +import { ShareType } from '@nextcloud/sharing' + +import VueQrcode from '@chenfengyuan/vue-qrcode' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcActionCheckbox from '@nextcloud/vue/components/NcActionCheckbox' +import NcActionInput from '@nextcloud/vue/components/NcActionInput' +import NcActionLink from '@nextcloud/vue/components/NcActionLink' +import NcActionText from '@nextcloud/vue/components/NcActionText' +import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcDialog from '@nextcloud/vue/components/NcDialog' + +import Tune from 'vue-material-design-icons/Tune.vue' +import IconCalendarBlank from 'vue-material-design-icons/CalendarBlankOutline.vue' +import IconQr from 'vue-material-design-icons/Qrcode.vue' +import ErrorIcon from 'vue-material-design-icons/Exclamation.vue' +import LockIcon from 'vue-material-design-icons/LockOutline.vue' +import CheckIcon from 'vue-material-design-icons/CheckBold.vue' +import ClipboardIcon from 'vue-material-design-icons/ContentCopy.vue' +import CloseIcon from 'vue-material-design-icons/Close.vue' +import PlusIcon from 'vue-material-design-icons/Plus.vue' + +import SharingEntryQuickShareSelect from './SharingEntryQuickShareSelect.vue' +import ShareExpiryTime from './ShareExpiryTime.vue' + +import ExternalShareAction from './ExternalShareAction.vue' +import GeneratePassword from '../utils/GeneratePassword.ts' +import Share from '../models/Share.ts' +import SharesMixin from '../mixins/SharesMixin.js' +import ShareDetails from '../mixins/ShareDetails.js' +import logger from '../services/logger.ts' export default { name: 'SharingEntryLink', components: { - Actions, - ActionButton, - ActionCheckbox, - ActionInput, - ActionLink, - ActionText, - ActionTextEditable, - ActionSeparator, - Avatar, ExternalShareAction, - SharePermissionsEditor, - }, - - directives: { - Tooltip, + NcActions, + NcActionButton, + NcActionCheckbox, + NcActionInput, + NcActionLink, + NcActionText, + NcActionSeparator, + NcAvatar, + NcDialog, + VueQrcode, + Tune, + IconCalendarBlank, + IconQr, + ErrorIcon, + LockIcon, + CheckIcon, + ClipboardIcon, + CloseIcon, + PlusIcon, + SharingEntryQuickShareSelect, + ShareExpiryTime, }, - mixins: [SharesMixin], + mixins: [SharesMixin, ShareDetails], props: { canReshare: { type: Boolean, default: true, }, + index: { + type: Number, + default: null, + }, }, data() { return { + shareCreationComplete: false, copySuccess: true, copied: false, + defaultExpirationDateEnabled: false, // Are we waiting for password/expiration date pending: false, ExternalLegacyLinkActions: OCA.Sharing.ExternalLinkActions.state, ExternalShareActions: OCA.Sharing.ExternalShareActions.state, + + // tracks whether modal should be opened or not + showQRCode: false, } }, @@ -374,6 +328,8 @@ export default { * @return {string} */ title() { + const l10nOptions = { escape: false /* no escape as this string is already escaped by Vue */ } + // if we have a valid existing share (not pending) if (this.share && this.share.id) { if (!this.isShareOwner && this.share.ownerDisplayName) { @@ -381,27 +337,46 @@ export default { return t('files_sharing', '{shareWith} by {initiator}', { shareWith: this.share.shareWith, initiator: this.share.ownerDisplayName, - }) + }, l10nOptions) } return t('files_sharing', 'Shared via link by {initiator}', { initiator: this.share.ownerDisplayName, - }) + }, l10nOptions) } if (this.share.label && this.share.label.trim() !== '') { if (this.isEmailShareType) { + if (this.isFileRequest) { + return t('files_sharing', 'File request ({label})', { + label: this.share.label.trim(), + }, l10nOptions) + } return t('files_sharing', 'Mail share ({label})', { label: this.share.label.trim(), - }) + }, l10nOptions) } return t('files_sharing', 'Share link ({label})', { label: this.share.label.trim(), - }) + }, l10nOptions) } if (this.isEmailShareType) { + if (!this.share.shareWith || this.share.shareWith.trim() === '') { + return this.isFileRequest + ? t('files_sharing', 'File request') + : t('files_sharing', 'Mail share') + } return this.share.shareWith } + + if (this.index === null) { + return t('files_sharing', 'Share link') + } + } + + if (this.index >= 1) { + return t('files_sharing', 'Share link ({index})', { index: this.index }) } - return t('files_sharing', 'Share link') + + return t('files_sharing', 'Create public link') }, /** @@ -417,48 +392,18 @@ export default { return null }, - /** - * Does the current share have an expiration date - * - * @return {boolean} - */ - hasExpirationDate: { - get() { - return this.config.isDefaultExpireDateEnforced - || !!this.share.expireDate - }, - set(enabled) { - let dateString = moment(this.config.defaultExpirationDateString) - if (!dateString.isValid()) { - dateString = moment() - } - this.share.state.expiration = enabled - ? dateString.format('YYYY-MM-DD') - : '' - console.debug('Expiration date status', enabled, this.share.expireDate) - }, - }, + passwordExpirationTime() { + if (this.share.passwordExpirationTime === null) { + return null + } - dateMaxEnforced() { - return this.config.isDefaultExpireDateEnforced - && moment().add(1 + this.config.defaultExpireDate, 'days') - }, + const expirationTime = moment(this.share.passwordExpirationTime) - /** - * Is the current share password protected ? - * - * @return {boolean} - */ - isPasswordProtected: { - get() { - return this.config.enforcePasswordForPublicLink - || !!this.share.password - }, - async set(enabled) { - // TODO: directly save after generation to make sure the share is always protected - Vue.set(this.share, 'password', enabled ? await GeneratePassword() : '') - Vue.set(this.share, 'newPassword', this.share.password) - }, + if (expirationTime.diff(moment()) < 0) { + return false + } + + return expirationTime.fromNow() }, /** @@ -500,7 +445,7 @@ export default { */ isEmailShareType() { return this.share - ? this.share.type === this.SHARE_TYPES.SHARE_TYPE_EMAIL + ? this.share.type === ShareType.Email : false }, @@ -525,13 +470,50 @@ export default { * * @return {boolean} */ + pendingDataIsMissing() { + return this.pendingPassword || this.pendingEnforcedPassword || this.pendingDefaultExpirationDate || this.pendingEnforcedExpirationDate + }, pendingPassword() { - return this.config.enforcePasswordForPublicLink && this.share && !this.share.id + return this.config.enableLinkPasswordByDefault && this.isPendingShare + }, + pendingEnforcedPassword() { + return this.config.enforcePasswordForPublicLink && this.isPendingShare + }, + pendingEnforcedExpirationDate() { + return this.config.isDefaultExpireDateEnforced && this.isPendingShare }, - pendingExpirationDate() { - return this.config.isDefaultExpireDateEnforced && this.share && !this.share.id + pendingDefaultExpirationDate() { + return (this.config.defaultExpirationDate instanceof Date || !isNaN(new Date(this.config.defaultExpirationDate).getTime())) && this.isPendingShare }, + isPendingShare() { + return !!(this.share && !this.share.id) + }, + sharePolicyHasEnforcedProperties() { + return this.config.enforcePasswordForPublicLink || this.config.isDefaultExpireDateEnforced + }, + + enforcedPropertiesMissing() { + // Ensure share exist and the share policy has required properties + if (!this.sharePolicyHasEnforcedProperties) { + return false + } + + if (!this.share) { + // if no share, we can't tell if properties are missing or not so we assume properties are missing + return true + } + + // If share has ID, then this is an incoming link share created from the existing link share + // Hence assume required properties + if (this.share.id) { + return true + } + // Check if either password or expiration date is missing and enforced + const isPasswordMissing = this.config.enforcePasswordForPublicLink && !this.share.password + const isExpireDateMissing = this.config.isDefaultExpireDateEnforced && !this.share.expireDate + return isPasswordMissing || isExpireDateMissing + }, // if newPassword exists, but is empty, it means // the user deleted the original password hasUnsavedPassword() { @@ -544,21 +526,31 @@ export default { * @return {string} */ shareLink() { - return window.location.protocol + '//' + window.location.host + generateUrl('/s/') + this.share.token + return generateUrl('/s/{token}', { token: this.share.token }, { baseURL: getBaseUrl() }) }, /** - * Clipboard v-tooltip message + * Tooltip message for actions button * * @return {string} */ - clipboardTooltip() { + actionsTooltip() { + return t('files_sharing', 'Actions for "{title}"', { title: this.title }) + }, + + /** + * Tooltip message for copy button + * + * @return {string} + */ + copyLinkTooltip() { if (this.copied) { - return this.copySuccess - ? t('files_sharing', 'Link copied') - : t('files_sharing', 'Cannot copy, please copy the link manually') + if (this.copySuccess) { + return '' + } + return t('files_sharing', 'Cannot copy, please copy the link manually') } - return t('files_sharing', 'Copy to clipboard') + return t('files_sharing', 'Copy public link of "{title}"', { title: this.title }) }, /** @@ -577,64 +569,85 @@ export default { * @return {Array} */ externalLinkActions() { + const filterValidAction = (action) => (action.shareType.includes(ShareType.Link) || action.shareType.includes(ShareType.Email)) && !action.advanced // filter only the registered actions for said link return this.ExternalShareActions.actions - .filter(action => action.shareType.includes(ShareTypes.SHARE_TYPE_LINK) - || action.shareType.includes(ShareTypes.SHARE_TYPE_EMAIL)) + .filter(filterValidAction) }, isPasswordPolicyEnabled() { return typeof this.config.passwordPolicy === 'object' }, + + canChangeHideDownload() { + const hasDisabledDownload = (shareAttribute) => shareAttribute.scope === 'permissions' && shareAttribute.key === 'download' && shareAttribute.value === false + return this.fileInfo.shareAttributes.some(hasDisabledDownload) + }, + + isFileRequest() { + return this.share.isFileRequest + }, + }, + mounted() { + this.defaultExpirationDateEnabled = this.config.defaultExpirationDate instanceof Date + if (this.share && this.isNewShare) { + this.share.expireDate = this.defaultExpirationDateEnabled ? this.formatDateToString(this.config.defaultExpirationDate) : '' + } }, methods: { /** + * Check if the share requires review + * + * @param {boolean} shareReviewComplete if the share was reviewed + * @return {boolean} + */ + shareRequiresReview(shareReviewComplete) { + // If a user clicks 'Create share' it means they have reviewed the share + if (shareReviewComplete) { + return false + } + return this.defaultExpirationDateEnabled || this.config.enableLinkPasswordByDefault + }, + /** * Create a new share link and append it to the list + * @param {boolean} shareReviewComplete if the share was reviewed */ - async onNewLinkShare() { + async onNewLinkShare(shareReviewComplete = false) { + logger.debug('onNewLinkShare called (with this.share)', this.share) // do not run again if already loading if (this.loading) { return } const shareDefaults = { - share_type: ShareTypes.SHARE_TYPE_LINK, + share_type: ShareType.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 GeneratePassword() + shareDefaults.expiration = this.formatDateToString(this.config.defaultExpirationDate) } - // do not push yet if we need a password or an expiration date: show pending menu - if (this.config.enforcePasswordForPublicLink || this.config.isDefaultExpireDateEnforced) { + logger.debug('Missing required properties?', this.enforcedPropertiesMissing) + // Do not push yet if we need a password or an expiration date: show pending menu + // A share would require a review for example is default expiration date is set but not enforced, this allows + // the user to review the share and remove the expiration date if they don't want it + if ((this.sharePolicyHasEnforcedProperties && this.enforcedPropertiesMissing) || this.shareRequiresReview(shareReviewComplete === true)) { this.pending = true + this.shareCreationComplete = false - // if a share already exists, pushing it - if (this.share && !this.share.id) { - // if the share is valid, create it on the server - 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 - } - } + logger.info('Share policy requires a review or has mandated properties (password, expirationDate)...') // ELSE, show the pending popovermenu - // if password enforced, pre-fill with random one - if (this.config.enforcePasswordForPublicLink) { - shareDefaults.password = await GeneratePassword() + // if password default or enforced, pre-fill with random one + if (this.config.enableLinkPasswordByDefault || this.config.enforcePasswordForPublicLink) { + shareDefaults.password = await GeneratePassword(true) } // create share & close menu const share = new Share(shareDefaults) + share.newPassword = share.password const component = await new Promise(resolve => { this.$emit('add:share', share, resolve) }) @@ -645,10 +658,34 @@ export default { this.pending = false component.open = true - // Nothing is enforced, creating share directly + // Nothing is enforced, creating share directly } else { + + // if a share already exists, pushing it + if (this.share && !this.share.id) { + // if the share is valid, create it on the server + if (this.checkShare(this.share)) { + try { + logger.info('Sending existing share to server', this.share) + await this.pushNewLinkShare(this.share, true) + this.shareCreationComplete = true + logger.info('Share created on server', this.share) + } catch (e) { + this.pending = false + logger.error('Error creating share', e) + return false + } + return true + } else { + this.open = true + showError(t('files_sharing', 'Error, please enter proper password and/or expiration date')) + return false + } + } + const share = new Share(shareDefaults) await this.pushNewLinkShare(share) + this.shareCreationComplete = true } }, @@ -658,7 +695,7 @@ export default { * accordingly * * @param {Share} share the new share - * @param {boolean} [update=false] do we update the current share ? + * @param {boolean} [update] do we update the current share ? */ async pushNewLinkShare(share, update) { try { @@ -671,22 +708,25 @@ export default { this.errors = {} const path = (this.fileInfo.path + '/' + this.fileInfo.name).replace('//', '/') - const newShare = await this.createShare({ + const options = { path, - shareType: ShareTypes.SHARE_TYPE_LINK, + shareType: ShareType.Link, password: share.password, - expireDate: share.expireDate, + expireDate: share.expireDate ?? '', + attributes: JSON.stringify(this.fileInfo.shareAttributes), // 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 + // lib/Controller/ShareAPIController.php to allow file requests // (currently not supported on create, only update) - }) + } - this.open = false + console.debug('Creating link share with options', options) + const newShare = await this.createShare(options) + this.open = false + this.shareCreationComplete = true console.debug('Link share created', newShare) - // if share already exists, copy link directly on next tick let component if (update) { @@ -702,6 +742,9 @@ export default { }) } + await this.getNode() + emit('files:node:updated', this.node) + // Execute the copy link method // freshly created share component // ! somehow does not works on firefox ! @@ -710,9 +753,16 @@ export default { // otherwise the user needs to copy/paste the password before finishing the share. component.copyLink() } + showSuccess(t('files_sharing', 'Link share created')) + + } catch (data) { + const message = data?.response?.data?.ocs?.meta?.message + if (!message) { + showError(t('files_sharing', 'Error while creating the share')) + console.error(data) + return + } - } catch ({ response }) { - const message = response.data.ocs.meta.message if (message.match(/password/i)) { this.onSyncError('password', message) } else if (message.match(/date/i)) { @@ -720,33 +770,17 @@ export default { } else { this.onSyncError('pending', message) } + throw data + } finally { this.loading = false - } - }, - - /** - * Label changed, let's save it to a different key - * - * @param {string} label the share label - */ - onLabelChange(label) { - this.$set(this.share, 'newLabel', label.trim()) - }, - - /** - * When the note change, we trim, save and dispatch - */ - onLabelSubmit() { - if (typeof this.share.newLabel === 'string') { - this.share.label = this.share.newLabel - this.$delete(this.share, 'newLabel') - this.queueUpdate('label') + this.shareCreationComplete = true } }, async copyLink() { try { - await this.$copyText(this.shareLink) + await navigator.clipboard.writeText(this.shareLink) + showSuccess(t('files_sharing', 'Link copied')) // focus and show the tooltip this.$refs.copyButton.$el.focus() this.copySuccess = true @@ -796,7 +830,7 @@ export default { }, /** - * Menu have been closed or password has been submited. + * Menu have been closed or password has been submitted. * The only property that does not get * synced automatically is the password * So let's check if we have an unsaved @@ -806,7 +840,7 @@ export default { */ onPasswordSubmit() { if (this.hasUnsavedPassword) { - this.share.password = this.share.newPassword.trim() + this.share.newPassword = this.share.newPassword.trim() this.queueUpdate('password') } }, @@ -821,7 +855,7 @@ export default { */ onPasswordProtectedByTalkChange() { if (this.hasUnsavedPassword) { - this.share.password = this.share.newPassword.trim() + this.share.newPassword = this.share.newPassword.trim() } this.queueUpdate('sendPasswordByTalk', 'password') @@ -836,6 +870,19 @@ export default { }, /** + * @param enabled True if expiration is enabled + */ + onExpirationDateToggleUpdate(enabled) { + this.share.expireDate = enabled ? this.formatDateToString(this.config.defaultExpirationDate) : '' + }, + + expirationDateChanged(event) { + const value = event?.target?.value + const isValid = !!value && !isNaN(new Date(value).getTime()) + this.defaultExpirationDateEnabled = isValid + }, + + /** * Cancel the share creation * Used in the pending popover */ @@ -843,10 +890,11 @@ export default { // 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) + if (!this.shareCreationComplete) { + this.$emit('remove:share', this.share) + } }, }, - } </script> @@ -855,23 +903,37 @@ export default { display: flex; align-items: center; min-height: 44px; - &__desc { + + &__summary { + padding: 8px; + padding-inline-start: 10px; display: flex; - flex-direction: column; justify-content: space-between; - padding: 8px; - line-height: 1.2em; - overflow: hidden; + flex: 1 0; + min-width: 0; + } + + &__desc { + display: flex; + flex-direction: column; + line-height: 1.2em; + + p { + color: var(--color-text-maxcontrast); + } - h5 { - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; + &__title { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } } - p { - color: var(--color-text-maxcontrast); + + &__actions { + display: flex; + align-items: center; + margin-inline-start: auto; } - } &:not(.sharing-entry--share) &__actions { .new-share-link { @@ -879,8 +941,8 @@ export default { } } - ::v-deep .avatar-link-share { - background-color: var(--color-primary); + :deep(.avatar-link-share) { + background-color: var(--color-primary-element); } .sharing-entry__action--public-upload { @@ -892,21 +954,34 @@ export default { height: 44px; margin: 0; padding: 14px; - margin-left: auto; + margin-inline-start: auto; } // put menus to the left // but only the first one .action-item { - margin-left: auto; - ~ .action-item, - ~ .sharing-entry__loading { - margin-left: 0; + + ~.action-item, + ~.sharing-entry__loading { + margin-inline-start: 0; } } .icon-checkmark-color { opacity: 1; + color: var(--color-success); + } +} + +// styling for the qr-code container +.qr-code-dialog { + display: flex; + width: 100%; + justify-content: center; + + &__img { + width: 100%; + height: auto; } } </style> |