diff options
Diffstat (limited to 'apps/files_sharing/src/views')
-rw-r--r-- | apps/files_sharing/src/views/CollaborationView.vue | 53 | ||||
-rw-r--r-- | apps/files_sharing/src/views/FilesHeaderNoteToRecipient.vue | 73 | ||||
-rw-r--r-- | apps/files_sharing/src/views/FilesViewFileDropEmptyContent.vue | 136 | ||||
-rw-r--r-- | apps/files_sharing/src/views/SharingDetailsTab.vue | 1310 | ||||
-rw-r--r-- | apps/files_sharing/src/views/SharingInherited.vue | 35 | ||||
-rw-r--r-- | apps/files_sharing/src/views/SharingLinkList.vue | 64 | ||||
-rw-r--r-- | apps/files_sharing/src/views/SharingList.vue | 59 | ||||
-rw-r--r-- | apps/files_sharing/src/views/SharingTab.vue | 478 |
8 files changed, 1938 insertions, 270 deletions
diff --git a/apps/files_sharing/src/views/CollaborationView.vue b/apps/files_sharing/src/views/CollaborationView.vue deleted file mode 100644 index a3249f8b5c7..00000000000 --- a/apps/files_sharing/src/views/CollaborationView.vue +++ /dev/null @@ -1,53 +0,0 @@ -<!-- - - @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> - - - - @author Julius Härtl <jus@bitgrid.net> - - - - @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> - <CollectionList v-if="fileId" - :id="fileId" - type="file" - :name="filename" /> -</template> - -<script> -import { CollectionList } from 'nextcloud-vue-collections' - -export default { - name: 'CollaborationView', - components: { - CollectionList, - }, - computed: { - fileId() { - if (this.$root.model && this.$root.model.id) { - return '' + this.$root.model.id - } - return null - }, - filename() { - if (this.$root.model && this.$root.model.name) { - return '' + this.$root.model.name - } - return '' - }, - }, -} -</script> diff --git a/apps/files_sharing/src/views/FilesHeaderNoteToRecipient.vue b/apps/files_sharing/src/views/FilesHeaderNoteToRecipient.vue new file mode 100644 index 00000000000..ec6348606fb --- /dev/null +++ b/apps/files_sharing/src/views/FilesHeaderNoteToRecipient.vue @@ -0,0 +1,73 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <NcNoteCard v-if="note.length > 0" + class="note-to-recipient" + type="info"> + <p v-if="displayName" class="note-to-recipient__heading"> + {{ t('files_sharing', 'Note from') }} + <NcUserBubble :user="user.id" :display-name="user.displayName" /> + </p> + <p v-else class="note-to-recipient__heading"> + {{ t('files_sharing', 'Note:') }} + </p> + <p class="note-to-recipient__text" v-text="note" /> + </NcNoteCard> +</template> + +<script setup lang="ts"> +import type { Folder } from '@nextcloud/files' +import { getCurrentUser } from '@nextcloud/auth' +import { t } from '@nextcloud/l10n' +import { computed, ref } from 'vue' + +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' +import NcUserBubble from '@nextcloud/vue/components/NcUserBubble' + +const folder = ref<Folder>() +const note = computed<string>(() => folder.value?.attributes.note ?? '') +const displayName = computed<string>(() => folder.value?.attributes['owner-display-name'] ?? '') +const user = computed(() => { + const id = folder.value?.owner + if (id !== getCurrentUser()?.uid) { + return { + id, + displayName: displayName.value, + } + } + return null +}) + +/** + * Update the current folder + * @param newFolder the new folder to show note for + */ +function updateFolder(newFolder: Folder) { + folder.value = newFolder +} + +defineExpose({ updateFolder }) +</script> + +<style scoped> +.note-to-recipient { + margin-inline: var(--row-height) +} + +.note-to-recipient__text { + /* respect new lines */ + white-space: pre-line; +} + +.note-to-recipient__heading { + font-weight: bold; +} + +@media screen and (max-width: 512px) { + .note-to-recipient { + margin-inline: var(--default-grid-baseline); + } +} +</style> diff --git a/apps/files_sharing/src/views/FilesViewFileDropEmptyContent.vue b/apps/files_sharing/src/views/FilesViewFileDropEmptyContent.vue new file mode 100644 index 00000000000..dac22748d8a --- /dev/null +++ b/apps/files_sharing/src/views/FilesViewFileDropEmptyContent.vue @@ -0,0 +1,136 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <NcEmptyContent class="file-drop-empty-content" + data-cy-files-sharing-file-drop + :name="name"> + <template #icon> + <NcIconSvgWrapper :svg="svgCloudUpload" /> + </template> + <template #description> + <p> + {{ shareNote || t('files_sharing', 'Upload files to {foldername}.', { foldername }) }} + </p> + <p v-if="disclaimer"> + {{ t('files_sharing', 'By uploading files, you agree to the terms of service.') }} + </p> + <NcNoteCard v-if="getSortedUploads().length" + class="file-drop-empty-content__note-card" + type="success"> + <h2 id="file-drop-empty-content__heading"> + {{ t('files_sharing', 'Successfully uploaded files') }} + </h2> + <ul aria-labelledby="file-drop-empty-content__heading" class="file-drop-empty-content__list"> + <li v-for="file in getSortedUploads()" :key="file"> + {{ file }} + </li> + </ul> + </NcNoteCard> + </template> + <template #action> + <template v-if="disclaimer"> + <!-- Terms of service if enabled --> + <NcButton type="primary" @click="showDialog = true"> + {{ t('files_sharing', 'View terms of service') }} + </NcButton> + <NcDialog close-on-click-outside + content-classes="terms-of-service-dialog" + :open.sync="showDialog" + :name="t('files_sharing', 'Terms of service')" + :message="disclaimer" /> + </template> + <UploadPicker allow-folders + :content="() => []" + no-menu + :destination="uploadDestination" + multiple /> + </template> + </NcEmptyContent> +</template> + +<script lang="ts"> +/* eslint-disable import/first */ + +// We need this on module level rather than on the instance as view will be refreshed by the files app after uploading +const uploads = new Set<string>() +</script> + +<script setup lang="ts"> +import { loadState } from '@nextcloud/initial-state' +import { translate as t } from '@nextcloud/l10n' +import { getUploader, UploadPicker, UploadStatus } from '@nextcloud/upload' +import { ref } from 'vue' + +import NcButton from '@nextcloud/vue/components/NcButton' +import NcDialog from '@nextcloud/vue/components/NcDialog' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' +import svgCloudUpload from '@mdi/svg/svg/cloud-upload-outline.svg?raw' + +defineProps<{ + foldername: string +}>() + +const disclaimer = loadState<string>('files_sharing', 'disclaimer', '') +const shareLabel = loadState<string>('files_sharing', 'label', '') +const shareNote = loadState<string>('files_sharing', 'note', '') + +const name = shareLabel || t('files_sharing', 'File drop') + +const showDialog = ref(false) +const uploadDestination = getUploader().destination + +getUploader() + .addNotifier((upload) => { + if (upload.status === UploadStatus.FINISHED && upload.file.name) { + // if a upload is finished and is not a meta upload (name is set) + // then we add the upload to the list of finished uploads to be shown to the user + uploads.add(upload.file.name) + } + }) + +/** + * Get the previous uploads as sorted list + */ +function getSortedUploads() { + return [...uploads].sort((a, b) => a.localeCompare(b)) +} +</script> + +<style scoped lang="scss"> +.file-drop-empty-content { + margin: auto; + max-width: max(50vw, 300px); + + .file-drop-empty-content__note-card { + width: fit-content; + margin-inline: auto; + } + + #file-drop-empty-content__heading { + margin-block: 0 10px; + font-weight: bold; + font-size: 20px; + } + + .file-drop-empty-content__list { + list-style: inside; + max-height: min(350px, 33vh); + overflow-y: scroll; + padding-inline-end: calc(2 * var(--default-grid-baseline)); + } + + :deep(.terms-of-service-dialog) { + min-height: min(100px, 20vh); + } + + /* TODO fix in library */ + :deep(.empty-content__action) { + display: flex; + gap: var(--default-grid-baseline); + } +} +</style> diff --git a/apps/files_sharing/src/views/SharingDetailsTab.vue b/apps/files_sharing/src/views/SharingDetailsTab.vue new file mode 100644 index 00000000000..b3a3b95d92e --- /dev/null +++ b/apps/files_sharing/src/views/SharingDetailsTab.vue @@ -0,0 +1,1310 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <div class="sharingTabDetailsView"> + <div class="sharingTabDetailsView__header"> + <span> + <NcAvatar v-if="isUserShare" + class="sharing-entry__avatar" + :is-no-user="share.shareType !== ShareType.User" + :user="share.shareWith" + :display-name="share.shareWithDisplayName" + :menu-position="'left'" + :url="share.shareWithAvatar" /> + <component :is="getShareTypeIcon(share.type)" :size="32" /> + </span> + <span> + <h1>{{ title }}</h1> + </span> + </div> + <div class="sharingTabDetailsView__wrapper"> + <div ref="quickPermissions" class="sharingTabDetailsView__quick-permissions"> + <div> + <NcCheckboxRadioSwitch :button-variant="true" + data-cy-files-sharing-share-permissions-bundle="read-only" + :checked.sync="sharingPermission" + :value="bundledPermissions.READ_ONLY.toString()" + name="sharing_permission_radio" + type="radio" + button-variant-grouped="vertical" + @update:checked="toggleCustomPermissions"> + {{ t('files_sharing', 'View only') }} + <template #icon> + <ViewIcon :size="20" /> + </template> + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch :button-variant="true" + data-cy-files-sharing-share-permissions-bundle="upload-edit" + :checked.sync="sharingPermission" + :value="allPermissions" + name="sharing_permission_radio" + type="radio" + button-variant-grouped="vertical" + @update:checked="toggleCustomPermissions"> + <template v-if="allowsFileDrop"> + {{ t('files_sharing', 'Allow upload and editing') }} + </template> + <template v-else> + {{ t('files_sharing', 'Allow editing') }} + </template> + <template #icon> + <EditIcon :size="20" /> + </template> + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch v-if="allowsFileDrop" + data-cy-files-sharing-share-permissions-bundle="file-drop" + :button-variant="true" + :checked.sync="sharingPermission" + :value="bundledPermissions.FILE_DROP.toString()" + name="sharing_permission_radio" + type="radio" + button-variant-grouped="vertical" + @update:checked="toggleCustomPermissions"> + {{ t('files_sharing', 'File request') }} + <small class="subline">{{ t('files_sharing', 'Upload only') }}</small> + <template #icon> + <UploadIcon :size="20" /> + </template> + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch :button-variant="true" + data-cy-files-sharing-share-permissions-bundle="custom" + :checked.sync="sharingPermission" + :value="'custom'" + name="sharing_permission_radio" + type="radio" + button-variant-grouped="vertical" + @update:checked="expandCustomPermissions"> + {{ t('files_sharing', 'Custom permissions') }} + <small class="subline">{{ customPermissionsList }}</small> + <template #icon> + <DotsHorizontalIcon :size="20" /> + </template> + </NcCheckboxRadioSwitch> + </div> + </div> + <div class="sharingTabDetailsView__advanced-control"> + <NcButton id="advancedSectionAccordionAdvancedControl" + type="tertiary" + alignment="end-reverse" + aria-controls="advancedSectionAccordionAdvanced" + :aria-expanded="advancedControlExpandedValue" + @click="advancedSectionAccordionExpanded = !advancedSectionAccordionExpanded"> + {{ t('files_sharing', 'Advanced settings') }} + <template #icon> + <MenuDownIcon v-if="!advancedSectionAccordionExpanded" /> + <MenuUpIcon v-else /> + </template> + </NcButton> + </div> + <div v-if="advancedSectionAccordionExpanded" + id="advancedSectionAccordionAdvanced" + class="sharingTabDetailsView__advanced" + aria-labelledby="advancedSectionAccordionAdvancedControl" + role="region"> + <section> + <NcInputField v-if="isPublicShare" + class="sharingTabDetailsView__label" + autocomplete="off" + :label="t('files_sharing', 'Share label')" + :value.sync="share.label" /> + <NcInputField v-if="config.allowCustomTokens && isPublicShare && !isNewShare" + autocomplete="off" + :label="t('files_sharing', 'Share link token')" + :helper-text="t('files_sharing', 'Set the public share link token to something easy to remember or generate a new token. It is not recommended to use a guessable token for shares which contain sensitive information.')" + show-trailing-button + :trailing-button-label="loadingToken ? t('files_sharing', 'Generating…') : t('files_sharing', 'Generate new token')" + :value.sync="share.token" + @trailing-button-click="generateNewToken"> + <template #trailing-button-icon> + <NcLoadingIcon v-if="loadingToken" /> + <Refresh v-else :size="20" /> + </template> + </NcInputField> + <template v-if="isPublicShare"> + <NcCheckboxRadioSwitch :checked.sync="isPasswordProtected" :disabled="isPasswordEnforced"> + {{ t('files_sharing', 'Set password') }} + </NcCheckboxRadioSwitch> + <NcPasswordField v-if="isPasswordProtected" + autocomplete="new-password" + :value="share.newPassword ?? ''" + :error="passwordError" + :helper-text="errorPasswordLabel || passwordHint" + :required="isPasswordEnforced && isNewShare" + :label="t('files_sharing', 'Password')" + @update:value="onPasswordChange" /> + + <!-- Migrate icons and remote -> icon="icon-info"--> + <span v-if="isEmailShareType && passwordExpirationTime" icon="icon-info"> + {{ t('files_sharing', 'Password expires {passwordExpirationTime}', { passwordExpirationTime }) }} + </span> + <span v-else-if="isEmailShareType && passwordExpirationTime !== null" icon="icon-error"> + {{ t('files_sharing', 'Password expired') }} + </span> + </template> + <NcCheckboxRadioSwitch v-if="canTogglePasswordProtectedByTalkAvailable" + :checked.sync="isPasswordProtectedByTalk" + @update:checked="onPasswordProtectedByTalkChange"> + {{ t('files_sharing', 'Video verification') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch :checked.sync="hasExpirationDate" :disabled="isExpiryDateEnforced"> + {{ isExpiryDateEnforced + ? t('files_sharing', 'Expiration date (enforced)') + : t('files_sharing', 'Set expiration date') }} + </NcCheckboxRadioSwitch> + <NcDateTimePickerNative v-if="hasExpirationDate" + id="share-date-picker" + :value="new Date(share.expireDate ?? dateTomorrow)" + :min="dateTomorrow" + :max="maxExpirationDateEnforced" + hide-label + :label="t('files_sharing', 'Expiration date')" + :placeholder="t('files_sharing', 'Expiration date')" + type="date" + @input="onExpirationChange" /> + <NcCheckboxRadioSwitch v-if="isPublicShare" + :disabled="canChangeHideDownload" + :checked.sync="share.hideDownload" + @update:checked="queueUpdate('hideDownload')"> + {{ t('files_sharing', 'Hide download') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch v-else + :disabled="!canSetDownload" + :checked.sync="canDownload" + data-cy-files-sharing-share-permissions-checkbox="download"> + {{ t('files_sharing', 'Allow download and sync') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch :checked.sync="writeNoteToRecipientIsChecked"> + {{ t('files_sharing', 'Note to recipient') }} + </NcCheckboxRadioSwitch> + <template v-if="writeNoteToRecipientIsChecked"> + <NcTextArea :label="t('files_sharing', 'Note to recipient')" + :placeholder="t('files_sharing', 'Enter a note for the share recipient')" + :value.sync="share.note" /> + </template> + <NcCheckboxRadioSwitch v-if="isPublicShare && isFolder" + :checked.sync="showInGridView"> + {{ t('files_sharing', 'Show files in grid view') }} + </NcCheckboxRadioSwitch> + <ExternalShareAction v-for="action in externalLinkActions" + :id="action.id" + ref="externalLinkActions" + :key="action.id" + :action="action" + :file-info="fileInfo" + :share="share" /> + <NcCheckboxRadioSwitch :checked.sync="setCustomPermissions"> + {{ t('files_sharing', 'Custom permissions') }} + </NcCheckboxRadioSwitch> + <section v-if="setCustomPermissions" class="custom-permissions-group"> + <NcCheckboxRadioSwitch :disabled="!canRemoveReadPermission" + :checked.sync="hasRead" + data-cy-files-sharing-share-permissions-checkbox="read"> + {{ t('files_sharing', 'Read') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch v-if="isFolder" + :disabled="!canSetCreate" + :checked.sync="canCreate" + data-cy-files-sharing-share-permissions-checkbox="create"> + {{ t('files_sharing', 'Create') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch :disabled="!canSetEdit" + :checked.sync="canEdit" + data-cy-files-sharing-share-permissions-checkbox="update"> + {{ t('files_sharing', 'Edit') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch v-if="resharingIsPossible" + :disabled="!canSetReshare" + :checked.sync="canReshare" + data-cy-files-sharing-share-permissions-checkbox="share"> + {{ t('files_sharing', 'Share') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch :disabled="!canSetDelete" + :checked.sync="canDelete" + data-cy-files-sharing-share-permissions-checkbox="delete"> + {{ t('files_sharing', 'Delete') }} + </NcCheckboxRadioSwitch> + </section> + </section> + </div> + </div> + + <div class="sharingTabDetailsView__footer"> + <div class="button-group"> + <NcButton data-cy-files-sharing-share-editor-action="cancel" + @click="cancel"> + {{ t('files_sharing', 'Cancel') }} + </NcButton> + <div class="sharingTabDetailsView__delete"> + <NcButton v-if="!isNewShare" + :aria-label="t('files_sharing', 'Delete share')" + :disabled="false" + :readonly="false" + variant="tertiary" + @click.prevent="removeShare"> + <template #icon> + <CloseIcon :size="20" /> + </template> + {{ t('files_sharing', 'Delete share') }} + </NcButton> + </div> + <NcButton type="primary" + data-cy-files-sharing-share-editor-action="save" + :disabled="creating" + @click="saveShare"> + {{ shareButtonText }} + <template v-if="creating" #icon> + <NcLoadingIcon /> + </template> + </NcButton> + </div> + </div> + </div> +</template> + +<script> +import { emit } from '@nextcloud/event-bus' +import { getLanguage } from '@nextcloud/l10n' +import { ShareType } from '@nextcloud/sharing' +import { showError } from '@nextcloud/dialogs' +import moment from '@nextcloud/moment' + +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' +import NcDateTimePickerNative from '@nextcloud/vue/components/NcDateTimePickerNative' +import NcInputField from '@nextcloud/vue/components/NcInputField' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' +import NcPasswordField from '@nextcloud/vue/components/NcPasswordField' +import NcTextArea from '@nextcloud/vue/components/NcTextArea' + +import CircleIcon from 'vue-material-design-icons/CircleOutline.vue' +import CloseIcon from 'vue-material-design-icons/Close.vue' +import EditIcon from 'vue-material-design-icons/PencilOutline.vue' +import EmailIcon from 'vue-material-design-icons/Email.vue' +import LinkIcon from 'vue-material-design-icons/Link.vue' +import GroupIcon from 'vue-material-design-icons/AccountGroup.vue' +import ShareIcon from 'vue-material-design-icons/ShareCircle.vue' +import UserIcon from 'vue-material-design-icons/AccountCircleOutline.vue' +import ViewIcon from 'vue-material-design-icons/Eye.vue' +import UploadIcon from 'vue-material-design-icons/Upload.vue' +import MenuDownIcon from 'vue-material-design-icons/MenuDown.vue' +import MenuUpIcon from 'vue-material-design-icons/MenuUp.vue' +import DotsHorizontalIcon from 'vue-material-design-icons/DotsHorizontal.vue' +import Refresh from 'vue-material-design-icons/Refresh.vue' + +import ExternalShareAction from '../components/ExternalShareAction.vue' + +import GeneratePassword from '../utils/GeneratePassword.ts' +import Share from '../models/Share.ts' +import ShareRequests from '../mixins/ShareRequests.js' +import SharesMixin from '../mixins/SharesMixin.js' +import { generateToken } from '../services/TokenService.ts' +import logger from '../services/logger.ts' + +import { + ATOMIC_PERMISSIONS, + BUNDLED_PERMISSIONS, + hasPermissions, +} from '../lib/SharePermissionsToolBox.js' + +export default { + name: 'SharingDetailsTab', + components: { + NcAvatar, + NcButton, + NcCheckboxRadioSwitch, + NcDateTimePickerNative, + NcInputField, + NcLoadingIcon, + NcPasswordField, + NcTextArea, + CloseIcon, + CircleIcon, + EditIcon, + ExternalShareAction, + LinkIcon, + GroupIcon, + ShareIcon, + UserIcon, + UploadIcon, + ViewIcon, + MenuDownIcon, + MenuUpIcon, + DotsHorizontalIcon, + Refresh, + }, + mixins: [ShareRequests, SharesMixin], + props: { + shareRequestValue: { + type: Object, + required: false, + }, + fileInfo: { + type: Object, + required: true, + }, + share: { + type: Object, + required: true, + }, + }, + data() { + return { + writeNoteToRecipientIsChecked: false, + sharingPermission: BUNDLED_PERMISSIONS.ALL.toString(), + revertSharingPermission: BUNDLED_PERMISSIONS.ALL.toString(), + setCustomPermissions: false, + passwordError: false, + advancedSectionAccordionExpanded: false, + bundledPermissions: BUNDLED_PERMISSIONS, + isFirstComponentLoad: true, + test: false, + creating: false, + initialToken: this.share.token, + loadingToken: false, + + ExternalShareActions: OCA.Sharing.ExternalShareActions.state, + } + }, + + computed: { + title() { + switch (this.share.type) { + case ShareType.User: + return t('files_sharing', 'Share with {user}', { user: this.share.shareWithDisplayName }) + case ShareType.Email: + return t('files_sharing', 'Share with email {email}', { email: this.share.shareWith }) + case ShareType.Link: + return t('files_sharing', 'Share link') + case ShareType.Group: + return t('files_sharing', 'Share with group') + case ShareType.Room: + return t('files_sharing', 'Share in conversation') + case ShareType.Remote: { + const [user, server] = this.share.shareWith.split('@') + if (this.config.showFederatedSharesAsInternal) { + return t('files_sharing', 'Share with {user}', { user }) + } + return t('files_sharing', 'Share with {user} on remote server {server}', { user, server }) + } + case ShareType.RemoteGroup: + return t('files_sharing', 'Share with remote group') + case ShareType.Guest: + return t('files_sharing', 'Share with guest') + default: { + if (this.share.id) { + // Share already exists + return t('files_sharing', 'Update share') + } else { + return t('files_sharing', 'Create share') + } + } + } + }, + allPermissions() { + return this.isFolder ? this.bundledPermissions.ALL.toString() : this.bundledPermissions.ALL_FILE.toString() + }, + /** + * Can the sharee edit the shared file ? + */ + canEdit: { + get() { + return this.share.hasUpdatePermission + }, + set(checked) { + this.updateAtomicPermissions({ isEditChecked: checked }) + }, + }, + /** + * Can the sharee create the shared file ? + */ + canCreate: { + get() { + return this.share.hasCreatePermission + }, + set(checked) { + this.updateAtomicPermissions({ isCreateChecked: checked }) + }, + }, + /** + * Can the sharee delete the shared file ? + */ + canDelete: { + get() { + return this.share.hasDeletePermission + }, + set(checked) { + this.updateAtomicPermissions({ isDeleteChecked: checked }) + }, + }, + /** + * Can the sharee reshare the file ? + */ + canReshare: { + get() { + return this.share.hasSharePermission + }, + set(checked) { + this.updateAtomicPermissions({ isReshareChecked: checked }) + }, + }, + + /** + * Change the default view for public shares from "list" to "grid" + */ + showInGridView: { + get() { + return this.getShareAttribute('config', 'grid_view', false) + }, + /** @param {boolean} value If the default view should be changed to "grid" */ + set(value) { + this.setShareAttribute('config', 'grid_view', value) + }, + }, + + /** + * Can the sharee download files or only view them ? + */ + canDownload: { + get() { + return this.getShareAttribute('permissions', 'download', true) + }, + set(checked) { + this.setShareAttribute('permissions', 'download', checked) + }, + }, + /** + * Is this share readable + * Needed for some federated shares that might have been added from file requests links + */ + hasRead: { + get() { + return this.share.hasReadPermission + }, + set(checked) { + this.updateAtomicPermissions({ isReadChecked: checked }) + }, + }, + /** + * Does the current share have an expiration date + * + * @return {boolean} + */ + hasExpirationDate: { + get() { + return this.isValidShareAttribute(this.share.expireDate) + }, + set(enabled) { + this.share.expireDate = enabled + ? this.formatDateToString(this.defaultExpiryDate) + : '' + }, + }, + /** + * Is the current share a folder ? + * + * @return {boolean} + */ + isFolder() { + return this.fileInfo.type === 'dir' + }, + /** + * @return {boolean} + */ + isSetDownloadButtonVisible() { + const allowedMimetypes = [ + // Office documents + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.oasis.opendocument.text', + 'application/vnd.oasis.opendocument.spreadsheet', + 'application/vnd.oasis.opendocument.presentation', + ] + + return this.isFolder || allowedMimetypes.includes(this.fileInfo.mimetype) + }, + isPasswordEnforced() { + return this.isPublicShare && this.config.enforcePasswordForPublicLink + }, + defaultExpiryDate() { + if ((this.isGroupShare || this.isUserShare) && this.config.isDefaultInternalExpireDateEnabled) { + return new Date(this.config.defaultInternalExpirationDate) + } else if (this.isRemoteShare && this.config.isDefaultRemoteExpireDateEnabled) { + return new Date(this.config.defaultRemoteExpireDateEnabled) + } else if (this.isPublicShare && this.config.isDefaultExpireDateEnabled) { + return new Date(this.config.defaultExpirationDate) + } + return new Date(new Date().setDate(new Date().getDate() + 1)) + }, + isUserShare() { + return this.share.type === ShareType.User + }, + isGroupShare() { + return this.share.type === ShareType.Group + }, + allowsFileDrop() { + if (this.isFolder && this.config.isPublicUploadEnabled) { + if (this.share.type === ShareType.Link || this.share.type === ShareType.Email) { + return true + } + } + return false + }, + hasFileDropPermissions() { + return this.share.permissions === this.bundledPermissions.FILE_DROP + }, + shareButtonText() { + if (this.isNewShare) { + return t('files_sharing', 'Save share') + } + return t('files_sharing', 'Update share') + + }, + resharingIsPossible() { + return this.config.isResharingAllowed && this.share.type !== ShareType.Link && this.share.type !== ShareType.Email + }, + /** + * Can the sharer set whether the sharee can edit the file ? + * + * @return {boolean} + */ + canSetEdit() { + // If the owner revoked the permission after the resharer granted it + // the share still has the permission, and the resharer is still + // allowed to revoke it too (but not to grant it again). + return (this.fileInfo.sharePermissions & OC.PERMISSION_UPDATE) || this.canEdit + }, + + /** + * Can the sharer set whether the sharee can create the file ? + * + * @return {boolean} + */ + canSetCreate() { + // If the owner revoked the permission after the resharer granted it + // the share still has the permission, and the resharer is still + // allowed to revoke it too (but not to grant it again). + return (this.fileInfo.sharePermissions & OC.PERMISSION_CREATE) || this.canCreate + }, + + /** + * Can the sharer set whether the sharee can delete the file ? + * + * @return {boolean} + */ + canSetDelete() { + // If the owner revoked the permission after the resharer granted it + // the share still has the permission, and the resharer is still + // allowed to revoke it too (but not to grant it again). + return (this.fileInfo.sharePermissions & OC.PERMISSION_DELETE) || this.canDelete + }, + /** + * Can the sharer set whether the sharee can reshare the file ? + * + * @return {boolean} + */ + canSetReshare() { + // If the owner revoked the permission after the resharer granted it + // the share still has the permission, and the resharer is still + // allowed to revoke it too (but not to grant it again). + return (this.fileInfo.sharePermissions & OC.PERMISSION_SHARE) || this.canReshare + }, + /** + * Can the sharer set whether the sharee can download the file ? + * + * @return {boolean} + */ + canSetDownload() { + // If the owner revoked the permission after the resharer granted it + // the share still has the permission, and the resharer is still + // allowed to revoke it too (but not to grant it again). + return (this.fileInfo.canDownload() || this.canDownload) + }, + canRemoveReadPermission() { + return this.allowsFileDrop && ( + this.share.type === ShareType.Link + || this.share.type === ShareType.Email + ) + }, + // if newPassword exists, but is empty, it means + // the user deleted the original password + hasUnsavedPassword() { + return this.share.newPassword !== undefined + }, + passwordExpirationTime() { + if (!this.isValidShareAttribute(this.share.passwordExpirationTime)) { + return null + } + + const expirationTime = moment(this.share.passwordExpirationTime) + + if (expirationTime.diff(moment()) < 0) { + return false + } + + return expirationTime.fromNow() + }, + + /** + * Is Talk enabled? + * + * @return {boolean} + */ + isTalkEnabled() { + return OC.appswebroots.spreed !== undefined + }, + + /** + * Is it possible to protect the password by Talk? + * + * @return {boolean} + */ + isPasswordProtectedByTalkAvailable() { + return this.isPasswordProtected && this.isTalkEnabled + }, + /** + * Is the current share password protected by Talk? + * + * @return {boolean} + */ + isPasswordProtectedByTalk: { + get() { + return this.share.sendPasswordByTalk + }, + async set(enabled) { + this.share.sendPasswordByTalk = enabled + }, + }, + /** + * Is the current share an email share ? + * + * @return {boolean} + */ + isEmailShareType() { + return this.share + ? this.share.type === ShareType.Email + : false + }, + canTogglePasswordProtectedByTalkAvailable() { + if (!this.isPublicShare || !this.isPasswordProtected) { + // Makes no sense + return false + } else if (this.isEmailShareType && !this.hasUnsavedPassword) { + // For email shares we need a new password in order to enable or + // disable + return false + } + + // Is Talk enabled? + return OC.appswebroots.spreed !== undefined + }, + canChangeHideDownload() { + const hasDisabledDownload = (shareAttribute) => shareAttribute.key === 'download' && shareAttribute.scope === 'permissions' && shareAttribute.value === false + return this.fileInfo.shareAttributes.some(hasDisabledDownload) + }, + customPermissionsList() { + // Key order will be different, because ATOMIC_PERMISSIONS are numbers + const translatedPermissions = { + [ATOMIC_PERMISSIONS.READ]: this.t('files_sharing', 'Read'), + [ATOMIC_PERMISSIONS.CREATE]: this.t('files_sharing', 'Create'), + [ATOMIC_PERMISSIONS.UPDATE]: this.t('files_sharing', 'Edit'), + [ATOMIC_PERMISSIONS.SHARE]: this.t('files_sharing', 'Share'), + [ATOMIC_PERMISSIONS.DELETE]: this.t('files_sharing', 'Delete'), + } + + const permissionsList = [ + ATOMIC_PERMISSIONS.READ, + ...(this.isFolder ? [ATOMIC_PERMISSIONS.CREATE] : []), + ATOMIC_PERMISSIONS.UPDATE, + ...(this.resharingIsPossible ? [ATOMIC_PERMISSIONS.SHARE] : []), + ...(this.isFolder ? [ATOMIC_PERMISSIONS.DELETE] : []), + ] + + return permissionsList.filter((permission) => hasPermissions(this.share.permissions, permission)) + .map((permission, index) => index === 0 + ? translatedPermissions[permission] + : translatedPermissions[permission].toLocaleLowerCase(getLanguage())) + .join(', ') + }, + advancedControlExpandedValue() { + return this.advancedSectionAccordionExpanded ? 'true' : 'false' + }, + errorPasswordLabel() { + if (this.passwordError) { + return t('files_sharing', 'Password field cannot be empty') + } + return undefined + }, + + passwordHint() { + if (this.isNewShare || this.hasUnsavedPassword) { + return undefined + } + return t('files_sharing', 'Replace current password') + }, + + /** + * Additional actions for the menu + * + * @return {Array} + */ + externalLinkActions() { + const filterValidAction = (action) => (action.shareType.includes(ShareType.Link) || action.shareType.includes(ShareType.Email)) && action.advanced + // filter only the advanced registered actions for said link + return this.ExternalShareActions.actions + .filter(filterValidAction) + }, + }, + watch: { + setCustomPermissions(isChecked) { + if (isChecked) { + this.sharingPermission = 'custom' + } else { + this.sharingPermission = this.revertSharingPermission + } + }, + }, + beforeMount() { + this.initializePermissions() + this.initializeAttributes() + logger.debug('Share object received', { share: this.share }) + logger.debug('Configuration object received', { config: this.config }) + }, + + mounted() { + this.$refs.quickPermissions?.querySelector('input:checked')?.focus() + }, + + methods: { + /** + * Set a share attribute on the current share + * @param {string} scope The attribute scope + * @param {string} key The attribute key + * @param {boolean} value The value + */ + setShareAttribute(scope, key, value) { + if (!this.share.attributes) { + this.$set(this.share, 'attributes', []) + } + + const attribute = this.share.attributes + .find((attr) => attr.scope === scope || attr.key === key) + + if (attribute) { + attribute.value = value + } else { + this.share.attributes.push({ + scope, + key, + value, + }) + } + }, + + /** + * Get the value of a share attribute + * @param {string} scope The attribute scope + * @param {string} key The attribute key + * @param {undefined|boolean} fallback The fallback to return if not found + */ + getShareAttribute(scope, key, fallback = undefined) { + const attribute = this.share.attributes?.find((attr) => attr.scope === scope && attr.key === key) + return attribute?.value ?? fallback + }, + + async generateNewToken() { + if (this.loadingToken) { + return + } + this.loadingToken = true + try { + this.share.token = await generateToken() + } catch (error) { + showError(t('files_sharing', 'Failed to generate a new token')) + } + this.loadingToken = false + }, + + cancel() { + this.share.token = this.initialToken + this.$emit('close-sharing-details') + }, + + updateAtomicPermissions({ + isReadChecked = this.hasRead, + isEditChecked = this.canEdit, + isCreateChecked = this.canCreate, + isDeleteChecked = this.canDelete, + isReshareChecked = this.canReshare, + } = {}) { + // calc permissions if checked + + if (!this.isFolder && (isCreateChecked || isDeleteChecked)) { + logger.debug('Ignoring create/delete permissions for file share — only available for folders') + isCreateChecked = false + isDeleteChecked = false + } + + const permissions = 0 + | (isReadChecked ? ATOMIC_PERMISSIONS.READ : 0) + | (isCreateChecked ? ATOMIC_PERMISSIONS.CREATE : 0) + | (isDeleteChecked ? ATOMIC_PERMISSIONS.DELETE : 0) + | (isEditChecked ? ATOMIC_PERMISSIONS.UPDATE : 0) + | (isReshareChecked ? ATOMIC_PERMISSIONS.SHARE : 0) + this.share.permissions = permissions + }, + expandCustomPermissions() { + if (!this.advancedSectionAccordionExpanded) { + this.advancedSectionAccordionExpanded = true + } + this.toggleCustomPermissions() + }, + toggleCustomPermissions(selectedPermission) { + const isCustomPermissions = this.sharingPermission === 'custom' + this.revertSharingPermission = !isCustomPermissions ? selectedPermission : 'custom' + this.setCustomPermissions = isCustomPermissions + }, + async initializeAttributes() { + + if (this.isNewShare) { + if ((this.config.enableLinkPasswordByDefault || this.isPasswordEnforced) && this.isPublicShare) { + this.$set(this.share, 'newPassword', await GeneratePassword(true)) + this.advancedSectionAccordionExpanded = true + } + /* Set default expiration dates if configured */ + if (this.isPublicShare && this.config.isDefaultExpireDateEnabled) { + this.share.expireDate = this.config.defaultExpirationDate.toDateString() + } else if (this.isRemoteShare && this.config.isDefaultRemoteExpireDateEnabled) { + this.share.expireDate = this.config.defaultRemoteExpirationDateString.toDateString() + } else if (this.config.isDefaultInternalExpireDateEnabled) { + this.share.expireDate = this.config.defaultInternalExpirationDate.toDateString() + } + + if (this.isValidShareAttribute(this.share.expireDate)) { + this.advancedSectionAccordionExpanded = true + } + + return + } + + // If there is an enforced expiry date, then existing shares created before enforcement + // have no expiry date, hence we set it here. + if (!this.isValidShareAttribute(this.share.expireDate) && this.isExpiryDateEnforced) { + this.hasExpirationDate = true + } + + if ( + this.isValidShareAttribute(this.share.password) + || this.isValidShareAttribute(this.share.expireDate) + || this.isValidShareAttribute(this.share.label) + ) { + this.advancedSectionAccordionExpanded = true + } + + if (this.isValidShareAttribute(this.share.note)) { + this.writeNoteToRecipientIsChecked = true + this.advancedSectionAccordionExpanded = true + } + + }, + handleShareType() { + if ('shareType' in this.share) { + this.share.type = this.share.shareType + } else if (this.share.share_type) { + this.share.type = this.share.share_type + } + }, + handleDefaultPermissions() { + if (this.isNewShare) { + const defaultPermissions = this.config.defaultPermissions + if (defaultPermissions === BUNDLED_PERMISSIONS.READ_ONLY || defaultPermissions === BUNDLED_PERMISSIONS.ALL) { + this.sharingPermission = defaultPermissions.toString() + } else { + this.sharingPermission = 'custom' + this.share.permissions = defaultPermissions + this.advancedSectionAccordionExpanded = true + this.setCustomPermissions = true + } + } + // Read permission required for share creation + if (!this.canRemoveReadPermission) { + this.hasRead = true + } + }, + handleCustomPermissions() { + if (!this.isNewShare && (this.hasCustomPermissions || this.share.setCustomPermissions)) { + this.sharingPermission = 'custom' + this.advancedSectionAccordionExpanded = true + this.setCustomPermissions = true + } else if (this.share.permissions) { + this.sharingPermission = this.share.permissions.toString() + } + }, + initializePermissions() { + this.handleShareType() + this.handleDefaultPermissions() + this.handleCustomPermissions() + }, + async saveShare() { + const permissionsAndAttributes = ['permissions', 'attributes', 'note', 'expireDate'] + const publicShareAttributes = ['label', 'password', 'hideDownload'] + if (this.config.allowCustomTokens) { + publicShareAttributes.push('token') + } + if (this.isPublicShare) { + permissionsAndAttributes.push(...publicShareAttributes) + } + const sharePermissionsSet = parseInt(this.sharingPermission) + if (this.setCustomPermissions) { + this.updateAtomicPermissions() + } else { + this.share.permissions = sharePermissionsSet + } + + if (!this.isFolder && this.share.permissions === BUNDLED_PERMISSIONS.ALL) { + // It's not possible to create an existing file. + this.share.permissions = BUNDLED_PERMISSIONS.ALL_FILE + } + if (!this.writeNoteToRecipientIsChecked) { + this.share.note = '' + } + if (this.isPasswordProtected) { + if (this.isPasswordEnforced && this.isNewShare && !this.isValidShareAttribute(this.share.password)) { + this.passwordError = true + } + } else { + this.share.password = '' + } + + if (!this.hasExpirationDate) { + this.share.expireDate = '' + } + + if (this.isNewShare) { + const incomingShare = { + permissions: this.share.permissions, + shareType: this.share.type, + shareWith: this.share.shareWith, + attributes: this.share.attributes, + note: this.share.note, + fileInfo: this.fileInfo, + } + + incomingShare.expireDate = this.hasExpirationDate ? this.share.expireDate : '' + + if (this.isPasswordProtected) { + incomingShare.password = this.share.newPassword + } + + let share + try { + this.creating = true + share = await this.addShare(incomingShare) + } catch (error) { + this.creating = false + // Error is already handled by ShareRequests mixin + return + } + + // ugly hack to make code work - we need the id to be set but at the same time we need to keep values we want to update + this.share._share.id = share.id + await this.queueUpdate(...permissionsAndAttributes) + // Also a ugly hack to update the updated permissions + for (const prop of permissionsAndAttributes) { + if (prop in share && prop in this.share) { + try { + share[prop] = this.share[prop] + } catch { + share._share[prop] = this.share[prop] + } + } + } + + this.share = share + this.creating = false + this.$emit('add:share', this.share) + } else { + // Let's update after creation as some attrs are only available after creation + await this.queueUpdate(...permissionsAndAttributes) + this.$emit('update:share', this.share) + } + + await this.getNode() + emit('files:node:updated', this.node) + + if (this.$refs.externalLinkActions?.length > 0) { + await Promise.allSettled(this.$refs.externalLinkActions.map((action) => { + if (typeof action.$children.at(0)?.onSave !== 'function') { + return Promise.resolve() + } + return action.$children.at(0)?.onSave?.() + })) + } + + this.$emit('close-sharing-details') + }, + /** + * Process the new share request + * + * @param {Share} share incoming share object + */ + async addShare(share) { + logger.debug('Adding a new share from the input for', { share }) + const path = this.path + try { + const resultingShare = await this.createShare({ + path, + shareType: share.shareType, + shareWith: share.shareWith, + permissions: share.permissions, + expireDate: share.expireDate, + attributes: JSON.stringify(share.attributes), + ...(share.note ? { note: share.note } : {}), + ...(share.password ? { password: share.password } : {}), + }) + return resultingShare + } catch (error) { + logger.error('Error while adding new share', { error }) + } finally { + // this.loading = false // No loader here yet + } + }, + async removeShare() { + await this.onDelete() + await this.getNode() + emit('files:node:updated', this.node) + this.$emit('close-sharing-details') + }, + /** + * 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) { + if (password === '') { + this.$delete(this.share, 'newPassword') + this.passwordError = this.isNewShare && this.isPasswordEnforced + return + } + this.passwordError = !this.isValidShareAttribute(password) + this.$set(this.share, 'newPassword', password) + }, + /** + * Update the password along with "sendPasswordByTalk". + * + * If the password was modified the new password is sent; otherwise + * updating a mail share would fail, as in that case it is required that + * a new password is set when enabling or disabling + * "sendPasswordByTalk". + */ + onPasswordProtectedByTalkChange() { + this.queueUpdate('sendPasswordByTalk', 'password') + }, + isValidShareAttribute(value) { + if ([null, undefined].includes(value)) { + return false + } + + if (!(value.trim().length > 0)) { + return false + } + + return true + }, + getShareTypeIcon(type) { + switch (type) { + case ShareType.Link: + return LinkIcon + case ShareType.Guest: + return UserIcon + case ShareType.RemoteGroup: + case ShareType.Group: + return GroupIcon + case ShareType.Email: + return EmailIcon + case ShareType.Team: + return CircleIcon + case ShareType.Room: + return ShareIcon + case ShareType.Deck: + return ShareIcon + case ShareType.ScienceMesh: + return ShareIcon + default: + return null // Or a default icon component if needed + } + }, + }, +} +</script> + +<style lang="scss" scoped> +.sharingTabDetailsView { + display: flex; + flex-direction: column; + width: 100%; + margin: 0 auto; + position: relative; + height: 100%; + overflow: hidden; + + &__header { + display: flex; + align-items: center; + box-sizing: border-box; + margin: 0.2em; + + span { + display: flex; + align-items: center; + + h1 { + font-size: 15px; + padding-inline-start: 0.3em; + } + + } + } + + &__wrapper { + position: relative; + overflow: scroll; + flex-shrink: 1; + padding: 4px; + padding-inline-end: 12px; + } + + &__quick-permissions { + display: flex; + justify-content: center; + width: 100%; + margin: 0 auto; + border-radius: 0; + + div { + width: 100%; + + span { + width: 100%; + + span:nth-child(1) { + align-items: center; + justify-content: center; + padding: 0.1em; + } + + :deep(label span) { + display: flex; + flex-direction: column; + } + + /* Target component based style in NcCheckboxRadioSwitch slot content*/ + :deep(span.checkbox-content__text.checkbox-radio-switch__text) { + flex-wrap: wrap; + + .subline { + display: block; + flex-basis: 100%; + } + } + } + + } + } + + &__advanced-control { + width: 100%; + + button { + margin-top: 0.5em; + } + + } + + &__advanced { + width: 100%; + margin-bottom: 0.5em; + text-align: start; + padding-inline-start: 0; + + section { + + textarea, + div.mx-datepicker { + width: 100%; + } + + textarea { + height: 80px; + margin: 0; + } + + /* + The following style is applied out of the component's scope + to remove padding from the label.checkbox-radio-switch__label, + which is used to group radio checkbox items. The use of ::v-deep + ensures that the padding is modified without being affected by + the component's scoping. + Without this achieving left alignment for the checkboxes would not + be possible. + */ + span :deep(label) { + padding-inline-start: 0 !important; + background-color: initial !important; + border: none !important; + } + + section.custom-permissions-group { + padding-inline-start: 1.5em; + } + } + } + + &__label { + padding-block-end: 6px; + } + + &__delete { + > button:first-child { + color: rgb(223, 7, 7); + } + } + + &__footer { + width: 100%; + display: flex; + position: sticky; + bottom: 0; + flex-direction: column; + justify-content: space-between; + align-items: flex-start; + background: linear-gradient(to bottom, rgba(255, 255, 255, 0), var(--color-main-background)); + + .button-group { + display: flex; + justify-content: space-between; + width: 100%; + margin-top: 16px; + + button { + margin-inline-start: 16px; + + &:first-child { + margin-inline-start: 0; + } + } + } + } +} +</style> diff --git a/apps/files_sharing/src/views/SharingInherited.vue b/apps/files_sharing/src/views/SharingInherited.vue index b570b47e257..809de522d93 100644 --- a/apps/files_sharing/src/views/SharingInherited.vue +++ b/apps/files_sharing/src/views/SharingInherited.vue @@ -1,27 +1,10 @@ <!-- - - @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> - <ul id="sharing-inherited-shares"> + <ul v-if="shares.length" id="sharing-inherited-shares"> <!-- Main collapsible entry --> <SharingEntrySimple class="sharing-entry__inherited" :title="mainTitle" @@ -47,12 +30,12 @@ <script> import { generateOcsUrl } from '@nextcloud/router' -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' import axios from '@nextcloud/axios' -import Share from '../models/Share' -import SharingEntryInherited from '../components/SharingEntryInherited' -import SharingEntrySimple from '../components/SharingEntrySimple' +import Share from '../models/Share.ts' +import SharingEntryInherited from '../components/SharingEntryInherited.vue' +import SharingEntrySimple from '../components/SharingEntrySimple.vue' export default { name: 'SharingInherited', @@ -94,7 +77,7 @@ export default { }, subTitle() { return (this.showInheritedShares && this.shares.length === 0) - ? t('files_sharing', 'No other users with access found') + ? t('files_sharing', 'No other accounts with access found') : '' }, toggleTooltip() { diff --git a/apps/files_sharing/src/views/SharingLinkList.vue b/apps/files_sharing/src/views/SharingLinkList.vue index ee7418c00d5..c3d9a7f83dc 100644 --- a/apps/files_sharing/src/views/SharingLinkList.vue +++ b/apps/files_sharing/src/views/SharingLinkList.vue @@ -1,33 +1,12 @@ <!-- - - @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> - <ul v-if="canLinkShare" class="sharing-link-list"> - <!-- If no link shares, show the add link default entry --> - <SharingEntryLink v-if="!hasLinkShares && canReshare" - :can-reshare="canReshare" - :file-info="fileInfo" - @add:share="addShare" /> - + <ul v-if="canLinkShare" + :aria-label="t('files_sharing', 'Link shares')" + class="sharing-link-list"> <!-- Else we display the list --> <template v-if="hasShares"> <!-- using shares[index] to work with .sync --> @@ -39,16 +18,27 @@ :file-info="fileInfo" @add:share="addShare(...arguments)" @update:share="awaitForShare(...arguments)" - @remove:share="removeShare" /> + @remove:share="removeShare" + @open-sharing-details="openSharingDetails(share)" /> </template> + + <!-- If no link shares, show the add link default entry --> + <SharingEntryLink v-if="!hasLinkShares && canReshare" + :can-reshare="canReshare" + :file-info="fileInfo" + @add:share="addShare" /> </ul> </template> <script> -// eslint-disable-next-line no-unused-vars -import Share from '../models/Share' -import ShareTypes from '../mixins/ShareTypes' -import SharingEntryLink from '../components/SharingEntryLink' +import { getCapabilities } from '@nextcloud/capabilities' + +import { t } from '@nextcloud/l10n' + +import Share from '../models/Share.js' +import SharingEntryLink from '../components/SharingEntryLink.vue' +import ShareDetails from '../mixins/ShareDetails.js' +import { ShareType } from '@nextcloud/sharing' export default { name: 'SharingLinkList', @@ -57,7 +47,7 @@ export default { SharingEntryLink, }, - mixins: [ShareTypes], + mixins: [ShareDetails], props: { fileInfo: { @@ -78,7 +68,7 @@ export default { data() { return { - canLinkShare: OC.getCapabilities().files_sharing.public.enabled, + canLinkShare: getCapabilities().files_sharing.public.enabled, } }, @@ -91,7 +81,7 @@ export default { * @return {Array} */ hasLinkShares() { - return this.shares.filter(share => share.type === this.SHARE_TYPES.SHARE_TYPE_LINK).length > 0 + return this.shares.filter(share => share.type === ShareType.Link).length > 0 }, /** @@ -105,6 +95,8 @@ export default { }, methods: { + t, + /** * Add a new share into the link shares list * and return the newly created share component @@ -114,7 +106,7 @@ export default { */ addShare(share, resolve) { // eslint-disable-next-line vue/no-mutating-props - this.shares.unshift(share) + this.shares.push(share) this.awaitForShare(share, resolve) }, diff --git a/apps/files_sharing/src/views/SharingList.vue b/apps/files_sharing/src/views/SharingList.vue index 0635ad27635..2167059772e 100644 --- a/apps/files_sharing/src/views/SharingList.vue +++ b/apps/files_sharing/src/views/SharingList.vue @@ -1,41 +1,24 @@ <!-- - - @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> - <ul class="sharing-sharee-list"> + <ul class="sharing-sharee-list" :aria-label="t('files_sharing', 'Shares')"> <SharingEntry v-for="share in shares" :key="share.id" :file-info="fileInfo" :share="share" :is-unique="isUnique(share)" - @remove:share="removeShare" /> + @open-sharing-details="openSharingDetails(share)" /> </ul> </template> <script> -// eslint-disable-next-line no-unused-vars -import Share from '../models/Share' -import SharingEntry from '../components/SharingEntry' -import ShareTypes from '../mixins/ShareTypes' +import { t } from '@nextcloud/l10n' +import SharingEntry from '../components/SharingEntry.vue' +import ShareDetails from '../mixins/ShareDetails.js' +import { ShareType } from '@nextcloud/sharing' export default { name: 'SharingList', @@ -44,12 +27,12 @@ export default { SharingEntry, }, - mixins: [ShareTypes], + mixins: [ShareDetails], props: { fileInfo: { type: Object, - default: () => {}, + default: () => { }, required: true, }, shares: { @@ -59,6 +42,11 @@ export default { }, }, + setup() { + return { + t, + } + }, computed: { hasShares() { return this.shares.length === 0 @@ -66,23 +54,10 @@ export default { isUnique() { return (share) => { return [...this.shares].filter((item) => { - return share.type === this.SHARE_TYPES.SHARE_TYPE_USER && share.shareWithDisplayName === item.shareWithDisplayName + return share.type === ShareType.User && share.shareWithDisplayName === item.shareWithDisplayName }).length <= 1 } }, }, - - methods: { - /** - * Remove a share from the shares list - * - * @param {Share} share the share to remove - */ - removeShare(share) { - const index = this.shares.findIndex(item => item === share) - // eslint-disable-next-line vue/no-mutating-props - this.shares.splice(index, 1) - }, - }, } </script> diff --git a/apps/files_sharing/src/views/SharingTab.vue b/apps/files_sharing/src/views/SharingTab.vue index f7920346981..2ed44a4b5ad 100644 --- a/apps/files_sharing/src/views/SharingTab.vue +++ b/apps/files_sharing/src/views/SharingTab.vue @@ -1,27 +1,10 @@ <!-- - - @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> - <div :class="{ 'icon-loading': loading }"> + <div class="sharingTab" :class="{ 'icon-loading': loading }"> <!-- error message --> <div v-if="error" class="emptycontent" :class="{ emptyContentWithSections: sections.length > 0 }"> <div class="icon icon-error" /> @@ -29,100 +12,204 @@ </div> <!-- shares content --> - <div v-else class="sharingTab__content"> + <div v-show="!showSharingDetailsView" + class="sharingTab__content"> <!-- shared with me information --> - <SharingEntrySimple v-if="isSharedWithMe" v-bind="sharedWithMe" class="sharing-entry__reshare"> - <template #avatar> - <NcAvatar :user="sharedWithMe.user" - :title="sharedWithMe.displayName" - class="sharing-entry__avatar" /> - </template> - </SharingEntrySimple> - - <!-- add new share input --> - <SharingInput v-if="!loading" - :can-reshare="canReshare" - :file-info="fileInfo" - :link-shares="linkShares" - :reshare="reshare" - :shares="shares" - @add:share="addShare" /> - - <!-- link shares list --> - <SharingLinkList v-if="!loading" - ref="linkShareList" - :can-reshare="canReshare" - :file-info="fileInfo" - :shares="linkShares" /> - - <!-- other shares list --> - <SharingList v-if="!loading" - ref="shareList" - :shares="shares" - :file-info="fileInfo" /> - - <!-- inherited shares --> - <SharingInherited v-if="canReshare && !loading" :file-info="fileInfo" /> - - <!-- internal link copy --> - <SharingEntryInternal :file-info="fileInfo" /> - - <!-- projects --> - <CollectionList v-if="projectsEnabled && fileInfo" - :id="`${fileInfo.id}`" - type="file" - :name="fileInfo.name" /> + <ul v-if="isSharedWithMe"> + <SharingEntrySimple v-bind="sharedWithMe" class="sharing-entry__reshare"> + <template #avatar> + <NcAvatar :user="sharedWithMe.user" + :display-name="sharedWithMe.displayName" + class="sharing-entry__avatar" /> + </template> + </SharingEntrySimple> + </ul> + + <section> + <div class="section-header"> + <h4>{{ t('files_sharing', 'Internal shares') }}</h4> + <NcPopover popup-role="dialog"> + <template #trigger> + <NcButton class="hint-icon" + type="tertiary-no-background" + :aria-label="t('files_sharing', 'Internal shares explanation')"> + <template #icon> + <InfoIcon :size="20" /> + </template> + </NcButton> + </template> + <p class="hint-body"> + {{ internalSharesHelpText }} + </p> + </NcPopover> + </div> + <!-- add new share input --> + <SharingInput v-if="!loading" + :can-reshare="canReshare" + :file-info="fileInfo" + :link-shares="linkShares" + :reshare="reshare" + :shares="shares" + :placeholder="internalShareInputPlaceholder" + @open-sharing-details="toggleShareDetailsView" /> + + <!-- other shares list --> + <SharingList v-if="!loading" + ref="shareList" + :shares="shares" + :file-info="fileInfo" + @open-sharing-details="toggleShareDetailsView" /> + + <!-- inherited shares --> + <SharingInherited v-if="canReshare && !loading" :file-info="fileInfo" /> + + <!-- internal link copy --> + <SharingEntryInternal :file-info="fileInfo" /> + </section> + + <section> + <div class="section-header"> + <h4>{{ t('files_sharing', 'External shares') }}</h4> + <NcPopover popup-role="dialog"> + <template #trigger> + <NcButton class="hint-icon" + type="tertiary-no-background" + :aria-label="t('files_sharing', 'External shares explanation')"> + <template #icon> + <InfoIcon :size="20" /> + </template> + </NcButton> + </template> + <p class="hint-body"> + {{ externalSharesHelpText }} + </p> + </NcPopover> + </div> + <SharingInput v-if="!loading" + :can-reshare="canReshare" + :file-info="fileInfo" + :link-shares="linkShares" + :is-external="true" + :placeholder="externalShareInputPlaceholder" + :reshare="reshare" + :shares="shares" + @open-sharing-details="toggleShareDetailsView" /> + <!-- Non link external shares list --> + <SharingList v-if="!loading" + :shares="externalShares" + :file-info="fileInfo" + @open-sharing-details="toggleShareDetailsView" /> + <!-- link shares list --> + <SharingLinkList v-if="!loading && isLinkSharingAllowed" + ref="linkShareList" + :can-reshare="canReshare" + :file-info="fileInfo" + :shares="linkShares" + @open-sharing-details="toggleShareDetailsView" /> + </section> + + <section v-if="sections.length > 0 && !showSharingDetailsView"> + <div class="section-header"> + <h4>{{ t('files_sharing', 'Additional shares') }}</h4> + <NcPopover popup-role="dialog"> + <template #trigger> + <NcButton class="hint-icon" + type="tertiary-no-background" + :aria-label="t('files_sharing', 'Additional shares explanation')"> + <template #icon> + <InfoIcon :size="20" /> + </template> + </NcButton> + </template> + <p class="hint-body"> + {{ additionalSharesHelpText }} + </p> + </NcPopover> + </div> + <!-- additional entries, use it with cautious --> + <div v-for="(component, index) in sectionComponents" + :key="index" + class="sharingTab__additionalContent"> + <component :is="component" :file-info="fileInfo" /> + </div> + + <!-- projects (deprecated as of NC25 (replaced by related_resources) - see instance config "projects.enabled" ; ignore this / remove it / move into own section) --> + <div v-if="projectsEnabled" + v-show="!showSharingDetailsView && fileInfo" + class="sharingTab__additionalContent"> + <NcCollectionList :id="`${fileInfo.id}`" + type="file" + :name="fileInfo.name" /> + </div> + </section> </div> - <!-- additional entries, use it with cautious --> - <div v-for="(section, index) in sections" - :ref="'section-' + index" - :key="index" - class="sharingTab__additionalContent"> - <component :is="section($refs['section-'+index], fileInfo)" :file-info="fileInfo" /> - </div> + <!-- share details --> + <SharingDetailsTab v-if="showSharingDetailsView" + :file-info="shareDetailsData.fileInfo" + :share="shareDetailsData.share" + @close-sharing-details="toggleShareDetailsView" + @add:share="addShare" + @remove:share="removeShare" /> </div> </template> <script> -import { CollectionList } from 'nextcloud-vue-collections' +import { getCurrentUser } from '@nextcloud/auth' +import { getCapabilities } from '@nextcloud/capabilities' +import { orderBy } from '@nextcloud/files' +import { loadState } from '@nextcloud/initial-state' import { generateOcsUrl } from '@nextcloud/router' -import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar' +import { ShareType } from '@nextcloud/sharing' + +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcCollectionList from '@nextcloud/vue/components/NcCollectionList' +import NcPopover from '@nextcloud/vue/components/NcPopover' +import InfoIcon from 'vue-material-design-icons/InformationOutline.vue' + import axios from '@nextcloud/axios' -import { loadState } from '@nextcloud/initial-state' +import moment from '@nextcloud/moment' -import Config from '../services/ConfigService' -import { shareWithTitle } from '../utils/SharedWithMe' -import Share from '../models/Share' -import ShareTypes from '../mixins/ShareTypes' -import SharingEntryInternal from '../components/SharingEntryInternal' -import SharingEntrySimple from '../components/SharingEntrySimple' -import SharingInput from '../components/SharingInput' +import { shareWithTitle } from '../utils/SharedWithMe.js' -import SharingInherited from './SharingInherited' -import SharingLinkList from './SharingLinkList' -import SharingList from './SharingList' +import Config from '../services/ConfigService.ts' +import Share from '../models/Share.ts' +import SharingEntryInternal from '../components/SharingEntryInternal.vue' +import SharingEntrySimple from '../components/SharingEntrySimple.vue' +import SharingInput from '../components/SharingInput.vue' + +import SharingInherited from './SharingInherited.vue' +import SharingLinkList from './SharingLinkList.vue' +import SharingList from './SharingList.vue' +import SharingDetailsTab from './SharingDetailsTab.vue' + +import ShareDetails from '../mixins/ShareDetails.js' +import logger from '../services/logger.ts' export default { name: 'SharingTab', components: { + InfoIcon, NcAvatar, - CollectionList, + NcButton, + NcCollectionList, + NcPopover, SharingEntryInternal, SharingEntrySimple, SharingInherited, SharingInput, SharingLinkList, SharingList, + SharingDetailsTab, }, - - mixins: [ShareTypes], + mixins: [ShareDetails], data() { return { config: new Config(), - + deleteEvent: null, error: '', expirationInterval: null, loading: true, @@ -134,9 +221,17 @@ export default { sharedWithMe: {}, shares: [], linkShares: [], + externalShares: [], sections: OCA.Sharing.ShareTabSections.getSections(), projectsEnabled: loadState('core', 'projects_enabled', false), + showSharingDetailsView: false, + shareDetailsData: {}, + returnFocusElement: null, + + internalSharesHelpText: t('files_sharing', 'Share files within your organization. Recipients who can already view the file can also use this link for easy access.'), + externalSharesHelpText: t('files_sharing', 'Share files with others outside your organization via public links and email addresses. You can also share to Nextcloud accounts on other instances using their federated cloud ID.'), + additionalSharesHelpText: t('files_sharing', 'Shares from apps or other sources which are not included in internal or external shares.'), } }, @@ -147,15 +242,54 @@ export default { * @return {boolean} */ isSharedWithMe() { - return Object.keys(this.sharedWithMe).length > 0 + return !!this.sharedWithMe?.user + }, + + /** + * Is link sharing allowed for the current user? + * + * @return {boolean} + */ + isLinkSharingAllowed() { + const currentUser = getCurrentUser() + if (!currentUser) { + return false + } + + const capabilities = getCapabilities() + const publicSharing = capabilities.files_sharing?.public || {} + return publicSharing.enabled === true }, canReshare() { return !!(this.fileInfo.permissions & OC.PERMISSION_SHARE) || !!(this.reshare && this.reshare.hasSharePermission && this.config.isResharingAllowed) }, - }, + internalShareInputPlaceholder() { + return this.config.showFederatedSharesAsInternal && this.config.isFederationEnabled + // TRANSLATORS: Type as in with a keyboard + ? t('files_sharing', 'Type names, teams, federated cloud IDs') + // TRANSLATORS: Type as in with a keyboard + : t('files_sharing', 'Type names or teams') + }, + + externalShareInputPlaceholder() { + if (!this.isLinkSharingAllowed) { + // TRANSLATORS: Type as in with a keyboard + return this.config.isFederationEnabled ? t('files_sharing', 'Type a federated cloud ID') : '' + } + return !this.config.showFederatedSharesAsInternal && !this.config.isFederationEnabled + // TRANSLATORS: Type as in with a keyboard + ? t('files_sharing', 'Type an email') + // TRANSLATORS: Type as in with a keyboard + : t('files_sharing', 'Type an email or federated cloud ID') + }, + + sectionComponents() { + return this.sections.map((section) => section(undefined, this.fileInfo)) + }, + }, methods: { /** * Update current fileInfo and fetch new data @@ -167,7 +301,6 @@ export default { this.resetState() this.getShares() }, - /** * Get the existing shares infos */ @@ -205,7 +338,7 @@ export default { this.processSharedWithMe(sharedWithMe) this.processShares(shares) } catch (error) { - if (error.response.data?.ocs?.meta?.message) { + if (error?.response?.data?.ocs?.meta?.message) { this.error = error.response.data.ocs.meta.message } else { this.error = t('files_sharing', 'Unable to load the shares list') @@ -225,6 +358,8 @@ export default { this.sharedWithMe = {} this.shares = [] this.linkShares = [] + this.showSharingDetailsView = false + this.shareDetailsData = {} }, /** @@ -236,7 +371,7 @@ export default { updateExpirationSubtitle(share) { const expiration = moment(share.expireDate).unix() this.$set(this.sharedWithMe, 'subtitle', t('files_sharing', 'Expires {relativetime}', { - relativetime: OC.Util.relativeModifiedDate(expiration * 1000), + relativetime: moment(expiration * 1000).fromNow(), })) // share have expired @@ -256,16 +391,41 @@ export default { */ processShares({ data }) { if (data.ocs && data.ocs.data && data.ocs.data.length > 0) { - // create Share objects and sort by newest - const shares = data.ocs.data - .map(share => new Share(share)) - .sort((a, b) => b.createdTime - a.createdTime) - - this.linkShares = shares.filter(share => share.type === this.SHARE_TYPES.SHARE_TYPE_LINK || share.type === this.SHARE_TYPES.SHARE_TYPE_EMAIL) - this.shares = shares.filter(share => share.type !== this.SHARE_TYPES.SHARE_TYPE_LINK && share.type !== this.SHARE_TYPES.SHARE_TYPE_EMAIL) + const shares = orderBy( + data.ocs.data.map(share => new Share(share)), + [ + // First order by the "share with" label + (share) => share.shareWithDisplayName, + // Then by the label + (share) => share.label, + // And last resort order by createdTime + (share) => share.createdTime, + ], + ) + + for (const share of shares) { + if ([ShareType.Link, ShareType.Email].includes(share.type)) { + this.linkShares.push(share) + } else if ([ShareType.Remote, ShareType.RemoteGroup].includes(share.type)) { + if (this.config.showFederatedSharesToTrustedServersAsInternal) { + if (share.isTrustedServer) { + this.shares.push(share) + } else { + this.externalShares.push(share) + } + } else if (this.config.showFederatedSharesAsInternal) { + this.shares.push(share) + } else { + this.externalShares.push(share) + } + } else { + this.shares.push(share) + } + } - console.debug('Processed', this.linkShares.length, 'link share(s)') - console.debug('Processed', this.shares.length, 'share(s)') + logger.debug(`Processed ${this.linkShares.length} link share(s)`) + logger.debug(`Processed ${this.shares.length} share(s)`) + logger.debug(`Processed ${this.externalShares.length} external share(s)`) } }, @@ -298,7 +458,7 @@ export default { // interval update this.expirationInterval = setInterval(this.updateExpirationSubtitle, 10000, share) } - } else if (this.fileInfo && this.fileInfo.shareOwnerId !== undefined ? this.fileInfo.shareOwnerId !== OC.currentUser : false) { + } else if (this.fileInfo && this.fileInfo.shareOwnerId !== undefined ? this.fileInfo.shareOwnerId !== getCurrentUser().uid : false) { // Fallback to compare owner and current user. this.sharedWithMe = { displayName: this.fileInfo.shareOwner, @@ -307,7 +467,7 @@ export default { 'Shared with you by {owner}', { owner: this.fileInfo.shareOwner }, undefined, - { escape: false } + { escape: false }, ), user: this.fileInfo.shareOwnerId, } @@ -321,17 +481,43 @@ export default { * @param {Share} share the share to add to the array * @param {Function} [resolve] a function to run after the share is added and its component initialized */ - addShare(share, resolve = () => {}) { + addShare(share, resolve = () => { }) { // only catching share type MAIL as link shares are added differently // meaning: not from the ShareInput - if (share.type === this.SHARE_TYPES.SHARE_TYPE_EMAIL) { + if (share.type === ShareType.Email) { this.linkShares.unshift(share) + } else if ([ShareType.Remote, ShareType.RemoteGroup].includes(share.type)) { + if (this.config.showFederatedSharesAsInternal) { + this.shares.unshift(share) + } if (this.config.showFederatedSharesToTrustedServersAsInternal) { + if (share.isTrustedServer) { + this.shares.unshift(share) + } + } else { + this.externalShares.unshift(share) + } } else { this.shares.unshift(share) } this.awaitForShare(share, resolve) }, - + /** + * Remove a share from the shares list + * + * @param {Share} share the share to remove + */ + removeShare(share) { + // Get reference for this.linkShares or this.shares + const shareList + = share.type === ShareType.Email + || share.type === ShareType.Link + ? this.linkShares + : this.shares + const index = shareList.findIndex(item => item.id === share.id) + if (index !== -1) { + shareList.splice(index, 1) + } + }, /** * Await for next tick and render after the list updated * Then resolve with the matched vue component of the @@ -341,20 +527,45 @@ export default { * @param {Function} resolve a function to execute after */ awaitForShare(share, resolve) { - let listComponent = this.$refs.shareList - // Only mail shares comes from the input, link shares - // are managed internally in the SharingLinkList component - if (share.type === this.SHARE_TYPES.SHARE_TYPE_EMAIL) { - listComponent = this.$refs.linkShareList - } - this.$nextTick(() => { + let listComponent = this.$refs.shareList + // Only mail shares comes from the input, link shares + // are managed internally in the SharingLinkList component + if (share.type === ShareType.Email) { + listComponent = this.$refs.linkShareList + } const newShare = listComponent.$children.find(component => component.share === share) if (newShare) { resolve(newShare) } }) }, + + toggleShareDetailsView(eventData) { + if (!this.showSharingDetailsView) { + const isAction = Array.from(document.activeElement.classList) + .some(className => className.startsWith('action-')) + if (isAction) { + const menuId = document.activeElement.closest('[role="menu"]')?.id + this.returnFocusElement = document.querySelector(`[aria-controls="${menuId}"]`) + } else { + this.returnFocusElement = document.activeElement + } + } + + if (eventData) { + this.shareDetailsData = eventData + } + + this.showSharingDetailsView = !this.showSharingDetailsView + + if (!this.showSharingDetailsView) { + this.$nextTick(() => { // Wait for next tick as the element must be visible to be focused + this.returnFocusElement?.focus() + this.returnFocusElement = null + }) + } + }, }, } </script> @@ -365,11 +576,52 @@ export default { } .sharingTab { + position: relative; + height: 100%; + &__content { padding: 0 6px; + + section { + padding-bottom: 16px; + + .section-header { + margin-top: 2px; + margin-bottom: 2px; + display: flex; + align-items: center; + padding-bottom: 4px; + + h4 { + margin: 0; + font-size: 16px; + } + + .visually-hidden { + display: none; + } + + .hint-icon { + color: var(--color-primary-element); + } + + } + + } + + & > section:not(:last-child) { + border-bottom: 2px solid var(--color-border); + } + } + &__additionalContent { margin: 44px 0; } } + +.hint-body { + max-width: 300px; + padding: var(--border-radius-element); +} </style> |