aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files_sharing/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/files_sharing/src')
-rw-r--r--apps/files_sharing/src/additionalScripts.js30
-rw-r--r--apps/files_sharing/src/collaborationresources.js44
-rw-r--r--apps/files_sharing/src/collaborationresourceshandler.js25
-rw-r--r--apps/files_sharing/src/components/ExternalShareAction.vue25
-rw-r--r--apps/files_sharing/src/components/FileListFilterAccount.vue138
-rw-r--r--apps/files_sharing/src/components/NewFileRequestDialog.vue468
-rw-r--r--apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogDatePassword.vue258
-rw-r--r--apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogFinish.vue236
-rw-r--r--apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogIntro.vue166
-rw-r--r--apps/files_sharing/src/components/PersonalSettings.vue29
-rw-r--r--apps/files_sharing/src/components/SelectShareFolderDialogue.vue42
-rw-r--r--apps/files_sharing/src/components/ShareExpiryTime.vue91
-rw-r--r--apps/files_sharing/src/components/SharePermissionsEditor.vue294
-rw-r--r--apps/files_sharing/src/components/SharingEntry.vue483
-rw-r--r--apps/files_sharing/src/components/SharingEntryInherited.vue43
-rw-r--r--apps/files_sharing/src/components/SharingEntryInternal.vue40
-rw-r--r--apps/files_sharing/src/components/SharingEntryLink.vue724
-rw-r--r--apps/files_sharing/src/components/SharingEntryQuickShareSelect.vue206
-rw-r--r--apps/files_sharing/src/components/SharingEntrySimple.vue32
-rw-r--r--apps/files_sharing/src/components/SharingInput.vue284
-rw-r--r--apps/files_sharing/src/eventbus.d.ts15
-rw-r--r--apps/files_sharing/src/files_actions/acceptShareAction.spec.ts217
-rw-r--r--apps/files_sharing/src/files_actions/acceptShareAction.ts48
-rw-r--r--apps/files_sharing/src/files_actions/openInFilesAction.spec.ts78
-rw-r--r--apps/files_sharing/src/files_actions/openInFilesAction.ts50
-rw-r--r--apps/files_sharing/src/files_actions/rejectShareAction.spec.ts243
-rw-r--r--apps/files_sharing/src/files_actions/rejectShareAction.ts66
-rw-r--r--apps/files_sharing/src/files_actions/restoreShareAction.spec.ts191
-rw-r--r--apps/files_sharing/src/files_actions/restoreShareAction.ts47
-rw-r--r--apps/files_sharing/src/files_actions/sharingStatusAction.scss29
-rw-r--r--apps/files_sharing/src/files_actions/sharingStatusAction.ts144
-rw-r--r--apps/files_sharing/src/files_filters/AccountFilter.ts162
-rw-r--r--apps/files_sharing/src/files_headers/noteToRecipient.ts40
-rw-r--r--apps/files_sharing/src/files_newMenu/newFileRequest.ts42
-rw-r--r--apps/files_sharing/src/files_sharing.js25
-rw-r--r--apps/files_sharing/src/files_sharing_tab.js43
-rw-r--r--apps/files_sharing/src/files_views/publicFileDrop.ts60
-rw-r--r--apps/files_sharing/src/files_views/publicFileShare.ts66
-rw-r--r--apps/files_sharing/src/files_views/publicShare.ts28
-rw-r--r--apps/files_sharing/src/files_views/shares.spec.ts132
-rw-r--r--apps/files_sharing/src/files_views/shares.ts156
-rw-r--r--apps/files_sharing/src/index.js38
-rw-r--r--apps/files_sharing/src/init-public.ts63
-rw-r--r--apps/files_sharing/src/init.ts33
-rw-r--r--apps/files_sharing/src/lib/SharePermissionsToolBox.js22
-rw-r--r--apps/files_sharing/src/lib/SharePermissionsToolBox.spec.js24
-rw-r--r--apps/files_sharing/src/main.ts21
-rw-r--r--apps/files_sharing/src/mixins/ShareDetails.js82
-rw-r--r--apps/files_sharing/src/mixins/ShareRequests.js60
-rw-r--r--apps/files_sharing/src/mixins/ShareTypes.js32
-rw-r--r--apps/files_sharing/src/mixins/SharesMixin.js249
-rw-r--r--apps/files_sharing/src/models/Share.ts (renamed from apps/files_sharing/src/models/Share.js)385
-rw-r--r--apps/files_sharing/src/personal-settings.js28
-rw-r--r--apps/files_sharing/src/public-nickname-handler.ts86
-rw-r--r--apps/files_sharing/src/router/index.ts76
-rw-r--r--apps/files_sharing/src/services/ConfigService.js328
-rw-r--r--apps/files_sharing/src/services/ConfigService.ts333
-rw-r--r--apps/files_sharing/src/services/ExternalLinkActions.js23
-rw-r--r--apps/files_sharing/src/services/ExternalShareActions.js31
-rw-r--r--apps/files_sharing/src/services/GuestNameValidity.ts45
-rw-r--r--apps/files_sharing/src/services/ShareSearch.js21
-rw-r--r--apps/files_sharing/src/services/SharingService.spec.ts516
-rw-r--r--apps/files_sharing/src/services/SharingService.ts244
-rw-r--r--apps/files_sharing/src/services/TabSections.js27
-rw-r--r--apps/files_sharing/src/services/TokenService.ts20
-rw-r--r--apps/files_sharing/src/services/logger.ts10
-rw-r--r--apps/files_sharing/src/share.js72
-rw-r--r--apps/files_sharing/src/sharebreadcrumbview.js27
-rw-r--r--apps/files_sharing/src/sharing.d.ts10
-rw-r--r--apps/files_sharing/src/style/sharebreadcrumb.scss33
-rw-r--r--apps/files_sharing/src/utils/AccountIcon.spec.ts40
-rw-r--r--apps/files_sharing/src/utils/AccountIcon.ts28
-rw-r--r--apps/files_sharing/src/utils/GeneratePassword.js61
-rw-r--r--apps/files_sharing/src/utils/GeneratePassword.ts66
-rw-r--r--apps/files_sharing/src/utils/NodeShareUtils.ts58
-rw-r--r--apps/files_sharing/src/utils/SharedWithMe.js40
-rw-r--r--apps/files_sharing/src/views/CollaborationView.vue53
-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.vue1310
-rw-r--r--apps/files_sharing/src/views/SharingInherited.vue35
-rw-r--r--apps/files_sharing/src/views/SharingLinkList.vue64
-rw-r--r--apps/files_sharing/src/views/SharingList.vue59
-rw-r--r--apps/files_sharing/src/views/SharingTab.vue478
84 files changed, 8138 insertions, 2842 deletions
diff --git a/apps/files_sharing/src/additionalScripts.js b/apps/files_sharing/src/additionalScripts.js
index 6cc039a876a..e8807a7325e 100644
--- a/apps/files_sharing/src/additionalScripts.js
+++ b/apps/files_sharing/src/additionalScripts.js
@@ -1,33 +1,15 @@
/**
- * @copyright Copyright (c) 2016 Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * 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: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
+import { getCSPNonce } from '@nextcloud/auth'
-import './share'
-import './sharebreadcrumbview'
+import './share.js'
+import './sharebreadcrumbview.js'
import './style/sharebreadcrumb.scss'
import './collaborationresourceshandler.js'
// eslint-disable-next-line camelcase
-__webpack_nonce__ = btoa(OC.requestToken)
+__webpack_nonce__ = getCSPNonce()
window.OCA.Sharing = OCA.Sharing
diff --git a/apps/files_sharing/src/collaborationresources.js b/apps/files_sharing/src/collaborationresources.js
deleted file mode 100644
index 1e6eda02a93..00000000000
--- a/apps/files_sharing/src/collaborationresources.js
+++ /dev/null
@@ -1,44 +0,0 @@
-/**
- * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- *
- * @license AGPL-3.0-or-later
- *
- * 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/>.
- *
- */
-
-import Vue from 'vue'
-import Vuex from 'vuex'
-import NcPopoverMenu from '@nextcloud/vue/dist/Components/NcPopoverMenu'
-import Tooltip from '@nextcloud/vue/dist/Directives/Tooltip'
-import ClickOutside from 'vue-click-outside'
-
-import View from './views/CollaborationView'
-
-Vue.prototype.t = t
-Tooltip.options.defaultHtml = false
-
-// eslint-disable-next-line vue/match-component-file-name
-Vue.component('NcPopoverMenu', NcPopoverMenu)
-Vue.directive('ClickOutside', ClickOutside)
-Vue.directive('Tooltip', Tooltip)
-Vue.use(Vuex)
-
-export {
- Vue,
- View,
-}
diff --git a/apps/files_sharing/src/collaborationresourceshandler.js b/apps/files_sharing/src/collaborationresourceshandler.js
index e81b590b2b8..6f3645385b7 100644
--- a/apps/files_sharing/src/collaborationresourceshandler.js
+++ b/apps/files_sharing/src/collaborationresourceshandler.js
@@ -1,28 +1,11 @@
/**
- * @copyright Copyright (c) 2016 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- *
- * @license AGPL-3.0-or-later
- *
- * 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: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
+import { getCSPNonce } from '@nextcloud/auth'
// eslint-disable-next-line camelcase
-__webpack_nonce__ = btoa(OC.requestToken)
+__webpack_nonce__ = getCSPNonce()
window.OCP.Collaboration.registerType('file', {
action: () => {
diff --git a/apps/files_sharing/src/components/ExternalShareAction.vue b/apps/files_sharing/src/components/ExternalShareAction.vue
index 39caa1260c8..c2c86cc8679 100644
--- a/apps/files_sharing/src/components/ExternalShareAction.vue
+++ b/apps/files_sharing/src/components/ExternalShareAction.vue
@@ -1,24 +1,7 @@
<!--
- - @copyright Copyright (c) 2021 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: 2021 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<Component :is="data.is"
@@ -29,7 +12,7 @@
</template>
<script>
-import Share from '../models/Share'
+import Share from '../models/Share.ts'
export default {
name: 'ExternalShareAction',
diff --git a/apps/files_sharing/src/components/FileListFilterAccount.vue b/apps/files_sharing/src/components/FileListFilterAccount.vue
new file mode 100644
index 00000000000..150516e139b
--- /dev/null
+++ b/apps/files_sharing/src/components/FileListFilterAccount.vue
@@ -0,0 +1,138 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <FileListFilter class="file-list-filter-accounts"
+ :is-active="selectedAccounts.length > 0"
+ :filter-name="t('files_sharing', 'People')"
+ @reset-filter="resetFilter">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiAccountMultipleOutline" />
+ </template>
+ <NcActionInput v-if="availableAccounts.length > 1"
+ :label="t('files_sharing', 'Filter accounts')"
+ :label-outside="false"
+ :show-trailing-button="false"
+ type="search"
+ :value.sync="accountFilter" />
+ <NcActionButton v-for="account of shownAccounts"
+ :key="account.id"
+ class="file-list-filter-accounts__item"
+ type="radio"
+ :model-value="selectedAccounts.includes(account)"
+ :value="account.id"
+ @click="toggleAccount(account.id)">
+ <template #icon>
+ <NcAvatar class="file-list-filter-accounts__avatar"
+ v-bind="account"
+ :size="24"
+ disable-menu
+ :show-user-status="false" />
+ </template>
+ {{ account.displayName }}
+ </NcActionButton>
+ </FileListFilter>
+</template>
+
+<script setup lang="ts">
+import type { IAccountData } from '../files_filters/AccountFilter.ts'
+
+import { translate as t } from '@nextcloud/l10n'
+import { mdiAccountMultipleOutline } from '@mdi/js'
+import { computed, ref, watch } from 'vue'
+
+import FileListFilter from '../../../files/src/components/FileListFilter/FileListFilter.vue'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcActionInput from '@nextcloud/vue/components/NcActionInput'
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+
+interface IUserSelectData {
+ id: string
+ user: string
+ displayName: string
+}
+
+const emit = defineEmits<{
+ (event: 'update:accounts', value: IAccountData[]): void
+}>()
+
+const accountFilter = ref('')
+const availableAccounts = ref<IUserSelectData[]>([])
+const selectedAccounts = ref<IUserSelectData[]>([])
+
+/**
+ * Currently shown accounts (filtered)
+ */
+const shownAccounts = computed(() => {
+ if (!accountFilter.value) {
+ return availableAccounts.value
+ }
+ const queryParts = accountFilter.value.toLocaleLowerCase().trim().split(' ')
+ return availableAccounts.value.filter((account) =>
+ queryParts.every((part) =>
+ account.user.toLocaleLowerCase().includes(part)
+ || account.displayName.toLocaleLowerCase().includes(part),
+ ),
+ )
+})
+
+/**
+ * Toggle an account as selected
+ * @param accountId The account to toggle
+ */
+function toggleAccount(accountId: string) {
+ const account = availableAccounts.value.find(({ id }) => id === accountId)
+ if (account && selectedAccounts.value.includes(account)) {
+ selectedAccounts.value = selectedAccounts.value.filter(({ id }) => id !== accountId)
+ } else {
+ if (account) {
+ selectedAccounts.value = [...selectedAccounts.value, account]
+ }
+ }
+}
+
+// Watch selected account, on change we emit the new account data to the filter instance
+watch(selectedAccounts, () => {
+ // Emit selected accounts as account data
+ const accounts = selectedAccounts.value.map(({ id: uid, displayName }) => ({ uid, displayName }))
+ emit('update:accounts', accounts)
+})
+
+/**
+ * Reset this filter
+ */
+function resetFilter() {
+ selectedAccounts.value = []
+ accountFilter.value = ''
+}
+
+/**
+ * Update list of available accounts in current view.
+ *
+ * @param accounts - Accounts to use
+ */
+function setAvailableAccounts(accounts: IAccountData[]): void {
+ availableAccounts.value = accounts.map(({ uid, displayName }) => ({ displayName, id: uid, user: uid }))
+}
+
+defineExpose({
+ resetFilter,
+ setAvailableAccounts,
+ toggleAccount,
+})
+</script>
+
+<style scoped lang="scss">
+.file-list-filter-accounts {
+ &__item {
+ min-width: 250px;
+ }
+
+ &__avatar {
+ // 24px is the avatar size
+ margin: calc((var(--default-clickable-area) - 24px) / 2)
+ }
+}
+</style>
diff --git a/apps/files_sharing/src/components/NewFileRequestDialog.vue b/apps/files_sharing/src/components/NewFileRequestDialog.vue
new file mode 100644
index 00000000000..392f286e104
--- /dev/null
+++ b/apps/files_sharing/src/components/NewFileRequestDialog.vue
@@ -0,0 +1,468 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <NcDialog can-close
+ class="file-request-dialog"
+ data-cy-file-request-dialog
+ :close-on-click-outside="false"
+ :name="currentStep !== STEP.LAST ? t('files_sharing', 'Create a file request') : t('files_sharing', 'File request created')"
+ size="normal"
+ @closing="onCancel">
+ <!-- Header -->
+ <NcNoteCard v-show="currentStep === STEP.FIRST" type="info" class="file-request-dialog__header">
+ <p id="file-request-dialog-description" class="file-request-dialog__description">
+ {{ t('files_sharing', 'Collect files from others even if they do not have an account.') }}
+ {{ t('files_sharing', 'To ensure you can receive files, verify you have enough storage available.') }}
+ </p>
+ </NcNoteCard>
+
+ <!-- Main form -->
+ <form ref="form"
+ class="file-request-dialog__form"
+ aria-describedby="file-request-dialog-description"
+ :aria-label="t('files_sharing', 'File request')"
+ aria-live="polite"
+ data-cy-file-request-dialog-form
+ @submit.prevent.stop="">
+ <FileRequestIntro v-show="currentStep === STEP.FIRST"
+ :context="context"
+ :destination.sync="destination"
+ :disabled="loading"
+ :label.sync="label"
+ :note.sync="note" />
+
+ <FileRequestDatePassword v-show="currentStep === STEP.SECOND"
+ :disabled="loading"
+ :expiration-date.sync="expirationDate"
+ :password.sync="password" />
+
+ <FileRequestFinish v-if="share"
+ v-show="currentStep === STEP.LAST"
+ :emails="emails"
+ :is-share-by-mail-enabled="isShareByMailEnabled"
+ :share="share"
+ @add-email="email => emails.push(email)"
+ @remove-email="onRemoveEmail" />
+ </form>
+
+ <!-- Controls -->
+ <template #actions>
+ <!-- Back -->
+ <NcButton v-show="currentStep === STEP.SECOND"
+ :aria-label="t('files_sharing', 'Previous step')"
+ :disabled="loading"
+ data-cy-file-request-dialog-controls="back"
+ type="tertiary"
+ @click="currentStep = STEP.FIRST">
+ {{ t('files_sharing', 'Previous step') }}
+ </NcButton>
+
+ <!-- Align right -->
+ <span class="dialog__actions-separator" />
+
+ <!-- Cancel the creation -->
+ <NcButton v-if="currentStep !== STEP.LAST"
+ :aria-label="t('files_sharing', 'Cancel')"
+ :disabled="loading"
+ :title="t('files_sharing', 'Cancel the file request creation')"
+ data-cy-file-request-dialog-controls="cancel"
+ type="tertiary"
+ @click="onCancel">
+ {{ t('files_sharing', 'Cancel') }}
+ </NcButton>
+
+ <!-- Cancel email and just close -->
+ <NcButton v-else-if="emails.length !== 0"
+ :aria-label="t('files_sharing', 'Close without sending emails')"
+ :disabled="loading"
+ :title="t('files_sharing', 'Close without sending emails')"
+ data-cy-file-request-dialog-controls="cancel"
+ type="tertiary"
+ @click="onCancel">
+ {{ t('files_sharing', 'Close') }}
+ </NcButton>
+
+ <!-- Next -->
+ <NcButton v-if="currentStep !== STEP.LAST"
+ :aria-label="t('files_sharing', 'Continue')"
+ :disabled="loading"
+ data-cy-file-request-dialog-controls="next"
+ @click="onPageNext">
+ <template #icon>
+ <NcLoadingIcon v-if="loading" />
+ <IconNext v-else :size="20" />
+ </template>
+ {{ t('files_sharing', 'Continue') }}
+ </NcButton>
+
+ <!-- Finish -->
+ <NcButton v-else
+ :aria-label="finishButtonLabel"
+ :disabled="loading"
+ data-cy-file-request-dialog-controls="finish"
+ type="primary"
+ @click="onFinish">
+ <template #icon>
+ <NcLoadingIcon v-if="loading" />
+ <IconCheck v-else :size="20" />
+ </template>
+ {{ finishButtonLabel }}
+ </NcButton>
+ </template>
+ </NcDialog>
+</template>
+
+<script lang="ts">
+import type { AxiosError } from '@nextcloud/axios'
+import type { Folder, Node } from '@nextcloud/files'
+import type { OCSResponse } from '@nextcloud/typings/ocs'
+import type { PropType } from 'vue'
+
+import { defineComponent } from 'vue'
+import { emit } from '@nextcloud/event-bus'
+import { generateOcsUrl } from '@nextcloud/router'
+import { Permission } from '@nextcloud/files'
+import { ShareType } from '@nextcloud/sharing'
+import { showError, showSuccess } from '@nextcloud/dialogs'
+import { n, t } from '@nextcloud/l10n'
+import axios from '@nextcloud/axios'
+
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcDialog from '@nextcloud/vue/components/NcDialog'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+
+import IconCheck from 'vue-material-design-icons/Check.vue'
+import IconNext from 'vue-material-design-icons/ArrowRight.vue'
+
+import Config from '../services/ConfigService'
+import FileRequestDatePassword from './NewFileRequestDialog/NewFileRequestDialogDatePassword.vue'
+import FileRequestFinish from './NewFileRequestDialog/NewFileRequestDialogFinish.vue'
+import FileRequestIntro from './NewFileRequestDialog/NewFileRequestDialogIntro.vue'
+import logger from '../services/logger'
+import Share from '../models/Share.ts'
+
+enum STEP {
+ FIRST = 0,
+ SECOND = 1,
+ LAST = 2,
+}
+
+const sharingConfig = new Config()
+
+export default defineComponent({
+ name: 'NewFileRequestDialog',
+
+ components: {
+ FileRequestDatePassword,
+ FileRequestFinish,
+ FileRequestIntro,
+ IconCheck,
+ IconNext,
+ NcButton,
+ NcDialog,
+ NcLoadingIcon,
+ NcNoteCard,
+ },
+
+ props: {
+ context: {
+ type: Object as PropType<Folder>,
+ required: true,
+ },
+ content: {
+ type: Array as PropType<Node[]>,
+ required: true,
+ },
+ },
+
+ setup() {
+ return {
+ STEP,
+ n,
+ t,
+
+ isShareByMailEnabled: sharingConfig.isMailShareAllowed,
+ }
+ },
+
+ data() {
+ return {
+ currentStep: STEP.FIRST,
+ loading: false,
+
+ destination: this.context.path || '/',
+ label: '',
+ note: '',
+
+ expirationDate: null as Date | null,
+ password: null as string | null,
+
+ share: null as Share | null,
+ emails: [] as string[],
+ }
+ },
+
+ computed: {
+ finishButtonLabel() {
+ if (this.emails.length === 0) {
+ return t('files_sharing', 'Close')
+ }
+ return n('files_sharing', 'Send email and close', 'Send {count} emails and close', this.emails.length, { count: this.emails.length })
+ },
+ },
+
+ methods: {
+ onPageNext() {
+ const form = this.$refs.form as HTMLFormElement
+
+ // Reset custom validity
+ form.querySelectorAll('input').forEach(input => input.setCustomValidity(''))
+
+ // custom destination validation
+ // cannot share root
+ if (this.destination === '/' || this.destination === '') {
+ const destinationInput = form.querySelector('input[name="destination"]') as HTMLInputElement
+ destinationInput?.setCustomValidity(t('files_sharing', 'Please select a folder, you cannot share the root directory.'))
+ form.reportValidity()
+ return
+ }
+
+ // If the form is not valid, show the error message
+ if (!form.checkValidity()) {
+ form.reportValidity()
+ return
+ }
+
+ if (this.currentStep === STEP.FIRST) {
+ this.currentStep = STEP.SECOND
+ return
+ }
+
+ this.createShare()
+ },
+
+ onRemoveEmail(email: string) {
+ const index = this.emails.indexOf(email)
+ this.emails.splice(index, 1)
+ },
+
+ onCancel() {
+ this.$emit('close')
+ },
+
+ async onFinish() {
+ if (this.emails.length === 0 || this.isShareByMailEnabled === false) {
+ showSuccess(t('files_sharing', 'File request created'))
+ this.$emit('close')
+ return
+ }
+
+ if (sharingConfig.isMailShareAllowed && this.emails.length > 0) {
+ await this.setShareEmails()
+ await this.sendEmails()
+ showSuccess(n('files_sharing', 'File request created and email sent', 'File request created and {count} emails sent', this.emails.length, { count: this.emails.length }))
+ } else {
+ showSuccess(t('files_sharing', 'File request created'))
+ }
+
+ this.$emit('close')
+ },
+
+ async createShare() {
+ this.loading = true
+
+ let expireDate = ''
+ if (this.expirationDate) {
+ const year = this.expirationDate.getFullYear()
+ const month = (this.expirationDate.getMonth() + 1).toString().padStart(2, '0')
+ const day = this.expirationDate.getDate().toString().padStart(2, '0')
+
+ // Format must be YYYY-MM-DD
+ expireDate = `${year}-${month}-${day}`
+ }
+ const shareUrl = generateOcsUrl('apps/files_sharing/api/v1/shares')
+ try {
+ const request = await axios.post<OCSResponse>(shareUrl, {
+ // Always create a file request, but without mail share
+ // permissions, only a share link will be created.
+ shareType: sharingConfig.isMailShareAllowed ? ShareType.Email : ShareType.Link,
+ permissions: Permission.CREATE,
+
+ label: this.label,
+ path: this.destination,
+ note: this.note,
+
+ password: this.password || '',
+ expireDate: expireDate || '',
+
+ // Empty string
+ shareWith: '',
+ attributes: JSON.stringify([{
+ value: true,
+ key: 'enabled',
+ scope: 'fileRequest',
+ }]),
+ })
+
+ // If not an ocs request
+ if (!request?.data?.ocs) {
+ throw request
+ }
+
+ const share = new Share(request.data.ocs.data)
+ this.share = share
+
+ logger.info('New file request created', { share })
+ emit('files_sharing:share:created', { share })
+
+ // Move to the last page
+ this.currentStep = STEP.LAST
+ } catch (error) {
+ const errorMessage = (error as AxiosError<OCSResponse>)?.response?.data?.ocs?.meta?.message
+ showError(
+ errorMessage
+ ? t('files_sharing', 'Error creating the share: {errorMessage}', { errorMessage })
+ : t('files_sharing', 'Error creating the share'),
+ )
+ logger.error('Error while creating share', { error, errorMessage })
+ throw error
+ } finally {
+ this.loading = false
+ }
+ },
+
+ async setShareEmails() {
+ this.loading = true
+
+ // This should never happen™
+ if (!this.share || !this.share?.id) {
+ throw new Error('Share ID is missing')
+ }
+
+ const shareUrl = generateOcsUrl('apps/files_sharing/api/v1/shares/{id}', { id: this.share.id })
+ try {
+ // Convert link share to email share
+ const request = await axios.put<OCSResponse>(shareUrl, {
+ attributes: JSON.stringify([{
+ value: this.emails,
+ key: 'emails',
+ scope: 'shareWith',
+ },
+ {
+ value: true,
+ key: 'enabled',
+ scope: 'fileRequest',
+ }]),
+ })
+
+ // If not an ocs request
+ if (!request?.data?.ocs) {
+ throw request
+ }
+ } catch (error) {
+ this.onEmailSendError(error)
+ throw error
+ } finally {
+ this.loading = false
+ }
+ },
+
+ async sendEmails() {
+ this.loading = true
+
+ // This should never happen™
+ if (!this.share || !this.share?.id) {
+ throw new Error('Share ID is missing')
+ }
+
+ const shareUrl = generateOcsUrl('apps/files_sharing/api/v1/shares/{id}/send-email', { id: this.share.id })
+ try {
+ // Convert link share to email share
+ const request = await axios.post<OCSResponse>(shareUrl, {
+ password: this.password || undefined,
+ })
+
+ // If not an ocs request
+ if (!request?.data?.ocs) {
+ throw request
+ }
+ } catch (error) {
+ this.onEmailSendError(error)
+ throw error
+ } finally {
+ this.loading = false
+ }
+ },
+
+ onEmailSendError(error: AxiosError<OCSResponse>) {
+ const errorMessage = error.response?.data?.ocs?.meta?.message
+ showError(
+ errorMessage
+ ? t('files_sharing', 'Error sending emails: {errorMessage}', { errorMessage })
+ : t('files_sharing', 'Error sending emails'),
+ )
+ logger.error('Error while sending emails', { error, errorMessage })
+ },
+ },
+})
+</script>
+
+<style lang="scss">
+.file-request-dialog {
+ --margin: 18px;
+
+ &__header {
+ margin: 0 var(--margin);
+ }
+
+ &__form {
+ position: relative;
+ overflow: auto;
+ padding: var(--margin) var(--margin);
+ // overlap header bottom padding
+ margin-top: calc(-1 * var(--margin));
+ }
+
+ fieldset {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ margin-top: var(--margin);
+
+ legend {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ }
+ }
+
+ // Using a NcNoteCard was a bit much sometimes.
+ // Using a simple paragraph instead does it.
+ &__info {
+ color: var(--color-text-maxcontrast);
+ padding-block: 4px;
+ display: flex;
+ align-items: center;
+ .file-request-dialog__info-icon {
+ margin-inline-end: 8px;
+ }
+ }
+
+ .dialog__actions {
+ width: auto;
+ margin-inline: 12px;
+ span.dialog__actions-separator {
+ margin-inline-start: auto;
+ }
+ }
+
+ .input-field__helper-text-message {
+ // reduce helper text standing out
+ color: var(--color-text-maxcontrast);
+ }
+}
+</style>
diff --git a/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogDatePassword.vue b/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogDatePassword.vue
new file mode 100644
index 00000000000..7e6d56e8794
--- /dev/null
+++ b/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogDatePassword.vue
@@ -0,0 +1,258 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <div>
+ <!-- Password and expiration summary -->
+ <NcNoteCard v-if="passwordAndExpirationSummary" type="success">
+ {{ passwordAndExpirationSummary }}
+ </NcNoteCard>
+
+ <!-- Expiration date -->
+ <fieldset class="file-request-dialog__expiration" data-cy-file-request-dialog-fieldset="expiration">
+ <!-- Enable expiration -->
+ <legend>{{ t('files_sharing', 'When should the request expire?') }}</legend>
+ <NcCheckboxRadioSwitch v-show="!isExpirationDateEnforced"
+ :checked="isExpirationDateEnforced || expirationDate !== null"
+ :disabled="disabled || isExpirationDateEnforced"
+ @update:checked="onToggleDeadline">
+ {{ t('files_sharing', 'Set a submission expiration date') }}
+ </NcCheckboxRadioSwitch>
+
+ <!-- Date picker -->
+ <NcDateTimePickerNative v-if="expirationDate !== null"
+ id="file-request-dialog-expirationDate"
+ :disabled="disabled"
+ :hide-label="true"
+ :label="t('files_sharing', 'Expiration date')"
+ :max="maxDate"
+ :min="minDate"
+ :placeholder="t('files_sharing', 'Select a date')"
+ :required="defaultExpireDateEnforced"
+ :value="expirationDate"
+ name="expirationDate"
+ type="date"
+ @input="$emit('update:expirationDate', $event)" />
+
+ <p v-if="defaultExpireDateEnforced" class="file-request-dialog__info">
+ <IconInfo :size="18" class="file-request-dialog__info-icon" />
+ {{ t('files_sharing', 'Your administrator has enforced a {count} days expiration policy.', { count: defaultExpireDate }) }}
+ </p>
+ </fieldset>
+
+ <!-- Password -->
+ <fieldset class="file-request-dialog__password" data-cy-file-request-dialog-fieldset="password">
+ <!-- Enable password -->
+ <legend>{{ t('files_sharing', 'What password should be used for the request?') }}</legend>
+ <NcCheckboxRadioSwitch v-show="!isPasswordEnforced"
+ :checked="isPasswordEnforced || password !== null"
+ :disabled="disabled || isPasswordEnforced"
+ @update:checked="onTogglePassword">
+ {{ t('files_sharing', 'Set a password') }}
+ </NcCheckboxRadioSwitch>
+
+ <div v-if="password !== null" class="file-request-dialog__password-field">
+ <NcPasswordField ref="passwordField"
+ :check-password-strength="true"
+ :disabled="disabled"
+ :label="t('files_sharing', 'Password')"
+ :placeholder="t('files_sharing', 'Enter a valid password')"
+ :required="enforcePasswordForPublicLink"
+ :value="password"
+ name="password"
+ @update:value="$emit('update:password', $event)" />
+ <NcButton :aria-label="t('files_sharing', 'Generate a new password')"
+ :title="t('files_sharing', 'Generate a new password')"
+ type="tertiary-no-background"
+ @click="onGeneratePassword">
+ <template #icon>
+ <IconPasswordGen :size="20" />
+ </template>
+ </NcButton>
+ </div>
+
+ <p v-if="enforcePasswordForPublicLink" class="file-request-dialog__info">
+ <IconInfo :size="18" class="file-request-dialog__info-icon" />
+ {{ t('files_sharing', 'Your administrator has enforced a password protection.') }}
+ </p>
+ </fieldset>
+ </div>
+</template>
+
+<script lang="ts">
+import { defineComponent, type PropType } from 'vue'
+import { t } from '@nextcloud/l10n'
+
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
+import NcDateTimePickerNative from '@nextcloud/vue/components/NcDateTimePickerNative'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
+
+import IconInfo from 'vue-material-design-icons/Information.vue'
+import IconPasswordGen from 'vue-material-design-icons/AutoFix.vue'
+
+import Config from '../../services/ConfigService'
+import GeneratePassword from '../../utils/GeneratePassword'
+
+const sharingConfig = new Config()
+
+export default defineComponent({
+ name: 'NewFileRequestDialogDatePassword',
+
+ components: {
+ IconInfo,
+ IconPasswordGen,
+ NcButton,
+ NcCheckboxRadioSwitch,
+ NcDateTimePickerNative,
+ NcNoteCard,
+ NcPasswordField,
+ },
+
+ props: {
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ expirationDate: {
+ type: Date as PropType<Date | null>,
+ required: false,
+ default: null,
+ },
+ password: {
+ type: String as PropType<string | null>,
+ required: false,
+ default: null,
+ },
+ },
+
+ emits: [
+ 'update:expirationDate',
+ 'update:password',
+ ],
+
+ setup() {
+ return {
+ t,
+
+ // Default expiration date if defaultExpireDateEnabled is true
+ defaultExpireDate: sharingConfig.defaultExpireDate,
+ // Default expiration date is enabled for public links (can be disabled)
+ defaultExpireDateEnabled: sharingConfig.isDefaultExpireDateEnabled,
+ // Default expiration date is enforced for public links (can't be disabled)
+ defaultExpireDateEnforced: sharingConfig.isDefaultExpireDateEnforced,
+
+ // Default password protection is enabled for public links (can be disabled)
+ enableLinkPasswordByDefault: sharingConfig.enableLinkPasswordByDefault,
+ // Password protection is enforced for public links (can't be disabled)
+ enforcePasswordForPublicLink: sharingConfig.enforcePasswordForPublicLink,
+ }
+ },
+
+ data() {
+ return {
+ maxDate: null as Date | null,
+ minDate: new Date(new Date().setDate(new Date().getDate() + 1)),
+ }
+ },
+
+ computed: {
+ passwordAndExpirationSummary(): string {
+ if (this.expirationDate && this.password) {
+ return t('files_sharing', 'The request will expire on {date} at midnight and will be password protected.', {
+ date: this.expirationDate.toLocaleDateString(),
+ })
+ }
+
+ if (this.expirationDate) {
+ return t('files_sharing', 'The request will expire on {date} at midnight.', {
+ date: this.expirationDate.toLocaleDateString(),
+ })
+ }
+
+ if (this.password) {
+ return t('files_sharing', 'The request will be password protected.')
+ }
+
+ return ''
+ },
+
+ isExpirationDateEnforced(): boolean {
+ // Both fields needs to be enabled in the settings
+ return this.defaultExpireDateEnabled
+ && this.defaultExpireDateEnforced
+ },
+
+ isPasswordEnforced(): boolean {
+ // Both fields needs to be enabled in the settings
+ return this.enableLinkPasswordByDefault
+ && this.enforcePasswordForPublicLink
+ },
+ },
+
+ mounted() {
+ // If defined, we set the default expiration date
+ if (this.defaultExpireDate) {
+ this.$emit('update:expirationDate', sharingConfig.defaultExpirationDate)
+ }
+
+ // If enforced, we cannot set a date before the default expiration days (see admin settings)
+ if (this.isExpirationDateEnforced) {
+ this.maxDate = sharingConfig.defaultExpirationDate
+ }
+
+ // If enabled by default, we generate a valid password
+ if (this.isPasswordEnforced) {
+ this.generatePassword()
+ }
+ },
+
+ methods: {
+ onToggleDeadline(checked: boolean) {
+ this.$emit('update:expirationDate', checked ? (this.maxDate || this.minDate) : null)
+ },
+
+ async onTogglePassword(checked: boolean) {
+ if (checked) {
+ this.generatePassword()
+ return
+ }
+ this.$emit('update:password', null)
+ },
+
+ async onGeneratePassword() {
+ await this.generatePassword()
+ this.showPassword()
+ },
+
+ async generatePassword() {
+ await GeneratePassword().then(password => {
+ this.$emit('update:password', password)
+ })
+ },
+
+ showPassword() {
+ // @ts-expect-error isPasswordHidden is private
+ this.$refs.passwordField.isPasswordHidden = false
+ },
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.file-request-dialog__password-field {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ // Compensate label gab with legend
+ margin-top: 12px;
+ > div {
+ // Force margin to 0 as we handle it above
+ margin: 0;
+ }
+}
+</style>
diff --git a/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogFinish.vue b/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogFinish.vue
new file mode 100644
index 00000000000..7826aab581e
--- /dev/null
+++ b/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogFinish.vue
@@ -0,0 +1,236 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <div>
+ <!-- Request note -->
+ <NcNoteCard type="success">
+ {{ t('files_sharing', 'You can now share the link below to allow people to upload files to your directory.') }}
+ </NcNoteCard>
+
+ <!-- Copy share link -->
+ <NcInputField ref="clipboard"
+ :value="shareLink"
+ :label="t('files_sharing', 'Share link')"
+ :readonly="true"
+ :show-trailing-button="true"
+ :trailing-button-label="t('files_sharing', 'Copy')"
+ data-cy-file-request-dialog-fieldset="link"
+ @click="copyShareLink"
+ @trailing-button-click="copyShareLink">
+ <template #trailing-button-icon>
+ <IconCheck v-if="isCopied" :size="20" />
+ <IconClipboard v-else :size="20" />
+ </template>
+ </NcInputField>
+
+ <template v-if="isShareByMailEnabled">
+ <!-- Email share-->
+ <NcTextField :value.sync="email"
+ :label="t('files_sharing', 'Send link via email')"
+ :placeholder="t('files_sharing', 'Enter an email address or paste a list')"
+ data-cy-file-request-dialog-fieldset="email"
+ type="email"
+ @keypress.enter.stop="addNewEmail"
+ @paste.stop.prevent="onPasteEmails"
+ @focusout.native="addNewEmail" />
+
+ <!-- Email list -->
+ <div v-if="emails.length > 0" class="file-request-dialog__emails">
+ <NcChip v-for="mail in emails"
+ :key="mail"
+ :aria-label-close="t('files_sharing', 'Remove email')"
+ :text="mail"
+ @close="$emit('remove-email', mail)">
+ <template #icon>
+ <NcAvatar :disable-menu="true"
+ :disable-tooltip="true"
+ :display-name="mail"
+ :is-no-user="true"
+ :show-user-status="false"
+ :size="24" />
+ </template>
+ </NcChip>
+ </div>
+ </template>
+ </div>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+import Share from '../../models/Share.ts'
+
+import { defineComponent } from 'vue'
+import { generateUrl, getBaseUrl } from '@nextcloud/router'
+import { showError, showSuccess } from '@nextcloud/dialogs'
+import { n, t } from '@nextcloud/l10n'
+
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcInputField from '@nextcloud/vue/components/NcInputField'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+import NcChip from '@nextcloud/vue/components/NcChip'
+
+import IconCheck from 'vue-material-design-icons/Check.vue'
+import IconClipboard from 'vue-material-design-icons/ClipboardText.vue'
+
+export default defineComponent({
+ name: 'NewFileRequestDialogFinish',
+
+ components: {
+ IconCheck,
+ IconClipboard,
+ NcAvatar,
+ NcInputField,
+ NcNoteCard,
+ NcTextField,
+ NcChip,
+ },
+
+ props: {
+ share: {
+ type: Object as PropType<Share>,
+ required: true,
+ },
+ emails: {
+ type: Array as PropType<string[]>,
+ required: true,
+ },
+ isShareByMailEnabled: {
+ type: Boolean,
+ required: true,
+ },
+ },
+
+ emits: ['add-email', 'remove-email'],
+
+ setup() {
+ return {
+ n, t,
+ }
+ },
+
+ data() {
+ return {
+ isCopied: false,
+ email: '',
+ }
+ },
+
+ computed: {
+ shareLink() {
+ return generateUrl('/s/{token}', { token: this.share.token }, { baseURL: getBaseUrl() })
+ },
+ },
+
+ methods: {
+ async copyShareLink(event: MouseEvent) {
+ if (this.isCopied) {
+ this.isCopied = false
+ return
+ }
+
+ if (!navigator.clipboard) {
+ // Clipboard API not available
+ window.prompt(t('files_sharing', 'Automatically copying failed, please copy the share link manually'), this.shareLink)
+ return
+ }
+
+ await navigator.clipboard.writeText(this.shareLink)
+
+ showSuccess(t('files_sharing', 'Link copied'))
+ this.isCopied = true
+ event.target?.select?.()
+
+ setTimeout(() => {
+ this.isCopied = false
+ }, 3000)
+ },
+
+ addNewEmail(e: KeyboardEvent) {
+ if (this.email.trim() === '') {
+ return
+ }
+
+ if (e.target instanceof HTMLInputElement) {
+ // Reset the custom validity
+ e.target.setCustomValidity('')
+
+ // Check if the field is valid
+ if (e.target.checkValidity() === false) {
+ e.target.reportValidity()
+ return
+ }
+
+ // The email is already in the list
+ if (this.emails.includes(this.email.trim())) {
+ e.target.setCustomValidity(t('files_sharing', 'Email already added'))
+ e.target.reportValidity()
+ return
+ }
+
+ // Check if the email is valid
+ if (!this.isValidEmail(this.email.trim())) {
+ e.target.setCustomValidity(t('files_sharing', 'Invalid email address'))
+ e.target.reportValidity()
+ return
+ }
+
+ this.$emit('add-email', this.email.trim())
+ this.email = ''
+ }
+ },
+
+ // Handle dumping a list of emails
+ onPasteEmails(e: ClipboardEvent) {
+ const clipboardData = e.clipboardData
+ if (!clipboardData) {
+ return
+ }
+
+ const pastedText = clipboardData.getData('text')
+ const emails = pastedText.split(/[\s,;]+/).filter(Boolean).map((email) => email.trim())
+
+ const duplicateEmails = emails.filter((email) => this.emails.includes(email))
+ const validEmails = emails.filter((email) => this.isValidEmail(email) && !duplicateEmails.includes(email))
+ const invalidEmails = emails.filter((email) => !this.isValidEmail(email))
+ validEmails.forEach((email) => this.$emit('add-email', email))
+
+ // Warn about invalid emails
+ if (invalidEmails.length > 0) {
+ showError(n('files_sharing', 'The following email address is not valid: {emails}', 'The following email addresses are not valid: {emails}', invalidEmails.length, { emails: invalidEmails.join(', ') }))
+ }
+
+ // Warn about duplicate emails
+ if (duplicateEmails.length > 0) {
+ showError(n('files_sharing', '{count} email address already added', '{count} email addresses already added', duplicateEmails.length, { count: duplicateEmails.length }))
+ }
+
+ if (validEmails.length > 0) {
+ showSuccess(n('files_sharing', '{count} email address added', '{count} email addresses added', validEmails.length, { count: validEmails.length }))
+ }
+
+ this.email = ''
+ },
+
+ // No need to have a fancy regex, just check for an @
+ isValidEmail(email: string): boolean {
+ return email.includes('@')
+ },
+ },
+})
+</script>
+<style scoped>
+.input-field,
+.file-request-dialog__emails {
+ margin-top: var(--margin);
+}
+
+.file-request-dialog__emails {
+ display: flex;
+ gap: var(--default-grid-baseline);
+ flex-wrap: wrap;
+}
+</style>
diff --git a/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogIntro.vue b/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogIntro.vue
new file mode 100644
index 00000000000..5ac60c37e29
--- /dev/null
+++ b/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogIntro.vue
@@ -0,0 +1,166 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <div>
+ <!-- Request label -->
+ <fieldset class="file-request-dialog__label" data-cy-file-request-dialog-fieldset="label">
+ <legend>
+ {{ t('files_sharing', 'What are you requesting?') }}
+ </legend>
+ <NcTextField :value="label"
+ :disabled="disabled"
+ :label="t('files_sharing', 'Request subject')"
+ :placeholder="t('files_sharing', 'Birthday party photos, History assignment…')"
+ :required="false"
+ name="label"
+ @update:value="$emit('update:label', $event)" />
+ </fieldset>
+
+ <!-- Request destination -->
+ <fieldset class="file-request-dialog__destination" data-cy-file-request-dialog-fieldset="destination">
+ <legend>
+ {{ t('files_sharing', 'Where should these files go?') }}
+ </legend>
+ <NcTextField :value="destination"
+ :disabled="disabled"
+ :label="t('files_sharing', 'Upload destination')"
+ :minlength="2/* cannot share root */"
+ :placeholder="t('files_sharing', 'Select a destination')"
+ :readonly="false /* cannot validate a readonly input */"
+ :required="true /* cannot be empty */"
+ :show-trailing-button="destination !== context.path"
+ :trailing-button-icon="'undo'"
+ :trailing-button-label="t('files_sharing', 'Revert to default')"
+ name="destination"
+ @click="onPickDestination"
+ @keypress.prevent.stop="/* prevent typing in the input, we use the picker */"
+ @paste.prevent.stop="/* prevent pasting in the input, we use the picker */"
+ @trailing-button-click="$emit('update:destination', '')">
+ <IconFolder :size="18" />
+ </NcTextField>
+
+ <p class="file-request-dialog__info">
+ <IconLock :size="18" class="file-request-dialog__info-icon" />
+ {{ t('files_sharing', 'The uploaded files are visible only to you unless you choose to share them.') }}
+ </p>
+ </fieldset>
+
+ <!-- Request note -->
+ <fieldset class="file-request-dialog__note" data-cy-file-request-dialog-fieldset="note">
+ <legend>
+ {{ t('files_sharing', 'Add a note') }}
+ </legend>
+ <NcTextArea :value="note"
+ :disabled="disabled"
+ :label="t('files_sharing', 'Note for recipient')"
+ :placeholder="t('files_sharing', 'Add a note to help people understand what you are requesting.')"
+ :required="false"
+ name="note"
+ @update:value="$emit('update:note', $event)" />
+
+ <p class="file-request-dialog__info">
+ <IconInfo :size="18" class="file-request-dialog__info-icon" />
+ {{ t('files_sharing', 'You can add links, date or any other information that will help the recipient understand what you are requesting.') }}
+ </p>
+ </fieldset>
+ </div>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+import type { Folder, Node } from '@nextcloud/files'
+
+import { defineComponent } from 'vue'
+import { getFilePickerBuilder } from '@nextcloud/dialogs'
+import { t } from '@nextcloud/l10n'
+
+import IconFolder from 'vue-material-design-icons/Folder.vue'
+import IconInfo from 'vue-material-design-icons/InformationOutline.vue'
+import IconLock from 'vue-material-design-icons/Lock.vue'
+import NcTextArea from '@nextcloud/vue/components/NcTextArea'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+
+export default defineComponent({
+ name: 'NewFileRequestDialogIntro',
+
+ components: {
+ IconFolder,
+ IconInfo,
+ IconLock,
+ NcTextArea,
+ NcTextField,
+ },
+
+ props: {
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ context: {
+ type: Object as PropType<Folder>,
+ required: true,
+ },
+ label: {
+ type: String,
+ required: true,
+ },
+ destination: {
+ type: String,
+ required: true,
+ },
+ note: {
+ type: String,
+ required: true,
+ },
+ },
+
+ emits: [
+ 'update:destination',
+ 'update:label',
+ 'update:note',
+ ],
+
+ setup() {
+ return {
+ t,
+ }
+ },
+
+ methods: {
+ onPickDestination() {
+ const filepicker = getFilePickerBuilder(t('files_sharing', 'Select a destination'))
+ .addMimeTypeFilter('httpd/unix-directory')
+ .allowDirectories(true)
+ .addButton({
+ label: t('files_sharing', 'Select'),
+ callback: this.onPickedDestination,
+ })
+ .setFilter(node => node.path !== '/')
+ .startAt(this.destination)
+ .build()
+ try {
+ filepicker.pick()
+ } catch (e) {
+ // ignore cancel
+ }
+ },
+
+ onPickedDestination(nodes: Node[]) {
+ const node = nodes[0]
+ if (node) {
+ this.$emit('update:destination', node.path)
+ }
+ },
+ },
+})
+</script>
+<style scoped>
+.file-request-dialog__note :deep(textarea) {
+ width: 100% !important;
+ min-height: 80px;
+}
+</style>
diff --git a/apps/files_sharing/src/components/PersonalSettings.vue b/apps/files_sharing/src/components/PersonalSettings.vue
index 526bee07324..19c9c2aec87 100644
--- a/apps/files_sharing/src/components/PersonalSettings.vue
+++ b/apps/files_sharing/src/components/PersonalSettings.vue
@@ -1,24 +1,7 @@
<!--
- - @copyright 2019 Roeland Jago Douma <roeland@famdouma.nl>
- -
- - @author 2019 Roeland Jago Douma <roeland@famdouma.nl>
- - @author Hinrich Mahler <nextcloud@mahlerhome.de>
- -
- - @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 v-if="!enforceAcceptShares || allowCustomDirectory" id="files-sharing-personal-settings" class="section">
@@ -29,7 +12,7 @@
class="checkbox"
type="checkbox"
@change="toggleEnabled">
- <label for="files-sharing-personal-settings-accept">{{ t('files_sharing', 'Accept user and group shares by default') }}</label>
+ <label for="files-sharing-personal-settings-accept">{{ t('files_sharing', 'Accept shares from other accounts and groups by default') }}</label>
</p>
<p v-if="allowCustomDirectory">
<SelectShareFolderDialogue />
@@ -43,7 +26,7 @@ import { loadState } from '@nextcloud/initial-state'
import { showError } from '@nextcloud/dialogs'
import axios from '@nextcloud/axios'
-import SelectShareFolderDialogue from './SelectShareFolderDialogue'
+import SelectShareFolderDialogue from './SelectShareFolderDialogue.vue'
export default {
name: 'PersonalSettings',
@@ -69,7 +52,7 @@ export default {
accept: this.accepting,
})
} catch (error) {
- showError(t('sharing', 'Error while toggling options'))
+ showError(t('files_sharing', 'Error while toggling options'))
console.error(error)
}
},
diff --git a/apps/files_sharing/src/components/SelectShareFolderDialogue.vue b/apps/files_sharing/src/components/SelectShareFolderDialogue.vue
index 405c6fd16ce..959fecaa4a4 100644
--- a/apps/files_sharing/src/components/SelectShareFolderDialogue.vue
+++ b/apps/files_sharing/src/components/SelectShareFolderDialogue.vue
@@ -1,32 +1,17 @@
<!--
- - @copyright 2021 Hinrich Mahler <nextcloud@mahlerhome.de>
- -
- - @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: 2021 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<div class="share-folder">
- <span>{{ t('files_sharing', 'Set default folder for accepted shares') }} </span>
-
<!-- Folder picking form -->
<form class="share-folder__form" @reset.prevent.stop="resetFolder">
- <input class="share-folder__picker"
+ <NcTextField class="share-folder__picker"
type="text"
- :placeholder="readableDirectory"
- @click.prevent="pickFolder">
+ :label="t('files_sharing', 'Set default folder for accepted shares')"
+ :value="readableDirectory"
+ @click.prevent="pickFolder" />
<!-- Show reset button if folder is different -->
<input v-if="readableDirectory !== defaultDirectory"
@@ -44,12 +29,16 @@ import path from 'path'
import { generateUrl } from '@nextcloud/router'
import { getFilePickerBuilder, showError } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
const defaultDirectory = loadState('files_sharing', 'default_share_folder', '/')
const directory = loadState('files_sharing', 'share_folder', defaultDirectory)
export default {
name: 'SelectShareFolderDialogue',
+ components: {
+ NcTextField,
+ },
data() {
return {
directory,
@@ -68,10 +57,9 @@ export default {
async pickFolder() {
// Setup file picker
- const picker = getFilePickerBuilder(t('files', 'Choose a default folder for accepted shares'))
+ const picker = getFilePickerBuilder(t('files_sharing', 'Choose a default folder for accepted shares'))
.startAt(this.readableDirectory)
.setMultiSelect(false)
- .setModal(true)
.setType(1)
.setMimeTypeFilter(['httpd/unix-directory'])
.allowDirectories()
@@ -81,7 +69,7 @@ export default {
// Init user folder picking
const dir = await picker.pick() || '/'
if (!dir.startsWith('/')) {
- throw new Error(t('files', 'Invalid path selected'))
+ throw new Error(t('files_sharing', 'Invalid path selected'))
}
// Fix potential path issues and save results
@@ -90,7 +78,7 @@ export default {
shareFolder: this.directory,
})
} catch (error) {
- showError(error.message || t('files', 'Unknown error'))
+ showError(error.message || t('files_sharing', 'Unknown error'))
}
},
@@ -110,7 +98,7 @@ export default {
&__picker {
cursor: pointer;
- min-width: 266px;
+ max-width: 300px;
}
// Make the reset button looks like text
diff --git a/apps/files_sharing/src/components/ShareExpiryTime.vue b/apps/files_sharing/src/components/ShareExpiryTime.vue
new file mode 100644
index 00000000000..939142616e9
--- /dev/null
+++ b/apps/files_sharing/src/components/ShareExpiryTime.vue
@@ -0,0 +1,91 @@
+<!--
+ - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <div class="share-expiry-time">
+ <NcPopover popup-role="dialog">
+ <template #trigger>
+ <NcButton v-if="expiryTime"
+ class="hint-icon"
+ type="tertiary"
+ :aria-label="t('files_sharing', 'Share expiration: {date}', { date: new Date(expiryTime).toLocaleString() })">
+ <template #icon>
+ <ClockIcon :size="20" />
+ </template>
+ </NcButton>
+ </template>
+ <h3 class="hint-heading">
+ {{ t('files_sharing', 'Share Expiration') }}
+ </h3>
+ <p v-if="expiryTime" class="hint-body">
+ <NcDateTime :timestamp="expiryTime"
+ :format="timeFormat"
+ :relative-time="false" /> (<NcDateTime :timestamp="expiryTime" />)
+ </p>
+ </NcPopover>
+ </div>
+</template>
+
+<script>
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcPopover from '@nextcloud/vue/components/NcPopover'
+import NcDateTime from '@nextcloud/vue/components/NcDateTime'
+import ClockIcon from 'vue-material-design-icons/Clock.vue'
+
+export default {
+ name: 'ShareExpiryTime',
+
+ components: {
+ NcButton,
+ NcPopover,
+ NcDateTime,
+ ClockIcon,
+ },
+
+ props: {
+ share: {
+ type: Object,
+ required: true,
+ },
+ },
+
+ computed: {
+ expiryTime() {
+ return this.share?.expireDate ? new Date(this.share.expireDate).getTime() : null
+ },
+ timeFormat() {
+ return { dateStyle: 'full', timeStyle: 'short' }
+ },
+ },
+}
+</script>
+
+<style scoped lang="scss">
+.share-expiry-time {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+
+ .hint-icon {
+ padding: 0;
+ margin: 0;
+ width: 24px;
+ height: 24px;
+ }
+}
+
+.hint-heading {
+ text-align: center;
+ font-size: 1rem;
+ margin-top: 8px;
+ padding-bottom: 8px;
+ margin-bottom: 0;
+ border-bottom: 1px solid var(--color-border);
+}
+
+.hint-body {
+ padding: var(--border-radius-element);
+ max-width: 300px;
+}
+</style>
diff --git a/apps/files_sharing/src/components/SharePermissionsEditor.vue b/apps/files_sharing/src/components/SharePermissionsEditor.vue
deleted file mode 100644
index c5e652b2cda..00000000000
--- a/apps/files_sharing/src/components/SharePermissionsEditor.vue
+++ /dev/null
@@ -1,294 +0,0 @@
-<!--
- - @copyright Copyright (c) 2022 Louis Chmn <louis@chmn.me>
- -
- - @author Louis Chmn <louis@chmn.me>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
-
-<template>
- <li>
- <ul>
- <!-- file -->
- <NcActionCheckbox v-if="!isFolder"
- :checked="shareHasPermissions(atomicPermissions.UPDATE)"
- :disabled="saving"
- @update:checked="toggleSharePermissions(atomicPermissions.UPDATE)">
- {{ t('files_sharing', 'Allow editing') }}
- </NcActionCheckbox>
-
- <!-- folder -->
- <template v-if="isFolder && fileHasCreatePermission && config.isPublicUploadEnabled">
- <template v-if="!showCustomPermissionsForm">
- <NcActionRadio :checked="sharePermissionEqual(bundledPermissions.READ_ONLY)"
- :value="bundledPermissions.READ_ONLY"
- :name="randomFormName"
- :disabled="saving"
- @change="setSharePermissions(bundledPermissions.READ_ONLY)">
- {{ t('files_sharing', 'Read only') }}
- </NcActionRadio>
-
- <NcActionRadio :checked="sharePermissionEqual(bundledPermissions.UPLOAD_AND_UPDATE)"
- :value="bundledPermissions.UPLOAD_AND_UPDATE"
- :disabled="saving"
- :name="randomFormName"
- @change="setSharePermissions(bundledPermissions.UPLOAD_AND_UPDATE)">
- {{ t('files_sharing', 'Allow upload and editing') }}
- </NcActionRadio>
- <NcActionRadio :checked="sharePermissionEqual(bundledPermissions.FILE_DROP)"
- :value="bundledPermissions.FILE_DROP"
- :disabled="saving"
- :name="randomFormName"
- class="sharing-entry__action--public-upload"
- @change="setSharePermissions(bundledPermissions.FILE_DROP)">
- {{ t('files_sharing', 'File drop (upload only)') }}
- </NcActionRadio>
-
- <!-- custom permissions button -->
- <NcActionButton :title="t('files_sharing', 'Custom permissions')"
- @click="showCustomPermissionsForm = true">
- <template #icon>
- <Tune />
- </template>
- {{ sharePermissionsIsBundle ? "" : sharePermissionsSummary }}
- </NcActionButton>
- </template>
-
- <!-- custom permissions -->
- <span v-else :class="{error: !sharePermissionsSetIsValid}">
- <NcActionCheckbox :checked="shareHasPermissions(atomicPermissions.READ)"
- :disabled="saving || !canToggleSharePermissions(atomicPermissions.READ)"
- @update:checked="toggleSharePermissions(atomicPermissions.READ)">
- {{ t('files_sharing', 'Read') }}
- </NcActionCheckbox>
- <NcActionCheckbox :checked="shareHasPermissions(atomicPermissions.CREATE)"
- :disabled="saving || !canToggleSharePermissions(atomicPermissions.CREATE)"
- @update:checked="toggleSharePermissions(atomicPermissions.CREATE)">
- {{ t('files_sharing', 'Upload') }}
- </NcActionCheckbox>
- <NcActionCheckbox :checked="shareHasPermissions(atomicPermissions.UPDATE)"
- :disabled="saving || !canToggleSharePermissions(atomicPermissions.UPDATE)"
- @update:checked="toggleSharePermissions(atomicPermissions.UPDATE)">
- {{ t('files_sharing', 'Edit') }}
- </NcActionCheckbox>
- <NcActionCheckbox :checked="shareHasPermissions(atomicPermissions.DELETE)"
- :disabled="saving || !canToggleSharePermissions(atomicPermissions.DELETE)"
- @update:checked="toggleSharePermissions(atomicPermissions.DELETE)">
- {{ t('files_sharing', 'Delete') }}
- </NcActionCheckbox>
-
- <NcActionButton @click="showCustomPermissionsForm = false">
- <template #icon>
- <ChevronLeft />
- </template>
- {{ t('files_sharing', 'Bundled permissions') }}
- </NcActionButton>
- </span>
- </template>
- </ul>
- </li>
-</template>
-
-<script>
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton'
-import NcActionRadio from '@nextcloud/vue/dist/Components/NcActionRadio'
-import NcActionCheckbox from '@nextcloud/vue/dist/Components/NcActionCheckbox'
-
-import SharesMixin from '../mixins/SharesMixin'
-import {
- ATOMIC_PERMISSIONS,
- BUNDLED_PERMISSIONS,
- hasPermissions,
- permissionsSetIsValid,
- togglePermissions,
- canTogglePermissions,
-} from '../lib/SharePermissionsToolBox'
-
-import Tune from 'vue-material-design-icons/Tune'
-import ChevronLeft from 'vue-material-design-icons/ChevronLeft'
-
-export default {
- name: 'SharePermissionsEditor',
-
- components: {
- NcActionButton,
- NcActionCheckbox,
- NcActionRadio,
- Tune,
- ChevronLeft,
- },
-
- mixins: [SharesMixin],
-
- data() {
- return {
- randomFormName: Math.random().toString(27).substring(2),
-
- showCustomPermissionsForm: false,
-
- atomicPermissions: ATOMIC_PERMISSIONS,
- bundledPermissions: BUNDLED_PERMISSIONS,
- }
- },
-
- computed: {
- /**
- * Return the summary of custom checked permissions.
- *
- * @return {string}
- */
- sharePermissionsSummary() {
- return Object.values(this.atomicPermissions)
- .filter(permission => this.shareHasPermissions(permission))
- .map(permission => {
- switch (permission) {
- case this.atomicPermissions.CREATE:
- return this.t('files_sharing', 'Upload')
- case this.atomicPermissions.READ:
- return this.t('files_sharing', 'Read')
- case this.atomicPermissions.UPDATE:
- return this.t('files_sharing', 'Edit')
- case this.atomicPermissions.DELETE:
- return this.t('files_sharing', 'Delete')
- default:
- return null
- }
- })
- .filter(permissionLabel => permissionLabel !== null)
- .join(', ')
- },
-
- /**
- * Return whether the share's permission is a bundle.
- *
- * @return {boolean}
- */
- sharePermissionsIsBundle() {
- return Object.values(BUNDLED_PERMISSIONS)
- .map(bundle => this.sharePermissionEqual(bundle))
- .filter(isBundle => isBundle)
- .length > 0
- },
-
- /**
- * Return whether the share's permission is valid.
- *
- * @return {boolean}
- */
- sharePermissionsSetIsValid() {
- return permissionsSetIsValid(this.share.permissions)
- },
-
- /**
- * Is the current share a folder ?
- * TODO: move to a proper FileInfo model?
- *
- * @return {boolean}
- */
- isFolder() {
- return this.fileInfo.type === 'dir'
- },
-
- /**
- * Does the current file/folder have create permissions.
- * TODO: move to a proper FileInfo model?
- *
- * @return {boolean}
- */
- fileHasCreatePermission() {
- return !!(this.fileInfo.permissions & ATOMIC_PERMISSIONS.CREATE)
- },
- },
-
- mounted() {
- // Show the Custom Permissions view on open if the permissions set is not a bundle.
- this.showCustomPermissionsForm = !this.sharePermissionsIsBundle
- },
-
- methods: {
- /**
- * Return whether the share has the exact given permissions.
- *
- * @param {number} permissions - the permissions to check.
- *
- * @return {boolean}
- */
- sharePermissionEqual(permissions) {
- // We use the share's permission without PERMISSION_SHARE as it is not relevant here.
- return (this.share.permissions & ~ATOMIC_PERMISSIONS.SHARE) === permissions
- },
-
- /**
- * Return whether the share has the given permissions.
- *
- * @param {number} permissions - the permissions to check.
- *
- * @return {boolean}
- */
- shareHasPermissions(permissions) {
- return hasPermissions(this.share.permissions, permissions)
- },
-
- /**
- * Set the share permissions to the given permissions.
- *
- * @param {number} permissions - the permissions to set.
- *
- * @return {void}
- */
- setSharePermissions(permissions) {
- this.share.permissions = permissions
- this.queueUpdate('permissions')
- },
-
- /**
- * Return whether some given permissions can be toggled.
- *
- * @param {number} permissionsToToggle - the permissions to toggle.
- *
- * @return {boolean}
- */
- canToggleSharePermissions(permissionsToToggle) {
- return canTogglePermissions(this.share.permissions, permissionsToToggle)
- },
-
- /**
- * Toggle a given permission.
- *
- * @param {number} permissions - the permissions to toggle.
- *
- * @return {void}
- */
- toggleSharePermissions(permissions) {
- this.share.permissions = togglePermissions(this.share.permissions, permissions)
-
- if (!permissionsSetIsValid(this.share.permissions)) {
- return
- }
-
- this.queueUpdate('permissions')
- },
- },
-}
-</script>
-<style lang="scss" scoped>
-.error {
- ::v-deep .action-checkbox__label:before {
- border: 1px solid var(--color-error);
- }
-}
-</style>
diff --git a/apps/files_sharing/src/components/SharingEntry.vue b/apps/files_sharing/src/components/SharingEntry.vue
index b4549112964..342b40ce384 100644
--- a/apps/files_sharing/src/components/SharingEntry.vue
+++ b/apps/files_sharing/src/components/SharingEntry.vue
@@ -1,192 +1,101 @@
<!--
- - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<li class="sharing-entry">
<NcAvatar class="sharing-entry__avatar"
- :is-no-user="share.type !== SHARE_TYPES.SHARE_TYPE_USER"
+ :is-no-user="share.type !== ShareType.User"
:user="share.shareWith"
- :title="share.type === SHARE_TYPES.SHARE_TYPE_USER ? share.shareWithDisplayName : ''"
+ :display-name="share.shareWithDisplayName"
:menu-position="'left'"
:url="share.shareWithAvatar" />
- <component :is="share.shareWithLink ? 'a' : 'div'"
- :title="tooltip"
- :aria-label="tooltip"
- :href="share.shareWithLink"
- class="sharing-entry__desc">
- <span>{{ title }}<span v-if="!isUnique" class="sharing-entry__desc-unique"> ({{ share.shareWithDisplayNameUnique }})</span></span>
- <p v-if="hasStatus">
- <span>{{ share.status.icon || '' }}</span>
- <span>{{ share.status.message || '' }}</span>
- </p>
- </component>
- <NcActions menu-align="right"
- class="sharing-entry__actions"
- @close="onMenuClose">
- <template v-if="share.canEdit">
- <!-- edit permission -->
- <NcActionCheckbox ref="canEdit"
- :checked.sync="canEdit"
- :value="permissionsEdit"
- :disabled="saving || !canSetEdit">
- {{ t('files_sharing', 'Allow editing') }}
- </NcActionCheckbox>
-
- <!-- create permission -->
- <NcActionCheckbox v-if="isFolder"
- ref="canCreate"
- :checked.sync="canCreate"
- :value="permissionsCreate"
- :disabled="saving || !canSetCreate">
- {{ t('files_sharing', 'Allow creating') }}
- </NcActionCheckbox>
-
- <!-- delete permission -->
- <NcActionCheckbox v-if="isFolder"
- ref="canDelete"
- :checked.sync="canDelete"
- :value="permissionsDelete"
- :disabled="saving || !canSetDelete">
- {{ t('files_sharing', 'Allow deleting') }}
- </NcActionCheckbox>
-
- <!-- reshare permission -->
- <NcActionCheckbox v-if="config.isResharingAllowed"
- ref="canReshare"
- :checked.sync="canReshare"
- :value="permissionsShare"
- :disabled="saving || !canSetReshare">
- {{ t('files_sharing', 'Allow resharing') }}
- </NcActionCheckbox>
-
- <NcActionCheckbox v-if="isSetDownloadButtonVisible"
- ref="canDownload"
- :checked.sync="canDownload"
- :disabled="saving || !canSetDownload">
- {{ allowDownloadText }}
- </NcActionCheckbox>
-
- <!-- expiration date -->
- <NcActionCheckbox :checked.sync="hasExpirationDate"
- :disabled="config.isDefaultInternalExpireDateEnforced || saving"
- @uncheck="onExpirationDisable">
- {{ config.isDefaultInternalExpireDateEnforced
- ? t('files_sharing', 'Expiration date enforced')
- : t('files_sharing', 'Set expiration date') }}
- </NcActionCheckbox>
- <NcActionInput v-if="hasExpirationDate"
- ref="expireDate"
- :is-native-picker="true"
- :hide-label="true"
- :class="{ error: errors.expireDate}"
- :disabled="saving"
- :value="new Date(share.expireDate)"
- type="date"
- :min="dateTomorrow"
- :max="dateMaxEnforced"
- @input="onExpirationChange">
- {{ t('files_sharing', 'Enter a date') }}
- </NcActionInput>
-
- <!-- note -->
- <template v-if="canHaveNote">
- <NcActionCheckbox :checked.sync="hasNote"
- :disabled="saving"
- @uncheck="queueUpdate('note')">
- {{ t('files_sharing', 'Note to recipient') }}
- </NcActionCheckbox>
- <NcActionTextEditable v-if="hasNote"
- ref="note"
- :class="{ error: errors.note}"
- :disabled="saving"
- :value="share.newNote || share.note"
- icon="icon-edit"
- @update:value="onNoteChange"
- @submit="onNoteSubmit" />
- </template>
+ <div class="sharing-entry__summary">
+ <component :is="share.shareWithLink ? 'a' : 'div'"
+ :title="tooltip"
+ :aria-label="tooltip"
+ :href="share.shareWithLink"
+ class="sharing-entry__summary__desc">
+ <span>{{ title }}
+ <span v-if="!isUnique" class="sharing-entry__summary__desc-unique">
+ ({{ share.shareWithDisplayNameUnique }})
+ </span>
+ <small v-if="hasStatus && share.status.message">({{ share.status.message }})</small>
+ </span>
+ </component>
+ <SharingEntryQuickShareSelect :share="share"
+ :file-info="fileInfo"
+ @open-sharing-details="openShareDetailsForCustomSettings(share)" />
+ </div>
+ <ShareExpiryTime v-if="share && share.expireDate" :share="share" />
+ <NcButton v-if="share.canEdit"
+ class="sharing-entry__action"
+ data-cy-files-sharing-share-actions
+ :aria-label="t('files_sharing', 'Open Sharing Details')"
+ type="tertiary"
+ @click="openSharingDetails(share)">
+ <template #icon>
+ <DotsHorizontalIcon :size="20" />
</template>
-
- <NcActionButton v-if="share.canDelete"
- icon="icon-close"
- :disabled="saving"
- @click.prevent="onDelete">
- {{ t('files_sharing', 'Unshare') }}
- </NcActionButton>
- </NcActions>
+ </NcButton>
</li>
</template>
<script>
-import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar'
-import NcActions from '@nextcloud/vue/dist/Components/NcActions'
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton'
-import NcActionCheckbox from '@nextcloud/vue/dist/Components/NcActionCheckbox'
-import NcActionInput from '@nextcloud/vue/dist/Components/NcActionInput'
-import NcActionTextEditable from '@nextcloud/vue/dist/Components/NcActionTextEditable'
+import { ShareType } from '@nextcloud/sharing'
+
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcSelect from '@nextcloud/vue/components/NcSelect'
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import DotsHorizontalIcon from 'vue-material-design-icons/DotsHorizontal.vue'
+
+import ShareExpiryTime from './ShareExpiryTime.vue'
+import SharingEntryQuickShareSelect from './SharingEntryQuickShareSelect.vue'
import SharesMixin from '../mixins/SharesMixin.js'
+import ShareDetails from '../mixins/ShareDetails.js'
export default {
name: 'SharingEntry',
components: {
- NcActions,
- NcActionButton,
- NcActionCheckbox,
- NcActionInput,
- NcActionTextEditable,
+ NcButton,
NcAvatar,
+ DotsHorizontalIcon,
+ NcSelect,
+ ShareExpiryTime,
+ SharingEntryQuickShareSelect,
},
- mixins: [SharesMixin],
-
- data() {
- return {
- permissionsEdit: OC.PERMISSION_UPDATE,
- permissionsCreate: OC.PERMISSION_CREATE,
- permissionsDelete: OC.PERMISSION_DELETE,
- permissionsRead: OC.PERMISSION_READ,
- permissionsShare: OC.PERMISSION_SHARE,
- }
- },
+ mixins: [SharesMixin, ShareDetails],
computed: {
title() {
let title = this.share.shareWithDisplayName
- if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_GROUP) {
+
+ const showAsInternal = this.config.showFederatedSharesAsInternal
+ || (this.share.isTrustedServer && this.config.showFederatedSharesToTrustedServersAsInternal)
+
+ if (this.share.type === ShareType.Group || (this.share.type === ShareType.RemoteGroup && showAsInternal)) {
title += ` (${t('files_sharing', 'group')})`
- } else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_ROOM) {
+ } else if (this.share.type === ShareType.Room) {
title += ` (${t('files_sharing', 'conversation')})`
- } else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_REMOTE) {
+ } else if (this.share.type === ShareType.Remote && !showAsInternal) {
title += ` (${t('files_sharing', 'remote')})`
- } else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP) {
+ } else if (this.share.type === ShareType.RemoteGroup) {
title += ` (${t('files_sharing', 'remote group')})`
- } else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_GUEST) {
+ } else if (this.share.type === ShareType.Guest) {
title += ` (${t('files_sharing', 'guest')})`
}
+ if (!this.isShareOwner && this.share.ownerDisplayName) {
+ title += ' ' + t('files_sharing', 'by {initiator}', {
+ initiator: this.share.ownerDisplayName,
+ })
+ }
return title
},
-
tooltip() {
if (this.share.owner !== this.share.uidFileOwner) {
const data = {
@@ -195,9 +104,9 @@ export default {
user: this.share.shareWithDisplayName,
owner: this.share.ownerDisplayName,
}
- if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_GROUP) {
+ if (this.share.type === ShareType.Group) {
return t('files_sharing', 'Shared with the group {user} by {owner}', data)
- } else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_ROOM) {
+ } else if (this.share.type === ShareType.Room) {
return t('files_sharing', 'Shared with the conversation {user} by {owner}', data)
}
@@ -206,244 +115,19 @@ export default {
return null
},
- canHaveNote() {
- return !this.isRemote
- },
-
- isRemote() {
- return this.share.type === this.SHARE_TYPES.SHARE_TYPE_REMOTE
- || this.share.type === this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP
- },
-
- /**
- * 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)
- },
-
- /**
- * Can the sharee edit the shared file ?
- */
- canEdit: {
- get() {
- return this.share.hasUpdatePermission
- },
- set(checked) {
- this.updatePermissions({ isEditChecked: checked })
- },
- },
-
- /**
- * Can the sharee create the shared file ?
- */
- canCreate: {
- get() {
- return this.share.hasCreatePermission
- },
- set(checked) {
- this.updatePermissions({ isCreateChecked: checked })
- },
- },
-
- /**
- * Can the sharee delete the shared file ?
- */
- canDelete: {
- get() {
- return this.share.hasDeletePermission
- },
- set(checked) {
- this.updatePermissions({ isDeleteChecked: checked })
- },
- },
-
- /**
- * Can the sharee reshare the file ?
- */
- canReshare: {
- get() {
- return this.share.hasSharePermission
- },
- set(checked) {
- this.updatePermissions({ isReshareChecked: checked })
- },
- },
-
- /**
- * Can the sharee download files or only view them ?
- */
- canDownload: {
- get() {
- return this.share.hasDownloadPermission
- },
- set(checked) {
- this.updatePermissions({ isDownloadChecked: checked })
- },
- },
-
- /**
- * Is this share readable
- * Needed for some federated shares that might have been added from file drop links
- */
- hasRead: {
- get() {
- return this.share.hasReadPermission
- },
- },
-
- /**
- * Is the current share a folder ?
- *
- * @return {boolean}
- */
- isFolder() {
- return this.fileInfo.type === 'dir'
- },
-
- /**
- * Does the current share have an expiration date
- *
- * @return {boolean}
- */
- hasExpirationDate: {
- get() {
- return this.config.isDefaultInternalExpireDateEnforced || !!this.share.expireDate
- },
- set(enabled) {
- const defaultExpirationDate = this.config.defaultInternalExpirationDate
- || new Date(new Date().setDate(new Date().getDate() + 1))
- this.share.expireDate = enabled
- ? this.formatDateToString(defaultExpirationDate)
- : ''
- console.debug('Expiration date status', enabled, this.share.expireDate)
- },
- },
-
- dateMaxEnforced() {
- if (!this.isRemote && this.config.isDefaultInternalExpireDateEnforced) {
- return new Date(new Date().setDate(new Date().getDate() + 1 + this.config.defaultInternalExpireDate))
- } else if (this.config.isDefaultRemoteExpireDateEnforced) {
- return new Date(new Date().setDate(new Date().getDate() + 1 + this.config.defaultRemoteExpireDate))
- }
- return null
- },
-
/**
* @return {boolean}
*/
hasStatus() {
- if (this.share.type !== this.SHARE_TYPES.SHARE_TYPE_USER) {
+ if (this.share.type !== ShareType.User) {
return false
}
return (typeof this.share.status === 'object' && !Array.isArray(this.share.status))
},
-
- /**
- * @return {string}
- */
- allowDownloadText() {
- return t('files_sharing', 'Allow download')
- },
-
- /**
- * @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)
- },
},
methods: {
- updatePermissions({
- isEditChecked = this.canEdit,
- isCreateChecked = this.canCreate,
- isDeleteChecked = this.canDelete,
- isReshareChecked = this.canReshare,
- isDownloadChecked = this.canDownload,
- } = {}) {
- // calc permissions if checked
- const permissions = 0
- | (this.hasRead ? this.permissionsRead : 0)
- | (isCreateChecked ? this.permissionsCreate : 0)
- | (isDeleteChecked ? this.permissionsDelete : 0)
- | (isEditChecked ? this.permissionsEdit : 0)
- | (isReshareChecked ? this.permissionsShare : 0)
-
- this.share.permissions = permissions
- if (this.share.hasDownloadPermission !== isDownloadChecked) {
- this.share.hasDownloadPermission = isDownloadChecked
- }
- this.queueUpdate('permissions', 'attributes')
- },
-
/**
* Save potential changed data on menu close
*/
@@ -459,21 +143,34 @@ export default {
display: flex;
align-items: center;
height: 44px;
- &__desc {
+ &__summary {
+ padding: 8px;
+ padding-inline-start: 10px;
display: flex;
flex-direction: column;
- justify-content: space-between;
- padding: 8px;
- line-height: 1.2em;
- p {
- color: var(--color-text-maxcontrast);
- }
- &-unique {
- color: var(--color-text-maxcontrast);
+ justify-content: center;
+ align-items: flex-start;
+ flex: 1 0;
+ min-width: 0;
+
+ &__desc {
+ display: inline-block;
+ padding-bottom: 0;
+ line-height: 1.2em;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ p,
+ small {
+ color: var(--color-text-maxcontrast);
+ }
+
+ &-unique {
+ color: var(--color-text-maxcontrast);
+ }
}
}
- &__actions {
- margin-left: auto;
- }
+
}
</style>
diff --git a/apps/files_sharing/src/components/SharingEntryInherited.vue b/apps/files_sharing/src/components/SharingEntryInherited.vue
index e4979fdc44d..e7dfffd5776 100644
--- a/apps/files_sharing/src/components/SharingEntryInherited.vue
+++ b/apps/files_sharing/src/components/SharingEntryInherited.vue
@@ -1,24 +1,7 @@
<!--
- - @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>
<SharingEntrySimple :key="share.id"
@@ -26,8 +9,7 @@
:title="share.shareWithDisplayName">
<template #avatar>
<NcAvatar :user="share.shareWith"
- :aria-label="share.shareWithDisplayName"
- :title="share.shareWithDisplayName"
+ :display-name="share.shareWithDisplayName"
class="sharing-entry__avatar" />
</template>
<NcActionText icon="icon-user">
@@ -49,15 +31,15 @@
<script>
import { generateUrl } from '@nextcloud/router'
import { basename } from '@nextcloud/paths'
-import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar'
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton'
-import NcActionLink from '@nextcloud/vue/dist/Components/NcActionLink'
-import NcActionText from '@nextcloud/vue/dist/Components/NcActionText'
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcActionLink from '@nextcloud/vue/components/NcActionLink'
+import NcActionText from '@nextcloud/vue/components/NcActionText'
// eslint-disable-next-line no-unused-vars
-import Share from '../models/Share'
-import SharesMixin from '../mixins/SharesMixin'
-import SharingEntrySimple from '../components/SharingEntrySimple'
+import Share from '../models/Share.js'
+import SharesMixin from '../mixins/SharesMixin.js'
+import SharingEntrySimple from '../components/SharingEntrySimple.vue'
export default {
name: 'SharingEntryInherited',
@@ -103,13 +85,14 @@ export default {
flex-direction: column;
justify-content: space-between;
padding: 8px;
+ padding-inline-start: 10px;
line-height: 1.2em;
p {
color: var(--color-text-maxcontrast);
}
}
&__actions {
- margin-left: auto;
+ margin-inline-start: auto;
}
}
</style>
diff --git a/apps/files_sharing/src/components/SharingEntryInternal.vue b/apps/files_sharing/src/components/SharingEntryInternal.vue
index d3b55d4991c..027d2a3d5c3 100644
--- a/apps/files_sharing/src/components/SharingEntryInternal.vue
+++ b/apps/files_sharing/src/components/SharingEntryInternal.vue
@@ -1,4 +1,7 @@
-
+<!--
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<ul>
<SharingEntrySimple ref="shareEntrySimple"
@@ -9,12 +12,16 @@
<div class="avatar-external icon-external-white" />
</template>
- <NcActionLink :href="internalLink"
+ <NcActionButton :title="copyLinkTooltip"
:aria-label="copyLinkTooltip"
- :title="copyLinkTooltip"
- target="_blank"
- :icon="copied && copySuccess ? 'icon-checkmark-color' : 'icon-clippy'"
- @click.prevent="copyLink" />
+ @click="copyLink">
+ <template #icon>
+ <CheckIcon v-if="copied && copySuccess"
+ :size="20"
+ class="icon-checkmark-color" />
+ <ClipboardIcon v-else :size="20" />
+ </template>
+ </NcActionButton>
</SharingEntrySimple>
</ul>
</template>
@@ -22,15 +29,21 @@
<script>
import { generateUrl } from '@nextcloud/router'
import { showSuccess } from '@nextcloud/dialogs'
-import NcActionLink from '@nextcloud/vue/dist/Components/NcActionLink'
-import SharingEntrySimple from './SharingEntrySimple'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+
+import CheckIcon from 'vue-material-design-icons/Check.vue'
+import ClipboardIcon from 'vue-material-design-icons/ContentCopy.vue'
+
+import SharingEntrySimple from './SharingEntrySimple.vue'
export default {
name: 'SharingEntryInternal',
components: {
- NcActionLink,
+ NcActionButton,
SharingEntrySimple,
+ CheckIcon,
+ ClipboardIcon,
},
props: {
@@ -70,14 +83,11 @@ export default {
}
return t('files_sharing', 'Cannot copy, please copy the link manually')
}
- return t('files_sharing', 'Copy internal link to clipboard')
+ return t('files_sharing', 'Copy internal link')
},
internalLinkSubtitle() {
- if (this.fileInfo.type === 'dir') {
- return t('files_sharing', 'Only works for users with access to this folder')
- }
- return t('files_sharing', 'Only works for users with access to this file')
+ return t('files_sharing', 'For people who already have access')
},
},
@@ -86,7 +96,6 @@ export default {
try {
await navigator.clipboard.writeText(this.internalLink)
showSuccess(t('files_sharing', 'Link copied'))
- // focus and show the tooltip (note: cannot set ref on NcActionLink)
this.$refs.shareEntrySimple.$refs.actionsComponent.$el.focus()
this.copySuccess = true
this.copied = true
@@ -118,6 +127,7 @@ export default {
}
.icon-checkmark-color {
opacity: 1;
+ color: var(--color-success);
}
}
</style>
diff --git a/apps/files_sharing/src/components/SharingEntryLink.vue b/apps/files_sharing/src/components/SharingEntryLink.vue
index 7622efa6fac..6865af1b864 100644
--- a/apps/files_sharing/src/components/SharingEntryLink.vue
+++ b/apps/files_sharing/src/components/SharingEntryLink.vue
@@ -1,62 +1,64 @@
<!--
- - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <li :class="{'sharing-entry--share': share}" class="sharing-entry sharing-entry__link">
+ <li :class="{ 'sharing-entry--share': share }"
+ class="sharing-entry sharing-entry__link">
<NcAvatar :is-no-user="true"
:icon-class="isEmailShareType ? 'avatar-link-share icon-mail-white' : 'avatar-link-share icon-public-white'"
class="sharing-entry__avatar" />
- <div class="sharing-entry__desc">
- <span class="sharing-entry__title" :title="title">
- {{ title }}
- </span>
- <p v-if="subtitle">
- {{ subtitle }}
- </p>
- </div>
- <!-- clipboard -->
- <NcActions v-if="share && !isEmailShareType && share.token"
- ref="copyButton"
- class="sharing-entry__copy">
- <NcActionLink :href="shareLink"
- target="_blank"
- :title="copyLinkTooltip"
- :aria-label="copyLinkTooltip"
- :icon="copied && copySuccess ? 'icon-checkmark-color' : 'icon-clippy'"
- @click.stop.prevent="copyLink" />
- </NcActions>
+ <div class="sharing-entry__summary">
+ <div class="sharing-entry__desc">
+ <span class="sharing-entry__title" :title="title">
+ {{ title }}
+ </span>
+ <p v-if="subtitle">
+ {{ subtitle }}
+ </p>
+ <SharingEntryQuickShareSelect v-if="share && share.permissions !== undefined"
+ :share="share"
+ :file-info="fileInfo"
+ @open-sharing-details="openShareDetailsForCustomSettings(share)" />
+ </div>
+
+ <div class="sharing-entry__actions">
+ <ShareExpiryTime v-if="share && share.expireDate" :share="share" />
+
+ <!-- clipboard -->
+ <div>
+ <NcActions v-if="share && (!isEmailShareType || isFileRequest) && share.token" ref="copyButton" class="sharing-entry__copy">
+ <NcActionButton :aria-label="copyLinkTooltip"
+ :title="copyLinkTooltip"
+ :href="shareLink"
+ @click.prevent="copyLink">
+ <template #icon>
+ <CheckIcon v-if="copied && copySuccess"
+ :size="20"
+ class="icon-checkmark-color" />
+ <ClipboardIcon v-else :size="20" />
+ </template>
+ </NcActionButton>
+ </NcActions>
+ </div>
+ </div>
+ </div>
<!-- pending actions -->
- <NcActions v-if="!pending && (pendingPassword || pendingExpirationDate)"
+ <NcActions v-if="!pending && pendingDataIsMissing"
class="sharing-entry__actions"
:aria-label="actionsTooltip"
menu-align="right"
:open.sync="open"
- @close="onNewLinkShare">
+ @close="onCancel">
<!-- pending data menu -->
<NcActionText v-if="errors.pending"
- icon="icon-error"
- :class="{ error: errors.pending}">
+ class="error">
+ <template #icon>
+ <ErrorIcon :size="20" />
+ </template>
{{ errors.pending }}
</NcActionText>
<NcActionText v-else icon="icon-info">
@@ -64,52 +66,66 @@
</NcActionText>
<!-- password -->
- <NcActionText v-if="pendingPassword" icon="icon-password">
- {{ t('files_sharing', 'Password protection (enforced)') }}
- </NcActionText>
- <NcActionCheckbox v-else-if="config.enableLinkPasswordByDefault"
+ <NcActionCheckbox v-if="pendingPassword"
:checked.sync="isPasswordProtected"
:disabled="config.enforcePasswordForPublicLink || saving"
class="share-link-password-checkbox"
@uncheck="onPasswordDisable">
- {{ t('files_sharing', 'Password protection') }}
+ {{ config.enforcePasswordForPublicLink ? t('files_sharing', 'Password protection (enforced)') : t('files_sharing', 'Password protection') }}
</NcActionCheckbox>
- <NcActionInput v-if="pendingPassword || share.password"
+ <NcActionInput v-if="pendingEnforcedPassword || isPasswordProtected"
class="share-link-password"
- :value.sync="share.password"
+ :label="t('files_sharing', 'Enter a password')"
+ :value.sync="share.newPassword"
:disabled="saving"
:required="config.enableLinkPasswordByDefault || config.enforcePasswordForPublicLink"
:minlength="isPasswordPolicyEnabled && config.passwordPolicy.minLength"
- icon=""
autocomplete="new-password"
- @submit="onNewLinkShare">
- {{ t('files_sharing', 'Enter a password') }}
+ @submit="onNewLinkShare(true)">
+ <template #icon>
+ <LockIcon :size="20" />
+ </template>
</NcActionInput>
+ <NcActionCheckbox v-if="pendingDefaultExpirationDate"
+ :checked.sync="defaultExpirationDateEnabled"
+ :disabled="pendingEnforcedExpirationDate || saving"
+ class="share-link-expiration-date-checkbox"
+ @update:model-value="onExpirationDateToggleUpdate">
+ {{ config.isDefaultExpireDateEnforced ? t('files_sharing', 'Enable link expiration (enforced)') : t('files_sharing', 'Enable link expiration') }}
+ </NcActionCheckbox>
+
<!-- expiration date -->
- <NcActionText v-if="pendingExpirationDate" icon="icon-calendar-dark">
- {{ t('files_sharing', 'Expiration date (enforced)') }}
- </NcActionText>
- <NcActionInput v-if="pendingExpirationDate"
+ <NcActionInput v-if="(pendingDefaultExpirationDate || pendingEnforcedExpirationDate) && defaultExpirationDateEnabled"
+ data-cy-files-sharing-expiration-date-input
class="share-link-expire-date"
+ :label="pendingEnforcedExpirationDate ? t('files_sharing', 'Enter expiration date (enforced)') : t('files_sharing', 'Enter expiration date')"
:disabled="saving"
:is-native-picker="true"
:hide-label="true"
:value="new Date(share.expireDate)"
type="date"
:min="dateTomorrow"
- :max="dateMaxEnforced"
- @input="onExpirationChange">
- <!-- let's not submit when picked, the user
- might want to still edit or copy the password -->
- {{ t('files_sharing', 'Enter a date') }}
+ :max="maxExpirationDateEnforced"
+ @update:model-value="onExpirationChange"
+ @change="expirationDateChanged">
+ <template #icon>
+ <IconCalendarBlank :size="20" />
+ </template>
</NcActionInput>
- <NcActionButton icon="icon-checkmark" @click.prevent.stop="onNewLinkShare">
+ <NcActionButton :disabled="pendingEnforcedPassword && !share.newPassword"
+ @click.prevent.stop="onNewLinkShare(true)">
+ <template #icon>
+ <CheckIcon :size="20" />
+ </template>
{{ t('files_sharing', 'Create share') }}
</NcActionButton>
- <NcActionButton icon="icon-close" @click.prevent.stop="onCancel">
+ <NcActionButton @click.prevent.stop="onCancel">
+ <template #icon>
+ <CloseIcon :size="20" />
+ </template>
{{ t('files_sharing', 'Cancel') }}
</NcActionButton>
</NcActions>
@@ -123,111 +139,24 @@
@close="onMenuClose">
<template v-if="share">
<template v-if="share.canEdit && canReshare">
- <!-- Custom Label -->
- <NcActionInput ref="label"
- :class="{ error: errors.label }"
- :disabled="saving"
- :label="t('files_sharing', 'Share label')"
- :value="share.newLabel !== undefined ? share.newLabel : share.label"
- icon="icon-edit"
- maxlength="255"
- @update:value="onLabelChange"
- @submit="onLabelSubmit" />
-
- <SharePermissionsEditor :can-reshare="canReshare"
- :share.sync="share"
- :file-info="fileInfo" />
-
- <NcActionSeparator />
-
- <NcActionCheckbox :checked.sync="share.hideDownload"
- :disabled="saving || canChangeHideDownload"
- @change="queueUpdate('hideDownload')">
- {{ t('files_sharing', 'Hide download') }}
- </NcActionCheckbox>
-
- <!-- password -->
- <NcActionCheckbox :checked.sync="isPasswordProtected"
- :disabled="config.enforcePasswordForPublicLink || saving"
- class="share-link-password-checkbox"
- @uncheck="onPasswordDisable">
- {{ config.enforcePasswordForPublicLink
- ? t('files_sharing', 'Password protection (enforced)')
- : t('files_sharing', 'Password protect') }}
- </NcActionCheckbox>
-
- <NcActionInput v-if="isPasswordProtected"
- ref="password"
- class="share-link-password"
- :class="{ error: errors.password}"
- :disabled="saving"
- :required="config.enforcePasswordForPublicLink"
- :value="hasUnsavedPassword ? share.newPassword : '***************'"
- icon="icon-password"
- autocomplete="new-password"
- :type="hasUnsavedPassword ? 'text': 'password'"
- @update:value="onPasswordChange"
- @submit="onPasswordSubmit">
- {{ t('files_sharing', 'Enter a password') }}
- </NcActionInput>
- <NcActionText v-if="isEmailShareType && passwordExpirationTime" icon="icon-info">
- {{ t('files_sharing', 'Password expires {passwordExpirationTime}', {passwordExpirationTime}) }}
- </NcActionText>
- <NcActionText v-else-if="isEmailShareType && passwordExpirationTime !== null" icon="icon-error">
- {{ t('files_sharing', 'Password expired') }}
- </NcActionText>
-
- <!-- password protected by Talk -->
- <NcActionCheckbox v-if="isPasswordProtectedByTalkAvailable"
- :checked.sync="isPasswordProtectedByTalk"
- :disabled="!canTogglePasswordProtectedByTalkAvailable || saving"
- class="share-link-password-talk-checkbox"
- @change="onPasswordProtectedByTalkChange">
- {{ t('files_sharing', 'Video verification') }}
- </NcActionCheckbox>
-
- <!-- expiration date -->
- <NcActionCheckbox :checked.sync="hasExpirationDate"
- :disabled="config.isDefaultExpireDateEnforced || saving"
- class="share-link-expire-date-checkbox"
- @uncheck="onExpirationDisable">
- {{ config.isDefaultExpireDateEnforced
- ? t('files_sharing', 'Expiration date (enforced)')
- : t('files_sharing', 'Set expiration date') }}
- </NcActionCheckbox>
- <NcActionInput v-if="hasExpirationDate"
- ref="expireDate"
- :is-native-picker="true"
- :hide-label="true"
- class="share-link-expire-date"
- :class="{ error: errors.expireDate}"
- :disabled="saving"
- :value="new Date(share.expireDate)"
- type="date"
- :min="dateTomorrow"
- :max="dateMaxEnforced"
- @input="onExpirationChange">
- {{ t('files_sharing', 'Enter a date') }}
- </NcActionInput>
-
- <!-- note -->
- <NcActionCheckbox :checked.sync="hasNote"
- :disabled="saving"
- @uncheck="queueUpdate('note')">
- {{ t('files_sharing', 'Note to recipient') }}
- </NcActionCheckbox>
-
- <NcActionTextEditable v-if="hasNote"
- ref="note"
- :class="{ error: errors.note}"
- :disabled="saving"
- :placeholder="t('files_sharing', 'Enter a note for the share recipient')"
- :value="share.newNote || share.note"
- icon="icon-edit"
- @update:value="onNoteChange"
- @submit="onNoteSubmit" />
+ <NcActionButton :disabled="saving"
+ :close-after-click="true"
+ @click.prevent="openSharingDetails">
+ <template #icon>
+ <Tune :size="20" />
+ </template>
+ {{ t('files_sharing', 'Customize link') }}
+ </NcActionButton>
</template>
+ <NcActionButton :close-after-click="true"
+ @click.prevent="showQRCode = true">
+ <template #icon>
+ <IconQr :size="20" />
+ </template>
+ {{ t('files_sharing', 'Generate QR code') }}
+ </NcActionButton>
+
<NcActionSeparator />
<!-- external actions -->
@@ -239,26 +168,31 @@
:share="share" />
<!-- external legacy sharing via url (social...) -->
- <NcActionLink v-for="({icon, url, name}, index) in externalLegacyLinkActions"
- :key="index"
+ <NcActionLink v-for="({ icon, url, name }, actionIndex) in externalLegacyLinkActions"
+ :key="actionIndex"
:href="url(shareLink)"
:icon="icon"
target="_blank">
{{ name }}
</NcActionLink>
- <NcActionButton v-if="share.canDelete"
- icon="icon-close"
- :disabled="saving"
- @click.prevent="onDelete">
- {{ t('files_sharing', 'Unshare') }}
- </NcActionButton>
<NcActionButton v-if="!isEmailShareType && canReshare"
class="new-share-link"
- icon="icon-add"
@click.prevent.stop="onNewLinkShare">
+ <template #icon>
+ <PlusIcon :size="20" />
+ </template>
{{ t('files_sharing', 'Add another link') }}
</NcActionButton>
+
+ <NcActionButton v-if="share.canDelete"
+ :disabled="saving"
+ @click.prevent="onDelete">
+ <template #icon>
+ <CloseIcon :size="20" />
+ </template>
+ {{ t('files_sharing', 'Unshare') }}
+ </NcActionButton>
</template>
<!-- Create new share -->
@@ -272,49 +206,91 @@
<!-- loading indicator to replace the menu -->
<div v-else class="icon-loading-small sharing-entry__loading" />
+
+ <!-- Modal to open whenever we have a QR code -->
+ <NcDialog v-if="showQRCode"
+ size="normal"
+ :open.sync="showQRCode"
+ :name="title"
+ :close-on-click-outside="true"
+ @close="showQRCode = false">
+ <div class="qr-code-dialog">
+ <VueQrcode tag="img"
+ :value="shareLink"
+ class="qr-code-dialog__img" />
+ </div>
+ </NcDialog>
</li>
</template>
<script>
-import { generateUrl } from '@nextcloud/router'
import { showError, showSuccess } from '@nextcloud/dialogs'
-import { Type as ShareTypes } from '@nextcloud/sharing'
-import Vue from 'vue'
-
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton'
-import NcActionCheckbox from '@nextcloud/vue/dist/Components/NcActionCheckbox'
-import NcActionInput from '@nextcloud/vue/dist/Components/NcActionInput'
-import NcActionLink from '@nextcloud/vue/dist/Components/NcActionLink'
-import NcActionText from '@nextcloud/vue/dist/Components/NcActionText'
-import NcActionSeparator from '@nextcloud/vue/dist/Components/NcActionSeparator'
-import NcActionTextEditable from '@nextcloud/vue/dist/Components/NcActionTextEditable'
-import NcActions from '@nextcloud/vue/dist/Components/NcActions'
-import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar'
+import { emit } from '@nextcloud/event-bus'
+import { t } from '@nextcloud/l10n'
+import moment from '@nextcloud/moment'
+import { generateUrl, getBaseUrl } from '@nextcloud/router'
+import { ShareType } from '@nextcloud/sharing'
+
+import VueQrcode from '@chenfengyuan/vue-qrcode'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcActionCheckbox from '@nextcloud/vue/components/NcActionCheckbox'
+import NcActionInput from '@nextcloud/vue/components/NcActionInput'
+import NcActionLink from '@nextcloud/vue/components/NcActionLink'
+import NcActionText from '@nextcloud/vue/components/NcActionText'
+import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcDialog from '@nextcloud/vue/components/NcDialog'
+
+import Tune from 'vue-material-design-icons/Tune.vue'
+import IconCalendarBlank from 'vue-material-design-icons/CalendarBlankOutline.vue'
+import IconQr from 'vue-material-design-icons/Qrcode.vue'
+import ErrorIcon from 'vue-material-design-icons/Exclamation.vue'
+import LockIcon from 'vue-material-design-icons/LockOutline.vue'
+import CheckIcon from 'vue-material-design-icons/CheckBold.vue'
+import ClipboardIcon from 'vue-material-design-icons/ContentCopy.vue'
+import CloseIcon from 'vue-material-design-icons/Close.vue'
+import PlusIcon from 'vue-material-design-icons/Plus.vue'
+
+import SharingEntryQuickShareSelect from './SharingEntryQuickShareSelect.vue'
+import ShareExpiryTime from './ShareExpiryTime.vue'
import ExternalShareAction from './ExternalShareAction.vue'
-import SharePermissionsEditor from './SharePermissionsEditor.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 SharesMixin from '../mixins/SharesMixin.js'
+import ShareDetails from '../mixins/ShareDetails.js'
+import logger from '../services/logger.ts'
export default {
name: 'SharingEntryLink',
components: {
+ ExternalShareAction,
NcActions,
NcActionButton,
NcActionCheckbox,
NcActionInput,
NcActionLink,
NcActionText,
- NcActionTextEditable,
NcActionSeparator,
NcAvatar,
- ExternalShareAction,
- SharePermissionsEditor,
+ NcDialog,
+ VueQrcode,
+ Tune,
+ IconCalendarBlank,
+ IconQr,
+ ErrorIcon,
+ LockIcon,
+ CheckIcon,
+ ClipboardIcon,
+ CloseIcon,
+ PlusIcon,
+ SharingEntryQuickShareSelect,
+ ShareExpiryTime,
},
- mixins: [SharesMixin],
+ mixins: [SharesMixin, ShareDetails],
props: {
canReshare: {
@@ -329,14 +305,19 @@ export default {
data() {
return {
+ shareCreationComplete: false,
copySuccess: true,
copied: false,
+ defaultExpirationDateEnabled: false,
// Are we waiting for password/expiration date
pending: false,
ExternalLegacyLinkActions: OCA.Sharing.ExternalLinkActions.state,
ExternalShareActions: OCA.Sharing.ExternalShareActions.state,
+
+ // tracks whether modal should be opened or not
+ showQRCode: false,
}
},
@@ -347,6 +328,8 @@ export default {
* @return {string}
*/
title() {
+ const l10nOptions = { escape: false /* no escape as this string is already escaped by Vue */ }
+
// if we have a valid existing share (not pending)
if (this.share && this.share.id) {
if (!this.isShareOwner && this.share.ownerDisplayName) {
@@ -354,30 +337,46 @@ export default {
return t('files_sharing', '{shareWith} by {initiator}', {
shareWith: this.share.shareWith,
initiator: this.share.ownerDisplayName,
- })
+ }, l10nOptions)
}
return t('files_sharing', 'Shared via link by {initiator}', {
initiator: this.share.ownerDisplayName,
- })
+ }, l10nOptions)
}
if (this.share.label && this.share.label.trim() !== '') {
if (this.isEmailShareType) {
+ if (this.isFileRequest) {
+ return t('files_sharing', 'File request ({label})', {
+ label: this.share.label.trim(),
+ }, l10nOptions)
+ }
return t('files_sharing', 'Mail share ({label})', {
label: this.share.label.trim(),
- })
+ }, l10nOptions)
}
return t('files_sharing', 'Share link ({label})', {
label: this.share.label.trim(),
- })
+ }, l10nOptions)
}
if (this.isEmailShareType) {
+ if (!this.share.shareWith || this.share.shareWith.trim() === '') {
+ return this.isFileRequest
+ ? t('files_sharing', 'File request')
+ : t('files_sharing', 'Mail share')
+ }
return this.share.shareWith
}
+
+ if (this.index === null) {
+ return t('files_sharing', 'Share link')
+ }
}
- if (this.index > 1) {
+
+ if (this.index >= 1) {
return t('files_sharing', 'Share link ({index})', { index: this.index })
}
- return t('files_sharing', 'Share link')
+
+ return t('files_sharing', 'Create public link')
},
/**
@@ -393,50 +392,6 @@ export default {
return null
},
- /**
- * Does the current share have an expiration date
- *
- * @return {boolean}
- */
- hasExpirationDate: {
- get() {
- return this.config.isDefaultExpireDateEnforced
- || !!this.share.expireDate
- },
- set(enabled) {
- const defaultExpirationDate = this.config.defaultExpirationDate
- || new Date(new Date().setDate(new Date().getDate() + 1))
- this.share.expireDate = enabled
- ? this.formatDateToString(defaultExpirationDate)
- : ''
- console.debug('Expiration date status', enabled, this.share.expireDate)
- },
- },
-
- dateMaxEnforced() {
- if (this.config.isDefaultExpireDateEnforced) {
- return new Date(new Date().setDate(new Date().getDate() + this.config.defaultExpireDate))
- }
- return null
- },
-
- /**
- * Is the current share password protected ?
- *
- * @return {boolean}
- */
- isPasswordProtected: {
- get() {
- return this.config.enforcePasswordForPublicLink
- || !!this.share.password
- },
- async set(enabled) {
- // TODO: directly save after generation to make sure the share is always protected
- Vue.set(this.share, 'password', enabled ? await GeneratePassword() : '')
- Vue.set(this.share, 'newPassword', this.share.password)
- },
- },
-
passwordExpirationTime() {
if (this.share.passwordExpirationTime === null) {
return null
@@ -490,7 +445,7 @@ export default {
*/
isEmailShareType() {
return this.share
- ? this.share.type === this.SHARE_TYPES.SHARE_TYPE_EMAIL
+ ? this.share.type === ShareType.Email
: false
},
@@ -515,13 +470,50 @@ export default {
*
* @return {boolean}
*/
+ pendingDataIsMissing() {
+ return this.pendingPassword || this.pendingEnforcedPassword || this.pendingDefaultExpirationDate || this.pendingEnforcedExpirationDate
+ },
pendingPassword() {
- return this.config.enforcePasswordForPublicLink && this.share && !this.share.id
+ return this.config.enableLinkPasswordByDefault && this.isPendingShare
+ },
+ pendingEnforcedPassword() {
+ return this.config.enforcePasswordForPublicLink && this.isPendingShare
+ },
+ pendingEnforcedExpirationDate() {
+ return this.config.isDefaultExpireDateEnforced && this.isPendingShare
},
- pendingExpirationDate() {
- return this.config.isDefaultExpireDateEnforced && this.share && !this.share.id
+ pendingDefaultExpirationDate() {
+ return (this.config.defaultExpirationDate instanceof Date || !isNaN(new Date(this.config.defaultExpirationDate).getTime())) && this.isPendingShare
},
+ isPendingShare() {
+ return !!(this.share && !this.share.id)
+ },
+ sharePolicyHasEnforcedProperties() {
+ return this.config.enforcePasswordForPublicLink || this.config.isDefaultExpireDateEnforced
+ },
+
+ enforcedPropertiesMissing() {
+ // Ensure share exist and the share policy has required properties
+ if (!this.sharePolicyHasEnforcedProperties) {
+ return false
+ }
+ if (!this.share) {
+ // if no share, we can't tell if properties are missing or not so we assume properties are missing
+ return true
+ }
+
+ // If share has ID, then this is an incoming link share created from the existing link share
+ // Hence assume required properties
+ if (this.share.id) {
+ return true
+ }
+ // Check if either password or expiration date is missing and enforced
+ const isPasswordMissing = this.config.enforcePasswordForPublicLink && !this.share.password
+ const isExpireDateMissing = this.config.isDefaultExpireDateEnforced && !this.share.expireDate
+
+ return isPasswordMissing || isExpireDateMissing
+ },
// if newPassword exists, but is empty, it means
// the user deleted the original password
hasUnsavedPassword() {
@@ -534,7 +526,7 @@ export default {
* @return {string}
*/
shareLink() {
- return window.location.protocol + '//' + window.location.host + generateUrl('/s/') + this.share.token
+ return generateUrl('/s/{token}', { token: this.share.token }, { baseURL: getBaseUrl() })
},
/**
@@ -558,7 +550,7 @@ export default {
}
return t('files_sharing', 'Cannot copy, please copy the link manually')
}
- return t('files_sharing', 'Copy public link of "{title}" to clipboard', { title: this.title })
+ return t('files_sharing', 'Copy public link of "{title}"', { title: this.title })
},
/**
@@ -577,10 +569,10 @@ export default {
* @return {Array}
*/
externalLinkActions() {
+ const filterValidAction = (action) => (action.shareType.includes(ShareType.Link) || action.shareType.includes(ShareType.Email)) && !action.advanced
// filter only the registered actions for said link
return this.ExternalShareActions.actions
- .filter(action => action.shareType.includes(ShareTypes.SHARE_TYPE_LINK)
- || action.shareType.includes(ShareTypes.SHARE_TYPE_EMAIL))
+ .filter(filterValidAction)
},
isPasswordPolicyEnabled() {
@@ -588,65 +580,74 @@ export default {
},
canChangeHideDownload() {
- const hasDisabledDownload = (shareAttribute) => shareAttribute.key === 'download' && shareAttribute.scope === 'permissions' && shareAttribute.enabled === false
-
+ const hasDisabledDownload = (shareAttribute) => shareAttribute.scope === 'permissions' && shareAttribute.key === 'download' && shareAttribute.value === false
return this.fileInfo.shareAttributes.some(hasDisabledDownload)
},
+
+ isFileRequest() {
+ return this.share.isFileRequest
+ },
+ },
+ mounted() {
+ this.defaultExpirationDateEnabled = this.config.defaultExpirationDate instanceof Date
+ if (this.share && this.isNewShare) {
+ this.share.expireDate = this.defaultExpirationDateEnabled ? this.formatDateToString(this.config.defaultExpirationDate) : ''
+ }
},
methods: {
/**
+ * Check if the share requires review
+ *
+ * @param {boolean} shareReviewComplete if the share was reviewed
+ * @return {boolean}
+ */
+ shareRequiresReview(shareReviewComplete) {
+ // If a user clicks 'Create share' it means they have reviewed the share
+ if (shareReviewComplete) {
+ return false
+ }
+ return this.defaultExpirationDateEnabled || this.config.enableLinkPasswordByDefault
+ },
+ /**
* Create a new share link and append it to the list
+ * @param {boolean} shareReviewComplete if the share was reviewed
*/
- async onNewLinkShare() {
+ async onNewLinkShare(shareReviewComplete = false) {
+ logger.debug('onNewLinkShare called (with this.share)', this.share)
// do not run again if already loading
if (this.loading) {
return
}
const shareDefaults = {
- share_type: ShareTypes.SHARE_TYPE_LINK,
+ share_type: ShareType.Link,
}
if (this.config.isDefaultExpireDateEnforced) {
// default is empty string if not set
// expiration is the share object key, not expireDate
shareDefaults.expiration = this.formatDateToString(this.config.defaultExpirationDate)
}
- if (this.config.enableLinkPasswordByDefault) {
- shareDefaults.password = await GeneratePassword()
- }
- // do not push yet if we need a password or an expiration date: show pending menu
- if (this.config.enforcePasswordForPublicLink || this.config.isDefaultExpireDateEnforced) {
+ logger.debug('Missing required properties?', this.enforcedPropertiesMissing)
+ // Do not push yet if we need a password or an expiration date: show pending menu
+ // A share would require a review for example is default expiration date is set but not enforced, this allows
+ // the user to review the share and remove the expiration date if they don't want it
+ if ((this.sharePolicyHasEnforcedProperties && this.enforcedPropertiesMissing) || this.shareRequiresReview(shareReviewComplete === true)) {
this.pending = true
+ this.shareCreationComplete = false
- // if a share already exists, pushing it
- if (this.share && !this.share.id) {
- // if the share is valid, create it on the server
- if (this.checkShare(this.share)) {
- try {
- await this.pushNewLinkShare(this.share, true)
- } catch (e) {
- this.pending = false
- console.error(e)
- return false
- }
- return true
- } else {
- this.open = true
- OC.Notification.showTemporary(t('files_sharing', 'Error, please enter proper password and/or expiration date'))
- return false
- }
- }
+ logger.info('Share policy requires a review or has mandated properties (password, expirationDate)...')
// ELSE, show the pending popovermenu
- // if password enforced, pre-fill with random one
- if (this.config.enforcePasswordForPublicLink) {
- shareDefaults.password = await GeneratePassword()
+ // if password default or enforced, pre-fill with random one
+ if (this.config.enableLinkPasswordByDefault || this.config.enforcePasswordForPublicLink) {
+ shareDefaults.password = await GeneratePassword(true)
}
// create share & close menu
const share = new Share(shareDefaults)
+ share.newPassword = share.password
const component = await new Promise(resolve => {
this.$emit('add:share', share, resolve)
})
@@ -657,10 +658,34 @@ export default {
this.pending = false
component.open = true
- // Nothing is enforced, creating share directly
+ // Nothing is enforced, creating share directly
} else {
+
+ // if a share already exists, pushing it
+ if (this.share && !this.share.id) {
+ // if the share is valid, create it on the server
+ if (this.checkShare(this.share)) {
+ try {
+ logger.info('Sending existing share to server', this.share)
+ await this.pushNewLinkShare(this.share, true)
+ this.shareCreationComplete = true
+ logger.info('Share created on server', this.share)
+ } catch (e) {
+ this.pending = false
+ logger.error('Error creating share', e)
+ return false
+ }
+ return true
+ } else {
+ this.open = true
+ showError(t('files_sharing', 'Error, please enter proper password and/or expiration date'))
+ return false
+ }
+ }
+
const share = new Share(shareDefaults)
await this.pushNewLinkShare(share)
+ this.shareCreationComplete = true
}
},
@@ -670,7 +695,7 @@ export default {
* accordingly
*
* @param {Share} share the new share
- * @param {boolean} [update=false] do we update the current share ?
+ * @param {boolean} [update] do we update the current share ?
*/
async pushNewLinkShare(share, update) {
try {
@@ -685,14 +710,14 @@ export default {
const path = (this.fileInfo.path + '/' + this.fileInfo.name).replace('//', '/')
const options = {
path,
- shareType: ShareTypes.SHARE_TYPE_LINK,
+ shareType: ShareType.Link,
password: share.password,
- expireDate: share.expireDate,
+ expireDate: share.expireDate ?? '',
attributes: JSON.stringify(this.fileInfo.shareAttributes),
// we do not allow setting the publicUpload
// before the share creation.
// Todo: We also need to fix the createShare method in
- // lib/Controller/ShareAPIController.php to allow file drop
+ // lib/Controller/ShareAPIController.php to allow file requests
// (currently not supported on create, only update)
}
@@ -700,8 +725,8 @@ export default {
const newShare = await this.createShare(options)
this.open = false
+ this.shareCreationComplete = true
console.debug('Link share created', newShare)
-
// if share already exists, copy link directly on next tick
let component
if (update) {
@@ -717,6 +742,9 @@ export default {
})
}
+ await this.getNode()
+ emit('files:node:updated', this.node)
+
// Execute the copy link method
// freshly created share component
// ! somehow does not works on firefox !
@@ -725,12 +753,12 @@ export default {
// otherwise the user needs to copy/paste the password before finishing the share.
component.copyLink()
}
- showSuccess(t('sharing', 'Link share created'))
+ showSuccess(t('files_sharing', 'Link share created'))
} catch (data) {
const message = data?.response?.data?.ocs?.meta?.message
if (!message) {
- showError(t('sharing', 'Error while creating the share'))
+ showError(t('files_sharing', 'Error while creating the share'))
console.error(data)
return
}
@@ -743,28 +771,10 @@ export default {
this.onSyncError('pending', message)
}
throw data
+
} finally {
this.loading = false
- }
- },
-
- /**
- * Label changed, let's save it to a different key
- *
- * @param {string} label the share label
- */
- onLabelChange(label) {
- this.$set(this.share, 'newLabel', label.trim())
- },
-
- /**
- * When the note change, we trim, save and dispatch
- */
- onLabelSubmit() {
- if (typeof this.share.newLabel === 'string') {
- this.share.label = this.share.newLabel
- this.$delete(this.share, 'newLabel')
- this.queueUpdate('label')
+ this.shareCreationComplete = true
}
},
async copyLink() {
@@ -830,7 +840,7 @@ export default {
*/
onPasswordSubmit() {
if (this.hasUnsavedPassword) {
- this.share.password = this.share.newPassword.trim()
+ this.share.newPassword = this.share.newPassword.trim()
this.queueUpdate('password')
}
},
@@ -845,7 +855,7 @@ export default {
*/
onPasswordProtectedByTalkChange() {
if (this.hasUnsavedPassword) {
- this.share.password = this.share.newPassword.trim()
+ this.share.newPassword = this.share.newPassword.trim()
}
this.queueUpdate('sendPasswordByTalk', 'password')
@@ -860,6 +870,19 @@ export default {
},
/**
+ * @param enabled True if expiration is enabled
+ */
+ onExpirationDateToggleUpdate(enabled) {
+ this.share.expireDate = enabled ? this.formatDateToString(this.config.defaultExpirationDate) : ''
+ },
+
+ expirationDateChanged(event) {
+ const value = event?.target?.value
+ const isValid = !!value && !isNaN(new Date(value).getTime())
+ this.defaultExpirationDateEnabled = isValid
+ },
+
+ /**
* Cancel the share creation
* Used in the pending popover
*/
@@ -867,7 +890,9 @@ export default {
// this.share already exists at this point,
// but is incomplete as not pushed to server
// YET. We can safely delete the share :)
- this.$emit('remove:share', this.share)
+ if (!this.shareCreationComplete) {
+ this.$emit('remove:share', this.share)
+ }
},
},
}
@@ -878,23 +903,37 @@ export default {
display: flex;
align-items: center;
min-height: 44px;
- &__desc {
+
+ &__summary {
+ padding: 8px;
+ padding-inline-start: 10px;
display: flex;
- flex-direction: column;
justify-content: space-between;
- padding: 8px;
- line-height: 1.2em;
- overflow: hidden;
+ flex: 1 0;
+ min-width: 0;
+ }
+
+ &__desc {
+ display: flex;
+ flex-direction: column;
+ line-height: 1.2em;
+
+ p {
+ color: var(--color-text-maxcontrast);
+ }
+
+ &__title {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ }
+ }
- p {
- color: var(--color-text-maxcontrast);
+ &__actions {
+ display: flex;
+ align-items: center;
+ margin-inline-start: auto;
}
- }
- &__title {
- text-overflow: ellipsis;
- overflow: hidden;
- white-space: nowrap;
- }
&:not(.sharing-entry--share) &__actions {
.new-share-link {
@@ -902,8 +941,8 @@ export default {
}
}
- ::v-deep .avatar-link-share {
- background-color: var(--color-primary);
+ :deep(.avatar-link-share) {
+ background-color: var(--color-primary-element);
}
.sharing-entry__action--public-upload {
@@ -915,21 +954,34 @@ export default {
height: 44px;
margin: 0;
padding: 14px;
- margin-left: auto;
+ margin-inline-start: auto;
}
// put menus to the left
// but only the first one
.action-item {
- margin-left: auto;
- ~ .action-item,
- ~ .sharing-entry__loading {
- margin-left: 0;
+
+ ~.action-item,
+ ~.sharing-entry__loading {
+ margin-inline-start: 0;
}
}
.icon-checkmark-color {
opacity: 1;
+ color: var(--color-success);
+ }
+}
+
+// styling for the qr-code container
+.qr-code-dialog {
+ display: flex;
+ width: 100%;
+ justify-content: center;
+
+ &__img {
+ width: 100%;
+ height: auto;
}
}
</style>
diff --git a/apps/files_sharing/src/components/SharingEntryQuickShareSelect.vue b/apps/files_sharing/src/components/SharingEntryQuickShareSelect.vue
new file mode 100644
index 00000000000..102eea63cb6
--- /dev/null
+++ b/apps/files_sharing/src/components/SharingEntryQuickShareSelect.vue
@@ -0,0 +1,206 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcActions ref="quickShareActions"
+ class="share-select"
+ :menu-name="selectedOption"
+ :aria-label="ariaLabel"
+ type="tertiary-no-background"
+ :disabled="!share.canEdit"
+ force-name>
+ <template #icon>
+ <DropdownIcon :size="15" />
+ </template>
+ <NcActionButton v-for="option in options"
+ :key="option.label"
+ type="radio"
+ :model-value="option.label === selectedOption"
+ close-after-click
+ @click="selectOption(option.label)">
+ <template #icon>
+ <component :is="option.icon" />
+ </template>
+ {{ option.label }}
+ </NcActionButton>
+ </NcActions>
+</template>
+
+<script>
+import { ShareType } from '@nextcloud/sharing'
+import { subscribe, unsubscribe } from '@nextcloud/event-bus'
+import DropdownIcon from 'vue-material-design-icons/TriangleSmallDown.vue'
+import SharesMixin from '../mixins/SharesMixin.js'
+import ShareDetails from '../mixins/ShareDetails.js'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import IconEyeOutline from 'vue-material-design-icons/EyeOutline.vue'
+import IconPencil from 'vue-material-design-icons/PencilOutline.vue'
+import IconFileUpload from 'vue-material-design-icons/FileUpload.vue'
+import IconTune from 'vue-material-design-icons/Tune.vue'
+
+import {
+ BUNDLED_PERMISSIONS,
+ ATOMIC_PERMISSIONS,
+} from '../lib/SharePermissionsToolBox.js'
+
+export default {
+ name: 'SharingEntryQuickShareSelect',
+
+ components: {
+ DropdownIcon,
+ NcActions,
+ NcActionButton,
+ },
+
+ mixins: [SharesMixin, ShareDetails],
+
+ props: {
+ share: {
+ type: Object,
+ required: true,
+ },
+ },
+
+ emits: ['open-sharing-details'],
+
+ data() {
+ return {
+ selectedOption: '',
+ }
+ },
+
+ computed: {
+ ariaLabel() {
+ return t('files_sharing', 'Quick share options, the current selected is "{selectedOption}"', { selectedOption: this.selectedOption })
+ },
+ canViewText() {
+ return t('files_sharing', 'View only')
+ },
+ canEditText() {
+ return t('files_sharing', 'Can edit')
+ },
+ fileDropText() {
+ return t('files_sharing', 'File request')
+ },
+ customPermissionsText() {
+ return t('files_sharing', 'Custom permissions')
+ },
+ preSelectedOption() {
+ // We remove the share permission for the comparison as it is not relevant for bundled permissions.
+ if ((this.share.permissions & ~ATOMIC_PERMISSIONS.SHARE) === BUNDLED_PERMISSIONS.READ_ONLY) {
+ return this.canViewText
+ } else if (this.share.permissions === BUNDLED_PERMISSIONS.ALL || this.share.permissions === BUNDLED_PERMISSIONS.ALL_FILE) {
+ return this.canEditText
+ } else if ((this.share.permissions & ~ATOMIC_PERMISSIONS.SHARE) === BUNDLED_PERMISSIONS.FILE_DROP) {
+ return this.fileDropText
+ }
+
+ return this.customPermissionsText
+
+ },
+ options() {
+ const options = [{
+ label: this.canViewText,
+ icon: IconEyeOutline,
+ }, {
+ label: this.canEditText,
+ icon: IconPencil,
+ }]
+ if (this.supportsFileDrop) {
+ options.push({
+ label: this.fileDropText,
+ icon: IconFileUpload,
+ })
+ }
+ options.push({
+ label: this.customPermissionsText,
+ icon: IconTune,
+ })
+
+ return options
+ },
+ supportsFileDrop() {
+ if (this.isFolder && this.config.isPublicUploadEnabled) {
+ const shareType = this.share.type ?? this.share.shareType
+ return [ShareType.Link, ShareType.Email].includes(shareType)
+ }
+ return false
+ },
+ dropDownPermissionValue() {
+ switch (this.selectedOption) {
+ case this.canEditText:
+ return this.isFolder ? BUNDLED_PERMISSIONS.ALL : BUNDLED_PERMISSIONS.ALL_FILE
+ case this.fileDropText:
+ return BUNDLED_PERMISSIONS.FILE_DROP
+ case this.customPermissionsText:
+ return 'custom'
+ case this.canViewText:
+ default:
+ return BUNDLED_PERMISSIONS.READ_ONLY
+ }
+ },
+ },
+
+ created() {
+ this.selectedOption = this.preSelectedOption
+ },
+ mounted() {
+ subscribe('update:share', (share) => {
+ if (share.id === this.share.id) {
+ this.share.permissions = share.permissions
+ this.selectedOption = this.preSelectedOption
+ }
+ })
+ },
+ unmounted() {
+ unsubscribe('update:share')
+ },
+ methods: {
+ selectOption(optionLabel) {
+ this.selectedOption = optionLabel
+ if (optionLabel === this.customPermissionsText) {
+ this.$emit('open-sharing-details')
+ } else {
+ this.share.permissions = this.dropDownPermissionValue
+ this.queueUpdate('permissions')
+ // TODO: Add a focus method to NcActions or configurable returnFocus enabling to NcActionButton with closeAfterClick
+ this.$refs.quickShareActions.$refs.menuButton.$el.focus()
+ }
+ },
+ },
+
+}
+</script>
+
+<style lang="scss" scoped>
+.share-select {
+ display: block;
+
+ // TODO: NcActions should have a slot for custom trigger button like NcPopover
+ // Overrider NcActionms button to make it small
+ :deep(.action-item__menutoggle) {
+ color: var(--color-primary-element) !important;
+ font-size: 12.5px !important;
+ height: auto !important;
+ min-height: auto !important;
+
+ .button-vue__text {
+ font-weight: normal !important;
+ }
+
+ .button-vue__icon {
+ height: 24px !important;
+ min-height: 24px !important;
+ width: 24px !important;
+ min-width: 24px !important;
+ }
+
+ .button-vue__wrapper {
+ // Emulate NcButton's alignment=center-reverse
+ flex-direction: row-reverse !important;
+ }
+ }
+}
+</style>
diff --git a/apps/files_sharing/src/components/SharingEntrySimple.vue b/apps/files_sharing/src/components/SharingEntrySimple.vue
index 43a6172fb15..a00333ba0ce 100644
--- a/apps/files_sharing/src/components/SharingEntrySimple.vue
+++ b/apps/files_sharing/src/components/SharingEntrySimple.vue
@@ -1,24 +1,7 @@
<!--
- - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<li class="sharing-entry">
@@ -29,8 +12,8 @@
{{ subtitle }}
</p>
</div>
- <NcActions ref="actionsComponent"
- v-if="$slots['default']"
+ <NcActions v-if="$slots['default']"
+ ref="actionsComponent"
class="sharing-entry__actions"
menu-align="right"
:aria-expanded="ariaExpandedValue">
@@ -40,7 +23,7 @@
</template>
<script>
-import NcActions from '@nextcloud/vue/dist/Components/NcActions'
+import NcActions from '@nextcloud/vue/components/NcActions'
export default {
name: 'SharingEntrySimple',
@@ -87,6 +70,7 @@ export default {
min-height: 44px;
&__desc {
padding: 8px;
+ padding-inline-start: 10px;
line-height: 1.2em;
position: relative;
flex: 1 1;
@@ -102,7 +86,7 @@ export default {
max-width: inherit;
}
&__actions {
- margin-left: auto !important;
+ margin-inline-start: auto !important;
}
}
</style>
diff --git a/apps/files_sharing/src/components/SharingInput.vue b/apps/files_sharing/src/components/SharingInput.vue
index 46c495d8279..6fb33aba6b2 100644
--- a/apps/files_sharing/src/components/SharingInput.vue
+++ b/apps/files_sharing/src/components/SharingInput.vue
@@ -1,30 +1,17 @@
<!--
- - @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="sharing-search">
- <label for="sharing-search-input">{{ t('files_sharing', 'Search for share recipients') }}</label>
+ <label class="hidden-visually" :for="shareInputId">
+ {{ isExternal ? t('files_sharing', 'Enter external recipients')
+ : t('files_sharing', 'Search for internal recipients') }}
+ </label>
<NcSelect ref="select"
- id="sharing-search-input"
+ v-model="value"
+ :input-id="shareInputId"
class="sharing-search__input"
:disabled="!canReshare"
:loading="loading"
@@ -33,11 +20,11 @@
:clear-search-on-blur="() => false"
:user-select="true"
:options="options"
- v-model="value"
+ :label-outside="true"
@search="asyncFind"
- @option:selected="addShare">
+ @option:selected="onSelected">
<template #no-options="{ search }">
- {{ search ? noResultText : t('files_sharing', 'No recommendations. Start typing.') }}
+ {{ search ? noResultText : placeholder }}
</template>
</NcSelect>
</div>
@@ -46,15 +33,16 @@
<script>
import { generateOcsUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
+import { getCapabilities } from '@nextcloud/capabilities'
import axios from '@nextcloud/axios'
import debounce from 'debounce'
-import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
+import NcSelect from '@nextcloud/vue/components/NcSelect'
-import Config from '../services/ConfigService'
-import GeneratePassword from '../utils/GeneratePassword'
-import Share from '../models/Share'
-import ShareRequests from '../mixins/ShareRequests'
-import ShareTypes from '../mixins/ShareTypes'
+import Config from '../services/ConfigService.ts'
+import Share from '../models/Share.ts'
+import ShareRequests from '../mixins/ShareRequests.js'
+import ShareDetails from '../mixins/ShareDetails.js'
+import { ShareType } from '@nextcloud/sharing'
export default {
name: 'SharingInput',
@@ -63,7 +51,7 @@ export default {
NcSelect,
},
- mixins: [ShareTypes, ShareRequests],
+ mixins: [ShareRequests, ShareDetails],
props: {
shares: {
@@ -89,6 +77,20 @@ export default {
type: Boolean,
required: true,
},
+ isExternal: {
+ type: Boolean,
+ default: false,
+ },
+ placeholder: {
+ type: String,
+ default: '',
+ },
+ },
+
+ setup() {
+ return {
+ shareInputId: `share-input-${Math.random().toString(36).slice(2, 7)}`,
+ }
},
data() {
@@ -121,6 +123,10 @@ export default {
if (!this.canReshare) {
return t('files_sharing', 'Resharing is not allowed')
}
+ if (this.placeholder) {
+ return this.placeholder
+ }
+
// We can always search with email addresses for users too
if (!allowRemoteSharing) {
return t('files_sharing', 'Name or email …')
@@ -149,10 +155,18 @@ export default {
},
mounted() {
- this.getRecommendations()
+ if (!this.isExternal) {
+ // We can only recommend users, groups etc for internal shares
+ this.getRecommendations()
+ }
},
methods: {
+ onSelected(option) {
+ this.value = null // Reset selected option
+ this.openSharingDetails(option)
+ },
+
async asyncFind(query) {
// save current query to check if we display
// recommendations or search results
@@ -169,28 +183,46 @@ export default {
* Get suggestions
*
* @param {string} search the search query
- * @param {boolean} [lookup=false] search on lookup server
+ * @param {boolean} [lookup] search on lookup server
*/
async getSuggestions(search, lookup = false) {
this.loading = true
- if (OC.getCapabilities().files_sharing.sharee.query_lookup_default === true) {
+ if (getCapabilities().files_sharing.sharee.query_lookup_default === true) {
lookup = true
}
- const shareType = [
- this.SHARE_TYPES.SHARE_TYPE_USER,
- this.SHARE_TYPES.SHARE_TYPE_GROUP,
- this.SHARE_TYPES.SHARE_TYPE_REMOTE,
- this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP,
- this.SHARE_TYPES.SHARE_TYPE_CIRCLE,
- this.SHARE_TYPES.SHARE_TYPE_ROOM,
- this.SHARE_TYPES.SHARE_TYPE_GUEST,
- this.SHARE_TYPES.SHARE_TYPE_DECK,
- ]
-
- if (OC.getCapabilities().files_sharing.public.enabled === true) {
- shareType.push(this.SHARE_TYPES.SHARE_TYPE_EMAIL)
+ const remoteTypes = [ShareType.Remote, ShareType.RemoteGroup]
+ const shareType = []
+
+ const showFederatedAsInternal = this.config.showFederatedSharesAsInternal
+ || this.config.showFederatedSharesToTrustedServersAsInternal
+
+ // For internal users, add remote types if config says to show them as internal
+ const shouldAddRemoteTypes = (!this.isExternal && showFederatedAsInternal)
+ // For external users, add them if config *doesn't* say to show them as internal
+ || (this.isExternal && !showFederatedAsInternal)
+ // Edge case: federated-to-trusted is a separate "add" trigger for external users
+ || (this.isExternal && this.config.showFederatedSharesToTrustedServersAsInternal)
+
+ if (this.isExternal) {
+ if (getCapabilities().files_sharing.public.enabled === true) {
+ shareType.push(ShareType.Email)
+ }
+ } else {
+ shareType.push(
+ ShareType.User,
+ ShareType.Group,
+ ShareType.Team,
+ ShareType.Room,
+ ShareType.Guest,
+ ShareType.Deck,
+ ShareType.ScienceMesh,
+ )
+ }
+
+ if (shouldAddRemoteTypes) {
+ shareType.push(...remoteTypes)
}
let request = null
@@ -210,13 +242,10 @@ export default {
return
}
- const data = request.data.ocs.data
- const exact = request.data.ocs.data.exact
- data.exact = [] // removing exact from general results
-
+ const { exact, ...data } = request.data.ocs.data
// flatten array of arrays
- const rawExactSuggestions = Object.values(exact).reduce((arr, elem) => arr.concat(elem), [])
- const rawSuggestions = Object.values(data).reduce((arr, elem) => arr.concat(elem), [])
+ const rawExactSuggestions = Object.values(exact).flat()
+ const rawSuggestions = Object.values(data).flat()
// remove invalid data and format to user-select layout
const exactSuggestions = this.filterOutExistingShares(rawExactSuggestions)
@@ -235,7 +264,7 @@ export default {
lookupEntry.push({
id: 'global-lookup',
isNoUser: true,
- displayName: t('files_sharing', 'Search globally'),
+ displayName: t('files_sharing', 'Search everywhere'),
lookup: true,
})
}
@@ -327,7 +356,7 @@ export default {
return arr
}
try {
- if (share.value.shareType === this.SHARE_TYPES.SHARE_TYPE_USER) {
+ if (share.value.shareType === ShareType.User) {
// filter out current user
if (share.value.shareWith === getCurrentUser().uid) {
return arr
@@ -340,7 +369,12 @@ export default {
}
// filter out existing mail shares
- if (share.value.shareType === this.SHARE_TYPES.SHARE_TYPE_EMAIL) {
+ if (share.value.shareType === ShareType.Email) {
+ // When sharing internally, we don't want to suggest email addresses
+ // that the user previously created shares to
+ if (!this.isExternal) {
+ return arr
+ }
const emails = this.linkShares.map(elem => elem.shareWith)
if (emails.indexOf(share.value.shareWith.trim()) !== -1) {
return arr
@@ -378,41 +412,46 @@ export default {
*/
shareTypeToIcon(type) {
switch (type) {
- case this.SHARE_TYPES.SHARE_TYPE_GUEST:
+ case ShareType.Guest:
// default is a user, other icons are here to differentiate
// themselves from it, so let's not display the user icon
- // case this.SHARE_TYPES.SHARE_TYPE_REMOTE:
- // case this.SHARE_TYPES.SHARE_TYPE_USER:
+ // case ShareType.Remote:
+ // case ShareType.User:
return {
icon: 'icon-user',
iconTitle: t('files_sharing', 'Guest'),
}
- case this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP:
- case this.SHARE_TYPES.SHARE_TYPE_GROUP:
+ case ShareType.RemoteGroup:
+ case ShareType.Group:
return {
icon: 'icon-group',
iconTitle: t('files_sharing', 'Group'),
}
- case this.SHARE_TYPES.SHARE_TYPE_EMAIL:
+ case ShareType.Email:
return {
icon: 'icon-mail',
iconTitle: t('files_sharing', 'Email'),
}
- case this.SHARE_TYPES.SHARE_TYPE_CIRCLE:
+ case ShareType.Team:
return {
- icon: 'icon-circle',
- iconTitle: t('files_sharing', 'Circle'),
+ icon: 'icon-teams',
+ iconTitle: t('files_sharing', 'Team'),
}
- case this.SHARE_TYPES.SHARE_TYPE_ROOM:
+ case ShareType.Room:
return {
icon: 'icon-room',
iconTitle: t('files_sharing', 'Talk conversation'),
}
- case this.SHARE_TYPES.SHARE_TYPE_DECK:
+ case ShareType.Deck:
return {
icon: 'icon-deck',
iconTitle: t('files_sharing', 'Deck board'),
}
+ case ShareType.Sciencemesh:
+ return {
+ icon: 'icon-sciencemesh',
+ iconTitle: t('files_sharing', 'ScienceMesh'),
+ }
default:
return {}
}
@@ -425,106 +464,35 @@ export default {
* @return {object}
*/
formatForMultiselect(result) {
- let subtitle
- if (result.value.shareType === this.SHARE_TYPES.SHARE_TYPE_USER && this.config.shouldAlwaysShowUnique) {
- subtitle = result.shareWithDisplayNameUnique ?? ''
- } else if ((result.value.shareType === this.SHARE_TYPES.SHARE_TYPE_REMOTE
- || result.value.shareType === this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP
- ) && result.value.server) {
- subtitle = t('files_sharing', 'on {server}', { server: result.value.server })
- } else if (result.value.shareType === this.SHARE_TYPES.SHARE_TYPE_EMAIL) {
- subtitle = result.value.shareWith
+ let subname
+ let displayName = result.name || result.label
+
+ if (result.value.shareType === ShareType.User && this.config.shouldAlwaysShowUnique) {
+ subname = result.shareWithDisplayNameUnique ?? ''
+ } else if (result.value.shareType === ShareType.Email) {
+ subname = result.value.shareWith
+ } else if (result.value.shareType === ShareType.Remote || result.value.shareType === ShareType.RemoteGroup) {
+ if (this.config.showFederatedSharesAsInternal) {
+ subname = result.extra?.email?.value ?? ''
+ displayName = result.extra?.name?.value ?? displayName
+ } else if (result.value.server) {
+ subname = t('files_sharing', 'on {server}', { server: result.value.server })
+ }
} else {
- subtitle = result.shareWithDescription ?? ''
+ subname = result.shareWithDescription ?? ''
}
return {
- id: `${result.value.shareType}-${result.value.shareWith}`,
shareWith: result.value.shareWith,
shareType: result.value.shareType,
user: result.uuid || result.value.shareWith,
- isNoUser: result.value.shareType !== this.SHARE_TYPES.SHARE_TYPE_USER,
- displayName: result.name || result.label,
- subtitle,
+ isNoUser: result.value.shareType !== ShareType.User,
+ displayName,
+ subname,
shareWithDisplayNameUnique: result.shareWithDisplayNameUnique || '',
...this.shareTypeToIcon(result.value.shareType),
}
},
-
- /**
- * Process the new share request
- *
- * @param {object} value the multiselect option
- */
- async addShare(value) {
- // Clear the displayed selection
- this.value = null
-
- if (value.lookup) {
- await this.getSuggestions(this.query, true)
-
- this.$nextTick(() => {
- // open the dropdown again
- this.$refs.select.$children[0].open = true
- })
- return true
- }
-
- // handle externalResults from OCA.Sharing.ShareSearch
- if (value.handler) {
- const share = await value.handler(this)
- this.$emit('add:share', new Share(share))
- return true
- }
-
- this.loading = true
- console.debug('Adding a new share from the input for', value)
- try {
- let password = null
-
- if (this.config.enforcePasswordForPublicLink
- && value.shareType === this.SHARE_TYPES.SHARE_TYPE_EMAIL) {
- password = await GeneratePassword()
- }
-
- const path = (this.fileInfo.path + '/' + this.fileInfo.name).replace('//', '/')
- const share = await this.createShare({
- path,
- shareType: value.shareType,
- shareWith: value.shareWith,
- password,
- permissions: this.fileInfo.sharePermissions & OC.getCapabilities().files_sharing.default_permissions,
- attributes: JSON.stringify(this.fileInfo.shareAttributes),
- })
-
- // If we had a password, we need to show it to the user as it was generated
- if (password) {
- share.newPassword = password
- // Wait for the newly added share
- const component = await new Promise(resolve => {
- this.$emit('add:share', share, resolve)
- })
-
- // open the menu on the
- // freshly created share component
- component.open = true
- } else {
- // Else we just add it normally
- this.$emit('add:share', share)
- }
-
- await this.getRecommendations()
- } catch (error) {
- this.$nextTick(() => {
- // open the dropdown again on error
- this.$refs.select.$children[0].open = true
- })
- this.query = value.shareWith
- console.error('Error while adding new share', error)
- } finally {
- this.loading = false
- }
- },
},
}
</script>
@@ -553,7 +521,7 @@ export default {
background-repeat: no-repeat;
background-position: center;
background-color: var(--color-text-maxcontrast) !important;
- div {
+ .avatardiv__initials-wrapper {
display: none;
}
}
diff --git a/apps/files_sharing/src/eventbus.d.ts b/apps/files_sharing/src/eventbus.d.ts
new file mode 100644
index 00000000000..cc10ff8468f
--- /dev/null
+++ b/apps/files_sharing/src/eventbus.d.ts
@@ -0,0 +1,15 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Folder, Node } from '@nextcloud/files'
+
+declare module '@nextcloud/event-bus' {
+ export interface NextcloudEvents {
+ // mapping of 'event name' => 'event type'
+ 'files:list:updated': { folder: Folder, contents: Node[] }
+ 'files:config:updated': { key: string, value: boolean }
+ }
+}
+
+export {}
diff --git a/apps/files_sharing/src/files_actions/acceptShareAction.spec.ts b/apps/files_sharing/src/files_actions/acceptShareAction.spec.ts
new file mode 100644
index 00000000000..4003e0799ac
--- /dev/null
+++ b/apps/files_sharing/src/files_actions/acceptShareAction.spec.ts
@@ -0,0 +1,217 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'
+
+import { action } from './acceptShareAction'
+import { File, Permission, View, FileAction } from '@nextcloud/files'
+import { ShareType } from '@nextcloud/sharing'
+import * as eventBus from '@nextcloud/event-bus'
+import axios from '@nextcloud/axios'
+
+import '../main.ts'
+
+vi.mock('@nextcloud/axios')
+
+const view = {
+ id: 'files',
+ name: 'Files',
+} as View
+
+const pendingShareView = {
+ id: 'pendingshares',
+ name: 'Pending shares',
+} as View
+
+// Mock webroot variable
+beforeAll(() => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (window as any)._oc_webroot = ''
+})
+
+describe('Accept share action conditions tests', () => {
+ test('Default values', () => {
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ })
+
+ expect(action).toBeInstanceOf(FileAction)
+ expect(action.id).toBe('accept-share')
+ expect(action.displayName([file], pendingShareView)).toBe('Accept share')
+ expect(action.iconSvgInline([file], pendingShareView)).toMatch(/<svg.+<\/svg>/)
+ expect(action.default).toBeUndefined()
+ expect(action.order).toBe(1)
+ expect(action.inline).toBeDefined()
+ expect(action.inline!(file, pendingShareView)).toBe(true)
+ })
+
+ test('Default values for multiple files', () => {
+ const file1 = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ })
+ const file2 = new File({
+ id: 2,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ })
+
+ expect(action.displayName([file1, file2], pendingShareView)).toBe('Accept shares')
+ })
+})
+
+describe('Accept share action enabled tests', () => {
+ test('Enabled with on pending shares view', () => {
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file], pendingShareView)).toBe(true)
+ })
+
+ test('Disabled on wrong view', () => {
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([], view)).toBe(false)
+ })
+
+ test('Disabled without nodes', () => {
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([], pendingShareView)).toBe(false)
+ })
+})
+
+describe('Accept share action execute tests', () => {
+ beforeEach(() => { vi.resetAllMocks() })
+
+ test('Accept share action', async () => {
+ vi.spyOn(axios, 'post')
+ vi.spyOn(eventBus, 'emit')
+
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ attributes: {
+ id: 123,
+ share_type: ShareType.User,
+ },
+ })
+
+ const exec = await action.exec(file, pendingShareView, '/')
+
+ expect(exec).toBe(true)
+ expect(axios.post).toBeCalledTimes(1)
+ expect(axios.post).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/pending/123')
+
+ expect(eventBus.emit).toBeCalledTimes(1)
+ expect(eventBus.emit).toBeCalledWith('files:node:deleted', file)
+ })
+
+ test('Accept remote share action', async () => {
+ vi.spyOn(axios, 'post')
+ vi.spyOn(eventBus, 'emit')
+
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ attributes: {
+ id: 123,
+ remote: 3,
+ share_type: ShareType.User,
+ },
+ })
+
+ const exec = await action.exec(file, pendingShareView, '/')
+
+ expect(exec).toBe(true)
+ expect(axios.post).toBeCalledTimes(1)
+ expect(axios.post).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/remote_shares/pending/123')
+
+ expect(eventBus.emit).toBeCalledTimes(1)
+ expect(eventBus.emit).toBeCalledWith('files:node:deleted', file)
+ })
+
+ test('Accept share action batch', async () => {
+ vi.spyOn(axios, 'post')
+ vi.spyOn(eventBus, 'emit')
+
+ const file1 = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ attributes: {
+ id: 123,
+ share_type: ShareType.User,
+ },
+ })
+
+ const file2 = new File({
+ id: 2,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ attributes: {
+ id: 456,
+ share_type: ShareType.User,
+ },
+ })
+
+ const exec = await action.execBatch!([file1, file2], pendingShareView, '/')
+
+ expect(exec).toStrictEqual([true, true])
+ expect(axios.post).toBeCalledTimes(2)
+ expect(axios.post).toHaveBeenNthCalledWith(1, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/pending/123')
+ expect(axios.post).toHaveBeenNthCalledWith(2, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/pending/456')
+
+ expect(eventBus.emit).toBeCalledTimes(2)
+ expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:node:deleted', file1)
+ expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:node:deleted', file2)
+ })
+
+ test('Accept fails', async () => {
+ vi.spyOn(axios, 'post').mockImplementation(() => { throw new Error('Mock error') })
+
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ attributes: {
+ id: 123,
+ share_type: ShareType.User,
+ },
+ })
+
+ const exec = await action.exec(file, pendingShareView, '/')
+
+ expect(exec).toBe(false)
+ expect(axios.post).toBeCalledTimes(1)
+ expect(axios.post).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/pending/123')
+
+ expect(eventBus.emit).toBeCalledTimes(0)
+ })
+})
diff --git a/apps/files_sharing/src/files_actions/acceptShareAction.ts b/apps/files_sharing/src/files_actions/acceptShareAction.ts
new file mode 100644
index 00000000000..f2177fdec1a
--- /dev/null
+++ b/apps/files_sharing/src/files_actions/acceptShareAction.ts
@@ -0,0 +1,48 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Node, View } from '@nextcloud/files'
+
+import { emit } from '@nextcloud/event-bus'
+import { generateOcsUrl } from '@nextcloud/router'
+import { registerFileAction, FileAction } from '@nextcloud/files'
+import { translatePlural as n } from '@nextcloud/l10n'
+import axios from '@nextcloud/axios'
+import CheckSvg from '@mdi/svg/svg/check.svg?raw'
+
+import { pendingSharesViewId } from '../files_views/shares'
+
+export const action = new FileAction({
+ id: 'accept-share',
+ displayName: (nodes: Node[]) => n('files_sharing', 'Accept share', 'Accept shares', nodes.length),
+ iconSvgInline: () => CheckSvg,
+
+ enabled: (nodes, view) => nodes.length > 0 && view.id === pendingSharesViewId,
+
+ async exec(node: Node) {
+ try {
+ const isRemote = !!node.attributes.remote
+ const url = generateOcsUrl('apps/files_sharing/api/v1/{shareBase}/pending/{id}', {
+ shareBase: isRemote ? 'remote_shares' : 'shares',
+ id: node.attributes.id,
+ })
+ await axios.post(url)
+
+ // Remove from current view
+ emit('files:node:deleted', node)
+
+ return true
+ } catch (error) {
+ return false
+ }
+ },
+ async execBatch(nodes: Node[], view: View, dir: string) {
+ return Promise.all(nodes.map(node => this.exec(node, view, dir)))
+ },
+
+ order: 1,
+ inline: () => true,
+})
+
+registerFileAction(action)
diff --git a/apps/files_sharing/src/files_actions/openInFilesAction.spec.ts b/apps/files_sharing/src/files_actions/openInFilesAction.spec.ts
new file mode 100644
index 00000000000..23c0938545c
--- /dev/null
+++ b/apps/files_sharing/src/files_actions/openInFilesAction.spec.ts
@@ -0,0 +1,78 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { File, Permission, View, DefaultType, FileAction } from '@nextcloud/files'
+import { describe, expect, test, vi } from 'vitest'
+import { deletedSharesViewId, pendingSharesViewId, sharedWithOthersViewId, sharedWithYouViewId, sharesViewId, sharingByLinksViewId } from '../files_views/shares'
+import { action } from './openInFilesAction'
+
+import '../main'
+
+const view = {
+ id: 'files',
+ name: 'Files',
+} as View
+
+const validViews = [
+ sharesViewId,
+ sharedWithYouViewId,
+ sharedWithOthersViewId,
+ sharingByLinksViewId,
+].map(id => ({ id, name: id })) as View[]
+
+const invalidViews = [
+ deletedSharesViewId,
+ pendingSharesViewId,
+].map(id => ({ id, name: id })) as View[]
+
+describe('Open in files action conditions tests', () => {
+ test('Default values', () => {
+ expect(action).toBeInstanceOf(FileAction)
+ expect(action.id).toBe('files_sharing:open-in-files')
+ expect(action.displayName([], validViews[0])).toBe('Open in Files')
+ expect(action.iconSvgInline([], validViews[0])).toBe('')
+ expect(action.default).toBe(DefaultType.HIDDEN)
+ expect(action.order).toBe(-1000)
+ expect(action.inline).toBeUndefined()
+ })
+})
+
+describe('Open in files action enabled tests', () => {
+ test('Enabled with on valid view', () => {
+ validViews.forEach(view => {
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([], view)).toBe(true)
+ })
+ })
+
+ test('Disabled on wrong view', () => {
+ invalidViews.forEach(view => {
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([], view)).toBe(false)
+ })
+ })
+})
+
+describe('Open in files action execute tests', () => {
+ test('Open in files', async () => {
+ const goToRouteMock = vi.fn()
+ // @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation
+ window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } }
+
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ root: '/files/admin',
+ permissions: Permission.READ,
+ })
+
+ const exec = await action.exec(file, view, '/')
+ // Silent action
+ expect(exec).toBe(null)
+ expect(goToRouteMock).toBeCalledTimes(1)
+ expect(goToRouteMock).toBeCalledWith(null, { fileid: '1', view: 'files' }, { dir: '/Foo', openfile: 'true' })
+ })
+})
diff --git a/apps/files_sharing/src/files_actions/openInFilesAction.ts b/apps/files_sharing/src/files_actions/openInFilesAction.ts
new file mode 100644
index 00000000000..133b4531bb5
--- /dev/null
+++ b/apps/files_sharing/src/files_actions/openInFilesAction.ts
@@ -0,0 +1,50 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { Node } from '@nextcloud/files'
+
+import { registerFileAction, FileAction, DefaultType, FileType } from '@nextcloud/files'
+import { translate as t } from '@nextcloud/l10n'
+import { sharesViewId, sharedWithYouViewId, sharedWithOthersViewId, sharingByLinksViewId } from '../files_views/shares'
+
+export const action = new FileAction({
+ id: 'files_sharing:open-in-files',
+ displayName: () => t('files_sharing', 'Open in Files'),
+ iconSvgInline: () => '',
+
+ enabled: (nodes, view) => [
+ sharesViewId,
+ sharedWithYouViewId,
+ sharedWithOthersViewId,
+ sharingByLinksViewId,
+ // Deleted and pending shares are not
+ // accessible in the files app.
+ ].includes(view.id),
+
+ async exec(node: Node) {
+ const isFolder = node.type === FileType.Folder
+
+ window.OCP.Files.Router.goToRoute(
+ null, // use default route
+ {
+ view: 'files',
+ fileid: String(node.fileid),
+ },
+ {
+ // If this node is a folder open the folder in files
+ dir: isFolder ? node.path : node.dirname,
+ // otherwise if this is a file, we should open it
+ openfile: isFolder ? undefined : 'true',
+ },
+ )
+ return null
+ },
+
+ // Before openFolderAction
+ order: -1000,
+ default: DefaultType.HIDDEN,
+})
+
+registerFileAction(action)
diff --git a/apps/files_sharing/src/files_actions/rejectShareAction.spec.ts b/apps/files_sharing/src/files_actions/rejectShareAction.spec.ts
new file mode 100644
index 00000000000..51ded69d1c5
--- /dev/null
+++ b/apps/files_sharing/src/files_actions/rejectShareAction.spec.ts
@@ -0,0 +1,243 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { File, Folder, Permission, View, FileAction } from '@nextcloud/files'
+import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'
+import { ShareType } from '@nextcloud/sharing'
+import * as eventBus from '@nextcloud/event-bus'
+import axios from '@nextcloud/axios'
+
+import { action } from './rejectShareAction'
+import '../main'
+
+vi.mock('@nextcloud/axios')
+
+const view = {
+ id: 'files',
+ name: 'Files',
+} as View
+
+const pendingShareView = {
+ id: 'pendingshares',
+ name: 'Pending shares',
+} as View
+
+// Mock webroot variable
+beforeAll(() => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (window as any)._oc_webroot = ''
+})
+
+describe('Reject share action conditions tests', () => {
+ test('Default values', () => {
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ })
+
+ expect(action).toBeInstanceOf(FileAction)
+ expect(action.id).toBe('reject-share')
+ expect(action.displayName([file], pendingShareView)).toBe('Reject share')
+ expect(action.iconSvgInline([file], pendingShareView)).toMatch(/<svg.+<\/svg>/)
+ expect(action.default).toBeUndefined()
+ expect(action.order).toBe(2)
+ expect(action.inline).toBeDefined()
+ expect(action.inline!(file, pendingShareView)).toBe(true)
+ })
+
+ test('Default values for multiple files', () => {
+ const file1 = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ })
+ const file2 = new File({
+ id: 2,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ })
+
+ expect(action.displayName([file1, file2], pendingShareView)).toBe('Reject shares')
+ })
+})
+
+describe('Reject share action enabled tests', () => {
+ test('Enabled with on pending shares view', () => {
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file], pendingShareView)).toBe(true)
+ })
+
+ test('Disabled on wrong view', () => {
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([], view)).toBe(false)
+ })
+
+ test('Disabled without nodes', () => {
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([], pendingShareView)).toBe(false)
+ })
+
+ test('Disabled if some nodes are remote group shares', () => {
+ const folder1 = new Folder({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/',
+ owner: 'admin',
+ permissions: Permission.READ,
+ attributes: {
+ share_type: ShareType.User,
+ },
+ })
+ const folder2 = new Folder({
+ id: 2,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/Bar/',
+ owner: 'admin',
+ permissions: Permission.READ,
+ attributes: {
+ remote_id: 1,
+ share_type: ShareType.RemoteGroup,
+ },
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([folder1], pendingShareView)).toBe(true)
+ expect(action.enabled!([folder2], pendingShareView)).toBe(false)
+ expect(action.enabled!([folder1, folder2], pendingShareView)).toBe(false)
+ })
+})
+
+describe('Reject share action execute tests', () => {
+ beforeEach(() => { vi.resetAllMocks() })
+
+ test('Reject share action', async () => {
+ vi.spyOn(axios, 'delete')
+ vi.spyOn(eventBus, 'emit')
+
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ attributes: {
+ id: 123,
+ share_type: ShareType.User,
+ },
+ })
+
+ const exec = await action.exec(file, pendingShareView, '/')
+
+ expect(exec).toBe(true)
+ expect(axios.delete).toBeCalledTimes(1)
+ expect(axios.delete).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/123')
+
+ expect(eventBus.emit).toBeCalledTimes(1)
+ expect(eventBus.emit).toBeCalledWith('files:node:deleted', file)
+ })
+
+ test('Reject remote share action', async () => {
+ vi.spyOn(axios, 'delete')
+ vi.spyOn(eventBus, 'emit')
+
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ attributes: {
+ id: 123,
+ remote: 3,
+ share_type: ShareType.User,
+ },
+ })
+
+ const exec = await action.exec(file, pendingShareView, '/')
+
+ expect(exec).toBe(true)
+ expect(axios.delete).toBeCalledTimes(1)
+ expect(axios.delete).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/remote_shares/123')
+
+ expect(eventBus.emit).toBeCalledTimes(1)
+ expect(eventBus.emit).toBeCalledWith('files:node:deleted', file)
+ })
+
+ test('Reject share action batch', async () => {
+ vi.spyOn(axios, 'delete')
+ vi.spyOn(eventBus, 'emit')
+
+ const file1 = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ attributes: {
+ id: 123,
+ share_type: ShareType.User,
+ },
+ })
+
+ const file2 = new File({
+ id: 2,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ attributes: {
+ id: 456,
+ share_type: ShareType.User,
+ },
+ })
+
+ const exec = await action.execBatch!([file1, file2], pendingShareView, '/')
+
+ expect(exec).toStrictEqual([true, true])
+ expect(axios.delete).toBeCalledTimes(2)
+ expect(axios.delete).toHaveBeenNthCalledWith(1, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/123')
+ expect(axios.delete).toHaveBeenNthCalledWith(2, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/456')
+
+ expect(eventBus.emit).toBeCalledTimes(2)
+ expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:node:deleted', file1)
+ expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:node:deleted', file2)
+ })
+
+ test('Reject fails', async () => {
+ vi.spyOn(axios, 'delete').mockImplementation(() => { throw new Error('Mock error') })
+
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ attributes: {
+ id: 123,
+ share_type: ShareType.User,
+ },
+ })
+
+ const exec = await action.exec(file, pendingShareView, '/')
+
+ expect(exec).toBe(false)
+ expect(axios.delete).toBeCalledTimes(1)
+ expect(axios.delete).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/123')
+
+ expect(eventBus.emit).toBeCalledTimes(0)
+ })
+})
diff --git a/apps/files_sharing/src/files_actions/rejectShareAction.ts b/apps/files_sharing/src/files_actions/rejectShareAction.ts
new file mode 100644
index 00000000000..22f77262ef2
--- /dev/null
+++ b/apps/files_sharing/src/files_actions/rejectShareAction.ts
@@ -0,0 +1,66 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Node, View } from '@nextcloud/files'
+
+import { emit } from '@nextcloud/event-bus'
+import { generateOcsUrl } from '@nextcloud/router'
+import { registerFileAction, FileAction } from '@nextcloud/files'
+import { translatePlural as n } from '@nextcloud/l10n'
+import { ShareType } from '@nextcloud/sharing'
+import { pendingSharesViewId } from '../files_views/shares'
+
+import axios from '@nextcloud/axios'
+import CloseSvg from '@mdi/svg/svg/close.svg?raw'
+
+export const action = new FileAction({
+ id: 'reject-share',
+ displayName: (nodes: Node[]) => n('files_sharing', 'Reject share', 'Reject shares', nodes.length),
+ iconSvgInline: () => CloseSvg,
+
+ enabled: (nodes, view) => {
+ if (view.id !== pendingSharesViewId) {
+ return false
+ }
+
+ if (nodes.length === 0) {
+ return false
+ }
+
+ // disable rejecting group shares from the pending list because they anyway
+ // land back into that same list after rejecting them
+ if (nodes.some(node => node.attributes.remote_id
+ && node.attributes.share_type === ShareType.RemoteGroup)) {
+ return false
+ }
+
+ return true
+ },
+
+ async exec(node: Node) {
+ try {
+ const isRemote = !!node.attributes.remote
+ const url = generateOcsUrl('apps/files_sharing/api/v1/{shareBase}/{id}', {
+ shareBase: isRemote ? 'remote_shares' : 'shares',
+ id: node.attributes.id,
+ })
+ await axios.delete(url)
+
+ // Remove from current view
+ emit('files:node:deleted', node)
+
+ return true
+ } catch (error) {
+ return false
+ }
+ },
+ async execBatch(nodes: Node[], view: View, dir: string) {
+ return Promise.all(nodes.map(node => this.exec(node, view, dir)))
+ },
+
+ order: 2,
+ inline: () => true,
+})
+
+registerFileAction(action)
diff --git a/apps/files_sharing/src/files_actions/restoreShareAction.spec.ts b/apps/files_sharing/src/files_actions/restoreShareAction.spec.ts
new file mode 100644
index 00000000000..015aa8aa95d
--- /dev/null
+++ b/apps/files_sharing/src/files_actions/restoreShareAction.spec.ts
@@ -0,0 +1,191 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { File, Permission, View, FileAction } from '@nextcloud/files'
+import { ShareType } from '@nextcloud/sharing'
+import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'
+
+import axios from '@nextcloud/axios'
+import * as eventBus from '@nextcloud/event-bus'
+import { action } from './restoreShareAction'
+import '../main.ts'
+
+vi.mock('@nextcloud/auth')
+vi.mock('@nextcloud/axios')
+
+const view = {
+ id: 'files',
+ name: 'Files',
+} as View
+
+const deletedShareView = {
+ id: 'deletedshares',
+ name: 'Deleted shares',
+} as View
+
+// Mock webroot variable
+beforeAll(() => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (window as any)._oc_webroot = ''
+})
+
+describe('Restore share action conditions tests', () => {
+ test('Default values', () => {
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ })
+
+ expect(action).toBeInstanceOf(FileAction)
+ expect(action.id).toBe('restore-share')
+ expect(action.displayName([file], deletedShareView)).toBe('Restore share')
+ expect(action.iconSvgInline([file], deletedShareView)).toMatch(/<svg.+<\/svg>/)
+ expect(action.default).toBeUndefined()
+ expect(action.order).toBe(1)
+ expect(action.inline).toBeDefined()
+ expect(action.inline!(file, deletedShareView)).toBe(true)
+ })
+
+ test('Default values for multiple files', () => {
+ const file1 = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ })
+ const file2 = new File({
+ id: 2,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ })
+
+ expect(action.displayName([file1, file2], deletedShareView)).toBe('Restore shares')
+ })
+})
+
+describe('Restore share action enabled tests', () => {
+ test('Enabled with on pending shares view', () => {
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.ALL,
+ })
+
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([file], deletedShareView)).toBe(true)
+ })
+
+ test('Disabled on wrong view', () => {
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([], view)).toBe(false)
+ })
+
+ test('Disabled without nodes', () => {
+ expect(action.enabled).toBeDefined()
+ expect(action.enabled!([], deletedShareView)).toBe(false)
+ })
+})
+
+describe('Restore share action execute tests', () => {
+ beforeEach(() => { vi.resetAllMocks() })
+
+ test('Restore share action', async () => {
+ vi.spyOn(axios, 'post')
+ vi.spyOn(eventBus, 'emit')
+
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ attributes: {
+ id: 123,
+ share_type: ShareType.User,
+ },
+ })
+
+ const exec = await action.exec(file, deletedShareView, '/')
+
+ expect(exec).toBe(true)
+ expect(axios.post).toBeCalledTimes(1)
+ expect(axios.post).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/deletedshares/123')
+
+ expect(eventBus.emit).toBeCalledTimes(1)
+ expect(eventBus.emit).toBeCalledWith('files:node:deleted', file)
+ })
+
+ test('Restore share action batch', async () => {
+ vi.spyOn(axios, 'post')
+ vi.spyOn(eventBus, 'emit')
+
+ const file1 = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ attributes: {
+ id: 123,
+ share_type: ShareType.User,
+ },
+ })
+
+ const file2 = new File({
+ id: 2,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ attributes: {
+ id: 456,
+ share_type: ShareType.User,
+ },
+ })
+
+ const exec = await action.execBatch!([file1, file2], deletedShareView, '/')
+
+ expect(exec).toStrictEqual([true, true])
+ expect(axios.post).toBeCalledTimes(2)
+ expect(axios.post).toHaveBeenNthCalledWith(1, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/deletedshares/123')
+ expect(axios.post).toHaveBeenNthCalledWith(2, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/deletedshares/456')
+
+ expect(eventBus.emit).toBeCalledTimes(2)
+ expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:node:deleted', file1)
+ expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:node:deleted', file2)
+ })
+
+ test('Restore fails', async () => {
+ vi.spyOn(axios, 'post')
+ .mockImplementation(() => { throw new Error('Mock error') })
+
+ const file = new File({
+ id: 1,
+ source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt',
+ owner: 'admin',
+ mime: 'text/plain',
+ permissions: Permission.READ,
+ attributes: {
+ id: 123,
+ share_type: ShareType.User,
+ },
+ })
+
+ const exec = await action.exec(file, deletedShareView, '/')
+
+ expect(exec).toBe(false)
+ expect(axios.post).toBeCalledTimes(1)
+ expect(axios.post).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/deletedshares/123')
+
+ expect(eventBus.emit).toBeCalledTimes(0)
+ })
+})
diff --git a/apps/files_sharing/src/files_actions/restoreShareAction.ts b/apps/files_sharing/src/files_actions/restoreShareAction.ts
new file mode 100644
index 00000000000..2d51de387ee
--- /dev/null
+++ b/apps/files_sharing/src/files_actions/restoreShareAction.ts
@@ -0,0 +1,47 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Node, View } from '@nextcloud/files'
+
+import { emit } from '@nextcloud/event-bus'
+import { FileAction, registerFileAction } from '@nextcloud/files'
+import { generateOcsUrl } from '@nextcloud/router'
+import { translatePlural as n } from '@nextcloud/l10n'
+import ArrowULeftTopSvg from '@mdi/svg/svg/arrow-u-left-top.svg?raw'
+import axios from '@nextcloud/axios'
+
+import { deletedSharesViewId } from '../files_views/shares'
+
+export const action = new FileAction({
+ id: 'restore-share',
+ displayName: (nodes: Node[]) => n('files_sharing', 'Restore share', 'Restore shares', nodes.length),
+
+ iconSvgInline: () => ArrowULeftTopSvg,
+
+ enabled: (nodes, view) => nodes.length > 0 && view.id === deletedSharesViewId,
+
+ async exec(node: Node) {
+ try {
+ const url = generateOcsUrl('apps/files_sharing/api/v1/deletedshares/{id}', {
+ id: node.attributes.id,
+ })
+ await axios.post(url)
+
+ // Remove from current view
+ emit('files:node:deleted', node)
+
+ return true
+ } catch (error) {
+ return false
+ }
+ },
+ async execBatch(nodes: Node[], view: View, dir: string) {
+ return Promise.all(nodes.map(node => this.exec(node, view, dir)))
+ },
+
+ order: 1,
+ inline: () => true,
+})
+
+registerFileAction(action)
diff --git a/apps/files_sharing/src/files_actions/sharingStatusAction.scss b/apps/files_sharing/src/files_actions/sharingStatusAction.scss
new file mode 100644
index 00000000000..3a6690f40f1
--- /dev/null
+++ b/apps/files_sharing/src/files_actions/sharingStatusAction.scss
@@ -0,0 +1,29 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+ // Only when rendered inline, when not enough space, this is put in the menu
+.action-items > .files-list__row-action-sharing-status {
+ // put icon at the end of the button
+ direction: rtl;
+ // align icons with text-less inline actions
+ padding-inline-end: 0 !important;
+}
+
+svg.sharing-status__avatar {
+ height: 32px !important;
+ width: 32px !important;
+ max-height: 32px !important;
+ max-width: 32px !important;
+ border-radius: 32px;
+ overflow: hidden;
+}
+
+.files-list__row-action-sharing-status {
+ .button-vue__text {
+ color: var(--color-primary-element);
+ }
+ .button-vue__icon {
+ color: var(--color-primary-element);
+ }
+}
diff --git a/apps/files_sharing/src/files_actions/sharingStatusAction.ts b/apps/files_sharing/src/files_actions/sharingStatusAction.ts
new file mode 100644
index 00000000000..18fa46d2781
--- /dev/null
+++ b/apps/files_sharing/src/files_actions/sharingStatusAction.ts
@@ -0,0 +1,144 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { getCurrentUser } from '@nextcloud/auth'
+import { Node, View, registerFileAction, FileAction, Permission } from '@nextcloud/files'
+import { translate as t } from '@nextcloud/l10n'
+import { ShareType } from '@nextcloud/sharing'
+import { isPublicShare } from '@nextcloud/sharing/public'
+
+import AccountGroupSvg from '@mdi/svg/svg/account-group-outline.svg?raw'
+import AccountPlusSvg from '@mdi/svg/svg/account-plus-outline.svg?raw'
+import LinkSvg from '@mdi/svg/svg/link.svg?raw'
+import CircleSvg from '../../../../core/img/apps/circles.svg?raw'
+
+import { action as sidebarAction } from '../../../files/src/actions/sidebarAction'
+import { generateAvatarSvg } from '../utils/AccountIcon'
+
+import './sharingStatusAction.scss'
+
+const isExternal = (node: Node) => {
+ return node.attributes?.['is-federated'] ?? false
+}
+
+export const ACTION_SHARING_STATUS = 'sharing-status'
+export const action = new FileAction({
+ id: ACTION_SHARING_STATUS,
+ displayName(nodes: Node[]) {
+ const node = nodes[0]
+ const shareTypes = Object.values(node?.attributes?.['share-types'] || {}).flat() as number[]
+
+ if (shareTypes.length > 0
+ || (node.owner !== getCurrentUser()?.uid || isExternal(node))) {
+ return t('files_sharing', 'Shared')
+ }
+
+ return ''
+ },
+
+ title(nodes: Node[]) {
+ const node = nodes[0]
+
+ if (node.owner && (node.owner !== getCurrentUser()?.uid || isExternal(node))) {
+ const ownerDisplayName = node?.attributes?.['owner-display-name']
+ return t('files_sharing', 'Shared by {ownerDisplayName}', { ownerDisplayName })
+ }
+
+ const shareTypes = Object.values(node?.attributes?.['share-types'] || {}).flat() as number[]
+ if (shareTypes.length > 1) {
+ return t('files_sharing', 'Shared multiple times with different people')
+ }
+
+ const sharees = node.attributes.sharees?.sharee as { id: string, 'display-name': string, type: ShareType }[] | undefined
+ if (!sharees) {
+ // No sharees so just show the default message to create a new share
+ return t('files_sharing', 'Sharing options')
+ }
+
+ const sharee = [sharees].flat()[0] // the property is sometimes weirdly normalized, so we need to compensate
+ switch (sharee.type) {
+ case ShareType.User:
+ return t('files_sharing', 'Shared with {user}', { user: sharee['display-name'] })
+ case ShareType.Group:
+ return t('files_sharing', 'Shared with group {group}', { group: sharee['display-name'] ?? sharee.id })
+ default:
+ return t('files_sharing', 'Shared with others')
+ }
+ },
+
+ iconSvgInline(nodes: Node[]) {
+ const node = nodes[0]
+ const shareTypes = Object.values(node?.attributes?.['share-types'] || {}).flat() as number[]
+
+ // Mixed share types
+ if (Array.isArray(node.attributes?.['share-types']) && node.attributes?.['share-types'].length > 1) {
+ return AccountPlusSvg
+ }
+
+ // Link shares
+ if (shareTypes.includes(ShareType.Link)
+ || shareTypes.includes(ShareType.Email)) {
+ return LinkSvg
+ }
+
+ // Group shares
+ if (shareTypes.includes(ShareType.Group)
+ || shareTypes.includes(ShareType.RemoteGroup)) {
+ return AccountGroupSvg
+ }
+
+ // Circle shares
+ if (shareTypes.includes(ShareType.Team)) {
+ return CircleSvg
+ }
+
+ if (node.owner && (node.owner !== getCurrentUser()?.uid || isExternal(node))) {
+ return generateAvatarSvg(node.owner, isExternal(node))
+ }
+
+ return AccountPlusSvg
+ },
+
+ enabled(nodes: Node[]) {
+ if (nodes.length !== 1) {
+ return false
+ }
+
+ // Do not leak information about users to public shares
+ if (isPublicShare()) {
+ return false
+ }
+
+ const node = nodes[0]
+ const shareTypes = node.attributes?.['share-types']
+ const isMixed = Array.isArray(shareTypes) && shareTypes.length > 0
+
+ // If the node is shared multiple times with
+ // different share types to the current user
+ if (isMixed) {
+ return true
+ }
+
+ // If the node is shared by someone else
+ if (node.owner !== getCurrentUser()?.uid || isExternal(node)) {
+ return true
+ }
+
+ return (node.permissions & Permission.SHARE) !== 0
+ },
+
+ async exec(node: Node, view: View, dir: string) {
+ // You need read permissions to see the sidebar
+ if ((node.permissions & Permission.READ) !== 0) {
+ window.OCA?.Files?.Sidebar?.setActiveTab?.('sharing')
+ return sidebarAction.exec(node, view, dir)
+ }
+ return null
+ },
+
+ inline: () => true,
+
+})
+
+registerFileAction(action)
diff --git a/apps/files_sharing/src/files_filters/AccountFilter.ts b/apps/files_sharing/src/files_filters/AccountFilter.ts
new file mode 100644
index 00000000000..4f185d9fd9c
--- /dev/null
+++ b/apps/files_sharing/src/files_filters/AccountFilter.ts
@@ -0,0 +1,162 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { IFileListFilterChip, INode } from '@nextcloud/files'
+
+import { subscribe } from '@nextcloud/event-bus'
+import { FileListFilter, registerFileListFilter } from '@nextcloud/files'
+import { ShareType } from '@nextcloud/sharing'
+import Vue from 'vue'
+
+import FileListFilterAccount from '../components/FileListFilterAccount.vue'
+import { isPublicShare } from '@nextcloud/sharing/public'
+
+export interface IAccountData {
+ uid: string
+ displayName: string
+}
+
+type CurrentInstance = Vue & {
+ resetFilter: () => void
+ setAvailableAccounts: (accounts: IAccountData[]) => void
+ toggleAccount: (account: string) => void
+}
+
+/**
+ * File list filter to filter by owner / sharee
+ */
+class AccountFilter extends FileListFilter {
+
+ private availableAccounts: IAccountData[]
+ private currentInstance?: CurrentInstance
+ private filterAccounts?: IAccountData[]
+
+ constructor() {
+ super('files_sharing:account', 100)
+ this.availableAccounts = []
+
+ subscribe('files:list:updated', ({ contents }) => {
+ this.updateAvailableAccounts(contents)
+ })
+ }
+
+ public mount(el: HTMLElement) {
+ if (this.currentInstance) {
+ this.currentInstance.$destroy()
+ }
+
+ const View = Vue.extend(FileListFilterAccount as never)
+ this.currentInstance = new View({ el })
+ .$on('update:accounts', (accounts?: IAccountData[]) => this.setAccounts(accounts))
+ .$mount() as CurrentInstance
+ this.currentInstance
+ .setAvailableAccounts(this.availableAccounts)
+ }
+
+ public filter(nodes: INode[]): INode[] {
+ if (!this.filterAccounts || this.filterAccounts.length === 0) {
+ return nodes
+ }
+
+ const userIds = this.filterAccounts.map(({ uid }) => uid)
+ // Filter if the owner of the node is in the list of filtered accounts
+ return nodes.filter((node) => {
+ const sharees = node.attributes.sharees?.sharee as { id: string }[] | undefined
+ // If the node provides no information lets keep it
+ if (!node.owner && !sharees) {
+ return true
+ }
+ // if the owner matches
+ if (node.owner && userIds.includes(node.owner)) {
+ return true
+ }
+ // Or any of the sharees (if only one share this will be an object, otherwise an array. So using `.flat()` to make it always an array)
+ if (sharees && [sharees].flat().some(({ id }) => userIds.includes(id))) {
+ return true
+ }
+ // Not a valid node for the current filter
+ return false
+ })
+ }
+
+ public reset(): void {
+ this.currentInstance?.resetFilter()
+ }
+
+ /**
+ * Set accounts that should be filtered.
+ *
+ * @param accounts - Account to filter or undefined if inactive.
+ */
+ public setAccounts(accounts?: IAccountData[]) {
+ this.filterAccounts = accounts
+ let chips: IFileListFilterChip[] = []
+ if (this.filterAccounts && this.filterAccounts.length > 0) {
+ chips = this.filterAccounts.map(({ displayName, uid }) => ({
+ text: displayName,
+ user: uid,
+ onclick: () => this.currentInstance?.toggleAccount(uid),
+ }))
+ }
+
+ this.updateChips(chips)
+ this.filterUpdated()
+ }
+
+ /**
+ * Update the accounts owning nodes or have nodes shared to them.
+ *
+ * @param nodes - The current content of the file list.
+ */
+ protected updateAvailableAccounts(nodes: INode[]): void {
+ const available = new Map<string, IAccountData>()
+
+ for (const node of nodes) {
+ const owner = node.owner
+ if (owner && !available.has(owner)) {
+ available.set(owner, {
+ uid: owner,
+ displayName: node.attributes['owner-display-name'] ?? node.owner,
+ })
+ }
+
+ // ensure sharees is an array (if only one share then it is just an object)
+ const sharees: { id: string, 'display-name': string, type: ShareType }[] = [node.attributes.sharees?.sharee].flat().filter(Boolean)
+ for (const sharee of [sharees].flat()) {
+ // Skip link shares and other without user
+ if (sharee.id === '') {
+ continue
+ }
+ if (sharee.type !== ShareType.User && sharee.type !== ShareType.Remote) {
+ continue
+ }
+ // Add if not already added
+ if (!available.has(sharee.id)) {
+ available.set(sharee.id, {
+ uid: sharee.id,
+ displayName: sharee['display-name'],
+ })
+ }
+ }
+ }
+
+ this.availableAccounts = [...available.values()]
+ if (this.currentInstance) {
+ this.currentInstance.setAvailableAccounts(this.availableAccounts)
+ }
+ }
+
+}
+
+/**
+ * Register the file list filter by owner or sharees
+ */
+export function registerAccountFilter() {
+ if (isPublicShare()) {
+ // We do not show the filter on public pages - it makes no sense
+ return
+ }
+
+ registerFileListFilter(new AccountFilter())
+}
diff --git a/apps/files_sharing/src/files_headers/noteToRecipient.ts b/apps/files_sharing/src/files_headers/noteToRecipient.ts
new file mode 100644
index 00000000000..7cf859172c5
--- /dev/null
+++ b/apps/files_sharing/src/files_headers/noteToRecipient.ts
@@ -0,0 +1,40 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { ComponentPublicInstance, VueConstructor } from 'vue'
+
+import { Folder, Header, registerFileListHeaders } from '@nextcloud/files'
+import Vue from 'vue'
+
+type IFilesHeaderNoteToRecipient = ComponentPublicInstance & { updateFolder: (folder: Folder) => void }
+
+/**
+ * Register the "note to recipient" as a files list header
+ */
+export default function registerNoteToRecipient() {
+ let FilesHeaderNoteToRecipient: VueConstructor
+ let instance: IFilesHeaderNoteToRecipient
+
+ registerFileListHeaders(new Header({
+ id: 'note-to-recipient',
+ order: 0,
+ // Always if there is a note
+ enabled: (folder: Folder) => Boolean(folder.attributes.note),
+ // Update the root folder if needed
+ updated: (folder: Folder) => {
+ if (instance) {
+ instance.updateFolder(folder)
+ }
+ },
+ // render simply spawns the component
+ render: async (el: HTMLElement, folder: Folder) => {
+ if (FilesHeaderNoteToRecipient === undefined) {
+ const { default: component } = await import('../views/FilesHeaderNoteToRecipient.vue')
+ FilesHeaderNoteToRecipient = Vue.extend(component)
+ }
+ instance = new FilesHeaderNoteToRecipient().$mount(el) as unknown as IFilesHeaderNoteToRecipient
+ instance.updateFolder(folder)
+ },
+ }))
+}
diff --git a/apps/files_sharing/src/files_newMenu/newFileRequest.ts b/apps/files_sharing/src/files_newMenu/newFileRequest.ts
new file mode 100644
index 00000000000..1d58e3552a2
--- /dev/null
+++ b/apps/files_sharing/src/files_newMenu/newFileRequest.ts
@@ -0,0 +1,42 @@
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { Entry, Folder, Node } from '@nextcloud/files'
+
+import { defineAsyncComponent } from 'vue'
+import { spawnDialog } from '@nextcloud/dialogs'
+import { translate as t } from '@nextcloud/l10n'
+import FileUploadSvg from '@mdi/svg/svg/file-upload-outline.svg?raw'
+
+import Config from '../services/ConfigService'
+import { isPublicShare } from '@nextcloud/sharing/public'
+const sharingConfig = new Config()
+
+const NewFileRequestDialogVue = defineAsyncComponent(() => import('../components/NewFileRequestDialog.vue'))
+
+export const EntryId = 'file-request'
+
+export const entry = {
+ id: EntryId,
+ displayName: t('files_sharing', 'Create file request'),
+ iconSvgInline: FileUploadSvg,
+ order: 10,
+ enabled(): boolean {
+ // not on public shares
+ if (isPublicShare()) {
+ return false
+ }
+ if (!sharingConfig.isPublicUploadEnabled) {
+ return false
+ }
+ // We will check for the folder permission on the dialog
+ return sharingConfig.isPublicShareAllowed
+ },
+ async handler(context: Folder, content: Node[]) {
+ spawnDialog(NewFileRequestDialogVue, {
+ context,
+ content,
+ })
+ },
+} as Entry
diff --git a/apps/files_sharing/src/files_sharing.js b/apps/files_sharing/src/files_sharing.js
deleted file mode 100644
index 97174542458..00000000000
--- a/apps/files_sharing/src/files_sharing.js
+++ /dev/null
@@ -1,25 +0,0 @@
-/**
- * @copyright Copyright (c) 2016 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- *
- * @license AGPL-3.0-or-later
- *
- * 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/>.
- *
- */
-
-import '../js/app'
-import '../js/sharedfilelist'
diff --git a/apps/files_sharing/src/files_sharing_tab.js b/apps/files_sharing/src/files_sharing_tab.js
index 8858f35570f..6afcfa76717 100644
--- a/apps/files_sharing/src/files_sharing_tab.js
+++ b/apps/files_sharing/src/files_sharing_tab.js
@@ -1,38 +1,23 @@
/**
- * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- *
- * @license AGPL-3.0-or-later
- *
- * 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
*/
import Vue from 'vue'
-import { translate as t, translatePlural as n } from '@nextcloud/l10n'
+import { getCSPNonce } from '@nextcloud/auth'
+import { t, n } from '@nextcloud/l10n'
-import SharingTab from './views/SharingTab.vue'
import ShareSearch from './services/ShareSearch.js'
import ExternalLinkActions from './services/ExternalLinkActions.js'
import ExternalShareActions from './services/ExternalShareActions.js'
import TabSections from './services/TabSections.js'
-// eslint-disable-next-line node/no-missing-import, import/no-unresolved
+// eslint-disable-next-line n/no-missing-import, import/no-unresolved
import ShareVariant from '@mdi/svg/svg/share-variant.svg?raw'
+// eslint-disable-next-line camelcase
+__webpack_nonce__ = getCSPNonce()
+
// Init Sharing Tab Service
if (!window.OCA.Sharing) {
window.OCA.Sharing = {}
@@ -46,7 +31,6 @@ Vue.prototype.t = t
Vue.prototype.n = n
// Init Sharing tab component
-const View = Vue.extend(SharingTab)
let TabInstance = null
window.addEventListener('DOMContentLoaded', function() {
@@ -57,6 +41,9 @@ window.addEventListener('DOMContentLoaded', function() {
iconSvg: ShareVariant,
async mount(el, fileInfo, context) {
+ const SharingTab = (await import('./views/SharingTab.vue')).default
+ const View = Vue.extend(SharingTab)
+
if (TabInstance) {
TabInstance.$destroy()
}
@@ -68,12 +55,16 @@ window.addEventListener('DOMContentLoaded', function() {
await TabInstance.update(fileInfo)
TabInstance.$mount(el)
},
+
update(fileInfo) {
TabInstance.update(fileInfo)
},
+
destroy() {
- TabInstance.$destroy()
- TabInstance = null
+ if (TabInstance) {
+ TabInstance.$destroy()
+ TabInstance = null
+ }
},
}))
}
diff --git a/apps/files_sharing/src/files_views/publicFileDrop.ts b/apps/files_sharing/src/files_views/publicFileDrop.ts
new file mode 100644
index 00000000000..65756e83c74
--- /dev/null
+++ b/apps/files_sharing/src/files_views/publicFileDrop.ts
@@ -0,0 +1,60 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { VueConstructor } from 'vue'
+
+import { Folder, Permission, View, getNavigation } from '@nextcloud/files'
+import { defaultRemoteURL, defaultRootPath } from '@nextcloud/files/dav'
+import { loadState } from '@nextcloud/initial-state'
+import { translate as t } from '@nextcloud/l10n'
+import svgCloudUpload from '@mdi/svg/svg/cloud-upload.svg?raw'
+import Vue from 'vue'
+
+export default () => {
+ const foldername = loadState<string>('files_sharing', 'filename')
+
+ let FilesViewFileDropEmptyContent: VueConstructor
+ let fileDropEmptyContentInstance: Vue
+
+ const view = new View({
+ id: 'public-file-drop',
+ name: t('files_sharing', 'File drop'),
+ caption: t('files_sharing', 'Upload files to {foldername}', { foldername }),
+ icon: svgCloudUpload,
+ order: 1,
+
+ emptyView: async (div: HTMLDivElement) => {
+ if (FilesViewFileDropEmptyContent === undefined) {
+ const { default: component } = await import('../views/FilesViewFileDropEmptyContent.vue')
+ FilesViewFileDropEmptyContent = Vue.extend(component)
+ }
+ if (fileDropEmptyContentInstance) {
+ fileDropEmptyContentInstance.$destroy()
+ }
+ fileDropEmptyContentInstance = new FilesViewFileDropEmptyContent({
+ propsData: {
+ foldername,
+ },
+ })
+ fileDropEmptyContentInstance.$mount(div)
+ },
+
+ getContents: async () => {
+ return {
+ contents: [],
+ // Fake a writeonly folder as root
+ folder: new Folder({
+ id: 0,
+ source: `${defaultRemoteURL}${defaultRootPath}`,
+ root: defaultRootPath,
+ owner: null,
+ permissions: Permission.CREATE,
+ }),
+ }
+ },
+ })
+
+ const Navigation = getNavigation()
+ Navigation.register(view)
+}
diff --git a/apps/files_sharing/src/files_views/publicFileShare.ts b/apps/files_sharing/src/files_views/publicFileShare.ts
new file mode 100644
index 00000000000..caa7f862e57
--- /dev/null
+++ b/apps/files_sharing/src/files_views/publicFileShare.ts
@@ -0,0 +1,66 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { FileStat, ResponseDataDetailed } from 'webdav'
+import { Folder, Permission, View, davGetDefaultPropfind, davRemoteURL, davResultToNode, davRootPath, getNavigation } from '@nextcloud/files'
+import { translate as t } from '@nextcloud/l10n'
+import { CancelablePromise } from 'cancelable-promise'
+import LinkSvg from '@mdi/svg/svg/link.svg?raw'
+
+import { client } from '../../../files/src/services/WebdavClient'
+import logger from '../services/logger'
+
+export default () => {
+ const view = new View({
+ id: 'public-file-share',
+ name: t('files_sharing', 'Public file share'),
+ caption: t('files_sharing', 'Publicly shared file.'),
+
+ emptyTitle: t('files_sharing', 'No file'),
+ emptyCaption: t('files_sharing', 'The file shared with you will show up here'),
+
+ icon: LinkSvg,
+ order: 1,
+
+ getContents: () => {
+ return new CancelablePromise(async (resolve, reject, onCancel) => {
+ const abort = new AbortController()
+ onCancel(() => abort.abort())
+ try {
+ const node = await client.stat(
+ davRootPath,
+ {
+ data: davGetDefaultPropfind(),
+ details: true,
+ signal: abort.signal,
+ },
+ ) as ResponseDataDetailed<FileStat>
+
+ resolve({
+ // We only have one file as the content
+ contents: [davResultToNode(node.data)],
+ // Fake a readonly folder as root
+ folder: new Folder({
+ id: 0,
+ source: `${davRemoteURL}${davRootPath}`,
+ root: davRootPath,
+ owner: null,
+ permissions: Permission.READ,
+ attributes: {
+ // Ensure the share note is set on the root
+ note: node.data.props?.note,
+ },
+ }),
+ })
+ } catch (e) {
+ logger.error(e as Error)
+ reject(e as Error)
+ }
+ })
+ },
+ })
+
+ const Navigation = getNavigation()
+ Navigation.register(view)
+}
diff --git a/apps/files_sharing/src/files_views/publicShare.ts b/apps/files_sharing/src/files_views/publicShare.ts
new file mode 100644
index 00000000000..4f5526bc829
--- /dev/null
+++ b/apps/files_sharing/src/files_views/publicShare.ts
@@ -0,0 +1,28 @@
+/**
+ * SPDX-FileCopyrightText: 2024 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 LinkSvg from '@mdi/svg/svg/link.svg?raw'
+
+import { getContents } from '../../../files/src/services/Files'
+
+export default () => {
+ const view = new View({
+ id: 'public-share',
+ name: t('files_sharing', 'Public share'),
+ caption: t('files_sharing', 'Publicly shared files.'),
+
+ emptyTitle: t('files_sharing', 'No files'),
+ emptyCaption: t('files_sharing', 'Files and folders shared with you will show up here'),
+
+ icon: LinkSvg,
+ order: 1,
+
+ getContents,
+ })
+
+ const Navigation = getNavigation()
+ Navigation.register(view)
+}
diff --git a/apps/files_sharing/src/files_views/shares.spec.ts b/apps/files_sharing/src/files_views/shares.spec.ts
new file mode 100644
index 00000000000..7e5b59e0ad9
--- /dev/null
+++ b/apps/files_sharing/src/files_views/shares.spec.ts
@@ -0,0 +1,132 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+/* eslint-disable n/no-extraneous-import */
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import type { OCSResponse } from '@nextcloud/typings/ocs'
+
+import { beforeEach, describe, expect, test, vi } from 'vitest'
+import { Folder, Navigation, View, getNavigation } from '@nextcloud/files'
+import * as ncInitialState from '@nextcloud/initial-state'
+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(() => {
+ delete window._nc_navigation
+ Navigation = getNavigation()
+ expect(window._nc_navigation).toBeDefined()
+ })
+
+ test('Default values', () => {
+ vi.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(7)
+
+ // one main view and no children
+ expect(Navigation.views.length).toBe(7)
+ expect(shareOverviewView).toBeDefined()
+ expect(sharesChildViews.length).toBe(6)
+
+ expect(shareOverviewView?.id).toBe('shareoverview')
+ expect(shareOverviewView?.name).toBe('Shares')
+ expect(shareOverviewView?.caption).toBe('Overview of shared files.')
+ expect(shareOverviewView?.icon).toMatch(/<svg.+<\/svg>/i)
+ 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: 'filerequest', name: 'File requests' },
+ { 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).match(/<svg.+<\/svg>/)
+ expect(view?.order).toBe(index + 1)
+ expect(view?.columns).toStrictEqual([])
+ expect(view?.getContents).toBeDefined()
+ })
+ })
+
+ test('Shared with others view is not registered if user has no storage quota', () => {
+ vi.spyOn(Navigation, 'register')
+ const spy = vi.spyOn(ncInitialState, 'loadState').mockImplementationOnce(() => ({ quota: 0 }))
+
+ expect(Navigation.views.length).toBe(0)
+ registerSharingViews()
+ expect(Navigation.register).toHaveBeenCalledTimes(6)
+ expect(Navigation.views.length).toBe(6)
+
+ const shareOverviewView = Navigation.views.find(view => view.id === 'shareoverview') as View
+ const sharesChildViews = Navigation.views.filter(view => view.parent === 'shareoverview') as View[]
+ expect(shareOverviewView).toBeDefined()
+ expect(sharesChildViews.length).toBe(5)
+
+ expect(spy).toHaveBeenCalled()
+ expect(spy).toHaveBeenCalledWith('files', 'storageStats', { quota: -1 })
+
+ const sharedWithOthersView = Navigation.views.find(view => view.id === 'sharingout')
+ expect(sharedWithOthersView).toBeUndefined()
+ })
+})
+
+describe('Sharing views contents', () => {
+ let Navigation
+ beforeEach(() => {
+ delete window._nc_navigation
+ Navigation = getNavigation()
+ expect(window._nc_navigation).toBeDefined()
+ })
+
+ test('Sharing overview get contents', async () => {
+ vi.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(7)
+ 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/files_views/shares.ts b/apps/files_sharing/src/files_views/shares.ts
new file mode 100644
index 00000000000..fd5e908638c
--- /dev/null
+++ b/apps/files_sharing/src/files_views/shares.ts
@@ -0,0 +1,156 @@
+/**
+ * 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 { ShareType } from '@nextcloud/sharing'
+import AccountClockSvg from '@mdi/svg/svg/account-clock.svg?raw'
+import AccountGroupSvg from '@mdi/svg/svg/account-group-outline.svg?raw'
+import AccountPlusSvg from '@mdi/svg/svg/account-plus-outline.svg?raw'
+import AccountSvg from '@mdi/svg/svg/account.svg?raw'
+import DeleteSvg from '@mdi/svg/svg/delete.svg?raw'
+import FileUploadSvg from '@mdi/svg/svg/file-upload-outline.svg?raw'
+import LinkSvg from '@mdi/svg/svg/link.svg?raw'
+
+import { getContents, isFileRequest } from '../services/SharingService'
+import { loadState } from '@nextcloud/initial-state'
+
+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 const fileRequestViewId = 'filerequest'
+
+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),
+ }))
+
+ // Don't show this view if the user has no storage quota
+ const storageStats = loadState('files', 'storageStats', { quota: -1 })
+ if (storageStats.quota !== 0) {
+ 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, [ShareType.Link]),
+ }))
+
+ Navigation.register(new View({
+ id: fileRequestViewId,
+ name: t('files_sharing', 'File requests'),
+ caption: t('files_sharing', 'List of file requests.'),
+
+ emptyTitle: t('files_sharing', 'No file requests'),
+ emptyCaption: t('files_sharing', 'File requests you have created will show up here'),
+
+ icon: FileUploadSvg,
+ order: 4,
+ parent: sharesViewId,
+
+ columns: [],
+
+ getContents: () => getContents(false, true, false, false, [ShareType.Link, ShareType.Email])
+ .then(({ folder, contents }) => {
+ return {
+ folder,
+ contents: contents.filter((node) => isFileRequest(node.attributes?.['share-attributes'] || [])),
+ }
+ }),
+ }))
+
+ 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: 5,
+ 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: 6,
+ parent: sharesViewId,
+
+ columns: [],
+
+ getContents: () => getContents(false, false, true, false),
+ }))
+}
diff --git a/apps/files_sharing/src/index.js b/apps/files_sharing/src/index.js
deleted file mode 100644
index 9f80c79270e..00000000000
--- a/apps/files_sharing/src/index.js
+++ /dev/null
@@ -1,38 +0,0 @@
-/**
- * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- *
- * @license AGPL-3.0-or-later
- *
- * 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/>.
- *
- */
-
-// register default shares types
-Object.assign(OC, {
- Share: {
- SHARE_TYPE_USER: 0,
- SHARE_TYPE_GROUP: 1,
- SHARE_TYPE_LINK: 3,
- SHARE_TYPE_EMAIL: 4,
- SHARE_TYPE_REMOTE: 6,
- SHARE_TYPE_CIRCLE: 7,
- SHARE_TYPE_GUEST: 8,
- SHARE_TYPE_REMOTE_GROUP: 9,
- SHARE_TYPE_ROOM: 10,
- SHARE_TYPE_DECK: 12,
- },
-})
diff --git a/apps/files_sharing/src/init-public.ts b/apps/files_sharing/src/init-public.ts
new file mode 100644
index 00000000000..72a3098a0e6
--- /dev/null
+++ b/apps/files_sharing/src/init-public.ts
@@ -0,0 +1,63 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { ShareAttribute } from './sharing.d.ts'
+import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
+import { Folder, getNavigation } from '@nextcloud/files'
+import { loadState } from '@nextcloud/initial-state'
+import registerFileDropView from './files_views/publicFileDrop.ts'
+import registerPublicShareView from './files_views/publicShare.ts'
+import registerPublicFileShareView from './files_views/publicFileShare.ts'
+import RouterService from '../../files/src/services/RouterService.ts'
+import router from './router/index.ts'
+import logger from './services/logger.ts'
+
+registerFileDropView()
+registerPublicShareView()
+registerPublicFileShareView()
+
+// Get the current view from state and set it active
+const view = loadState<string>('files_sharing', 'view')
+const navigation = getNavigation()
+navigation.setActive(navigation.views.find(({ id }) => id === view) ?? null)
+
+// Force our own router
+window.OCP.Files = window.OCP.Files ?? {}
+window.OCP.Files.Router = new RouterService(router)
+
+// If this is a single file share, so set the fileid as active in the URL
+const fileId = loadState<number|null>('files_sharing', 'fileId', null)
+const token = loadState<string>('files_sharing', 'sharingToken')
+if (fileId !== null) {
+ window.OCP.Files.Router.goToRoute(
+ 'filelist',
+ { ...window.OCP.Files.Router.params, token, fileid: String(fileId) },
+ { ...window.OCP.Files.Router.query, openfile: 'true' },
+ )
+}
+
+// When the file list is loaded we need to apply the "userconfig" setup on the share
+subscribe('files:list:updated', loadShareConfig)
+
+/**
+ * Event handler to load the view config for the current share.
+ * This is done on the `files:list:updated` event to ensure the list and especially the config store was correctly initialized.
+ *
+ * @param context The event context
+ * @param context.folder The current folder
+ */
+function loadShareConfig({ folder }: { folder: Folder }) {
+ // Only setup config once
+ unsubscribe('files:list:updated', loadShareConfig)
+
+ // Share attributes (the same) are set on all folders of a share
+ if (folder.attributes['share-attributes']) {
+ const shareAttributes = JSON.parse(folder.attributes['share-attributes'] || '[]') as Array<ShareAttribute>
+ const gridViewAttribute = shareAttributes.find(({ scope, key }: ShareAttribute) => scope === 'config' && key === 'grid_view')
+ if (gridViewAttribute !== undefined) {
+ logger.debug('Loading share attributes', { gridViewAttribute })
+ emit('files:config:updated', { key: 'grid_view', value: gridViewAttribute.value === true })
+ }
+ }
+}
diff --git a/apps/files_sharing/src/init.ts b/apps/files_sharing/src/init.ts
new file mode 100644
index 00000000000..f275f3beaf7
--- /dev/null
+++ b/apps/files_sharing/src/init.ts
@@ -0,0 +1,33 @@
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { addNewFileMenuEntry } from '@nextcloud/files'
+import { registerDavProperty } from '@nextcloud/files/dav'
+import { registerAccountFilter } from './files_filters/AccountFilter'
+import { entry as newFileRequest } from './files_newMenu/newFileRequest'
+
+import registerNoteToRecipient from './files_headers/noteToRecipient'
+import registerSharingViews from './files_views/shares'
+
+import './files_actions/acceptShareAction'
+import './files_actions/openInFilesAction'
+import './files_actions/rejectShareAction'
+import './files_actions/restoreShareAction'
+import './files_actions/sharingStatusAction'
+
+registerSharingViews()
+
+addNewFileMenuEntry(newFileRequest)
+
+registerDavProperty('nc:note', { nc: 'http://nextcloud.org/ns' })
+registerDavProperty('nc:sharees', { nc: 'http://nextcloud.org/ns' })
+registerDavProperty('nc:hide-download', { nc: 'http://nextcloud.org/ns' })
+registerDavProperty('nc:share-attributes', { nc: 'http://nextcloud.org/ns' })
+registerDavProperty('oc:share-types', { oc: 'http://owncloud.org/ns' })
+registerDavProperty('ocs:share-permissions', { ocs: 'http://open-collaboration-services.org/ns' })
+
+registerAccountFilter()
+
+// Add "note to recipient" message
+registerNoteToRecipient()
diff --git a/apps/files_sharing/src/lib/SharePermissionsToolBox.js b/apps/files_sharing/src/lib/SharePermissionsToolBox.js
index f5806df70bf..797645ae04d 100644
--- a/apps/files_sharing/src/lib/SharePermissionsToolBox.js
+++ b/apps/files_sharing/src/lib/SharePermissionsToolBox.js
@@ -1,23 +1,6 @@
/**
- * @copyright 2022 Louis Chmn <louis@chmn.me>
- *
- * @author Louis Chmn <louis@chmn.me>
- *
- * @license AGPL-3.0-or-later
- *
- * 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: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
export const ATOMIC_PERMISSIONS = {
@@ -34,6 +17,7 @@ export const BUNDLED_PERMISSIONS = {
UPLOAD_AND_UPDATE: ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.UPDATE | ATOMIC_PERMISSIONS.CREATE | ATOMIC_PERMISSIONS.DELETE,
FILE_DROP: ATOMIC_PERMISSIONS.CREATE,
ALL: ATOMIC_PERMISSIONS.UPDATE | ATOMIC_PERMISSIONS.CREATE | ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.DELETE | ATOMIC_PERMISSIONS.SHARE,
+ ALL_FILE: ATOMIC_PERMISSIONS.UPDATE | ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.SHARE,
}
/**
diff --git a/apps/files_sharing/src/lib/SharePermissionsToolBox.spec.js b/apps/files_sharing/src/lib/SharePermissionsToolBox.spec.js
index 7ae29c7134a..a58552063d8 100644
--- a/apps/files_sharing/src/lib/SharePermissionsToolBox.spec.js
+++ b/apps/files_sharing/src/lib/SharePermissionsToolBox.spec.js
@@ -1,24 +1,8 @@
/**
- * @copyright 2022 Louis Chmn <louis@chmn.me>
- *
- * @author Louis Chmn <louis@chmn.me>
- *
- * @license AGPL-3.0-or-later
- *
- * 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: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
+import { describe, expect, test } from 'vitest'
import {
ATOMIC_PERMISSIONS,
@@ -29,7 +13,7 @@ import {
permissionsSetIsValid,
togglePermissions,
canTogglePermissions,
-} from '../lib/SharePermissionsToolBox'
+} from '../lib/SharePermissionsToolBox.js'
describe('SharePermissionsToolBox', () => {
test('Adding permissions', () => {
diff --git a/apps/files_sharing/src/main.ts b/apps/files_sharing/src/main.ts
new file mode 100644
index 00000000000..3170fbc2a7b
--- /dev/null
+++ b/apps/files_sharing/src/main.ts
@@ -0,0 +1,21 @@
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+// register default shares types
+Object.assign(window.OC, {
+ Share: {
+ SHARE_TYPE_USER: 0,
+ SHARE_TYPE_GROUP: 1,
+ SHARE_TYPE_LINK: 3,
+ SHARE_TYPE_EMAIL: 4,
+ SHARE_TYPE_REMOTE: 6,
+ SHARE_TYPE_CIRCLE: 7,
+ SHARE_TYPE_GUEST: 8,
+ SHARE_TYPE_REMOTE_GROUP: 9,
+ SHARE_TYPE_ROOM: 10,
+ SHARE_TYPE_DECK: 12,
+ SHARE_TYPE_SCIENCEMESH: 15,
+ },
+})
diff --git a/apps/files_sharing/src/mixins/ShareDetails.js b/apps/files_sharing/src/mixins/ShareDetails.js
new file mode 100644
index 00000000000..6ccdf8d63d0
--- /dev/null
+++ b/apps/files_sharing/src/mixins/ShareDetails.js
@@ -0,0 +1,82 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import Share from '../models/Share.ts'
+import Config from '../services/ConfigService.ts'
+import { ATOMIC_PERMISSIONS } from '../lib/SharePermissionsToolBox.js'
+import logger from '../services/logger.ts'
+
+export default {
+ methods: {
+ async openSharingDetails(shareRequestObject) {
+ let share = {}
+ // handle externalResults from OCA.Sharing.ShareSearch
+ // TODO : Better name/interface for handler required
+ // For example `externalAppCreateShareHook` with proper documentation
+ if (shareRequestObject.handler) {
+ const handlerInput = {}
+ if (this.suggestions) {
+ handlerInput.suggestions = this.suggestions
+ handlerInput.fileInfo = this.fileInfo
+ handlerInput.query = this.query
+ }
+ const externalShareRequestObject = await shareRequestObject.handler(handlerInput)
+ share = this.mapShareRequestToShareObject(externalShareRequestObject)
+ } else {
+ share = this.mapShareRequestToShareObject(shareRequestObject)
+ }
+
+ if (this.fileInfo.type !== 'dir') {
+ const originalPermissions = share.permissions
+ const strippedPermissions = originalPermissions
+ & ~ATOMIC_PERMISSIONS.CREATE
+ & ~ATOMIC_PERMISSIONS.DELETE
+
+ if (originalPermissions !== strippedPermissions) {
+ logger.debug('Removed create/delete permissions from file share (only valid for folders)')
+ share.permissions = strippedPermissions
+ }
+ }
+
+ const shareDetails = {
+ fileInfo: this.fileInfo,
+ share,
+ }
+
+ this.$emit('open-sharing-details', shareDetails)
+ },
+ openShareDetailsForCustomSettings(share) {
+ share.setCustomPermissions = true
+ this.openSharingDetails(share)
+ },
+ mapShareRequestToShareObject(shareRequestObject) {
+
+ if (shareRequestObject.id) {
+ return shareRequestObject
+ }
+
+ const share = {
+ attributes: [
+ {
+ value: true,
+ key: 'download',
+ scope: 'permissions',
+ },
+ ],
+ hideDownload: false,
+ share_type: shareRequestObject.shareType,
+ share_with: shareRequestObject.shareWith,
+ is_no_user: shareRequestObject.isNoUser,
+ user: shareRequestObject.shareWith,
+ share_with_displayname: shareRequestObject.displayName,
+ subtitle: shareRequestObject.subtitle,
+ permissions: shareRequestObject.permissions ?? new Config().defaultPermissions,
+ expiration: '',
+ }
+
+ return new Share(share)
+ },
+ },
+}
diff --git a/apps/files_sharing/src/mixins/ShareRequests.js b/apps/files_sharing/src/mixins/ShareRequests.js
index 9eaad8c4161..2c33fa3b0c7 100644
--- a/apps/files_sharing/src/mixins/ShareRequests.js
+++ b/apps/files_sharing/src/mixins/ShareRequests.js
@@ -1,34 +1,17 @@
/**
- * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Joas Schilling <coding@schilljs.com>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- *
- * @license AGPL-3.0-or-later
- *
- * 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
*/
// TODO: remove when ie not supported
import 'url-search-params-polyfill'
+import { emit } from '@nextcloud/event-bus'
+import { showError } from '@nextcloud/dialogs'
import { generateOcsUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios'
-import Share from '../models/Share'
+
+import Share from '../models/Share.ts'
const shareUrl = generateOcsUrl('apps/files_sharing/api/v1/shares')
@@ -41,29 +24,32 @@ export default {
* @param {string} data.path path to the file/folder which should be shared
* @param {number} data.shareType 0 = user; 1 = group; 3 = public link; 6 = federated cloud share
* @param {string} data.shareWith user/group id with which the file should be shared (optional for shareType > 1)
- * @param {boolean} [data.publicUpload=false] allow public upload to a public shared folder
+ * @param {boolean} [data.publicUpload] allow public upload to a public shared folder
* @param {string} [data.password] password to protect public link Share with
- * @param {number} [data.permissions=31] 1 = read; 2 = update; 4 = create; 8 = delete; 16 = share; 31 = all (default: 31, for public shares: 1)
- * @param {boolean} [data.sendPasswordByTalk=false] send the password via a talk conversation
- * @param {string} [data.expireDate=''] expire the shareautomatically after
- * @param {string} [data.label=''] custom label
- * @param {string} [data.attributes=null] Share attributes encoded as json
+ * @param {number} [data.permissions] 1 = read; 2 = update; 4 = create; 8 = delete; 16 = share; 31 = all (default: 31, for public shares: 1)
+ * @param {boolean} [data.sendPasswordByTalk] send the password via a talk conversation
+ * @param {string} [data.expireDate] expire the share automatically after
+ * @param {string} [data.label] custom label
+ * @param {string} [data.attributes] Share attributes encoded as json
+ * @param {string} data.note custom note to recipient
* @return {Share} the new share
* @throws {Error}
*/
- async createShare({ path, permissions, shareType, shareWith, publicUpload, password, sendPasswordByTalk, expireDate, label, attributes }) {
+ async createShare({ path, permissions, shareType, shareWith, publicUpload, password, sendPasswordByTalk, expireDate, label, note, attributes }) {
try {
- const request = await axios.post(shareUrl, { path, permissions, shareType, shareWith, publicUpload, password, sendPasswordByTalk, expireDate, label, attributes })
+ const request = await axios.post(shareUrl, { path, permissions, shareType, shareWith, publicUpload, password, sendPasswordByTalk, expireDate, label, note, attributes })
if (!request?.data?.ocs) {
throw request
}
- return new Share(request.data.ocs.data)
+ const share = new Share(request.data.ocs.data)
+ emit('files_sharing:share:created', { share })
+ return share
} catch (error) {
console.error('Error while creating share', error)
const errorMessage = error?.response?.data?.ocs?.meta?.message
- OC.Notification.showTemporary(
+ showError(
errorMessage ? t('files_sharing', 'Error creating the share: {errorMessage}', { errorMessage }) : t('files_sharing', 'Error creating the share'),
- { type: 'error' }
+ { type: 'error' },
)
throw error
}
@@ -81,13 +67,14 @@ export default {
if (!request?.data?.ocs) {
throw request
}
+ emit('files_sharing:share:deleted', { id })
return true
} catch (error) {
console.error('Error while deleting share', error)
const errorMessage = error?.response?.data?.ocs?.meta?.message
OC.Notification.showTemporary(
errorMessage ? t('files_sharing', 'Error deleting the share: {errorMessage}', { errorMessage }) : t('files_sharing', 'Error deleting the share'),
- { type: 'error' }
+ { type: 'error' },
)
throw error
}
@@ -102,6 +89,7 @@ export default {
async updateShare(id, properties) {
try {
const request = await axios.put(shareUrl + `/${id}`, properties)
+ emit('files_sharing:share:updated', { id })
if (!request?.data?.ocs) {
throw request
} else {
@@ -113,7 +101,7 @@ export default {
const errorMessage = error?.response?.data?.ocs?.meta?.message
OC.Notification.showTemporary(
errorMessage ? t('files_sharing', 'Error updating the share: {errorMessage}', { errorMessage }) : t('files_sharing', 'Error updating the share'),
- { type: 'error' }
+ { type: 'error' },
)
}
const message = error.response.data.ocs.meta.message
diff --git a/apps/files_sharing/src/mixins/ShareTypes.js b/apps/files_sharing/src/mixins/ShareTypes.js
deleted file mode 100644
index 8b85f63f456..00000000000
--- a/apps/files_sharing/src/mixins/ShareTypes.js
+++ /dev/null
@@ -1,32 +0,0 @@
-/**
- * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- *
- * @license AGPL-3.0-or-later
- *
- * 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/>.
- *
- */
-
-import { Type as ShareTypes } from '@nextcloud/sharing'
-
-export default {
- data() {
- return {
- SHARE_TYPES: ShareTypes,
- }
- },
-}
diff --git a/apps/files_sharing/src/mixins/SharesMixin.js b/apps/files_sharing/src/mixins/SharesMixin.js
index 2a4637be121..a461da56d85 100644
--- a/apps/files_sharing/src/mixins/SharesMixin.js
+++ b/apps/files_sharing/src/mixins/SharesMixin.js
@@ -1,48 +1,34 @@
/**
- * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Daniel Calviño Sánchez <danxuliu@gmail.com>
- * @author Gary Kim <gary@garykim.dev>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- * @author Vincent Petry <vincent@nextcloud.com>
- *
- * @license AGPL-3.0-or-later
- *
- * 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
*/
-import { showError, showSuccess } from '@nextcloud/dialogs'
import { getCurrentUser } from '@nextcloud/auth'
-// eslint-disable-next-line import/no-unresolved, node/no-missing-import
+import { showError, showSuccess } from '@nextcloud/dialogs'
+import { ShareType } from '@nextcloud/sharing'
+import { emit } from '@nextcloud/event-bus'
+
import PQueue from 'p-queue'
import debounce from 'debounce'
-import Share from '../models/Share.js'
+import GeneratePassword from '../utils/GeneratePassword.ts'
+import Share from '../models/Share.ts'
import SharesRequests from './ShareRequests.js'
-import ShareTypes from './ShareTypes.js'
-import Config from '../services/ConfigService.js'
+import Config from '../services/ConfigService.ts'
+import logger from '../services/logger.ts'
+
+import {
+ BUNDLED_PERMISSIONS,
+} from '../lib/SharePermissionsToolBox.js'
+import { fetchNode } from '../../../files/src/services/WebdavClient.ts'
export default {
- mixins: [SharesRequests, ShareTypes],
+ mixins: [SharesRequests],
props: {
fileInfo: {
type: Object,
- default: () => {},
+ default: () => { },
required: true,
},
share: {
@@ -58,6 +44,8 @@ export default {
data() {
return {
config: new Config(),
+ node: null,
+ ShareType,
// errors helpers
errors: {},
@@ -80,7 +68,9 @@ export default {
},
computed: {
-
+ path() {
+ return (this.fileInfo.path + '/' + this.fileInfo.name).replace('//', '/')
+ },
/**
* Does the current share have a note
*
@@ -104,10 +94,10 @@ export default {
// Datepicker language
lang() {
const weekdaysShort = window.dayNamesShort
- ? window.dayNamesShort // provided by nextcloud
+ ? window.dayNamesShort // provided by Nextcloud
: ['Sun.', 'Mon.', 'Tue.', 'Wed.', 'Thu.', 'Fri.', 'Sat.']
const monthsShort = window.monthNamesShort
- ? window.monthNamesShort // provided by nextcloud
+ ? window.monthNamesShort // provided by Nextcloud
: ['Jan.', 'Feb.', 'Mar.', 'Apr.', 'May.', 'Jun.', 'Jul.', 'Aug.', 'Sep.', 'Oct.', 'Nov.', 'Dec.']
const firstDayOfWeek = window.firstDay ? window.firstDay : 0
@@ -121,15 +111,91 @@ export default {
monthFormat: 'MMM',
}
},
-
+ isNewShare() {
+ return !this.share.id
+ },
+ isFolder() {
+ return this.fileInfo.type === 'dir'
+ },
+ isPublicShare() {
+ const shareType = this.share.shareType ?? this.share.type
+ return [ShareType.Link, ShareType.Email].includes(shareType)
+ },
+ isRemoteShare() {
+ return this.share.type === ShareType.RemoteGroup || this.share.type === ShareType.Remote
+ },
isShareOwner() {
return this.share && this.share.owner === getCurrentUser().uid
},
-
+ isExpiryDateEnforced() {
+ if (this.isPublicShare) {
+ return this.config.isDefaultExpireDateEnforced
+ }
+ if (this.isRemoteShare) {
+ return this.config.isDefaultRemoteExpireDateEnforced
+ }
+ return this.config.isDefaultInternalExpireDateEnforced
+ },
+ hasCustomPermissions() {
+ const bundledPermissions = [
+ BUNDLED_PERMISSIONS.ALL,
+ BUNDLED_PERMISSIONS.READ_ONLY,
+ BUNDLED_PERMISSIONS.FILE_DROP,
+ ]
+ return !bundledPermissions.includes(this.share.permissions)
+ },
+ maxExpirationDateEnforced() {
+ if (this.isExpiryDateEnforced) {
+ if (this.isPublicShare) {
+ return this.config.defaultExpirationDate
+ }
+ if (this.isRemoteShare) {
+ return this.config.defaultRemoteExpirationDateString
+ }
+ // If it get's here then it must be an internal share
+ return this.config.defaultInternalExpirationDate
+ }
+ return null
+ },
+ /**
+ * Is the current share password protected ?
+ *
+ * @return {boolean}
+ */
+ isPasswordProtected: {
+ get() {
+ return this.config.enforcePasswordForPublicLink
+ || this.share.password !== ''
+ || this.share.newPassword !== undefined
+ },
+ async set(enabled) {
+ if (enabled) {
+ this.$set(this.share, 'newPassword', await GeneratePassword(true))
+ } else {
+ this.share.password = ''
+ this.$delete(this.share, 'newPassword')
+ }
+ },
+ },
},
methods: {
/**
+ * Fetch WebDAV node
+ *
+ * @return {Node}
+ */
+ async getNode() {
+ const node = { path: this.path }
+ try {
+ this.node = await fetchNode(node.path)
+ logger.info('Fetched node:', { node: this.node })
+ } catch (error) {
+ logger.error('Error:', error)
+ }
+ },
+
+ /**
* Check if a share is valid before
* firing the request
*
@@ -152,19 +218,7 @@ export default {
},
/**
- * @param {string} date a date with YYYY-MM-DD format
- * @return {Date} date
- */
- parseDateString(date) {
- if (!date) {
- return
- }
- const regex = /([0-9]{4}-[0-9]{2}-[0-9]{2})/i
- return new Date(date.match(regex)?.pop())
- },
-
- /**
- * @param {Date} date
+ * @param {Date} date the date to format
* @return {string} date a date with YYYY-MM-DD format
*/
formatDateToString(date) {
@@ -180,19 +234,13 @@ export default {
* @param {Date} date
*/
onExpirationChange(date) {
- this.share.expireDate = this.formatDateToString(date)
- this.queueUpdate('expireDate')
- },
-
- /**
- * Uncheck expire date
- * We need this method because @update:checked
- * is ran simultaneously as @uncheck, so
- * so we cannot ensure data is up-to-date
- */
- onExpirationDisable() {
- this.share.expireDate = ''
- this.queueUpdate('expireDate')
+ if (!date) {
+ this.share.expireDate = null
+ this.$set(this.share, 'expireDate', null)
+ return
+ }
+ const parsedDate = (date instanceof Date) ? date : new Date(date)
+ this.share.expireDate = this.formatDateToString(parsedDate)
},
/**
@@ -224,12 +272,14 @@ export default {
this.loading = true
this.open = false
await this.deleteShare(this.share.id)
- console.debug('Share deleted', this.share.id)
+ logger.debug('Share deleted', { shareId: this.share.id })
const message = this.share.itemType === 'file'
? t('files_sharing', 'File "{path}" has been unshared', { path: this.share.path })
: t('files_sharing', 'Folder "{path}" has been unshared', { path: this.share.path })
showSuccess(message)
this.$emit('remove:share', this.share)
+ await this.getNode()
+ emit('files:node:updated', this.node)
} catch (error) {
// re-open menu if error
this.open = true
@@ -253,22 +303,30 @@ export default {
const properties = {}
// force value to string because that is what our
// share api controller accepts
- propertyNames.forEach(name => {
- if ((typeof this.share[name]) === 'object') {
+ for (const name of propertyNames) {
+ if (name === 'password') {
+ properties[name] = this.share.newPassword ?? this.share.password
+ continue
+ }
+
+ if (this.share[name] === null || this.share[name] === undefined) {
+ properties[name] = ''
+ } else if ((typeof this.share[name]) === 'object') {
properties[name] = JSON.stringify(this.share[name])
} else {
properties[name] = this.share[name].toString()
}
- })
+ }
- this.updateQueue.add(async () => {
+ return this.updateQueue.add(async () => {
this.saving = true
this.errors = {}
try {
const updatedShare = await this.updateShare(this.share.id, properties)
- if (propertyNames.indexOf('password') >= 0) {
+ if (propertyNames.includes('password')) {
// reset password state after sync
+ this.share.password = this.share.newPassword ?? ''
this.$delete(this.share, 'newPassword')
// updates password expiration time after sync
@@ -276,18 +334,27 @@ export default {
}
// clear any previous errors
- this.$delete(this.errors, propertyNames[0])
- showSuccess(t('files_sharing', 'Share {propertyName} saved', { propertyName: propertyNames[0] }))
- } catch ({ message }) {
+ for (const property of propertyNames) {
+ this.$delete(this.errors, property)
+ }
+ showSuccess(this.updateSuccessMessage(propertyNames))
+ } catch (error) {
+ logger.error('Could not update share', { error, share: this.share, propertyNames })
+
+ const { message } = error
if (message && message !== '') {
- this.onSyncError(propertyNames[0], message)
- showError(t('files_sharing', message))
+ for (const property of propertyNames) {
+ this.onSyncError(property, message)
+ }
+ showError(message)
+ } else {
+ // We do not have information what happened, but we should still inform the user
+ showError(t('files_sharing', 'Could not update share'))
}
} finally {
this.saving = false
}
})
- return
}
// This share does not exists on the server yet
@@ -295,12 +362,45 @@ export default {
},
/**
+ * @param {string[]} names Properties changed
+ */
+ updateSuccessMessage(names) {
+ if (names.length !== 1) {
+ return t('files_sharing', 'Share saved')
+ }
+
+ switch (names[0]) {
+ case 'expireDate':
+ return t('files_sharing', 'Share expiry date saved')
+ case 'hideDownload':
+ return t('files_sharing', 'Share hide-download state saved')
+ case 'label':
+ return t('files_sharing', 'Share label saved')
+ case 'note':
+ return t('files_sharing', 'Share note for recipient saved')
+ case 'password':
+ return t('files_sharing', 'Share password saved')
+ case 'permissions':
+ return t('files_sharing', 'Share permissions saved')
+ default:
+ return t('files_sharing', 'Share saved')
+ }
+ },
+
+ /**
* Manage sync errors
*
* @param {string} property the errored property, e.g. 'password'
* @param {string} message the error message
*/
onSyncError(property, message) {
+ if (property === 'password' && this.share.newPassword) {
+ if (this.share.newPassword === this.share.password) {
+ this.share.password = ''
+ }
+ this.$delete(this.share, 'newPassword')
+ }
+
// re-open menu if closed
this.open = true
switch (property) {
@@ -335,7 +435,6 @@ export default {
}
}
},
-
/**
* Debounce queueUpdate to avoid requests spamming
* more importantly for text data
diff --git a/apps/files_sharing/src/models/Share.js b/apps/files_sharing/src/models/Share.ts
index 9b1535184a0..b0638b29448 100644
--- a/apps/files_sharing/src/models/Share.js
+++ b/apps/files_sharing/src/models/Share.ts
@@ -1,30 +1,12 @@
/**
- * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author Daniel Calviño Sánchez <danxuliu@gmail.com>
- * @author Gary Kim <gary@garykim.dev>
- * @author Georg Ehrke <oc.list@georgehrke.com>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * 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
*/
+import type { ShareType } from '@nextcloud/sharing'
+import type { ShareAttribute } from '../sharing'
+import { isFileRequest } from '../services/SharingService'
+
export default class Share {
_share
@@ -39,15 +21,19 @@ export default class Share {
ocsData = ocsData.ocs.data[0]
}
+ // string to int
+ if (typeof ocsData.id === 'string') {
+ ocsData.id = Number.parseInt(ocsData.id)
+ }
// convert int into boolean
ocsData.hide_download = !!ocsData.hide_download
ocsData.mail_send = !!ocsData.mail_send
- if (ocsData.attributes) {
+ if (ocsData.attributes && typeof ocsData.attributes === 'string') {
try {
ocsData.attributes = JSON.parse(ocsData.attributes)
} catch (e) {
- console.warn('Could not parse share attributes returned by server: "' + ocsData.attributes + '"')
+ console.warn('Could not parse share attributes returned by server', ocsData.attributes)
}
}
ocsData.attributes = ocsData.attributes ?? []
@@ -64,8 +50,6 @@ export default class Share {
* state and make the whole class reactive
*
* @return {object} the share raw state
- * @readonly
- * @memberof Sidebar
*/
get state() {
return this._share
@@ -73,104 +57,69 @@ export default class Share {
/**
* get the share id
- *
- * @return {number}
- * @readonly
- * @memberof Share
*/
- get id() {
+ get id(): number {
return this._share.id
}
/**
* Get the share type
- *
- * @return {number}
- * @readonly
- * @memberof Share
*/
- get type() {
+ get type(): ShareType {
return this._share.share_type
}
/**
* Get the share permissions
- * See OC.PERMISSION_* variables
- *
- * @return {number}
- * @readonly
- * @memberof Share
+ * See window.OC.PERMISSION_* variables
*/
- get permissions() {
+ get permissions(): number {
return this._share.permissions
}
/**
* Get the share attributes
- *
- * @return {Array}
- * @readonly
- * @memberof Share
*/
- get attributes() {
- return this._share.attributes
+ get attributes(): Array<ShareAttribute> {
+ return this._share.attributes || []
}
/**
* Set the share permissions
- * See OC.PERMISSION_* variables
- *
- * @param {number} permissions valid permission, See OC.PERMISSION_* variables
- * @memberof Share
+ * See window.OC.PERMISSION_* variables
*/
- set permissions(permissions) {
+ set permissions(permissions: number) {
this._share.permissions = permissions
}
// SHARE OWNER --------------------------------------------------
/**
* Get the share owner uid
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get owner() {
+ get owner(): string {
return this._share.uid_owner
}
/**
* Get the share owner's display name
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get ownerDisplayName() {
+ get ownerDisplayName(): string {
return this._share.displayname_owner
}
// SHARED WITH --------------------------------------------------
/**
* Get the share with entity uid
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get shareWith() {
+ get shareWith(): string {
return this._share.share_with
}
/**
* Get the share with entity display name
* fallback to its uid if none
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get shareWithDisplayName() {
+ get shareWithDisplayName(): string {
return this._share.share_with_displayname
|| this._share.share_with
}
@@ -178,59 +127,39 @@ export default class Share {
/**
* Unique display name in case of multiple
* duplicates results with the same name.
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get shareWithDisplayNameUnique() {
+ get shareWithDisplayNameUnique(): string {
return this._share.share_with_displayname_unique
|| this._share.share_with
}
/**
* Get the share with entity link
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get shareWithLink() {
+ get shareWithLink(): string {
return this._share.share_with_link
}
/**
* Get the share with avatar if any
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get shareWithAvatar() {
+ get shareWithAvatar(): string {
return this._share.share_with_avatar
}
// SHARED FILE OR FOLDER OWNER ----------------------------------
/**
* Get the shared item owner uid
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get uidFileOwner() {
+ get uidFileOwner(): string {
return this._share.uid_file_owner
}
/**
* Get the shared item display name
* fallback to its uid if none
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get displaynameFileOwner() {
+ get displaynameFileOwner(): string {
return this._share.displayname_file_owner
|| this._share.uid_file_owner
}
@@ -238,230 +167,176 @@ export default class Share {
// TIME DATA ----------------------------------------------------
/**
* Get the share creation timestamp
- *
- * @return {number}
- * @readonly
- * @memberof Share
*/
- get createdTime() {
+ get createdTime(): number {
return this._share.stime
}
/**
* Get the expiration date
- *
* @return {string} date with YYYY-MM-DD format
- * @readonly
- * @memberof Share
*/
- get expireDate() {
+ get expireDate(): string {
return this._share.expiration
}
/**
* Set the expiration date
- *
* @param {string} date the share expiration date with YYYY-MM-DD format
- * @memberof Share
*/
- set expireDate(date) {
+ set expireDate(date: string) {
this._share.expiration = date
}
// EXTRA DATA ---------------------------------------------------
/**
* Get the public share token
- *
- * @return {string} the token
- * @readonly
- * @memberof Share
*/
- get token() {
+ get token(): string {
return this._share.token
}
/**
+ * Set the public share token
+ */
+ set token(token: string) {
+ this._share.token = token
+ }
+
+ /**
* Get the share note if any
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get note() {
+ get note(): string {
return this._share.note
}
/**
* Set the share note if any
- *
- * @param {string} note the note
- * @memberof Share
*/
- set note(note) {
+ set note(note: string) {
this._share.note = note
}
/**
* Get the share label if any
* Should only exist on link shares
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get label() {
- return this._share.label
+ get label(): string {
+ return this._share.label ?? ''
}
/**
* Set the share label if any
* Should only be set on link shares
- *
- * @param {string} label the label
- * @memberof Share
*/
- set label(label) {
+ set label(label: string) {
this._share.label = label
}
/**
* Have a mail been sent
- *
- * @return {boolean}
- * @readonly
- * @memberof Share
*/
- get mailSend() {
+ get mailSend(): boolean {
return this._share.mail_send === true
}
/**
* Hide the download button on public page
- *
- * @return {boolean}
- * @readonly
- * @memberof Share
*/
- get hideDownload() {
+ get hideDownload(): boolean {
return this._share.hide_download === true
+ || this.attributes.find?.(({ scope, key, value }) => scope === 'permissions' && key === 'download' && !value) !== undefined
}
/**
* Hide the download button on public page
- *
- * @param {boolean} state hide the button ?
- * @memberof Share
*/
- set hideDownload(state) {
+ set hideDownload(state: boolean) {
+ // disabling hide-download also enables the download permission
+ // needed for regression in Nextcloud 31.0.0 until (incl.) 31.0.3
+ if (!state) {
+ const attribute = this.attributes.find(({ key, scope }) => key === 'download' && scope === 'permissions')
+ if (attribute) {
+ attribute.value = true
+ }
+ }
+
this._share.hide_download = state === true
}
/**
* Password protection of the share
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get password() {
+ get password():string {
return this._share.password
}
/**
* Password protection of the share
- *
- * @param {string} password the share password
- * @memberof Share
*/
- set password(password) {
+ set password(password: string) {
this._share.password = password
}
/**
* Password expiration time
- *
- * @return {string}
- * @readonly
- * @memberof Share
+ * @return {string} date with YYYY-MM-DD format
*/
- get passwordExpirationTime() {
+ get passwordExpirationTime(): string {
return this._share.password_expiration_time
}
/**
* Password expiration time
- *
- * @param {string} password expiration time
- * @memberof Share
+ * @param {string} passwordExpirationTime date with YYYY-MM-DD format
*/
- set passwordExpirationTime(passwordExpirationTime) {
+ set passwordExpirationTime(passwordExpirationTime: string) {
this._share.password_expiration_time = passwordExpirationTime
}
/**
* Password protection by Talk of the share
- *
- * @return {boolean}
- * @readonly
- * @memberof Share
*/
- get sendPasswordByTalk() {
+ get sendPasswordByTalk(): boolean {
return this._share.send_password_by_talk
}
/**
* Password protection by Talk of the share
*
- * @param {boolean} sendPasswordByTalk whether to send the password by Talk
- * or not
- * @memberof Share
+ * @param {boolean} sendPasswordByTalk whether to send the password by Talk or not
*/
- set sendPasswordByTalk(sendPasswordByTalk) {
+ set sendPasswordByTalk(sendPasswordByTalk: boolean) {
this._share.send_password_by_talk = sendPasswordByTalk
}
// SHARED ITEM DATA ---------------------------------------------
/**
* Get the shared item absolute full path
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get path() {
+ get path(): string {
return this._share.path
}
/**
* Return the item type: file or folder
- *
- * @return {string} 'folder' or 'file'
- * @readonly
- * @memberof Share
+ * @return {string} 'folder' | 'file'
*/
- get itemType() {
+ get itemType(): string {
return this._share.item_type
}
/**
* Get the shared item mimetype
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get mimetype() {
+ get mimetype(): string {
return this._share.mimetype
}
/**
* Get the shared item id
- *
- * @return {number}
- * @readonly
- * @memberof Share
*/
- get fileSource() {
+ get fileSource(): number {
return this._share.file_source
}
@@ -469,23 +344,15 @@ export default class Share {
* Get the target path on the receiving end
* e.g the file /xxx/aaa will be shared in
* the receiving root as /aaa, the fileTarget is /aaa
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get fileTarget() {
+ get fileTarget(): string {
return this._share.file_target
}
/**
* Get the parent folder id if any
- *
- * @return {number}
- * @readonly
- * @memberof Share
*/
- get fileParent() {
+ get fileParent(): number {
return this._share.file_parent
}
@@ -493,93 +360,72 @@ export default class Share {
/**
* Does this share have READ permissions
- *
- * @return {boolean}
- * @readonly
- * @memberof Share
*/
- get hasReadPermission() {
- return !!((this.permissions & OC.PERMISSION_READ))
+ get hasReadPermission(): boolean {
+ return !!((this.permissions & window.OC.PERMISSION_READ))
}
/**
* Does this share have CREATE permissions
- *
- * @return {boolean}
- * @readonly
- * @memberof Share
*/
- get hasCreatePermission() {
- return !!((this.permissions & OC.PERMISSION_CREATE))
+ get hasCreatePermission(): boolean {
+ return !!((this.permissions & window.OC.PERMISSION_CREATE))
}
/**
* Does this share have DELETE permissions
- *
- * @return {boolean}
- * @readonly
- * @memberof Share
*/
- get hasDeletePermission() {
- return !!((this.permissions & OC.PERMISSION_DELETE))
+ get hasDeletePermission(): boolean {
+ return !!((this.permissions & window.OC.PERMISSION_DELETE))
}
/**
* Does this share have UPDATE permissions
- *
- * @return {boolean}
- * @readonly
- * @memberof Share
*/
- get hasUpdatePermission() {
- return !!((this.permissions & OC.PERMISSION_UPDATE))
+ get hasUpdatePermission(): boolean {
+ return !!((this.permissions & window.OC.PERMISSION_UPDATE))
}
/**
* Does this share have SHARE permissions
- *
- * @return {boolean}
- * @readonly
- * @memberof Share
*/
- get hasSharePermission() {
- return !!((this.permissions & OC.PERMISSION_SHARE))
+ get hasSharePermission(): boolean {
+ return !!((this.permissions & window.OC.PERMISSION_SHARE))
}
/**
* Does this share have download permissions
- *
- * @return {boolean}
- * @readonly
- * @memberof Share
*/
- get hasDownloadPermission() {
- for (const i in this._share.attributes) {
- const attr = this._share.attributes[i]
- if (attr.scope === 'permissions' && attr.key === 'download') {
- return attr.enabled
- }
+ get hasDownloadPermission(): boolean {
+ const hasDisabledDownload = (attribute) => {
+ return attribute.scope === 'permissions' && attribute.key === 'download' && attribute.value === false
}
+ return this.attributes.some(hasDisabledDownload)
+ }
- return true
+ /**
+ * Is this mail share a file request ?
+ */
+ get isFileRequest(): boolean {
+ return isFileRequest(JSON.stringify(this.attributes))
}
set hasDownloadPermission(enabled) {
this.setAttribute('permissions', 'download', !!enabled)
}
- setAttribute(scope, key, enabled) {
+ setAttribute(scope, key, value) {
const attrUpdate = {
scope,
key,
- enabled,
+ value,
}
// try and replace existing
for (const i in this._share.attributes) {
const attr = this._share.attributes[i]
if (attr.scope === attrUpdate.scope && attr.key === attrUpdate.key) {
- this._share.attributes[i] = attrUpdate
+ this._share.attributes.splice(i, 1, attrUpdate)
return
}
}
@@ -592,45 +438,29 @@ export default class Share {
// ! meaning the permissions for the recipient
/**
* Can the current user EDIT this share ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Share
*/
- get canEdit() {
+ get canEdit(): boolean {
return this._share.can_edit === true
}
/**
* Can the current user DELETE this share ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Share
*/
- get canDelete() {
+ get canDelete(): boolean {
return this._share.can_delete === true
}
/**
* Top level accessible shared folder fileid for the current user
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get viaFileid() {
+ get viaFileid(): string {
return this._share.via_fileid
}
/**
* Top level accessible shared folder path for the current user
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get viaPath() {
+ get viaPath(): string {
return this._share.via_path
}
@@ -640,15 +470,15 @@ export default class Share {
return this._share.parent
}
- get storageId() {
+ get storageId(): string {
return this._share.storage_id
}
- get storage() {
+ get storage(): number {
return this._share.storage
}
- get itemSource() {
+ get itemSource(): number {
return this._share.item_source
}
@@ -656,4 +486,11 @@ export default class Share {
return this._share.status
}
+ /**
+ * Is the share from a trusted server
+ */
+ get isTrustedServer(): boolean {
+ return !!this._share.is_trusted_server
+ }
+
}
diff --git a/apps/files_sharing/src/personal-settings.js b/apps/files_sharing/src/personal-settings.js
index afc35dc98dc..e3184f0041e 100644
--- a/apps/files_sharing/src/personal-settings.js
+++ b/apps/files_sharing/src/personal-settings.js
@@ -1,33 +1,15 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * 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
*/
+import { getCSPNonce } from '@nextcloud/auth'
import Vue from 'vue'
-import { getRequestToken } from '@nextcloud/auth'
-import PersonalSettings from './components/PersonalSettings'
+import PersonalSettings from './components/PersonalSettings.vue'
// eslint-disable-next-line camelcase
-__webpack_nonce__ = btoa(getRequestToken())
+__webpack_nonce__ = getCSPNonce()
Vue.prototype.t = t
diff --git a/apps/files_sharing/src/public-nickname-handler.ts b/apps/files_sharing/src/public-nickname-handler.ts
new file mode 100644
index 00000000000..02bdc641aaf
--- /dev/null
+++ b/apps/files_sharing/src/public-nickname-handler.ts
@@ -0,0 +1,86 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { getBuilder } from '@nextcloud/browser-storage'
+import { getGuestNickname, type NextcloudUser } from '@nextcloud/auth'
+import { getUploader } from '@nextcloud/upload'
+import { loadState } from '@nextcloud/initial-state'
+import { showGuestUserPrompt } from '@nextcloud/dialogs'
+import { t } from '@nextcloud/l10n'
+
+import logger from './services/logger'
+import { subscribe } from '@nextcloud/event-bus'
+
+const storage = getBuilder('files_sharing').build()
+
+// Setup file-request nickname header for the uploader
+const registerFileRequestHeader = (nickname: string) => {
+ const uploader = getUploader()
+ uploader.setCustomHeader('X-NC-Nickname', encodeURIComponent(nickname))
+ logger.debug('Nickname header registered for uploader', { headers: uploader.customHeaders })
+}
+
+// Callback when a nickname was chosen
+const onUserInfoChanged = (guest: NextcloudUser) => {
+ logger.debug('User info changed', { guest })
+ registerFileRequestHeader(guest.displayName ?? '')
+}
+
+// Monitor nickname changes
+subscribe('user:info:changed', onUserInfoChanged)
+
+window.addEventListener('DOMContentLoaded', () => {
+ const nickname = getGuestNickname() ?? ''
+ const dialogShown = storage.getItem('public-auth-prompt-shown') !== null
+
+ // Check if a nickname is mandatory
+ const isFileRequest = loadState('files_sharing', 'isFileRequest', false)
+
+ const owner = loadState('files_sharing', 'owner', '')
+ const ownerDisplayName = loadState('files_sharing', 'ownerDisplayName', '')
+ const label = loadState('files_sharing', 'label', '')
+ const filename = loadState('files_sharing', 'filename', '')
+
+ // If the owner provided a custom label, use it instead of the filename
+ const folder = label || filename
+
+ const options = {
+ nickname,
+ notice: t('files_sharing', 'To upload files to {folder}, you need to provide your name first.', { folder }),
+ subtitle: undefined as string | undefined,
+ title: t('files_sharing', 'Upload files to {folder}', { folder }),
+ }
+
+ // If the guest already has a nickname, we just make them double check
+ if (nickname) {
+ options.notice = t('files_sharing', 'Please confirm your name to upload files to {folder}', { folder })
+ }
+
+ // If the account owner set their name as public,
+ // we show it in the subtitle
+ if (owner) {
+ options.subtitle = t('files_sharing', '{ownerDisplayName} shared a folder with you.', { ownerDisplayName })
+ }
+
+ // If this is a file request, then we need a nickname
+ if (isFileRequest) {
+ // If we don't have a nickname or the public auth prompt hasn't been shown yet, show it
+ // We still show the prompt if the user has a nickname to double check
+ if (!nickname || !dialogShown) {
+ logger.debug('Showing public auth prompt.', { nickname })
+ showGuestUserPrompt(options)
+ }
+ return
+ }
+
+ if (!dialogShown && !nickname) {
+ logger.debug('Public auth prompt not shown yet but nickname is not mandatory.', { nickname })
+ return
+ }
+
+ // Else, we just register the nickname header if any.
+ logger.debug('Public auth prompt already shown.', { nickname })
+ registerFileRequestHeader(nickname)
+})
diff --git a/apps/files_sharing/src/router/index.ts b/apps/files_sharing/src/router/index.ts
new file mode 100644
index 00000000000..fa613dd364f
--- /dev/null
+++ b/apps/files_sharing/src/router/index.ts
@@ -0,0 +1,76 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { RawLocation, Route } from 'vue-router'
+
+import { loadState } from '@nextcloud/initial-state'
+import { generateUrl } from '@nextcloud/router'
+import queryString from 'query-string'
+import Router, { isNavigationFailure, NavigationFailureType } from 'vue-router'
+import Vue from 'vue'
+import logger from '../services/logger'
+
+const view = loadState<string>('files_sharing', 'view')
+const sharingToken = loadState<string>('files_sharing', 'sharingToken')
+
+Vue.use(Router)
+
+// Prevent router from throwing errors when we're already on the page we're trying to go to
+const originalPush = Router.prototype.push
+Router.prototype.push = (function(this: Router, ...args: Parameters<typeof originalPush>) {
+ if (args.length > 1) {
+ return originalPush.call(this, ...args)
+ }
+ return originalPush.call<Router, [RawLocation], Promise<Route>>(this, args[0]).catch(ignoreDuplicateNavigation)
+}) as typeof originalPush
+
+const originalReplace = Router.prototype.replace
+Router.prototype.replace = (function(this: Router, ...args: Parameters<typeof originalReplace>) {
+ if (args.length > 1) {
+ return originalReplace.call(this, ...args)
+ }
+ return originalReplace.call<Router, [RawLocation], Promise<Route>>(this, args[0]).catch(ignoreDuplicateNavigation)
+}) as typeof originalReplace
+
+/**
+ * Ignore duplicated-navigation error but forward real exceptions
+ * @param error The thrown error
+ */
+function ignoreDuplicateNavigation(error: unknown): void {
+ if (isNavigationFailure(error, NavigationFailureType.duplicated)) {
+ logger.debug('Ignoring duplicated navigation from vue-router', { error })
+ } else {
+ throw error
+ }
+}
+
+const router = new Router({
+ mode: 'history',
+
+ // if index.php is in the url AND we got this far, then it's working:
+ // let's keep using index.php in the url
+ base: generateUrl('/s'),
+ linkActiveClass: 'active',
+
+ routes: [
+ {
+ path: '/',
+ // Pretending we're using the default view
+ redirect: { name: 'filelist', params: { view, token: sharingToken } },
+ },
+ {
+ path: '/:token',
+ name: 'filelist',
+ props: true,
+ },
+ ],
+
+ // Custom stringifyQuery to prevent encoding of slashes in the url
+ stringifyQuery(query) {
+ const result = queryString.stringify(query).replace(/%2F/gmi, '/')
+ return result ? ('?' + result) : ''
+ },
+})
+
+export default router
diff --git a/apps/files_sharing/src/services/ConfigService.js b/apps/files_sharing/src/services/ConfigService.js
deleted file mode 100644
index e3cd6ad8d46..00000000000
--- a/apps/files_sharing/src/services/ConfigService.js
+++ /dev/null
@@ -1,328 +0,0 @@
-/**
- * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author Arthur Schiwon <blizzz@arthur-schiwon.de>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- *
- * @license AGPL-3.0-or-later
- *
- * 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/>.
- *
- */
-
-export default class Config {
-
- /**
- * Is public upload allowed on link shares ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get isPublicUploadEnabled() {
- return document.getElementsByClassName('files-filestable')[0]
- && document.getElementsByClassName('files-filestable')[0].dataset.allowPublicUpload === 'yes'
- }
-
- /**
- * Are link share allowed ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get isShareWithLinkAllowed() {
- return document.getElementById('allowShareWithLink')
- && document.getElementById('allowShareWithLink').value === 'yes'
- }
-
- /**
- * Get the federated sharing documentation link
- *
- * @return {string}
- * @readonly
- * @memberof Config
- */
- get federatedShareDocLink() {
- return OC.appConfig.core.federatedCloudShareDoc
- }
-
- /**
- * Get the default link share expiration date
- *
- * @return {Date|null}
- * @readonly
- * @memberof Config
- */
- get defaultExpirationDate() {
- if (this.isDefaultExpireDateEnabled) {
- return new Date(new Date().setDate(new Date().getDate() + this.defaultExpireDate))
- }
- return null
- }
-
- /**
- * Get the default internal expiration date
- *
- * @return {Date|null}
- * @readonly
- * @memberof Config
- */
- get defaultInternalExpirationDate() {
- if (this.isDefaultInternalExpireDateEnabled) {
- return new Date(new Date().setDate(new Date().getDate() + this.defaultInternalExpireDate))
- }
- return null
- }
-
- /**
- * Get the default remote expiration date
- *
- * @return {Date|null}
- * @readonly
- * @memberof Config
- */
- get defaultRemoteExpirationDateString() {
- if (this.isDefaultRemoteExpireDateEnabled) {
- return new Date(new Date().setDate(new Date().getDate() + this.defaultRemoteExpireDate))
- }
- return null
- }
-
- /**
- * Are link shares password-enforced ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get enforcePasswordForPublicLink() {
- return OC.appConfig.core.enforcePasswordForPublicLink === true
- }
-
- /**
- * Is password asked by default on link shares ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get enableLinkPasswordByDefault() {
- return OC.appConfig.core.enableLinkPasswordByDefault === true
- }
-
- /**
- * Is link shares expiration enforced ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get isDefaultExpireDateEnforced() {
- return OC.appConfig.core.defaultExpireDateEnforced === true
- }
-
- /**
- * Is there a default expiration date for new link shares ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get isDefaultExpireDateEnabled() {
- return OC.appConfig.core.defaultExpireDateEnabled === true
- }
-
- /**
- * Is internal shares expiration enforced ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get isDefaultInternalExpireDateEnforced() {
- return OC.appConfig.core.defaultInternalExpireDateEnforced === true
- }
-
- /**
- * Is remote shares expiration enforced ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get isDefaultRemoteExpireDateEnforced() {
- return OC.appConfig.core.defaultRemoteExpireDateEnforced === true
- }
-
- /**
- * Is there a default expiration date for new internal shares ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get isDefaultInternalExpireDateEnabled() {
- return OC.appConfig.core.defaultInternalExpireDateEnabled === true
- }
-
- /**
- * Is there a default expiration date for new remote shares ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get isDefaultRemoteExpireDateEnabled() {
- return OC.appConfig.core.defaultRemoteExpireDateEnabled === true
- }
-
- /**
- * Are users on this server allowed to send shares to other servers ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get isRemoteShareAllowed() {
- return OC.appConfig.core.remoteShareAllowed === true
- }
-
- /**
- * Is sharing my mail (link share) enabled ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get isMailShareAllowed() {
- const capabilities = OC.getCapabilities()
- // eslint-disable-next-line camelcase
- return capabilities?.files_sharing?.sharebymail !== undefined
- // eslint-disable-next-line camelcase
- && capabilities?.files_sharing?.public?.enabled === true
- }
-
- /**
- * Get the default days to link shares expiration
- *
- * @return {number}
- * @readonly
- * @memberof Config
- */
- get defaultExpireDate() {
- return OC.appConfig.core.defaultExpireDate
- }
-
- /**
- * Get the default days to internal shares expiration
- *
- * @return {number}
- * @readonly
- * @memberof Config
- */
- get defaultInternalExpireDate() {
- return OC.appConfig.core.defaultInternalExpireDate
- }
-
- /**
- * Get the default days to remote shares expiration
- *
- * @return {number}
- * @readonly
- * @memberof Config
- */
- get defaultRemoteExpireDate() {
- return OC.appConfig.core.defaultRemoteExpireDate
- }
-
- /**
- * Is resharing allowed ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get isResharingAllowed() {
- return OC.appConfig.core.resharingAllowed === true
- }
-
- /**
- * Is password enforced for mail shares ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get isPasswordForMailSharesRequired() {
- return (OC.getCapabilities().files_sharing.sharebymail === undefined) ? false : OC.getCapabilities().files_sharing.sharebymail.password.enforced
- }
-
- /**
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get shouldAlwaysShowUnique() {
- return (OC.getCapabilities().files_sharing?.sharee?.always_show_unique === true)
- }
-
- /**
- * Is sharing with groups allowed ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get allowGroupSharing() {
- return OC.appConfig.core.allowGroupSharing === true
- }
-
- /**
- * Get the maximum results of a share search
- *
- * @return {number}
- * @readonly
- * @memberof Config
- */
- get maxAutocompleteResults() {
- return parseInt(OC.config['sharing.maxAutocompleteResults'], 10) || 25
- }
-
- /**
- * Get the minimal string length
- * to initiate a share search
- *
- * @return {number}
- * @readonly
- * @memberof Config
- */
- get minSearchStringLength() {
- return parseInt(OC.config['sharing.minSearchStringLength'], 10) || 0
- }
-
- /**
- * Get the password policy config
- *
- * @return {object}
- * @readonly
- * @memberof Config
- */
- get passwordPolicy() {
- const capabilities = OC.getCapabilities()
- return capabilities.password_policy ? capabilities.password_policy : {}
- }
-
-}
diff --git a/apps/files_sharing/src/services/ConfigService.ts b/apps/files_sharing/src/services/ConfigService.ts
new file mode 100644
index 00000000000..547038f362d
--- /dev/null
+++ b/apps/files_sharing/src/services/ConfigService.ts
@@ -0,0 +1,333 @@
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { getCapabilities } from '@nextcloud/capabilities'
+import { loadState } from '@nextcloud/initial-state'
+
+type PasswordPolicyCapabilities = {
+ enforceNonCommonPassword: boolean
+ enforceNumericCharacters: boolean
+ enforceSpecialCharacters: boolean
+ enforceUpperLowerCase: boolean
+ minLength: number
+}
+
+type FileSharingCapabilities = {
+ api_enabled: boolean,
+ public: {
+ enabled: boolean,
+ password: {
+ enforced: boolean,
+ askForOptionalPassword: boolean
+ },
+ expire_date: {
+ enabled: boolean,
+ days: number,
+ enforced: boolean
+ },
+ multiple_links: boolean,
+ expire_date_internal: {
+ enabled: boolean
+ },
+ expire_date_remote: {
+ enabled: boolean
+ },
+ send_mail: boolean,
+ upload: boolean,
+ upload_files_drop: boolean,
+ custom_tokens: boolean,
+ },
+ resharing: boolean,
+ user: {
+ send_mail: boolean,
+ expire_date: {
+ enabled: boolean
+ }
+ },
+ group_sharing: boolean,
+ group: {
+ enabled: boolean,
+ expire_date: {
+ enabled: true
+ }
+ },
+ default_permissions: number,
+ federation: {
+ outgoing: boolean,
+ incoming: boolean,
+ expire_date: {
+ enabled: boolean
+ },
+ expire_date_supported: {
+ enabled: boolean
+ }
+ },
+ sharee: {
+ query_lookup_default: boolean,
+ always_show_unique: boolean
+ },
+ sharebymail: {
+ enabled: boolean,
+ send_password_by_mail: boolean,
+ upload_files_drop: {
+ enabled: boolean
+ },
+ password: {
+ enabled: boolean,
+ enforced: boolean
+ },
+ expire_date: {
+ enabled: boolean,
+ enforced: boolean
+ }
+ }
+}
+
+type Capabilities = {
+ files_sharing: FileSharingCapabilities
+ password_policy: PasswordPolicyCapabilities
+}
+
+export default class Config {
+
+ _capabilities: Capabilities
+
+ constructor() {
+ this._capabilities = getCapabilities() as Capabilities
+ }
+
+ /**
+ * Get default share permissions, if any
+ */
+ get defaultPermissions(): number {
+ return this._capabilities.files_sharing?.default_permissions
+ }
+
+ /**
+ * Is public upload allowed on link shares ?
+ * This covers File request and Full upload/edit option.
+ */
+ get isPublicUploadEnabled(): boolean {
+ return this._capabilities.files_sharing?.public?.upload === true
+ }
+
+ /**
+ * Get the federated sharing documentation link
+ */
+ get federatedShareDocLink() {
+ return window.OC.appConfig.core.federatedCloudShareDoc
+ }
+
+ /**
+ * Get the default link share expiration date
+ */
+ get defaultExpirationDate(): Date|null {
+ if (this.isDefaultExpireDateEnabled && this.defaultExpireDate !== null) {
+ return new Date(new Date().setDate(new Date().getDate() + this.defaultExpireDate))
+ }
+ return null
+ }
+
+ /**
+ * Get the default internal expiration date
+ */
+ get defaultInternalExpirationDate(): Date|null {
+ if (this.isDefaultInternalExpireDateEnabled && this.defaultInternalExpireDate !== null) {
+ return new Date(new Date().setDate(new Date().getDate() + this.defaultInternalExpireDate))
+ }
+ return null
+ }
+
+ /**
+ * Get the default remote expiration date
+ */
+ get defaultRemoteExpirationDateString(): Date|null {
+ if (this.isDefaultRemoteExpireDateEnabled && this.defaultRemoteExpireDate !== null) {
+ return new Date(new Date().setDate(new Date().getDate() + this.defaultRemoteExpireDate))
+ }
+ return null
+ }
+
+ /**
+ * Are link shares password-enforced ?
+ */
+ get enforcePasswordForPublicLink(): boolean {
+ return window.OC.appConfig.core.enforcePasswordForPublicLink === true
+ }
+
+ /**
+ * Is password asked by default on link shares ?
+ */
+ get enableLinkPasswordByDefault(): boolean {
+ return window.OC.appConfig.core.enableLinkPasswordByDefault === true
+ }
+
+ /**
+ * Is link shares expiration enforced ?
+ */
+ get isDefaultExpireDateEnforced(): boolean {
+ return window.OC.appConfig.core.defaultExpireDateEnforced === true
+ }
+
+ /**
+ * Is there a default expiration date for new link shares ?
+ */
+ get isDefaultExpireDateEnabled(): boolean {
+ return window.OC.appConfig.core.defaultExpireDateEnabled === true
+ }
+
+ /**
+ * Is internal shares expiration enforced ?
+ */
+ get isDefaultInternalExpireDateEnforced(): boolean {
+ return window.OC.appConfig.core.defaultInternalExpireDateEnforced === true
+ }
+
+ /**
+ * Is there a default expiration date for new internal shares ?
+ */
+ get isDefaultInternalExpireDateEnabled(): boolean {
+ return window.OC.appConfig.core.defaultInternalExpireDateEnabled === true
+ }
+
+ /**
+ * Is remote shares expiration enforced ?
+ */
+ get isDefaultRemoteExpireDateEnforced(): boolean {
+ return window.OC.appConfig.core.defaultRemoteExpireDateEnforced === true
+ }
+
+ /**
+ * Is there a default expiration date for new remote shares ?
+ */
+ get isDefaultRemoteExpireDateEnabled(): boolean {
+ return window.OC.appConfig.core.defaultRemoteExpireDateEnabled === true
+ }
+
+ /**
+ * Are users on this server allowed to send shares to other servers ?
+ */
+ get isRemoteShareAllowed(): boolean {
+ return window.OC.appConfig.core.remoteShareAllowed === true
+ }
+
+ /**
+ * Is federation enabled ?
+ */
+ get isFederationEnabled(): boolean {
+ return this._capabilities?.files_sharing?.federation?.outgoing === true
+ }
+
+ /**
+ * Is public sharing enabled ?
+ */
+ get isPublicShareAllowed(): boolean {
+ return this._capabilities?.files_sharing?.public?.enabled === true
+ }
+
+ /**
+ * Is sharing my mail (link share) enabled ?
+ */
+ get isMailShareAllowed(): boolean {
+ // eslint-disable-next-line camelcase
+ return this._capabilities?.files_sharing?.sharebymail?.enabled === true
+ // eslint-disable-next-line camelcase
+ && this.isPublicShareAllowed === true
+ }
+
+ /**
+ * Get the default days to link shares expiration
+ */
+ get defaultExpireDate(): number|null {
+ return window.OC.appConfig.core.defaultExpireDate
+ }
+
+ /**
+ * Get the default days to internal shares expiration
+ */
+ get defaultInternalExpireDate(): number|null {
+ return window.OC.appConfig.core.defaultInternalExpireDate
+ }
+
+ /**
+ * Get the default days to remote shares expiration
+ */
+ get defaultRemoteExpireDate(): number|null {
+ return window.OC.appConfig.core.defaultRemoteExpireDate
+ }
+
+ /**
+ * Is resharing allowed ?
+ */
+ get isResharingAllowed(): boolean {
+ return window.OC.appConfig.core.resharingAllowed === true
+ }
+
+ /**
+ * Is password enforced for mail shares ?
+ */
+ get isPasswordForMailSharesRequired(): boolean {
+ return this._capabilities.files_sharing?.sharebymail?.password?.enforced === true
+ }
+
+ /**
+ * Always show the email or userid unique sharee label if enabled by the admin
+ */
+ get shouldAlwaysShowUnique(): boolean {
+ return this._capabilities.files_sharing?.sharee?.always_show_unique === true
+ }
+
+ /**
+ * Is sharing with groups allowed ?
+ */
+ get allowGroupSharing(): boolean {
+ return window.OC.appConfig.core.allowGroupSharing === true
+ }
+
+ /**
+ * Get the maximum results of a share search
+ */
+ get maxAutocompleteResults(): number {
+ return parseInt(window.OC.config['sharing.maxAutocompleteResults'], 10) || 25
+ }
+
+ /**
+ * Get the minimal string length
+ * to initiate a share search
+ */
+ get minSearchStringLength(): number {
+ return parseInt(window.OC.config['sharing.minSearchStringLength'], 10) || 0
+ }
+
+ /**
+ * Get the password policy configuration
+ */
+ get passwordPolicy(): PasswordPolicyCapabilities {
+ return this._capabilities?.password_policy || {}
+ }
+
+ /**
+ * Returns true if custom tokens are allowed
+ */
+ get allowCustomTokens(): boolean {
+ return this._capabilities?.files_sharing?.public?.custom_tokens
+ }
+
+ /**
+ * Show federated shares as internal shares
+ * @return {boolean}
+ */
+ get showFederatedSharesAsInternal(): boolean {
+ return loadState('files_sharing', 'showFederatedSharesAsInternal', false)
+ }
+
+ /**
+ * Show federated shares to trusted servers as internal shares
+ * @return {boolean}
+ */
+ get showFederatedSharesToTrustedServersAsInternal(): boolean {
+ return loadState('files_sharing', 'showFederatedSharesToTrustedServersAsInternal', false)
+ }
+
+}
diff --git a/apps/files_sharing/src/services/ExternalLinkActions.js b/apps/files_sharing/src/services/ExternalLinkActions.js
index 06cf97ed255..fe5130fbb49 100644
--- a/apps/files_sharing/src/services/ExternalLinkActions.js
+++ b/apps/files_sharing/src/services/ExternalLinkActions.js
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * 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
*/
export default class ExternalLinkActions {
@@ -52,7 +35,7 @@ export default class ExternalLinkActions {
* @return {boolean}
*/
registerAction(action) {
- console.warn('OCA.Sharing.ExternalLinkActions is deprecated, use OCA.Sharing.ExternalShareAction instead')
+ OC.debug && console.warn('OCA.Sharing.ExternalLinkActions is deprecated, use OCA.Sharing.ExternalShareAction instead')
if (typeof action === 'object' && action.icon && action.name && action.url) {
this._state.actions.push(action)
diff --git a/apps/files_sharing/src/services/ExternalShareActions.js b/apps/files_sharing/src/services/ExternalShareActions.js
index 6167346699e..6ffd7014fe2 100644
--- a/apps/files_sharing/src/services/ExternalShareActions.js
+++ b/apps/files_sharing/src/services/ExternalShareActions.js
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * 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
*/
export default class ExternalShareActions {
@@ -45,12 +28,18 @@ export default class ExternalShareActions {
}
/**
+ * @typedef ExternalShareActionData
+ * @property {import('vue').Component} is Vue component to render, for advanced actions the `async onSave` method of the component will be called when saved
+ */
+
+ /**
* Register a new option/entry for the a given share type
*
* @param {object} action new action component to register
* @param {string} action.id unique action id
- * @param {Function} action.data data to bind the component to
+ * @param {(data: any) => ExternalShareActionData & Record<string, unknown>} action.data data to bind the component to
* @param {Array} action.shareType list of \@nextcloud/sharing.Types.SHARE_XXX to be mounted on
+ * @param {boolean} action.advanced `true` if the action entry should be rendered within advanced settings
* @param {object} action.handlers list of listeners
* @return {boolean}
*/
@@ -59,7 +48,7 @@ export default class ExternalShareActions {
if (typeof action !== 'object'
|| typeof action.id !== 'string'
|| typeof action.data !== 'function' // () => {disabled: true}
- || !Array.isArray(action.shareType) // [\@nextcloud/sharing.Types.SHARE_TYPE_LINK, ...]
+ || !Array.isArray(action.shareType) // [\@nextcloud/sharing.Types.Link, ...]
|| typeof action.handlers !== 'object' // {click: () => {}, ...}
|| !Object.values(action.handlers).every(handler => typeof handler === 'function')) {
console.error('Invalid action provided', action)
diff --git a/apps/files_sharing/src/services/GuestNameValidity.ts b/apps/files_sharing/src/services/GuestNameValidity.ts
new file mode 100644
index 00000000000..0557c5253ca
--- /dev/null
+++ b/apps/files_sharing/src/services/GuestNameValidity.ts
@@ -0,0 +1,45 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { InvalidFilenameError, InvalidFilenameErrorReason, validateFilename } from '@nextcloud/files'
+import { t } from '@nextcloud/l10n'
+
+/**
+ * Get the validity of a filename (empty if valid).
+ * This can be used for `setCustomValidity` on input elements
+ * @param name The filename
+ * @param escape Escape the matched string in the error (only set when used in HTML)
+ */
+export function getGuestNameValidity(name: string, escape = false): string {
+ if (name.trim() === '') {
+ return t('files', 'Names must not be empty.')
+ }
+
+ if (name.startsWith('.')) {
+ return t('files', 'Names must not start with a dot.')
+ }
+
+ try {
+ validateFilename(name)
+ return ''
+ } catch (error) {
+ if (!(error instanceof InvalidFilenameError)) {
+ throw error
+ }
+
+ switch (error.reason) {
+ case InvalidFilenameErrorReason.Character:
+ return t('files', '"{char}" is not allowed inside a name.', { char: error.segment }, undefined, { escape })
+ case InvalidFilenameErrorReason.ReservedName:
+ return t('files', '"{segment}" is a reserved name and not allowed.', { segment: error.segment }, undefined, { escape: false })
+ case InvalidFilenameErrorReason.Extension:
+ if (error.segment.match(/\.[a-z]/i)) {
+ return t('files', '"{extension}" is not an allowed name.', { extension: error.segment }, undefined, { escape: false })
+ }
+ return t('files', 'Names must not end with "{extension}".', { extension: error.segment }, undefined, { escape: false })
+ default:
+ return t('files', 'Invalid name.')
+ }
+ }
+}
diff --git a/apps/files_sharing/src/services/ShareSearch.js b/apps/files_sharing/src/services/ShareSearch.js
index 1a9737cbfba..eff209aad2b 100644
--- a/apps/files_sharing/src/services/ShareSearch.js
+++ b/apps/files_sharing/src/services/ShareSearch.js
@@ -1,23 +1,6 @@
/**
- * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * 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
*/
export default class ShareSearch {
diff --git a/apps/files_sharing/src/services/SharingService.spec.ts b/apps/files_sharing/src/services/SharingService.spec.ts
new file mode 100644
index 00000000000..936c1afafc4
--- /dev/null
+++ b/apps/files_sharing/src/services/SharingService.spec.ts
@@ -0,0 +1,516 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { OCSResponse } from '@nextcloud/typings/ocs'
+
+import { File, Folder } from '@nextcloud/files'
+import { ShareType } from '@nextcloud/sharing'
+import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'
+
+import { getContents } from './SharingService'
+import * as auth from '@nextcloud/auth'
+import logger from './logger'
+
+const TAG_FAVORITE = '_$!<Favorite>!$_'
+
+const axios = vi.hoisted(() => ({ get: vi.fn() }))
+vi.mock('@nextcloud/auth')
+vi.mock('@nextcloud/axios', () => ({ default: axios }))
+
+// Mock TAG
+beforeAll(() => {
+ window.OC = {
+ ...window.OC,
+ TAG_FAVORITE,
+ }
+})
+
+describe('SharingService methods definitions', () => {
+ beforeEach(() => {
+ vi.resetAllMocks()
+ axios.get.mockImplementation(async (): Promise<unknown> => {
+ return {
+ data: {
+ ocs: {
+ meta: {
+ status: 'ok',
+ statuscode: 200,
+ message: 'OK',
+ },
+ data: [],
+ },
+ } as OCSResponse,
+ }
+ })
+ })
+
+ test('Shared with you', async () => {
+ await getContents(true, false, false, false, [])
+
+ expect(axios.get).toHaveBeenCalledTimes(2)
+ expect(axios.get).toHaveBeenNthCalledWith(1, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares', {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ params: {
+ shared_with_me: true,
+ include_tags: true,
+ },
+ })
+ expect(axios.get).toHaveBeenNthCalledWith(2, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/remote_shares', {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ params: {
+ include_tags: true,
+ },
+ })
+ })
+
+ test('Shared with others', async () => {
+ await getContents(false, true, false, false, [])
+
+ expect(axios.get).toHaveBeenCalledTimes(1)
+ expect(axios.get).toHaveBeenCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares', {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ params: {
+ shared_with_me: false,
+ include_tags: true,
+ },
+ })
+ })
+
+ test('Pending shares', async () => {
+ await getContents(false, false, true, false, [])
+
+ expect(axios.get).toHaveBeenCalledTimes(2)
+ expect(axios.get).toHaveBeenNthCalledWith(1, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/pending', {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ params: {
+ include_tags: true,
+ },
+ })
+ expect(axios.get).toHaveBeenNthCalledWith(2, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/remote_shares/pending', {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ params: {
+ include_tags: true,
+ },
+ })
+ })
+
+ test('Deleted shares', async () => {
+ await getContents(false, true, false, false, [])
+
+ expect(axios.get).toHaveBeenCalledTimes(1)
+ expect(axios.get).toHaveBeenCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares', {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ params: {
+ shared_with_me: false,
+ include_tags: true,
+ },
+ })
+ })
+
+ test('Unknown owner', async () => {
+ vi.spyOn(auth, 'getCurrentUser').mockReturnValue(null)
+ const results = await getContents(false, true, false, false, [])
+
+ expect(results.folder.owner).toEqual(null)
+ })
+})
+
+describe('SharingService filtering', () => {
+ beforeEach(() => {
+ vi.resetAllMocks()
+ axios.get.mockImplementation(async (): Promise<unknown> => {
+ return {
+ data: {
+ ocs: {
+ meta: {
+ status: 'ok',
+ statuscode: 200,
+ message: 'OK',
+ },
+ data: [
+ {
+ id: '62',
+ share_type: ShareType.User,
+ uid_owner: 'test',
+ displayname_owner: 'test',
+ permissions: 31,
+ stime: 1688666292,
+ expiration: '2023-07-13 00:00:00',
+ token: null,
+ path: '/Collaborators',
+ item_type: 'folder',
+ item_permissions: 31,
+ mimetype: 'httpd/unix-directory',
+ storage: 224,
+ item_source: 419413,
+ file_source: 419413,
+ file_parent: 419336,
+ file_target: '/Collaborators',
+ item_size: 41434,
+ item_mtime: 1688662980,
+ },
+ ],
+ },
+ },
+ }
+ })
+ })
+
+ test('Shared with others filtering', async () => {
+ const shares = await getContents(false, true, false, false, [ShareType.User])
+
+ expect(axios.get).toHaveBeenCalledTimes(1)
+ expect(shares.contents).toHaveLength(1)
+ expect(shares.contents[0].fileid).toBe(419413)
+ expect(shares.contents[0]).toBeInstanceOf(Folder)
+ })
+
+ test('Shared with others filtering empty', async () => {
+ const shares = await getContents(false, true, false, false, [ShareType.Link])
+
+ expect(axios.get).toHaveBeenCalledTimes(1)
+ expect(shares.contents).toHaveLength(0)
+ })
+})
+
+describe('SharingService share to Node mapping', () => {
+ const shareFile = {
+ id: '66',
+ share_type: 0,
+ uid_owner: 'test',
+ displayname_owner: 'test',
+ permissions: 19,
+ can_edit: true,
+ can_delete: true,
+ stime: 1688721609,
+ parent: null,
+ expiration: '2023-07-14 00:00:00',
+ token: null,
+ uid_file_owner: 'test',
+ note: '',
+ label: null,
+ displayname_file_owner: 'test',
+ path: '/document.md',
+ item_type: 'file',
+ item_permissions: 27,
+ mimetype: 'text/markdown',
+ has_preview: true,
+ storage_id: 'home::test',
+ storage: 224,
+ item_source: 530936,
+ file_source: 530936,
+ file_parent: 419336,
+ file_target: '/document.md',
+ item_size: 123,
+ item_mtime: 1688721600,
+ share_with: 'user00',
+ share_with_displayname: 'User00',
+ share_with_displayname_unique: 'user00@domain.com',
+ status: {
+ status: 'away',
+ message: null,
+ icon: null,
+ clearAt: null,
+ },
+ mail_send: 0,
+ hide_download: 0,
+ attributes: null,
+ tags: [],
+ }
+
+ const shareFolder = {
+ id: '67',
+ share_type: 0,
+ uid_owner: 'test',
+ displayname_owner: 'test',
+ permissions: 31,
+ can_edit: true,
+ can_delete: true,
+ stime: 1688721629,
+ parent: null,
+ expiration: '2023-07-14 00:00:00',
+ token: null,
+ uid_file_owner: 'test',
+ note: '',
+ label: null,
+ displayname_file_owner: 'test',
+ path: '/Folder',
+ item_type: 'folder',
+ item_permissions: 31,
+ mimetype: 'httpd/unix-directory',
+ has_preview: false,
+ storage_id: 'home::test',
+ storage: 224,
+ item_source: 531080,
+ file_source: 531080,
+ file_parent: 419336,
+ file_target: '/Folder',
+ item_size: 0,
+ item_mtime: 1688721623,
+ share_with: 'user00',
+ share_with_displayname: 'User00',
+ share_with_displayname_unique: 'user00@domain.com',
+ status: {
+ status: 'away',
+ message: null,
+ icon: null,
+ clearAt: null,
+ },
+ mail_send: 0,
+ hide_download: 0,
+ attributes: null,
+ tags: [TAG_FAVORITE],
+ }
+
+ const remoteFileAccepted = {
+ mimetype: 'text/markdown',
+ mtime: 1688721600,
+ permissions: 19,
+ type: 'file',
+ file_id: 1234,
+ id: 4,
+ share_type: ShareType.User,
+ parent: null,
+ remote: 'http://exampe.com',
+ remote_id: '12345',
+ share_token: 'share-token',
+ name: '/test.md',
+ mountpoint: '/shares/test.md',
+ owner: 'owner-uid',
+ user: 'sharee-uid',
+ accepted: true,
+ }
+
+ const remoteFilePending = {
+ mimetype: 'text/markdown',
+ mtime: 1688721600,
+ permissions: 19,
+ type: 'file',
+ file_id: 1234,
+ id: 4,
+ share_type: ShareType.User,
+ parent: null,
+ remote: 'http://exampe.com',
+ remote_id: '12345',
+ share_token: 'share-token',
+ name: '/test.md',
+ mountpoint: '/shares/test.md',
+ owner: 'owner-uid',
+ user: 'sharee-uid',
+ accepted: false,
+ }
+
+ const tempExternalFile = {
+ id: 65,
+ share_type: 0,
+ parent: -1,
+ remote: 'http://nextcloud1.local/',
+ remote_id: '71',
+ share_token: '9GpiAmTIjayclrE',
+ name: '/test.md',
+ owner: 'owner-uid',
+ user: 'sharee-uid',
+ mountpoint: '{{TemporaryMountPointName#/test.md}}',
+ accepted: 0,
+ }
+
+ beforeEach(() => { vi.resetAllMocks() })
+
+ test('File', async () => {
+ axios.get.mockReturnValueOnce(Promise.resolve({
+ data: {
+ ocs: {
+ data: [shareFile],
+ },
+ },
+ }))
+
+ const shares = await getContents(false, true, false, false)
+
+ expect(axios.get).toHaveBeenCalledTimes(1)
+ expect(shares.contents).toHaveLength(1)
+
+ const file = shares.contents[0] as File
+ expect(file).toBeInstanceOf(File)
+ expect(file.fileid).toBe(530936)
+ expect(file.source).toBe('http://nextcloud.local/remote.php/dav/files/test/document.md')
+ expect(file.owner).toBe('test')
+ expect(file.mime).toBe('text/markdown')
+ expect(file.mtime).toBeInstanceOf(Date)
+ expect(file.size).toBe(123)
+ expect(file.permissions).toBe(27)
+ expect(file.root).toBe('/files/test')
+ expect(file.attributes).toBeInstanceOf(Object)
+ expect(file.attributes['has-preview']).toBe(true)
+ expect(file.attributes.sharees).toEqual({
+ sharee: {
+ id: 'user00',
+ 'display-name': 'User00',
+ type: 0,
+ },
+ })
+ expect(file.attributes.favorite).toBe(0)
+ })
+
+ test('Folder', async () => {
+ axios.get.mockReturnValueOnce(Promise.resolve({
+ data: {
+ ocs: {
+ data: [shareFolder],
+ },
+ },
+ }))
+
+ const shares = await getContents(false, true, false, false)
+
+ expect(axios.get).toHaveBeenCalledTimes(1)
+ expect(shares.contents).toHaveLength(1)
+
+ const folder = shares.contents[0] as Folder
+ expect(folder).toBeInstanceOf(Folder)
+ expect(folder.fileid).toBe(531080)
+ expect(folder.source).toBe('http://nextcloud.local/remote.php/dav/files/test/Folder')
+ expect(folder.owner).toBe('test')
+ expect(folder.mime).toBe('httpd/unix-directory')
+ expect(folder.mtime).toBeInstanceOf(Date)
+ expect(folder.size).toBe(0)
+ expect(folder.permissions).toBe(31)
+ expect(folder.root).toBe('/files/test')
+ expect(folder.attributes).toBeInstanceOf(Object)
+ expect(folder.attributes['has-preview']).toBe(false)
+ expect(folder.attributes.previewUrl).toBeUndefined()
+ expect(folder.attributes.favorite).toBe(1)
+ })
+
+ describe('Remote file', () => {
+ test('Accepted', async () => {
+ axios.get.mockReturnValueOnce(Promise.resolve({
+ data: {
+ ocs: {
+ data: [remoteFileAccepted],
+ },
+ },
+ }))
+
+ const shares = await getContents(false, true, false, false)
+
+ expect(axios.get).toHaveBeenCalledTimes(1)
+ expect(shares.contents).toHaveLength(1)
+
+ const file = shares.contents[0] as File
+ expect(file).toBeInstanceOf(File)
+ expect(file.fileid).toBe(1234)
+ expect(file.source).toBe('http://nextcloud.local/remote.php/dav/files/test/shares/test.md')
+ expect(file.owner).toBe('owner-uid')
+ expect(file.mime).toBe('text/markdown')
+ expect(file.mtime?.getTime()).toBe(remoteFileAccepted.mtime * 1000)
+ // not available for remote shares
+ expect(file.size).toBe(undefined)
+ expect(file.permissions).toBe(19)
+ expect(file.root).toBe('/files/test')
+ expect(file.attributes).toBeInstanceOf(Object)
+ expect(file.attributes.favorite).toBe(0)
+ })
+
+ test('Pending', async () => {
+ axios.get.mockReturnValueOnce(Promise.resolve({
+ data: {
+ ocs: {
+ data: [remoteFilePending],
+ },
+ },
+ }))
+
+ const shares = await getContents(false, true, false, false)
+
+ expect(axios.get).toHaveBeenCalledTimes(1)
+ expect(shares.contents).toHaveLength(1)
+
+ const file = shares.contents[0] as File
+ expect(file).toBeInstanceOf(File)
+ expect(file.fileid).toBe(1234)
+ expect(file.source).toBe('http://nextcloud.local/remote.php/dav/files/test/shares/test.md')
+ expect(file.owner).toBe('owner-uid')
+ expect(file.mime).toBe('text/markdown')
+ expect(file.mtime?.getTime()).toBe(remoteFilePending.mtime * 1000)
+ // not available for remote shares
+ expect(file.size).toBe(undefined)
+ expect(file.permissions).toBe(0)
+ expect(file.root).toBe('/files/test')
+ expect(file.attributes).toBeInstanceOf(Object)
+ expect(file.attributes.favorite).toBe(0)
+ })
+ })
+
+ test('External temp file', async () => {
+ axios.get.mockReturnValueOnce(Promise.resolve({
+ data: {
+ ocs: {
+ data: [tempExternalFile],
+ },
+ },
+ }))
+
+ const shares = await getContents(false, true, false, false)
+
+ expect(axios.get).toHaveBeenCalledTimes(1)
+ expect(shares.contents).toHaveLength(1)
+
+ const file = shares.contents[0] as File
+ expect(file).toBeInstanceOf(File)
+ expect(file.fileid).toBe(65)
+ expect(file.source).toBe('http://nextcloud.local/remote.php/dav/files/test/test.md')
+ expect(file.owner).toBe('owner-uid')
+ expect(file.mime).toBe('text/markdown')
+ expect(file.mtime?.getTime()).toBe(undefined)
+ // not available for remote shares
+ expect(file.size).toBe(undefined)
+ expect(file.permissions).toBe(0)
+ expect(file.root).toBe('/files/test')
+ expect(file.attributes).toBeInstanceOf(Object)
+ expect(file.attributes.favorite).toBe(0)
+ })
+
+ test('Empty', async () => {
+ vi.spyOn(logger, 'error').mockImplementationOnce(() => {})
+ axios.get.mockReturnValueOnce(Promise.resolve({
+ data: {
+ ocs: {
+ data: [],
+ },
+ },
+ }))
+
+ const shares = await getContents(false, true, false, false)
+ expect(shares.contents).toHaveLength(0)
+ expect(logger.error).toHaveBeenCalledTimes(0)
+ })
+
+ test('Error', async () => {
+ vi.spyOn(logger, 'error').mockImplementationOnce(() => {})
+ axios.get.mockReturnValueOnce(Promise.resolve({
+ data: {
+ ocs: {
+ data: [null],
+ },
+ },
+ }))
+
+ const shares = await getContents(false, true, false, false)
+ expect(shares.contents).toHaveLength(0)
+ expect(logger.error).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/apps/files_sharing/src/services/SharingService.ts b/apps/files_sharing/src/services/SharingService.ts
new file mode 100644
index 00000000000..41c20f9aa73
--- /dev/null
+++ b/apps/files_sharing/src/services/SharingService.ts
@@ -0,0 +1,244 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+// TODO: Fix this instead of disabling ESLint!!!
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import type { AxiosPromise } from '@nextcloud/axios'
+import type { ContentsWithRoot } from '@nextcloud/files'
+import type { OCSResponse } from '@nextcloud/typings/ocs'
+import type { ShareAttribute } from '../sharing'
+
+import { getCurrentUser } from '@nextcloud/auth'
+import { Folder, File, Permission, davRemoteURL, davRootPath } from '@nextcloud/files'
+import { generateOcsUrl } from '@nextcloud/router'
+import axios from '@nextcloud/axios'
+
+import logger from './logger'
+
+const headers = {
+ 'Content-Type': 'application/json',
+}
+
+const ocsEntryToNode = async function(ocsEntry: any): Promise<Folder | File | null> {
+ try {
+ // Federated share handling
+ if (ocsEntry?.remote_id !== undefined) {
+ if (!ocsEntry.mimetype) {
+ const mime = (await import('mime')).default
+ // This won't catch files without an extension, but this is the best we can do
+ ocsEntry.mimetype = mime.getType(ocsEntry.name)
+ }
+ ocsEntry.item_type = ocsEntry.type || (ocsEntry.mimetype ? 'file' : 'folder')
+
+ // different naming for remote shares
+ ocsEntry.item_mtime = ocsEntry.mtime
+ ocsEntry.file_target = ocsEntry.file_target || ocsEntry.mountpoint
+
+ if (ocsEntry.file_target.includes('TemporaryMountPointName')) {
+ ocsEntry.file_target = ocsEntry.name
+ }
+
+ // If the share is not accepted yet we don't know which permissions it will have
+ if (!ocsEntry.accepted) {
+ // Need to set permissions to NONE for federated shares
+ ocsEntry.item_permissions = Permission.NONE
+ ocsEntry.permissions = Permission.NONE
+ }
+
+ ocsEntry.uid_owner = ocsEntry.owner
+ // TODO: have the real display name stored somewhere
+ ocsEntry.displayname_owner = ocsEntry.owner
+ }
+
+ const isFolder = ocsEntry?.item_type === 'folder'
+ const hasPreview = ocsEntry?.has_preview === true
+ const Node = isFolder ? Folder : File
+
+ // If this is an external share that is not yet accepted,
+ // we don't have an id. We can fallback to the row id temporarily
+ // local shares (this server) use `file_source`, but remote shares (federated) use `file_id`
+ const fileid = ocsEntry.file_source || ocsEntry.file_id || ocsEntry.id
+
+ // Generate path and strip double slashes
+ const path = ocsEntry.path || ocsEntry.file_target || ocsEntry.name
+ const source = `${davRemoteURL}${davRootPath}/${path.replace(/^\/+/, '')}`
+
+ let mtime = ocsEntry.item_mtime ? new Date((ocsEntry.item_mtime) * 1000) : undefined
+ // Prefer share time if more recent than item mtime
+ if (ocsEntry?.stime > (ocsEntry?.item_mtime || 0)) {
+ mtime = new Date((ocsEntry.stime) * 1000)
+ }
+
+ let sharees: { sharee: object } | undefined
+ if ('share_with' in ocsEntry) {
+ sharees = {
+ sharee: {
+ id: ocsEntry.share_with,
+ 'display-name': ocsEntry.share_with_displayname || ocsEntry.share_with,
+ type: ocsEntry.share_type,
+ },
+ }
+ }
+
+ return new Node({
+ id: fileid,
+ source,
+ owner: ocsEntry?.uid_owner,
+ mime: ocsEntry?.mimetype || 'application/octet-stream',
+ mtime,
+ size: ocsEntry?.item_size,
+ permissions: ocsEntry?.item_permissions || ocsEntry?.permissions,
+ root: davRootPath,
+ attributes: {
+ ...ocsEntry,
+ 'has-preview': hasPreview,
+ 'hide-download': ocsEntry?.hide_download === 1,
+ // Also check the sharingStatusAction.ts code
+ 'owner-id': ocsEntry?.uid_owner,
+ 'owner-display-name': ocsEntry?.displayname_owner,
+ 'share-types': ocsEntry?.share_type,
+ 'share-attributes': ocsEntry?.attributes || '[]',
+ sharees,
+ favorite: ocsEntry?.tags?.includes((window.OC as { TAG_FAVORITE: string }).TAG_FAVORITE) ? 1 : 0,
+ },
+ })
+ } catch (error) {
+ logger.error('Error while parsing OCS entry', { error })
+ return null
+ }
+}
+
+const getShares = function(shareWithMe = false): AxiosPromise<OCSResponse<any>> {
+ const url = generateOcsUrl('apps/files_sharing/api/v1/shares')
+ return axios.get(url, {
+ headers,
+ params: {
+ shared_with_me: shareWithMe,
+ include_tags: true,
+ },
+ })
+}
+
+const getSharedWithYou = function(): AxiosPromise<OCSResponse<any>> {
+ return getShares(true)
+}
+
+const getSharedWithOthers = function(): AxiosPromise<OCSResponse<any>> {
+ return getShares()
+}
+
+const getRemoteShares = function(): AxiosPromise<OCSResponse<any>> {
+ const url = generateOcsUrl('apps/files_sharing/api/v1/remote_shares')
+ return axios.get(url, {
+ headers,
+ params: {
+ include_tags: true,
+ },
+ })
+}
+
+const getPendingShares = function(): AxiosPromise<OCSResponse<any>> {
+ const url = generateOcsUrl('apps/files_sharing/api/v1/shares/pending')
+ return axios.get(url, {
+ headers,
+ params: {
+ include_tags: true,
+ },
+ })
+}
+
+const getRemotePendingShares = function(): AxiosPromise<OCSResponse<any>> {
+ const url = generateOcsUrl('apps/files_sharing/api/v1/remote_shares/pending')
+ return axios.get(url, {
+ headers,
+ params: {
+ include_tags: true,
+ },
+ })
+}
+
+const getDeletedShares = function(): AxiosPromise<OCSResponse<any>> {
+ const url = generateOcsUrl('apps/files_sharing/api/v1/deletedshares')
+ return axios.get(url, {
+ headers,
+ params: {
+ include_tags: true,
+ },
+ })
+}
+
+/**
+ * Check if a file request is enabled
+ * @param attributes the share attributes json-encoded array
+ */
+export const isFileRequest = (attributes = '[]'): boolean => {
+ const isFileRequest = (attribute) => {
+ return attribute.scope === 'fileRequest' && attribute.key === 'enabled' && attribute.value === true
+ }
+
+ try {
+ const attributesArray = JSON.parse(attributes) as Array<ShareAttribute>
+ return attributesArray.some(isFileRequest)
+ } catch (error) {
+ logger.error('Error while parsing share attributes', { error })
+ return false
+ }
+}
+
+/**
+ * Group an array of objects (here Nodes) by a key
+ * and return an array of arrays of them.
+ * @param nodes Nodes to group
+ * @param key The attribute to group by
+ */
+const groupBy = function(nodes: (Folder | File)[], key: string) {
+ return Object.values(nodes.reduce(function(acc, curr) {
+ (acc[curr[key]] = acc[curr[key]] || []).push(curr)
+ return acc
+ }, {})) as (Folder | File)[][]
+}
+
+export const getContents = async (sharedWithYou = true, sharedWithOthers = true, pendingShares = false, deletedshares = false, filterTypes: number[] = []): Promise<ContentsWithRoot> => {
+ const promises = [] as AxiosPromise<OCSResponse<any>>[]
+
+ if (sharedWithYou) {
+ promises.push(getSharedWithYou(), getRemoteShares())
+ }
+ if (sharedWithOthers) {
+ promises.push(getSharedWithOthers())
+ }
+ if (pendingShares) {
+ promises.push(getPendingShares(), getRemotePendingShares())
+ }
+ if (deletedshares) {
+ promises.push(getDeletedShares())
+ }
+
+ const responses = await Promise.all(promises)
+ const data = responses.map((response) => response.data.ocs.data).flat()
+ let contents = (await Promise.all(data.map(ocsEntryToNode)))
+ .filter((node) => node !== null) as (Folder | File)[]
+
+ if (filterTypes.length > 0) {
+ contents = contents.filter((node) => filterTypes.includes(node.attributes?.share_type))
+ }
+
+ // Merge duplicate shares and group their attributes
+ // Also check the sharingStatusAction.ts code
+ contents = groupBy(contents, 'source').map((nodes) => {
+ const node = nodes[0]
+ node.attributes['share-types'] = nodes.map(node => node.attributes['share-types'])
+ return node
+ })
+
+ return {
+ folder: new Folder({
+ id: 0,
+ source: `${davRemoteURL}${davRootPath}`,
+ owner: getCurrentUser()?.uid || null,
+ }),
+ contents,
+ }
+}
diff --git a/apps/files_sharing/src/services/TabSections.js b/apps/files_sharing/src/services/TabSections.js
index d266909b6cc..ab1237e7044 100644
--- a/apps/files_sharing/src/services/TabSections.js
+++ b/apps/files_sharing/src/services/TabSections.js
@@ -1,23 +1,14 @@
/**
- * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net>
- *
- * @author Julius Härtl <jus@bitgrid.net>
- *
- * @license AGPL-3.0-or-later
- *
- * 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
+ */
+
+/**
+ * Callback to render a section in the sharing tab.
*
+ * @callback registerSectionCallback
+ * @param {undefined} el - Deprecated and will always be undefined (formerly the root element)
+ * @param {object} fileInfo - File info object
*/
export default class TabSections {
diff --git a/apps/files_sharing/src/services/TokenService.ts b/apps/files_sharing/src/services/TokenService.ts
new file mode 100644
index 00000000000..c497531dfdb
--- /dev/null
+++ b/apps/files_sharing/src/services/TokenService.ts
@@ -0,0 +1,20 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import axios from '@nextcloud/axios'
+import { generateOcsUrl } from '@nextcloud/router'
+
+interface TokenData {
+ ocs: {
+ data: {
+ token: string,
+ }
+ }
+}
+
+export const generateToken = async (): Promise<string> => {
+ const { data } = await axios.get<TokenData>(generateOcsUrl('/apps/files_sharing/api/v1/token'))
+ return data.ocs.data.token
+}
diff --git a/apps/files_sharing/src/services/logger.ts b/apps/files_sharing/src/services/logger.ts
new file mode 100644
index 00000000000..ea582deee91
--- /dev/null
+++ b/apps/files_sharing/src/services/logger.ts
@@ -0,0 +1,10 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { getLoggerBuilder } from '@nextcloud/logger'
+
+export default getLoggerBuilder()
+ .setApp('files_sharing')
+ .detectUser()
+ .build()
diff --git a/apps/files_sharing/src/share.js b/apps/files_sharing/src/share.js
index be003d51fa4..cdc3c917dfa 100644
--- a/apps/files_sharing/src/share.js
+++ b/apps/files_sharing/src/share.js
@@ -1,41 +1,13 @@
/**
- * Copyright (c) 2014
- *
- * @author Arthur Schiwon <blizzz@arthur-schiwon.de>
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Daniel Calviño Sánchez <danxuliu@gmail.com>
- * @author Joas Schilling <coding@schilljs.com>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Julius Härtl <jus@bitgrid.net>
- * @author Maxence Lange <maxence@nextcloud.com>
- * @author Michael Jobst <mjobst+github@tecratech.de>
- * @author Michael Jobst <mjobst@necls.com>
- * @author Morris Jobke <hey@morrisjobke.de>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- * @author Samuel <faust64@gmail.com>
- * @author Vincent Petry <vincent@nextcloud.com>
- *
- * @license AGPL-3.0-or-later
- *
- * 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: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2011-2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
/* eslint-disable */
import escapeHTML from 'escape-html'
-import { Type as ShareTypes } from '@nextcloud/sharing'
+import { ShareType } from '@nextcloud/sharing'
import { getCapabilities } from '@nextcloud/capabilities'
(function() {
@@ -94,7 +66,7 @@ import { getCapabilities } from '@nextcloud/capabilities'
}
if (_.isFunction(fileData.canDownload) && !fileData.canDownload()) {
delete fileActions.actions.all.Download
- if (fileData.permissions & OC.PERMISSION_UPDATE === 0) {
+ if ((fileData.permissions & OC.PERMISSION_UPDATE) === 0) {
// neither move nor copy is allowed, remove the action completely
delete fileActions.actions.all.MoveCopy
}
@@ -183,23 +155,23 @@ import { getCapabilities } from '@nextcloud/capabilities'
var hasShares = false
_.each(shareTypesStr.split(',') || [], function(shareTypeStr) {
let shareType = parseInt(shareTypeStr, 10)
- if (shareType === ShareTypes.SHARE_TYPE_LINK) {
+ if (shareType === ShareType.Link) {
hasLink = true
- } else if (shareType === ShareTypes.SHARE_TYPE_EMAIL) {
+ } else if (shareType === ShareType.Email) {
hasLink = true
- } else if (shareType === ShareTypes.SHARE_TYPE_USER) {
+ } else if (shareType === ShareType.User) {
hasShares = true
- } else if (shareType === ShareTypes.SHARE_TYPE_GROUP) {
+ } else if (shareType === ShareType.Group) {
hasShares = true
- } else if (shareType === ShareTypes.SHARE_TYPE_REMOTE) {
+ } else if (shareType === ShareType.Remote) {
hasShares = true
- } else if (shareType === ShareTypes.SHARE_TYPE_REMOTE_GROUP) {
+ } else if (shareType === ShareType.RemoteGroup) {
hasShares = true
- } else if (shareType === ShareTypes.SHARE_TYPE_CIRCLE) {
+ } else if (shareType === ShareType.Team) {
hasShares = true
- } else if (shareType === ShareTypes.SHARE_TYPE_ROOM) {
+ } else if (shareType === ShareType.Room) {
hasShares = true
- } else if (shareType === ShareTypes.SHARE_TYPE_DECK) {
+ } else if (shareType === ShareType.Deck) {
hasShares = true
}
})
@@ -230,8 +202,8 @@ import { getCapabilities } from '@nextcloud/capabilities'
permissions: OC.PERMISSION_ALL,
iconClass: function(fileName, context) {
var shareType = parseInt(context.$file.data('share-types'), 10)
- if (shareType === ShareTypes.SHARE_TYPE_EMAIL
- || shareType === ShareTypes.SHARE_TYPE_LINK) {
+ if (shareType === ShareType.Email
+ || shareType === ShareType.Link) {
return 'icon-public'
}
return 'icon-shared'
@@ -330,7 +302,11 @@ import { getCapabilities } from '@nextcloud/capabilities'
var iconClass = 'icon-shared'
action.removeClass('shared-style')
// update folder icon
- if (type === 'dir' && (hasShares || hasLink || ownerId)) {
+ var isEncrypted = $tr.attr('data-e2eencrypted')
+ if (type === 'dir' && isEncrypted === 'true') {
+ shareFolderIcon = OC.MimeType.getIconUrl('dir-encrypted')
+ $tr.attr('data-icon', shareFolderIcon)
+ } else if (type === 'dir' && (hasShares || hasLink || ownerId)) {
if (typeof mountType !== 'undefined' && mountType !== 'shared-root' && mountType !== 'shared') {
shareFolderIcon = OC.MimeType.getIconUrl('dir-' + mountType)
} else if (hasLink) {
@@ -341,13 +317,9 @@ import { getCapabilities } from '@nextcloud/capabilities'
$tr.find('.filename .thumbnail').css('background-image', 'url(' + shareFolderIcon + ')')
$tr.attr('data-icon', shareFolderIcon)
} else if (type === 'dir') {
- var isEncrypted = $tr.attr('data-e2eencrypted')
// FIXME: duplicate of FileList._createRow logic for external folder,
// need to refactor the icon logic into a single code path eventually
- if (isEncrypted === 'true') {
- shareFolderIcon = OC.MimeType.getIconUrl('dir-encrypted')
- $tr.attr('data-icon', shareFolderIcon)
- } else if (mountType && mountType.indexOf('external') === 0) {
+ if (mountType && mountType.indexOf('external') === 0) {
shareFolderIcon = OC.MimeType.getIconUrl('dir-external')
$tr.attr('data-icon', shareFolderIcon)
} else {
diff --git a/apps/files_sharing/src/sharebreadcrumbview.js b/apps/files_sharing/src/sharebreadcrumbview.js
index ef04c9c029d..68ea75d4df9 100644
--- a/apps/files_sharing/src/sharebreadcrumbview.js
+++ b/apps/files_sharing/src/sharebreadcrumbview.js
@@ -1,28 +1,9 @@
/**
- * @copyright 2016 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license AGPL-3.0-or-later
- *
- * 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: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { Type as ShareTypes } from '@nextcloud/sharing'
+import { ShareType } from '@nextcloud/sharing'
(function() {
'use strict'
@@ -42,7 +23,7 @@ import { Type as ShareTypes } from '@nextcloud/sharing'
this.$el.removeClass('shared icon-public icon-shared')
if (isShared) {
this.$el.addClass('shared')
- if (data.dirInfo.shareTypes.indexOf(ShareTypes.SHARE_TYPE_LINK) !== -1) {
+ if (data.dirInfo.shareTypes.indexOf(ShareType.Link) !== -1) {
this.$el.addClass('icon-public')
} else {
this.$el.addClass('icon-shared')
diff --git a/apps/files_sharing/src/sharing.d.ts b/apps/files_sharing/src/sharing.d.ts
new file mode 100644
index 00000000000..5c1a211f346
--- /dev/null
+++ b/apps/files_sharing/src/sharing.d.ts
@@ -0,0 +1,10 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+export type ShareAttribute = {
+ value: boolean|string|number|null|object|Array<unknown>
+ key: string
+ scope: string
+}
diff --git a/apps/files_sharing/src/style/sharebreadcrumb.scss b/apps/files_sharing/src/style/sharebreadcrumb.scss
index f3096f45013..6ee05c45306 100644
--- a/apps/files_sharing/src/style/sharebreadcrumb.scss
+++ b/apps/files_sharing/src/style/sharebreadcrumb.scss
@@ -1,34 +1,17 @@
-/**
- * @copyright 2016 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2016 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @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: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-div.crumb span.icon-shared,
-div.crumb span.icon-public {
+li.crumb span.icon-shared,
+li.crumb span.icon-public {
display: inline-block;
cursor: pointer;
opacity: 0.2;
- margin-right: 6px;
+ margin-inline-end: 6px;
}
-div.crumb span.icon-shared.shared,
-div.crumb span.icon-public.shared {
+li.crumb span.icon-shared.shared,
+li.crumb span.icon-public.shared {
opacity: 0.7;
}
diff --git a/apps/files_sharing/src/utils/AccountIcon.spec.ts b/apps/files_sharing/src/utils/AccountIcon.spec.ts
new file mode 100644
index 00000000000..bbc7f031774
--- /dev/null
+++ b/apps/files_sharing/src/utils/AccountIcon.spec.ts
@@ -0,0 +1,40 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { describe, expect, it, afterEach } from 'vitest'
+import { generateAvatarSvg } from './AccountIcon'
+describe('AccountIcon', () => {
+
+ afterEach(() => {
+ delete document.body.dataset.themes
+ })
+
+ it('should generate regular account avatar svg', () => {
+ const svg = generateAvatarSvg('admin')
+ expect(svg).toContain('/avatar/admin/32')
+ expect(svg).not.toContain('dark')
+ expect(svg).toContain('?guestFallback=true')
+ })
+
+ it('should generate guest account avatar svg', () => {
+ const svg = generateAvatarSvg('admin', true)
+ expect(svg).toContain('/avatar/guest/admin/32')
+ expect(svg).not.toContain('dark')
+ expect(svg).not.toContain('?guestFallback=true')
+ })
+
+ it('should generate dark mode account avatar svg', () => {
+ document.body.dataset.themes = 'dark'
+ const svg = generateAvatarSvg('admin')
+ expect(svg).toContain('/avatar/admin/32/dark')
+ expect(svg).toContain('?guestFallback=true')
+ })
+
+ it('should generate dark mode guest account avatar svg', () => {
+ document.body.dataset.themes = 'dark'
+ const svg = generateAvatarSvg('admin', true)
+ expect(svg).toContain('/avatar/guest/admin/32/dark')
+ expect(svg).not.toContain('?guestFallback=true')
+ })
+})
diff --git a/apps/files_sharing/src/utils/AccountIcon.ts b/apps/files_sharing/src/utils/AccountIcon.ts
new file mode 100644
index 00000000000..21732f08f68
--- /dev/null
+++ b/apps/files_sharing/src/utils/AccountIcon.ts
@@ -0,0 +1,28 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { generateUrl } from '@nextcloud/router'
+
+const isDarkMode = () => {
+ return window?.matchMedia?.('(prefers-color-scheme: dark)')?.matches === true
+ || document.querySelector('[data-themes*=dark]') !== null
+}
+
+export const generateAvatarSvg = (userId: string, isGuest = false) => {
+ // normal avatar url: /avatar/{userId}/32?guestFallback=true
+ // dark avatar url: /avatar/{userId}/32/dark?guestFallback=true
+ // guest avatar url: /avatar/guest/{userId}/32
+ // guest dark avatar url: /avatar/guest/{userId}/32/dark
+ const basePath = isGuest ? `/avatar/guest/${userId}` : `/avatar/${userId}`
+ const darkModePath = isDarkMode() ? '/dark' : ''
+ const guestFallback = isGuest ? '' : '?guestFallback=true'
+
+ const url = `${basePath}/32${darkModePath}${guestFallback}`
+ const avatarUrl = generateUrl(url, { userId })
+
+ return `<svg width="32" height="32" viewBox="0 0 32 32"
+ xmlns="http://www.w3.org/2000/svg" class="sharing-status__avatar">
+ <image href="${avatarUrl}" height="32" width="32" />
+ </svg>`
+}
diff --git a/apps/files_sharing/src/utils/GeneratePassword.js b/apps/files_sharing/src/utils/GeneratePassword.js
deleted file mode 100644
index 63cc68983a1..00000000000
--- a/apps/files_sharing/src/utils/GeneratePassword.js
+++ /dev/null
@@ -1,61 +0,0 @@
-/**
- * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * 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/>.
- *
- */
-
-import axios from '@nextcloud/axios'
-import Config from '../services/ConfigService'
-import { showError, showSuccess } from '@nextcloud/dialogs'
-
-const config = new Config()
-// note: some chars removed on purpose to make them human friendly when read out
-const passwordSet = 'abcdefgijkmnopqrstwxyzABCDEFGHJKLMNPQRSTWXYZ23456789'
-
-/**
- * Generate a valid policy password or
- * request a valid password if password_policy
- * is enabled
- *
- * @return {string} a valid password
- */
-export default async function() {
- // password policy is enabled, let's request a pass
- if (config.passwordPolicy.api && config.passwordPolicy.api.generate) {
- try {
- const request = await axios.get(config.passwordPolicy.api.generate)
- if (request.data.ocs.data.password) {
- showSuccess(t('files_sharing', 'Password created successfully'))
- return request.data.ocs.data.password
- }
- } catch (error) {
- console.info('Error generating password from password_policy', error)
- showError(t('files_sharing', 'Error generating password from password policy'))
- }
- }
-
- const array = new Uint8Array(10)
- const ratio = passwordSet.length / 255
- self.crypto.getRandomValues(array)
- let password = ''
- for (let i = 0; i < array.length; i++) {
- password += passwordSet.charAt(array[i] * ratio)
- }
- return password
-}
diff --git a/apps/files_sharing/src/utils/GeneratePassword.ts b/apps/files_sharing/src/utils/GeneratePassword.ts
new file mode 100644
index 00000000000..82efaaa69d4
--- /dev/null
+++ b/apps/files_sharing/src/utils/GeneratePassword.ts
@@ -0,0 +1,66 @@
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import axios from '@nextcloud/axios'
+import Config from '../services/ConfigService.ts'
+import { showError, showSuccess } from '@nextcloud/dialogs'
+import { translate as t } from '@nextcloud/l10n'
+
+const config = new Config()
+// note: some chars removed on purpose to make them human friendly when read out
+const passwordSet = 'abcdefgijkmnopqrstwxyzABCDEFGHJKLMNPQRSTWXYZ23456789'
+
+/**
+ * Generate a valid policy password or request a valid password if password_policy is enabled
+ *
+ * @param {boolean} verbose If enabled the the status is shown to the user via toast
+ */
+export default async function(verbose = false): Promise<string> {
+ // password policy is enabled, let's request a pass
+ if (config.passwordPolicy.api && config.passwordPolicy.api.generate) {
+ try {
+ const request = await axios.get(config.passwordPolicy.api.generate)
+ if (request.data.ocs.data.password) {
+ if (verbose) {
+ showSuccess(t('files_sharing', 'Password created successfully'))
+ }
+ return request.data.ocs.data.password
+ }
+ } catch (error) {
+ console.info('Error generating password from password_policy', error)
+ if (verbose) {
+ showError(t('files_sharing', 'Error generating password from password policy'))
+ }
+ }
+ }
+
+ const array = new Uint8Array(10)
+ const ratio = passwordSet.length / 255
+ getRandomValues(array)
+ let password = ''
+ for (let i = 0; i < array.length; i++) {
+ password += passwordSet.charAt(array[i] * ratio)
+ }
+ return password
+}
+
+/**
+ * Fills the given array with cryptographically secure random values.
+ * If the crypto API is not available, it falls back to less secure Math.random().
+ * Crypto API is available in modern browsers on secure contexts (HTTPS).
+ *
+ * @param {Uint8Array} array - The array to fill with random values.
+ */
+function getRandomValues(array: Uint8Array): void {
+ if (self?.crypto?.getRandomValues) {
+ self.crypto.getRandomValues(array)
+ return
+ }
+
+ let len = array.length
+ while (len--) {
+ array[len] = Math.floor(Math.random() * 256)
+ }
+}
diff --git a/apps/files_sharing/src/utils/NodeShareUtils.ts b/apps/files_sharing/src/utils/NodeShareUtils.ts
new file mode 100644
index 00000000000..f14f981e2ad
--- /dev/null
+++ b/apps/files_sharing/src/utils/NodeShareUtils.ts
@@ -0,0 +1,58 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { getCurrentUser } from '@nextcloud/auth'
+import type { Node } from '@nextcloud/files'
+import { ShareType } from '@nextcloud/sharing'
+
+type Share = {
+ /** The recipient display name */
+ 'display-name': string
+ /** The recipient user id */
+ id: string
+ /** The share type */
+ type: ShareType
+}
+
+const getSharesAttribute = function(node: Node) {
+ return Object.values(node.attributes.sharees).flat() as Share[]
+}
+
+export const isNodeSharedWithMe = function(node: Node) {
+ const uid = getCurrentUser()?.uid
+ const shares = getSharesAttribute(node)
+
+ // If you're the owner, you can't share with yourself
+ if (node.owner === uid) {
+ return false
+ }
+
+ return shares.length > 0 && (
+ // If some shares are shared with you as a direct user share
+ shares.some(share => share.id === uid && share.type === ShareType.User)
+ // Or of the file is shared with a group you're in
+ // (if it's returned by the backend, we assume you're in it)
+ || shares.some(share => share.type === ShareType.Group)
+ )
+}
+
+export const isNodeSharedWithOthers = function(node: Node) {
+ const uid = getCurrentUser()?.uid
+ const shares = getSharesAttribute(node)
+
+ // If you're NOT the owner, you can't share with yourself
+ if (node.owner === uid) {
+ return false
+ }
+
+ return shares.length > 0
+ // If some shares are shared with you as a direct user share
+ && shares.some(share => share.id !== uid && share.type !== ShareType.Group)
+}
+
+export const isNodeShared = function(node: Node) {
+ const shares = getSharesAttribute(node)
+ return shares.length > 0
+}
diff --git a/apps/files_sharing/src/utils/SharedWithMe.js b/apps/files_sharing/src/utils/SharedWithMe.js
index bd39c765221..2f63932bfbe 100644
--- a/apps/files_sharing/src/utils/SharedWithMe.js
+++ b/apps/files_sharing/src/utils/SharedWithMe.js
@@ -1,30 +1,12 @@
/**
- * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author Joas Schilling <coding@schilljs.com>
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license AGPL-3.0-or-later
- *
- * 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
*/
-import { Type as ShareTypes } from '@nextcloud/sharing'
+import { ShareType } from '@nextcloud/sharing'
const shareWithTitle = function(share) {
- if (share.type === ShareTypes.SHARE_TYPE_GROUP) {
+ if (share.type === ShareType.Group) {
return t(
'files_sharing',
'Shared with you and the group {group} by {owner}',
@@ -33,9 +15,9 @@ const shareWithTitle = function(share) {
owner: share.ownerDisplayName,
},
undefined,
- { escape: false }
+ { escape: false },
)
- } else if (share.type === ShareTypes.SHARE_TYPE_CIRCLE) {
+ } else if (share.type === ShareType.Team) {
return t(
'files_sharing',
'Shared with you and {circle} by {owner}',
@@ -44,9 +26,9 @@ const shareWithTitle = function(share) {
owner: share.ownerDisplayName,
},
undefined,
- { escape: false }
+ { escape: false },
)
- } else if (share.type === ShareTypes.SHARE_TYPE_ROOM) {
+ } else if (share.type === ShareType.Room) {
if (share.shareWithDisplayName) {
return t(
'files_sharing',
@@ -56,7 +38,7 @@ const shareWithTitle = function(share) {
owner: share.ownerDisplayName,
},
undefined,
- { escape: false }
+ { escape: false },
)
} else {
return t(
@@ -66,7 +48,7 @@ const shareWithTitle = function(share) {
owner: share.ownerDisplayName,
},
undefined,
- { escape: false }
+ { escape: false },
)
}
} else {
@@ -75,7 +57,7 @@ const shareWithTitle = function(share) {
'Shared with you by {owner}',
{ owner: share.ownerDisplayName },
undefined,
- { escape: false }
+ { escape: false },
)
}
}
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>