aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files_sharing/src/views
diff options
context:
space:
mode:
Diffstat (limited to 'apps/files_sharing/src/views')
-rw-r--r--apps/files_sharing/src/views/CollaborationView.vue36
-rw-r--r--apps/files_sharing/src/views/FilesHeaderNoteToRecipient.vue73
-rw-r--r--apps/files_sharing/src/views/FilesViewFileDropEmptyContent.vue136
-rw-r--r--apps/files_sharing/src/views/SharingDetailsTab.vue461
-rw-r--r--apps/files_sharing/src/views/SharingInherited.vue6
-rw-r--r--apps/files_sharing/src/views/SharingLinkList.vue29
-rw-r--r--apps/files_sharing/src/views/SharingList.vue16
-rw-r--r--apps/files_sharing/src/views/SharingTab.vue351
-rw-r--r--apps/files_sharing/src/views/shares.spec.ts113
-rw-r--r--apps/files_sharing/src/views/shares.ts125
10 files changed, 819 insertions, 527 deletions
diff --git a/apps/files_sharing/src/views/CollaborationView.vue b/apps/files_sharing/src/views/CollaborationView.vue
deleted file mode 100644
index b75ad53e1b8..00000000000
--- a/apps/files_sharing/src/views/CollaborationView.vue
+++ /dev/null
@@ -1,36 +0,0 @@
-<!--
- - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- - SPDX-License-Identifier: AGPL-3.0-or-later
--->
-
-<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
index 9922caf4f4e..b3a3b95d92e 100644
--- a/apps/files_sharing/src/views/SharingDetailsTab.vue
+++ b/apps/files_sharing/src/views/SharingDetailsTab.vue
@@ -8,7 +8,7 @@
<span>
<NcAvatar v-if="isUserShare"
class="sharing-entry__avatar"
- :is-no-user="share.shareType !== SHARE_TYPES.SHARE_TYPE_USER"
+ :is-no-user="share.shareType !== ShareType.User"
:user="share.shareWith"
:display-name="share.shareWithDisplayName"
:menu-position="'left'"
@@ -38,7 +38,7 @@
<NcCheckboxRadioSwitch :button-variant="true"
data-cy-files-sharing-share-permissions-bundle="upload-edit"
:checked.sync="sharingPermission"
- :value="bundledPermissions.ALL.toString()"
+ :value="allPermissions"
name="sharing_permission_radio"
type="radio"
button-variant-grouped="vertical"
@@ -62,7 +62,7 @@
type="radio"
button-variant-grouped="vertical"
@update:checked="toggleCustomPermissions">
- {{ t('files_sharing', 'File drop') }}
+ {{ t('files_sharing', 'File request') }}
<small class="subline">{{ t('files_sharing', 'Upload only') }}</small>
<template #icon>
<UploadIcon :size="20" />
@@ -105,19 +105,33 @@
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="hasUnsavedPassword ? share.newPassword : ''"
+ :value="share.newPassword ?? ''"
:error="passwordError"
- :helper-text="errorPasswordLabel"
- :required="isPasswordEnforced"
+ :helper-text="errorPasswordLabel || passwordHint"
+ :required="isPasswordEnforced && isNewShare"
:label="t('files_sharing', 'Password')"
@update:value="onPasswordChange" />
@@ -144,7 +158,8 @@
:value="new Date(share.expireDate ?? dateTomorrow)"
:min="dateTomorrow"
:max="maxExpirationDateEnforced"
- :hide-label="true"
+ hide-label
+ :label="t('files_sharing', 'Expiration date')"
:placeholder="t('files_sharing', 'Expiration date')"
type="date"
@input="onExpirationChange" />
@@ -154,21 +169,24 @@
@update:checked="queueUpdate('hideDownload')">
{{ t('files_sharing', 'Hide download') }}
</NcCheckboxRadioSwitch>
- <NcCheckboxRadioSwitch v-if="!isPublicShare"
+ <NcCheckboxRadioSwitch v-else
:disabled="!canSetDownload"
:checked.sync="canDownload"
data-cy-files-sharing-share-permissions-checkbox="download">
- {{ t('files_sharing', 'Allow download') }}
+ {{ t('files_sharing', 'Allow download and sync') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch :checked.sync="writeNoteToRecipientIsChecked">
{{ t('files_sharing', 'Note to recipient') }}
</NcCheckboxRadioSwitch>
<template v-if="writeNoteToRecipientIsChecked">
- <label for="share-note-textarea">
- {{ t('files_sharing', 'Enter a note for the share recipient') }}
- </label>
- <textarea id="share-note-textarea" :value="share.note" @input="share.note = $event.target.value" />
+ <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"
@@ -180,7 +198,7 @@
{{ t('files_sharing', 'Custom permissions') }}
</NcCheckboxRadioSwitch>
<section v-if="setCustomPermissions" class="custom-permissions-group">
- <NcCheckboxRadioSwitch :disabled="!allowsFileDrop && share.type === SHARE_TYPES.SHARE_TYPE_LINK"
+ <NcCheckboxRadioSwitch :disabled="!canRemoveReadPermission"
:checked.sync="hasRead"
data-cy-files-sharing-share-permissions-checkbox="read">
{{ t('files_sharing', 'Read') }}
@@ -196,7 +214,7 @@
data-cy-files-sharing-share-permissions-checkbox="update">
{{ t('files_sharing', 'Edit') }}
</NcCheckboxRadioSwitch>
- <NcCheckboxRadioSwitch v-if="config.isResharingAllowed && share.type !== SHARE_TYPES.SHARE_TYPE_LINK"
+ <NcCheckboxRadioSwitch v-if="resharingIsPossible"
:disabled="!canSetReshare"
:checked.sync="canReshare"
data-cy-files-sharing-share-permissions-checkbox="share">
@@ -208,19 +226,6 @@
{{ t('files_sharing', 'Delete') }}
</NcCheckboxRadioSwitch>
</section>
- <div class="sharingTabDetailsView__delete">
- <NcButton v-if="!isNewShare"
- :aria-label="t('files_sharing', 'Delete share')"
- :disabled="false"
- :readonly="false"
- type="tertiary"
- @click.prevent="removeShare">
- <template #icon>
- <CloseIcon :size="16" />
- </template>
- {{ t('files_sharing', 'Delete share') }}
- </NcButton>
- </div>
</section>
</div>
</div>
@@ -228,11 +233,25 @@
<div class="sharingTabDetailsView__footer">
<div class="button-group">
<NcButton data-cy-files-sharing-share-editor-action="cancel"
- @click="$emit('close-sharing-details')">
+ @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>
@@ -245,19 +264,24 @@
</template>
<script>
+import { emit } from '@nextcloud/event-bus'
import { getLanguage } from '@nextcloud/l10n'
-import { Type as ShareType } from '@nextcloud/sharing'
-
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js'
-import NcPasswordField from '@nextcloud/vue/dist/Components/NcPasswordField.js'
-import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
-import NcDateTimePickerNative from '@nextcloud/vue/dist/Components/NcDateTimePickerNative.js'
-import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
-import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
+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/Pencil.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'
@@ -268,14 +292,16 @@ 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.js'
-import Share from '../models/Share.js'
+import GeneratePassword from '../utils/GeneratePassword.ts'
+import Share from '../models/Share.ts'
import ShareRequests from '../mixins/ShareRequests.js'
-import ShareTypes from '../mixins/ShareTypes.js'
import SharesMixin from '../mixins/SharesMixin.js'
+import { generateToken } from '../services/TokenService.ts'
+import logger from '../services/logger.ts'
import {
ATOMIC_PERMISSIONS,
@@ -288,11 +314,12 @@ export default {
components: {
NcAvatar,
NcButton,
- NcInputField,
- NcPasswordField,
- NcDateTimePickerNative,
NcCheckboxRadioSwitch,
+ NcDateTimePickerNative,
+ NcInputField,
NcLoadingIcon,
+ NcPasswordField,
+ NcTextArea,
CloseIcon,
CircleIcon,
EditIcon,
@@ -306,8 +333,9 @@ export default {
MenuDownIcon,
MenuUpIcon,
DotsHorizontalIcon,
+ Refresh,
},
- mixins: [ShareTypes, ShareRequests, SharesMixin],
+ mixins: [ShareRequests, SharesMixin],
props: {
shareRequestValue: {
type: Object,
@@ -334,6 +362,8 @@ export default {
isFirstComponentLoad: true,
test: false,
creating: false,
+ initialToken: this.share.token,
+ loadingToken: false,
ExternalShareActions: OCA.Sharing.ExternalShareActions.state,
}
@@ -342,34 +372,40 @@ export default {
computed: {
title() {
switch (this.share.type) {
- case this.SHARE_TYPES.SHARE_TYPE_USER:
- return t('files_sharing', 'Share with {userName}', { userName: this.share.shareWithDisplayName })
- case this.SHARE_TYPES.SHARE_TYPE_EMAIL:
+ 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 this.SHARE_TYPES.SHARE_TYPE_LINK:
+ case ShareType.Link:
return t('files_sharing', 'Share link')
- case this.SHARE_TYPES.SHARE_TYPE_GROUP:
+ case ShareType.Group:
return t('files_sharing', 'Share with group')
- case this.SHARE_TYPES.SHARE_TYPE_ROOM:
+ case ShareType.Room:
return t('files_sharing', 'Share in conversation')
- case this.SHARE_TYPES.SHARE_TYPE_REMOTE: {
+ 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 this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP:
+ case ShareType.RemoteGroup:
return t('files_sharing', 'Share with remote group')
- case this.SHARE_TYPES.SHARE_TYPE_GUEST:
+ 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')
- }
+ 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 ?
*/
@@ -414,24 +450,34 @@ export default {
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.share.attributes.find(attr => attr.key === 'download')?.enabled || false
+ return this.getShareAttribute('permissions', 'download', true)
},
set(checked) {
- // Find the 'download' attribute and update its value
- const downloadAttr = this.share.attributes.find(attr => attr.key === 'download')
- if (downloadAttr) {
- downloadAttr.enabled = checked
- }
+ this.setShareAttribute('permissions', 'download', checked)
},
},
/**
* Is this share readable
- * Needed for some federated shares that might have been added from file drop links
+ * Needed for some federated shares that might have been added from file requests links
*/
hasRead: {
get() {
@@ -457,26 +503,6 @@ export default {
},
},
/**
- * Is the current share password protected ?
- *
- * @return {boolean}
- */
- isPasswordProtected: {
- get() {
- return this.config.enforcePasswordForPublicLink
- || !!this.share.password
- },
- async set(enabled) {
- if (enabled) {
- this.share.password = await GeneratePassword()
- this.$set(this.share, 'newPassword', this.share.password)
- } else {
- this.share.password = ''
- this.$delete(this.share, 'newPassword')
- }
- },
- },
- /**
* Is the current share a folder ?
*
* @return {boolean}
@@ -517,17 +543,14 @@ export default {
return new Date(new Date().setDate(new Date().getDate() + 1))
},
isUserShare() {
- return this.share.type === this.SHARE_TYPES.SHARE_TYPE_USER
+ return this.share.type === ShareType.User
},
isGroupShare() {
- return this.share.type === this.SHARE_TYPES.SHARE_TYPE_GROUP
- },
- isNewShare() {
- return !this.share.id
+ return this.share.type === ShareType.Group
},
allowsFileDrop() {
if (this.isFolder && this.config.isPublicUploadEnabled) {
- if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_LINK || this.share.type === this.SHARE_TYPES.SHARE_TYPE_EMAIL) {
+ if (this.share.type === ShareType.Link || this.share.type === ShareType.Email) {
return true
}
}
@@ -543,6 +566,9 @@ export default {
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 ?
*
@@ -600,6 +626,12 @@ export default {
// 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() {
@@ -656,7 +688,7 @@ export default {
*/
isEmailShareType() {
return this.share
- ? this.share.type === this.SHARE_TYPES.SHARE_TYPE_EMAIL
+ ? this.share.type === ShareType.Email
: false
},
canTogglePasswordProtectedByTalkAvailable() {
@@ -673,7 +705,7 @@ export default {
return OC.appswebroots.spreed !== undefined
},
canChangeHideDownload() {
- const hasDisabledDownload = (shareAttribute) => shareAttribute.key === 'download' && shareAttribute.scope === 'permissions' && shareAttribute.enabled === false
+ const hasDisabledDownload = (shareAttribute) => shareAttribute.key === 'download' && shareAttribute.scope === 'permissions' && shareAttribute.value === false
return this.fileInfo.shareAttributes.some(hasDisabledDownload)
},
customPermissionsList() {
@@ -686,8 +718,15 @@ export default {
[ATOMIC_PERMISSIONS.DELETE]: this.t('files_sharing', 'Delete'),
}
- return [ATOMIC_PERMISSIONS.READ, ATOMIC_PERMISSIONS.CREATE, ATOMIC_PERMISSIONS.UPDATE, ATOMIC_PERMISSIONS.SHARE, ATOMIC_PERMISSIONS.DELETE]
- .filter((permission) => hasPermissions(this.share.permissions, permission))
+ 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()))
@@ -698,18 +737,25 @@ export default {
},
errorPasswordLabel() {
if (this.passwordError) {
- return t('files_sharing', "Password field can't be empty")
+ 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.SHARE_TYPE_LINK) || action.shareType.includes(ShareType.SHARE_TYPE_EMAIL)) && action.advanced
+ 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)
@@ -727,8 +773,8 @@ export default {
beforeMount() {
this.initializePermissions()
this.initializeAttributes()
- console.debug('shareSentIn', this.share)
- console.debug('config', this.config)
+ logger.debug('Share object received', { share: this.share })
+ logger.debug('Configuration object received', { config: this.config })
},
mounted() {
@@ -736,6 +782,60 @@ export default {
},
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,
@@ -744,6 +844,13 @@ export default {
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)
@@ -766,8 +873,8 @@ export default {
async initializeAttributes() {
if (this.isNewShare) {
- if (this.isPasswordEnforced && this.isPublicShare) {
- this.$set(this.share, 'newPassword', await GeneratePassword())
+ 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 */
@@ -800,6 +907,11 @@ export default {
this.advancedSectionAccordionExpanded = true
}
+ if (this.isValidShareAttribute(this.share.note)) {
+ this.writeNoteToRecipientIsChecked = true
+ this.advancedSectionAccordionExpanded = true
+ }
+
},
handleShareType() {
if ('shareType' in this.share) {
@@ -820,6 +932,10 @@ export default {
this.setCustomPermissions = true
}
}
+ // Read permission required for share creation
+ if (!this.canRemoveReadPermission) {
+ this.hasRead = true
+ }
},
handleCustomPermissions() {
if (!this.isNewShare && (this.hasCustomPermissions || this.share.setCustomPermissions)) {
@@ -838,6 +954,9 @@ export default {
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)
}
@@ -856,10 +975,7 @@ export default {
this.share.note = ''
}
if (this.isPasswordProtected) {
- if (this.hasUnsavedPassword && this.isValidShareAttribute(this.share.newPassword)) {
- this.share.password = this.share.newPassword
- this.$delete(this.share, 'newPassword')
- } else if (this.isPasswordEnforced && !this.isValidShareAttribute(this.share.password)) {
+ if (this.isPasswordEnforced && this.isNewShare && !this.isValidShareAttribute(this.share.password)) {
this.passwordError = true
}
} else {
@@ -883,19 +999,45 @@ export default {
incomingShare.expireDate = this.hasExpirationDate ? this.share.expireDate : ''
if (this.isPasswordProtected) {
- incomingShare.password = this.share.password
+ 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.creating = true
- const share = await this.addShare(incomingShare, this.fileInfo)
- this.creating = false
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)
- this.queueUpdate(...permissionsAndAttributes)
}
+ 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') {
@@ -911,12 +1053,11 @@ export default {
* Process the new share request
*
* @param {Share} share incoming share object
- * @param {object} fileInfo file data
*/
- async addShare(share, fileInfo) {
- console.debug('Adding a new share from the input for', share)
+ async addShare(share) {
+ logger.debug('Adding a new share from the input for', { share })
+ const path = this.path
try {
- const path = (fileInfo.path + '/' + fileInfo.name).replace('//', '/')
const resultingShare = await this.createShare({
path,
shareType: share.shareType,
@@ -929,13 +1070,15 @@ export default {
})
return resultingShare
} catch (error) {
- console.error('Error while adding new share', 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')
},
/**
@@ -949,6 +1092,11 @@ export default {
* @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)
},
@@ -961,10 +1109,6 @@ export default {
* "sendPasswordByTalk".
*/
onPasswordProtectedByTalkChange() {
- if (this.hasUnsavedPassword) {
- this.share.password = this.share.newPassword.trim()
- }
-
this.queueUpdate('sendPasswordByTalk', 'password')
},
isValidShareAttribute(value) {
@@ -980,22 +1124,22 @@ export default {
},
getShareTypeIcon(type) {
switch (type) {
- case this.SHARE_TYPES.SHARE_TYPE_LINK:
+ case ShareType.Link:
return LinkIcon
- case this.SHARE_TYPES.SHARE_TYPE_GUEST:
+ case ShareType.Guest:
return UserIcon
- case this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP:
- case this.SHARE_TYPES.SHARE_TYPE_GROUP:
+ case ShareType.RemoteGroup:
+ case ShareType.Group:
return GroupIcon
- case this.SHARE_TYPES.SHARE_TYPE_EMAIL:
+ case ShareType.Email:
return EmailIcon
- case this.SHARE_TYPES.SHARE_TYPE_CIRCLE:
+ case ShareType.Team:
return CircleIcon
- case this.SHARE_TYPES.SHARE_TYPE_ROOM:
+ case ShareType.Room:
return ShareIcon
- case this.SHARE_TYPES.SHARE_TYPE_DECK:
+ case ShareType.Deck:
return ShareIcon
- case this.SHARE_TYPES.SHARE_TYPE_SCIENCEMESH:
+ case ShareType.ScienceMesh:
return ShareIcon
default:
return null // Or a default icon component if needed
@@ -1027,7 +1171,7 @@ export default {
h1 {
font-size: 15px;
- padding-left: 0.3em;
+ padding-inline-start: 0.3em;
}
}
@@ -1038,7 +1182,7 @@ export default {
overflow: scroll;
flex-shrink: 1;
padding: 4px;
- padding-right: 12px;
+ padding-inline-end: 12px;
}
&__quick-permissions {
@@ -1060,12 +1204,9 @@ export default {
padding: 0.1em;
}
- ::v-deep label {
-
- span {
- display: flex;
- flex-direction: column;
- }
+ :deep(label span) {
+ display: flex;
+ flex-direction: column;
}
/* Target component based style in NcCheckboxRadioSwitch slot content*/
@@ -1094,8 +1235,8 @@ export default {
&__advanced {
width: 100%;
margin-bottom: 0.5em;
- text-align: left;
- padding-left: 0;
+ text-align: start;
+ padding-inline-start: 0;
section {
@@ -1110,30 +1251,32 @@ export default {
}
/*
- 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 {
- ::v-deep label {
- padding-left: 0 !important;
- background-color: initial !important;
- border: none !important;
- }
+ 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-left: 1.5em;
+ padding-inline-start: 1.5em;
}
}
}
+ &__label {
+ padding-block-end: 6px;
+ }
+
&__delete {
- >button:first-child {
+ > button:first-child {
color: rgb(223, 7, 7);
}
}
@@ -1155,10 +1298,10 @@ export default {
margin-top: 16px;
button {
- margin-left: 16px;
+ margin-inline-start: 16px;
&:first-child {
- margin-left: 0;
+ margin-inline-start: 0;
}
}
}
diff --git a/apps/files_sharing/src/views/SharingInherited.vue b/apps/files_sharing/src/views/SharingInherited.vue
index a9c034256ff..809de522d93 100644
--- a/apps/files_sharing/src/views/SharingInherited.vue
+++ b/apps/files_sharing/src/views/SharingInherited.vue
@@ -4,7 +4,7 @@
-->
<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"
@@ -30,10 +30,10 @@
<script>
import { generateOcsUrl } from '@nextcloud/router'
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import axios from '@nextcloud/axios'
-import Share from '../models/Share.js'
+import Share from '../models/Share.ts'
import SharingEntryInherited from '../components/SharingEntryInherited.vue'
import SharingEntrySimple from '../components/SharingEntrySimple.vue'
diff --git a/apps/files_sharing/src/views/SharingLinkList.vue b/apps/files_sharing/src/views/SharingLinkList.vue
index b10f41eaa15..c3d9a7f83dc 100644
--- a/apps/files_sharing/src/views/SharingLinkList.vue
+++ b/apps/files_sharing/src/views/SharingLinkList.vue
@@ -4,13 +4,9 @@
-->
<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 -->
@@ -25,17 +21,24 @@
@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>
import { getCapabilities } from '@nextcloud/capabilities'
-// eslint-disable-next-line no-unused-vars
+import { t } from '@nextcloud/l10n'
+
import Share from '../models/Share.js'
-import ShareTypes from '../mixins/ShareTypes.js'
import SharingEntryLink from '../components/SharingEntryLink.vue'
import ShareDetails from '../mixins/ShareDetails.js'
+import { ShareType } from '@nextcloud/sharing'
export default {
name: 'SharingLinkList',
@@ -44,7 +47,7 @@ export default {
SharingEntryLink,
},
- mixins: [ShareTypes, ShareDetails],
+ mixins: [ShareDetails],
props: {
fileInfo: {
@@ -78,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
},
/**
@@ -92,6 +95,8 @@ export default {
},
methods: {
+ t,
+
/**
* Add a new share into the link shares list
* and return the newly created share component
@@ -101,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 7294294afa4..2167059772e 100644
--- a/apps/files_sharing/src/views/SharingList.vue
+++ b/apps/files_sharing/src/views/SharingList.vue
@@ -4,7 +4,7 @@
-->
<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"
@@ -15,10 +15,10 @@
</template>
<script>
-// eslint-disable-next-line no-unused-vars
+import { t } from '@nextcloud/l10n'
import SharingEntry from '../components/SharingEntry.vue'
-import ShareTypes from '../mixins/ShareTypes.js'
import ShareDetails from '../mixins/ShareDetails.js'
+import { ShareType } from '@nextcloud/sharing'
export default {
name: 'SharingList',
@@ -27,7 +27,7 @@ export default {
SharingEntry,
},
- mixins: [ShareTypes, ShareDetails],
+ mixins: [ShareDetails],
props: {
fileInfo: {
@@ -41,6 +41,12 @@ export default {
required: true,
},
},
+
+ setup() {
+ return {
+ t,
+ }
+ },
computed: {
hasShares() {
return this.shares.length === 0
@@ -48,7 +54,7 @@ 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
}
},
diff --git a/apps/files_sharing/src/views/SharingTab.vue b/apps/files_sharing/src/views/SharingTab.vue
index 7e58cb6401e..2ed44a4b5ad 100644
--- a/apps/files_sharing/src/views/SharingTab.vue
+++ b/apps/files_sharing/src/views/SharingTab.vue
@@ -15,8 +15,8 @@
<div v-show="!showSharingDetailsView"
class="sharingTab__content">
<!-- shared with me information -->
- <ul>
- <SharingEntrySimple v-if="isSharedWithMe" v-bind="sharedWithMe" class="sharing-entry__reshare">
+ <ul v-if="isSharedWithMe">
+ <SharingEntrySimple v-bind="sharedWithMe" class="sharing-entry__reshare">
<template #avatar>
<NcAvatar :user="sharedWithMe.user"
:display-name="sharedWithMe.displayName"
@@ -25,50 +25,123 @@
</SharingEntrySimple>
</ul>
- <!-- add new share input -->
- <SharingInput v-if="!loading"
- :can-reshare="canReshare"
- :file-info="fileInfo"
- :link-shares="linkShares"
- :reshare="reshare"
- :shares="shares"
- @open-sharing-details="toggleShareDetailsView" />
-
- <!-- link shares list -->
- <SharingLinkList v-if="!loading"
- ref="linkShareList"
- :can-reshare="canReshare"
- :file-info="fileInfo"
- :shares="linkShares"
- @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" />
-
- <!-- projects -->
- <CollectionList v-if="projectsEnabled && fileInfo"
- :id="`${fileInfo.id}`"
- type="file"
- :name="fileInfo.name" />
- </div>
-
- <!-- additional entries, use it with cautious -->
- <div v-for="(section, index) in sections"
- v-show="!showSharingDetailsView"
- :ref="'section-' + index"
- :key="index"
- class="sharingTab__additionalContent">
- <component :is="section($refs['section-'+index], fileInfo)" :file-info="fileInfo" />
+ <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>
<!-- share details -->
@@ -82,16 +155,26 @@
</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.js'
+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.js'
import { shareWithTitle } from '../utils/SharedWithMe.js'
-import Share from '../models/Share.js'
-import ShareTypes from '../mixins/ShareTypes.js'
+
+import Config from '../services/ConfigService.ts'
+import Share from '../models/Share.ts'
import SharingEntryInternal from '../components/SharingEntryInternal.vue'
import SharingEntrySimple from '../components/SharingEntrySimple.vue'
import SharingInput from '../components/SharingInput.vue'
@@ -101,12 +184,18 @@ 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,
@@ -115,8 +204,7 @@ export default {
SharingList,
SharingDetailsTab,
},
-
- mixins: [ShareTypes],
+ mixins: [ShareDetails],
data() {
return {
@@ -133,12 +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.'),
}
},
@@ -149,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
@@ -169,7 +301,6 @@ export default {
this.resetState()
this.getShares()
},
-
/**
* Get the existing shares infos
*/
@@ -207,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')
@@ -240,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
@@ -260,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)`)
}
},
@@ -302,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,
@@ -328,8 +484,18 @@ export default {
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)
}
@@ -343,8 +509,8 @@ export default {
removeShare(share) {
// Get reference for this.linkShares or this.shares
const shareList
- = share.type === this.SHARE_TYPES.SHARE_TYPE_EMAIL
- || share.type === this.SHARE_TYPES.SHARE_TYPE_LINK
+ = share.type === ShareType.Email
+ || share.type === ShareType.Link
? this.linkShares
: this.shares
const index = shareList.findIndex(item => item.id === share.id)
@@ -365,7 +531,7 @@ export default {
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) {
+ if (share.type === ShareType.Email) {
listComponent = this.$refs.linkShareList
}
const newShare = listComponent.$children.find(component => component.share === share)
@@ -415,10 +581,47 @@ export default {
&__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>
diff --git a/apps/files_sharing/src/views/shares.spec.ts b/apps/files_sharing/src/views/shares.spec.ts
deleted file mode 100644
index cba8ffa94d7..00000000000
--- a/apps/files_sharing/src/views/shares.spec.ts
+++ /dev/null
@@ -1,113 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-/* eslint-disable n/no-extraneous-import */
-import type { OCSResponse } from '@nextcloud/typings/ocs'
-import { expect } from '@jest/globals'
-import { Folder, Navigation, View, getNavigation } from '@nextcloud/files'
-import axios from '@nextcloud/axios'
-
-import '../main'
-import registerSharingViews from './shares'
-
-declare global {
- interface Window {
- _nc_navigation?: Navigation
- }
-}
-
-describe('Sharing views definition', () => {
- let Navigation
- beforeEach(() => {
- Navigation = getNavigation()
- expect(window._nc_navigation).toBeDefined()
- })
-
- afterAll(() => {
- delete window._nc_navigation
- })
-
- test('Default values', () => {
- jest.spyOn(Navigation, 'register')
-
- expect(Navigation.views.length).toBe(0)
-
- registerSharingViews()
- const shareOverviewView = Navigation.views.find(view => view.id === 'shareoverview') as View
- const sharesChildViews = Navigation.views.filter(view => view.parent === 'shareoverview') as View[]
-
- expect(Navigation.register).toHaveBeenCalledTimes(6)
-
- // one main view and no children
- expect(Navigation.views.length).toBe(6)
- expect(shareOverviewView).toBeDefined()
- expect(sharesChildViews.length).toBe(5)
-
- expect(shareOverviewView?.id).toBe('shareoverview')
- expect(shareOverviewView?.name).toBe('Shares')
- expect(shareOverviewView?.caption).toBe('Overview of shared files.')
- expect(shareOverviewView?.icon).toBe('<svg>SvgMock</svg>')
- expect(shareOverviewView?.order).toBe(20)
- expect(shareOverviewView?.columns).toStrictEqual([])
- expect(shareOverviewView?.getContents).toBeDefined()
-
- const dataProvider = [
- { id: 'sharingin', name: 'Shared with you' },
- { id: 'sharingout', name: 'Shared with others' },
- { id: 'sharinglinks', name: 'Shared by link' },
- { id: 'deletedshares', name: 'Deleted shares' },
- { id: 'pendingshares', name: 'Pending shares' },
- ]
-
- sharesChildViews.forEach((view, index) => {
- expect(view?.id).toBe(dataProvider[index].id)
- expect(view?.parent).toBe('shareoverview')
- expect(view?.name).toBe(dataProvider[index].name)
- expect(view?.caption).toBeDefined()
- expect(view?.emptyTitle).toBeDefined()
- expect(view?.emptyCaption).toBeDefined()
- expect(view?.icon).toBe('<svg>SvgMock</svg>')
- expect(view?.order).toBe(index + 1)
- expect(view?.columns).toStrictEqual([])
- expect(view?.getContents).toBeDefined()
- })
- })
-})
-
-describe('Sharing views contents', () => {
- let Navigation
- beforeEach(() => {
- Navigation = getNavigation()
- expect(window._nc_navigation).toBeDefined()
- })
-
- afterAll(() => {
- delete window._nc_navigation
- })
-
- test('Sharing overview get contents', async () => {
- jest.spyOn(axios, 'get').mockImplementation(async (): Promise<any> => {
- return {
- data: {
- ocs: {
- meta: {
- status: 'ok',
- statuscode: 200,
- message: 'OK',
- },
- data: [],
- },
- } as OCSResponse<any>,
- }
- })
-
- registerSharingViews()
- expect(Navigation.views.length).toBe(6)
- Navigation.views.forEach(async (view: View) => {
- const content = await view.getContents('/')
- expect(content.contents).toStrictEqual([])
- expect(content.folder).toBeInstanceOf(Folder)
- })
- })
-})
diff --git a/apps/files_sharing/src/views/shares.ts b/apps/files_sharing/src/views/shares.ts
deleted file mode 100644
index e43c75b3a5e..00000000000
--- a/apps/files_sharing/src/views/shares.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-import { translate as t } from '@nextcloud/l10n'
-import { View, getNavigation } from '@nextcloud/files'
-import AccountClockSvg from '@mdi/svg/svg/account-clock.svg?raw'
-import AccountGroupSvg from '@mdi/svg/svg/account-group.svg?raw'
-import AccountPlusSvg from '@mdi/svg/svg/account-plus.svg?raw'
-import AccountSvg from '@mdi/svg/svg/account.svg?raw'
-import DeleteSvg from '@mdi/svg/svg/delete.svg?raw'
-import LinkSvg from '@mdi/svg/svg/link.svg?raw'
-
-import { getContents } from '../services/SharingService'
-
-export const sharesViewId = 'shareoverview'
-export const sharedWithYouViewId = 'sharingin'
-export const sharedWithOthersViewId = 'sharingout'
-export const sharingByLinksViewId = 'sharinglinks'
-export const deletedSharesViewId = 'deletedshares'
-export const pendingSharesViewId = 'pendingshares'
-
-export default () => {
- const Navigation = getNavigation()
- Navigation.register(new View({
- id: sharesViewId,
- name: t('files_sharing', 'Shares'),
- caption: t('files_sharing', 'Overview of shared files.'),
-
- emptyTitle: t('files_sharing', 'No shares'),
- emptyCaption: t('files_sharing', 'Files and folders you shared or have been shared with you will show up here'),
-
- icon: AccountPlusSvg,
- order: 20,
-
- columns: [],
-
- getContents: () => getContents(),
- }))
-
- Navigation.register(new View({
- id: sharedWithYouViewId,
- name: t('files_sharing', 'Shared with you'),
- caption: t('files_sharing', 'List of files that are shared with you.'),
-
- emptyTitle: t('files_sharing', 'Nothing shared with you yet'),
- emptyCaption: t('files_sharing', 'Files and folders others shared with you will show up here'),
-
- icon: AccountSvg,
- order: 1,
- parent: sharesViewId,
-
- columns: [],
-
- getContents: () => getContents(true, false, false, false),
- }))
-
- Navigation.register(new View({
- id: sharedWithOthersViewId,
- name: t('files_sharing', 'Shared with others'),
- caption: t('files_sharing', 'List of files that you shared with others.'),
-
- emptyTitle: t('files_sharing', 'Nothing shared yet'),
- emptyCaption: t('files_sharing', 'Files and folders you shared will show up here'),
-
- icon: AccountGroupSvg,
- order: 2,
- parent: sharesViewId,
-
- columns: [],
-
- getContents: () => getContents(false, true, false, false),
- }))
-
- Navigation.register(new View({
- id: sharingByLinksViewId,
- name: t('files_sharing', 'Shared by link'),
- caption: t('files_sharing', 'List of files that are shared by link.'),
-
- emptyTitle: t('files_sharing', 'No shared links'),
- emptyCaption: t('files_sharing', 'Files and folders you shared by link will show up here'),
-
- icon: LinkSvg,
- order: 3,
- parent: sharesViewId,
-
- columns: [],
-
- getContents: () => getContents(false, true, false, false, [window.OC.Share.SHARE_TYPE_LINK]),
- }))
-
- Navigation.register(new View({
- id: deletedSharesViewId,
- name: t('files_sharing', 'Deleted shares'),
- caption: t('files_sharing', 'List of shares you left.'),
-
- emptyTitle: t('files_sharing', 'No deleted shares'),
- emptyCaption: t('files_sharing', 'Shares you have left will show up here'),
-
- icon: DeleteSvg,
- order: 4,
- parent: sharesViewId,
-
- columns: [],
-
- getContents: () => getContents(false, false, false, true),
- }))
-
- Navigation.register(new View({
- id: pendingSharesViewId,
- name: t('files_sharing', 'Pending shares'),
- caption: t('files_sharing', 'List of unapproved shares.'),
-
- emptyTitle: t('files_sharing', 'No pending shares'),
- emptyCaption: t('files_sharing', 'Shares you have received but not approved will show up here'),
-
- icon: AccountClockSvg,
- order: 5,
- parent: sharesViewId,
-
- columns: [],
-
- getContents: () => getContents(false, false, true, false),
- }))
-}