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.js15
-rw-r--r--apps/files_sharing/src/collaborationresourceshandler.js25
-rw-r--r--apps/files_sharing/src/components/ExternalShareAction.vue46
-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.vue68
-rw-r--r--apps/files_sharing/src/components/SelectShareFolderDialogue.vue113
-rw-r--r--apps/files_sharing/src/components/ShareExpiryTime.vue91
-rw-r--r--apps/files_sharing/src/components/SharingEntry.vue176
-rw-r--r--apps/files_sharing/src/components/SharingEntryInherited.vue98
-rw-r--r--apps/files_sharing/src/components/SharingEntryInternal.vue133
-rw-r--r--apps/files_sharing/src/components/SharingEntryLink.vue987
-rw-r--r--apps/files_sharing/src/components/SharingEntryQuickShareSelect.vue206
-rw-r--r--apps/files_sharing/src/components/SharingEntrySimple.vue92
-rw-r--r--apps/files_sharing/src/components/SharingInput.vue530
-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_tab.js71
-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/init-public.ts63
-rw-r--r--apps/files_sharing/src/init.ts33
-rw-r--r--apps/files_sharing/src/lib/SharePermissionsToolBox.js107
-rw-r--r--apps/files_sharing/src/lib/SharePermissionsToolBox.spec.js80
-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.js112
-rw-r--r--apps/files_sharing/src/mixins/SharesMixin.js448
-rw-r--r--apps/files_sharing/src/models/Share.ts496
-rw-r--r--apps/files_sharing/src/personal-settings.js17
-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.ts333
-rw-r--r--apps/files_sharing/src/services/ExternalLinkActions.js48
-rw-r--r--apps/files_sharing/src/services/ExternalShareActions.js69
-rw-r--r--apps/files_sharing/src/services/GuestNameValidity.ts45
-rw-r--r--apps/files_sharing/src/services/ShareSearch.js54
-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.js33
-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.js505
-rw-r--r--apps/files_sharing/src/sharebreadcrumbview.js62
-rw-r--r--apps/files_sharing/src/sharing.d.ts10
-rw-r--r--apps/files_sharing/src/style/sharebreadcrumb.scss17
-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.ts66
-rw-r--r--apps/files_sharing/src/utils/NodeShareUtils.ts58
-rw-r--r--apps/files_sharing/src/utils/SharedWithMe.js65
-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.vue164
-rw-r--r--apps/files_sharing/src/views/SharingLinkList.vue142
-rw-r--r--apps/files_sharing/src/views/SharingList.vue63
-rw-r--r--apps/files_sharing/src/views/SharingTab.vue627
76 files changed, 12090 insertions, 0 deletions
diff --git a/apps/files_sharing/src/additionalScripts.js b/apps/files_sharing/src/additionalScripts.js
new file mode 100644
index 00000000000..e8807a7325e
--- /dev/null
+++ b/apps/files_sharing/src/additionalScripts.js
@@ -0,0 +1,15 @@
+/**
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { getCSPNonce } from '@nextcloud/auth'
+
+import './share.js'
+import './sharebreadcrumbview.js'
+import './style/sharebreadcrumb.scss'
+import './collaborationresourceshandler.js'
+
+// eslint-disable-next-line camelcase
+__webpack_nonce__ = getCSPNonce()
+
+window.OCA.Sharing = OCA.Sharing
diff --git a/apps/files_sharing/src/collaborationresourceshandler.js b/apps/files_sharing/src/collaborationresourceshandler.js
new file mode 100644
index 00000000000..6f3645385b7
--- /dev/null
+++ b/apps/files_sharing/src/collaborationresourceshandler.js
@@ -0,0 +1,25 @@
+/**
+ * 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__ = getCSPNonce()
+
+window.OCP.Collaboration.registerType('file', {
+ action: () => {
+ return new Promise((resolve, reject) => {
+ OC.dialogs.filepicker(t('files_sharing', 'Link to a file'), function(f) {
+ const client = OC.Files.getClient()
+ client.getFileInfo(f).then((status, fileInfo) => {
+ resolve(fileInfo.id)
+ }).fail(() => {
+ reject(new Error('Cannot get fileinfo'))
+ })
+ }, false, null, false, OC.dialogs.FILEPICKER_TYPE_CHOOSE, '', { allowDirectoryChooser: true })
+ })
+ },
+ typeString: t('files_sharing', 'Link to a file'),
+ typeIconClass: 'icon-files-dark',
+})
diff --git a/apps/files_sharing/src/components/ExternalShareAction.vue b/apps/files_sharing/src/components/ExternalShareAction.vue
new file mode 100644
index 00000000000..c2c86cc8679
--- /dev/null
+++ b/apps/files_sharing/src/components/ExternalShareAction.vue
@@ -0,0 +1,46 @@
+<!--
+ - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <Component :is="data.is"
+ v-bind="data"
+ v-on="action.handlers">
+ {{ data.text }}
+ </Component>
+</template>
+
+<script>
+import Share from '../models/Share.ts'
+
+export default {
+ name: 'ExternalShareAction',
+
+ props: {
+ id: {
+ type: String,
+ required: true,
+ },
+ action: {
+ type: Object,
+ default: () => ({}),
+ },
+ fileInfo: {
+ type: Object,
+ default: () => {},
+ required: true,
+ },
+ share: {
+ type: Share,
+ default: null,
+ },
+ },
+
+ computed: {
+ data() {
+ return this.action.data(this)
+ },
+ },
+}
+</script>
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
new file mode 100644
index 00000000000..19c9c2aec87
--- /dev/null
+++ b/apps/files_sharing/src/components/PersonalSettings.vue
@@ -0,0 +1,68 @@
+<!--
+ - 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">
+ <h2>{{ t('files_sharing', 'Sharing') }}</h2>
+ <p v-if="!enforceAcceptShares">
+ <input id="files-sharing-personal-settings-accept"
+ v-model="accepting"
+ class="checkbox"
+ type="checkbox"
+ @change="toggleEnabled">
+ <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 />
+ </p>
+ </div>
+</template>
+
+<script>
+import { generateUrl } from '@nextcloud/router'
+import { loadState } from '@nextcloud/initial-state'
+import { showError } from '@nextcloud/dialogs'
+import axios from '@nextcloud/axios'
+
+import SelectShareFolderDialogue from './SelectShareFolderDialogue.vue'
+
+export default {
+ name: 'PersonalSettings',
+ components: {
+ SelectShareFolderDialogue,
+ },
+
+ data() {
+ return {
+ // Share acceptance config
+ accepting: loadState('files_sharing', 'accept_default'),
+ enforceAcceptShares: loadState('files_sharing', 'enforce_accept'),
+
+ // Receiving share folder config
+ allowCustomDirectory: loadState('files_sharing', 'allow_custom_share_folder'),
+ }
+ },
+
+ methods: {
+ async toggleEnabled() {
+ try {
+ await axios.put(generateUrl('/apps/files_sharing/settings/defaultAccept'), {
+ accept: this.accepting,
+ })
+ } catch (error) {
+ showError(t('files_sharing', 'Error while toggling options'))
+ console.error(error)
+ }
+ },
+ },
+}
+</script>
+
+<style scoped lang="scss">
+p {
+ margin-top: 12px;
+ margin-bottom: 12px;
+}
+</style>
diff --git a/apps/files_sharing/src/components/SelectShareFolderDialogue.vue b/apps/files_sharing/src/components/SelectShareFolderDialogue.vue
new file mode 100644
index 00000000000..959fecaa4a4
--- /dev/null
+++ b/apps/files_sharing/src/components/SelectShareFolderDialogue.vue
@@ -0,0 +1,113 @@
+<!--
+ - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <div class="share-folder">
+ <!-- Folder picking form -->
+ <form class="share-folder__form" @reset.prevent.stop="resetFolder">
+ <NcTextField class="share-folder__picker"
+ type="text"
+ :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"
+ class="share-folder__reset"
+ type="reset"
+ :value="t('files_sharing', 'Reset')"
+ :aria-label="t('files_sharing', 'Reset folder to system default')">
+ </form>
+ </div>
+</template>
+
+<script>
+import axios from '@nextcloud/axios'
+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,
+ defaultDirectory,
+ }
+ },
+ computed: {
+ readableDirectory() {
+ if (!this.directory) {
+ return '/'
+ }
+ return this.directory
+ },
+ },
+ methods: {
+ async pickFolder() {
+
+ // Setup file picker
+ const picker = getFilePickerBuilder(t('files_sharing', 'Choose a default folder for accepted shares'))
+ .startAt(this.readableDirectory)
+ .setMultiSelect(false)
+ .setType(1)
+ .setMimeTypeFilter(['httpd/unix-directory'])
+ .allowDirectories()
+ .build()
+
+ try {
+ // Init user folder picking
+ const dir = await picker.pick() || '/'
+ if (!dir.startsWith('/')) {
+ throw new Error(t('files_sharing', 'Invalid path selected'))
+ }
+
+ // Fix potential path issues and save results
+ this.directory = path.normalize(dir)
+ await axios.put(generateUrl('/apps/files_sharing/settings/shareFolder'), {
+ shareFolder: this.directory,
+ })
+ } catch (error) {
+ showError(error.message || t('files_sharing', 'Unknown error'))
+ }
+ },
+
+ resetFolder() {
+ this.directory = this.defaultDirectory
+ axios.delete(generateUrl('/apps/files_sharing/settings/shareFolder'))
+ },
+ },
+}
+</script>
+
+<style scoped lang="scss">
+.share-folder {
+ &__form {
+ display: flex;
+ }
+
+ &__picker {
+ cursor: pointer;
+ max-width: 300px;
+ }
+
+ // Make the reset button looks like text
+ &__reset {
+ background-color: transparent;
+ border: none;
+ font-weight: normal;
+ text-decoration: underline;
+ font-size: inherit;
+ }
+}
+</style>
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/SharingEntry.vue b/apps/files_sharing/src/components/SharingEntry.vue
new file mode 100644
index 00000000000..342b40ce384
--- /dev/null
+++ b/apps/files_sharing/src/components/SharingEntry.vue
@@ -0,0 +1,176 @@
+<!--
+ - 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 !== ShareType.User"
+ :user="share.shareWith"
+ :display-name="share.shareWithDisplayName"
+ :menu-position="'left'"
+ :url="share.shareWithAvatar" />
+
+ <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>
+ </NcButton>
+ </li>
+</template>
+
+<script>
+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: {
+ NcButton,
+ NcAvatar,
+ DotsHorizontalIcon,
+ NcSelect,
+ ShareExpiryTime,
+ SharingEntryQuickShareSelect,
+ },
+
+ mixins: [SharesMixin, ShareDetails],
+
+ computed: {
+ title() {
+ let title = this.share.shareWithDisplayName
+
+ 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 === ShareType.Room) {
+ title += ` (${t('files_sharing', 'conversation')})`
+ } else if (this.share.type === ShareType.Remote && !showAsInternal) {
+ title += ` (${t('files_sharing', 'remote')})`
+ } else if (this.share.type === ShareType.RemoteGroup) {
+ title += ` (${t('files_sharing', 'remote group')})`
+ } 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 = {
+ // todo: strong or italic?
+ // but the t function escape any html from the data :/
+ user: this.share.shareWithDisplayName,
+ owner: this.share.ownerDisplayName,
+ }
+ if (this.share.type === ShareType.Group) {
+ return t('files_sharing', 'Shared with the group {user} by {owner}', data)
+ } else if (this.share.type === ShareType.Room) {
+ return t('files_sharing', 'Shared with the conversation {user} by {owner}', data)
+ }
+
+ return t('files_sharing', 'Shared with {user} by {owner}', data)
+ }
+ return null
+ },
+
+ /**
+ * @return {boolean}
+ */
+ hasStatus() {
+ if (this.share.type !== ShareType.User) {
+ return false
+ }
+
+ return (typeof this.share.status === 'object' && !Array.isArray(this.share.status))
+ },
+ },
+
+ methods: {
+ /**
+ * Save potential changed data on menu close
+ */
+ onMenuClose() {
+ this.onNoteSubmit()
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+.sharing-entry {
+ display: flex;
+ align-items: center;
+ height: 44px;
+ &__summary {
+ padding: 8px;
+ padding-inline-start: 10px;
+ display: flex;
+ flex-direction: column;
+ 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);
+ }
+ }
+ }
+
+}
+</style>
diff --git a/apps/files_sharing/src/components/SharingEntryInherited.vue b/apps/files_sharing/src/components/SharingEntryInherited.vue
new file mode 100644
index 00000000000..e7dfffd5776
--- /dev/null
+++ b/apps/files_sharing/src/components/SharingEntryInherited.vue
@@ -0,0 +1,98 @@
+<!--
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <SharingEntrySimple :key="share.id"
+ class="sharing-entry__inherited"
+ :title="share.shareWithDisplayName">
+ <template #avatar>
+ <NcAvatar :user="share.shareWith"
+ :display-name="share.shareWithDisplayName"
+ class="sharing-entry__avatar" />
+ </template>
+ <NcActionText icon="icon-user">
+ {{ t('files_sharing', 'Added by {initiator}', { initiator: share.ownerDisplayName }) }}
+ </NcActionText>
+ <NcActionLink v-if="share.viaPath && share.viaFileid"
+ icon="icon-folder"
+ :href="viaFileTargetUrl">
+ {{ t('files_sharing', 'Via “{folder}”', {folder: viaFolderName} ) }}
+ </NcActionLink>
+ <NcActionButton v-if="share.canDelete"
+ icon="icon-close"
+ @click.prevent="onDelete">
+ {{ t('files_sharing', 'Unshare') }}
+ </NcActionButton>
+ </SharingEntrySimple>
+</template>
+
+<script>
+import { generateUrl } from '@nextcloud/router'
+import { basename } from '@nextcloud/paths'
+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.js'
+import SharesMixin from '../mixins/SharesMixin.js'
+import SharingEntrySimple from '../components/SharingEntrySimple.vue'
+
+export default {
+ name: 'SharingEntryInherited',
+
+ components: {
+ NcActionButton,
+ NcActionLink,
+ NcActionText,
+ NcAvatar,
+ SharingEntrySimple,
+ },
+
+ mixins: [SharesMixin],
+
+ props: {
+ share: {
+ type: Share,
+ required: true,
+ },
+ },
+
+ computed: {
+ viaFileTargetUrl() {
+ return generateUrl('/f/{fileid}', {
+ fileid: this.share.viaFileid,
+ })
+ },
+
+ viaFolderName() {
+ return basename(this.share.viaPath)
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+.sharing-entry {
+ display: flex;
+ align-items: center;
+ height: 44px;
+ &__desc {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ padding: 8px;
+ padding-inline-start: 10px;
+ line-height: 1.2em;
+ p {
+ color: var(--color-text-maxcontrast);
+ }
+ }
+ &__actions {
+ margin-inline-start: auto;
+ }
+}
+</style>
diff --git a/apps/files_sharing/src/components/SharingEntryInternal.vue b/apps/files_sharing/src/components/SharingEntryInternal.vue
new file mode 100644
index 00000000000..027d2a3d5c3
--- /dev/null
+++ b/apps/files_sharing/src/components/SharingEntryInternal.vue
@@ -0,0 +1,133 @@
+<!--
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <ul>
+ <SharingEntrySimple ref="shareEntrySimple"
+ class="sharing-entry__internal"
+ :title="t('files_sharing', 'Internal link')"
+ :subtitle="internalLinkSubtitle">
+ <template #avatar>
+ <div class="avatar-external icon-external-white" />
+ </template>
+
+ <NcActionButton :title="copyLinkTooltip"
+ :aria-label="copyLinkTooltip"
+ @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>
+
+<script>
+import { generateUrl } from '@nextcloud/router'
+import { showSuccess } from '@nextcloud/dialogs'
+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: {
+ NcActionButton,
+ SharingEntrySimple,
+ CheckIcon,
+ ClipboardIcon,
+ },
+
+ props: {
+ fileInfo: {
+ type: Object,
+ default: () => {},
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ copied: false,
+ copySuccess: false,
+ }
+ },
+
+ computed: {
+ /**
+ * Get the internal link to this file id
+ *
+ * @return {string}
+ */
+ internalLink() {
+ return window.location.protocol + '//' + window.location.host + generateUrl('/f/') + this.fileInfo.id
+ },
+
+ /**
+ * Tooltip message
+ *
+ * @return {string}
+ */
+ copyLinkTooltip() {
+ if (this.copied) {
+ if (this.copySuccess) {
+ return ''
+ }
+ return t('files_sharing', 'Cannot copy, please copy the link manually')
+ }
+ return t('files_sharing', 'Copy internal link')
+ },
+
+ internalLinkSubtitle() {
+ return t('files_sharing', 'For people who already have access')
+ },
+ },
+
+ methods: {
+ async copyLink() {
+ try {
+ await navigator.clipboard.writeText(this.internalLink)
+ showSuccess(t('files_sharing', 'Link copied'))
+ this.$refs.shareEntrySimple.$refs.actionsComponent.$el.focus()
+ this.copySuccess = true
+ this.copied = true
+ } catch (error) {
+ this.copySuccess = false
+ this.copied = true
+ console.error(error)
+ } finally {
+ setTimeout(() => {
+ this.copySuccess = false
+ this.copied = false
+ }, 4000)
+ }
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+.sharing-entry__internal {
+ .avatar-external {
+ width: 32px;
+ height: 32px;
+ line-height: 32px;
+ font-size: 18px;
+ background-color: var(--color-text-maxcontrast);
+ border-radius: 50%;
+ flex-shrink: 0;
+ }
+ .icon-checkmark-color {
+ opacity: 1;
+ color: var(--color-success);
+ }
+}
+</style>
diff --git a/apps/files_sharing/src/components/SharingEntryLink.vue b/apps/files_sharing/src/components/SharingEntryLink.vue
new file mode 100644
index 00000000000..6865af1b864
--- /dev/null
+++ b/apps/files_sharing/src/components/SharingEntryLink.vue
@@ -0,0 +1,987 @@
+<!--
+ - 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">
+ <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__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 && pendingDataIsMissing"
+ class="sharing-entry__actions"
+ :aria-label="actionsTooltip"
+ menu-align="right"
+ :open.sync="open"
+ @close="onCancel">
+ <!-- pending data menu -->
+ <NcActionText v-if="errors.pending"
+ class="error">
+ <template #icon>
+ <ErrorIcon :size="20" />
+ </template>
+ {{ errors.pending }}
+ </NcActionText>
+ <NcActionText v-else icon="icon-info">
+ {{ t('files_sharing', 'Please enter the following required information before creating the share') }}
+ </NcActionText>
+
+ <!-- password -->
+ <NcActionCheckbox v-if="pendingPassword"
+ :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 protection') }}
+ </NcActionCheckbox>
+
+ <NcActionInput v-if="pendingEnforcedPassword || isPasswordProtected"
+ class="share-link-password"
+ :label="t('files_sharing', 'Enter a password')"
+ :value.sync="share.newPassword"
+ :disabled="saving"
+ :required="config.enableLinkPasswordByDefault || config.enforcePasswordForPublicLink"
+ :minlength="isPasswordPolicyEnabled && config.passwordPolicy.minLength"
+ autocomplete="new-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 -->
+ <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="maxExpirationDateEnforced"
+ @update:model-value="onExpirationChange"
+ @change="expirationDateChanged">
+ <template #icon>
+ <IconCalendarBlank :size="20" />
+ </template>
+ </NcActionInput>
+
+ <NcActionButton :disabled="pendingEnforcedPassword && !share.newPassword"
+ @click.prevent.stop="onNewLinkShare(true)">
+ <template #icon>
+ <CheckIcon :size="20" />
+ </template>
+ {{ t('files_sharing', 'Create share') }}
+ </NcActionButton>
+ <NcActionButton @click.prevent.stop="onCancel">
+ <template #icon>
+ <CloseIcon :size="20" />
+ </template>
+ {{ t('files_sharing', 'Cancel') }}
+ </NcActionButton>
+ </NcActions>
+
+ <!-- actions -->
+ <NcActions v-else-if="!loading"
+ class="sharing-entry__actions"
+ :aria-label="actionsTooltip"
+ menu-align="right"
+ :open.sync="open"
+ @close="onMenuClose">
+ <template v-if="share">
+ <template v-if="share.canEdit && canReshare">
+ <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 -->
+ <ExternalShareAction v-for="action in externalLinkActions"
+ :id="action.id"
+ :key="action.id"
+ :action="action"
+ :file-info="fileInfo"
+ :share="share" />
+
+ <!-- external legacy sharing via url (social...) -->
+ <NcActionLink v-for="({ icon, url, name }, actionIndex) in externalLegacyLinkActions"
+ :key="actionIndex"
+ :href="url(shareLink)"
+ :icon="icon"
+ target="_blank">
+ {{ name }}
+ </NcActionLink>
+
+ <NcActionButton v-if="!isEmailShareType && canReshare"
+ class="new-share-link"
+ @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 -->
+ <NcActionButton v-else-if="canReshare"
+ class="new-share-link"
+ :title="t('files_sharing', 'Create a new share link')"
+ :aria-label="t('files_sharing', 'Create a new share link')"
+ :icon="loading ? 'icon-loading-small' : 'icon-add'"
+ @click.prevent.stop="onNewLinkShare" />
+ </NcActions>
+
+ <!-- loading indicator to replace the menu -->
+ <div v-else class="icon-loading-small sharing-entry__loading" />
+
+ <!-- Modal to open whenever we have a QR code -->
+ <NcDialog v-if="showQRCode"
+ size="normal"
+ :open.sync="showQRCode"
+ :name="title"
+ :close-on-click-outside="true"
+ @close="showQRCode = false">
+ <div class="qr-code-dialog">
+ <VueQrcode tag="img"
+ :value="shareLink"
+ class="qr-code-dialog__img" />
+ </div>
+ </NcDialog>
+ </li>
+</template>
+
+<script>
+import { showError, showSuccess } from '@nextcloud/dialogs'
+import { emit } from '@nextcloud/event-bus'
+import { t } from '@nextcloud/l10n'
+import moment from '@nextcloud/moment'
+import { generateUrl, getBaseUrl } from '@nextcloud/router'
+import { ShareType } from '@nextcloud/sharing'
+
+import VueQrcode from '@chenfengyuan/vue-qrcode'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcActionCheckbox from '@nextcloud/vue/components/NcActionCheckbox'
+import NcActionInput from '@nextcloud/vue/components/NcActionInput'
+import NcActionLink from '@nextcloud/vue/components/NcActionLink'
+import NcActionText from '@nextcloud/vue/components/NcActionText'
+import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcDialog from '@nextcloud/vue/components/NcDialog'
+
+import Tune from 'vue-material-design-icons/Tune.vue'
+import IconCalendarBlank from 'vue-material-design-icons/CalendarBlankOutline.vue'
+import IconQr from 'vue-material-design-icons/Qrcode.vue'
+import ErrorIcon from 'vue-material-design-icons/Exclamation.vue'
+import LockIcon from 'vue-material-design-icons/LockOutline.vue'
+import CheckIcon from 'vue-material-design-icons/CheckBold.vue'
+import ClipboardIcon from 'vue-material-design-icons/ContentCopy.vue'
+import CloseIcon from 'vue-material-design-icons/Close.vue'
+import PlusIcon from 'vue-material-design-icons/Plus.vue'
+
+import SharingEntryQuickShareSelect from './SharingEntryQuickShareSelect.vue'
+import ShareExpiryTime from './ShareExpiryTime.vue'
+
+import ExternalShareAction from './ExternalShareAction.vue'
+import GeneratePassword from '../utils/GeneratePassword.ts'
+import Share from '../models/Share.ts'
+import SharesMixin from '../mixins/SharesMixin.js'
+import ShareDetails from '../mixins/ShareDetails.js'
+import logger from '../services/logger.ts'
+
+export default {
+ name: 'SharingEntryLink',
+
+ components: {
+ ExternalShareAction,
+ NcActions,
+ NcActionButton,
+ NcActionCheckbox,
+ NcActionInput,
+ NcActionLink,
+ NcActionText,
+ NcActionSeparator,
+ NcAvatar,
+ NcDialog,
+ VueQrcode,
+ Tune,
+ IconCalendarBlank,
+ IconQr,
+ ErrorIcon,
+ LockIcon,
+ CheckIcon,
+ ClipboardIcon,
+ CloseIcon,
+ PlusIcon,
+ SharingEntryQuickShareSelect,
+ ShareExpiryTime,
+ },
+
+ mixins: [SharesMixin, ShareDetails],
+
+ props: {
+ canReshare: {
+ type: Boolean,
+ default: true,
+ },
+ index: {
+ type: Number,
+ default: null,
+ },
+ },
+
+ data() {
+ return {
+ shareCreationComplete: false,
+ copySuccess: true,
+ copied: false,
+ defaultExpirationDateEnabled: false,
+
+ // Are we waiting for password/expiration date
+ pending: false,
+
+ ExternalLegacyLinkActions: OCA.Sharing.ExternalLinkActions.state,
+ ExternalShareActions: OCA.Sharing.ExternalShareActions.state,
+
+ // tracks whether modal should be opened or not
+ showQRCode: false,
+ }
+ },
+
+ computed: {
+ /**
+ * Link share label
+ *
+ * @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) {
+ if (this.isEmailShareType) {
+ return t('files_sharing', '{shareWith} by {initiator}', {
+ shareWith: this.share.shareWith,
+ initiator: this.share.ownerDisplayName,
+ }, l10nOptions)
+ }
+ return t('files_sharing', 'Shared via link by {initiator}', {
+ initiator: this.share.ownerDisplayName,
+ }, l10nOptions)
+ }
+ if (this.share.label && this.share.label.trim() !== '') {
+ if (this.isEmailShareType) {
+ if (this.isFileRequest) {
+ return t('files_sharing', 'File request ({label})', {
+ label: this.share.label.trim(),
+ }, l10nOptions)
+ }
+ return t('files_sharing', 'Mail share ({label})', {
+ label: this.share.label.trim(),
+ }, l10nOptions)
+ }
+ return t('files_sharing', 'Share link ({label})', {
+ label: this.share.label.trim(),
+ }, l10nOptions)
+ }
+ if (this.isEmailShareType) {
+ if (!this.share.shareWith || this.share.shareWith.trim() === '') {
+ return this.isFileRequest
+ ? t('files_sharing', 'File request')
+ : t('files_sharing', 'Mail share')
+ }
+ return this.share.shareWith
+ }
+
+ if (this.index === null) {
+ return t('files_sharing', 'Share link')
+ }
+ }
+
+ if (this.index >= 1) {
+ return t('files_sharing', 'Share link ({index})', { index: this.index })
+ }
+
+ return t('files_sharing', 'Create public link')
+ },
+
+ /**
+ * Show the email on a second line if a label is set for mail shares
+ *
+ * @return {string}
+ */
+ subtitle() {
+ if (this.isEmailShareType
+ && this.title !== this.share.shareWith) {
+ return this.share.shareWith
+ }
+ return null
+ },
+
+ passwordExpirationTime() {
+ if (this.share.passwordExpirationTime === null) {
+ 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.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
+ }
+
+ // Anything else should be fine
+ return true
+ },
+
+ /**
+ * Pending data.
+ * If the share still doesn't have an id, it is not synced
+ * Therefore this is still not valid and requires user input
+ *
+ * @return {boolean}
+ */
+ pendingDataIsMissing() {
+ return this.pendingPassword || this.pendingEnforcedPassword || this.pendingDefaultExpirationDate || this.pendingEnforcedExpirationDate
+ },
+ pendingPassword() {
+ return this.config.enableLinkPasswordByDefault && this.isPendingShare
+ },
+ pendingEnforcedPassword() {
+ return this.config.enforcePasswordForPublicLink && this.isPendingShare
+ },
+ pendingEnforcedExpirationDate() {
+ return this.config.isDefaultExpireDateEnforced && this.isPendingShare
+ },
+ 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() {
+ return this.share.newPassword !== undefined
+ },
+
+ /**
+ * Return the public share link
+ *
+ * @return {string}
+ */
+ shareLink() {
+ return generateUrl('/s/{token}', { token: this.share.token }, { baseURL: getBaseUrl() })
+ },
+
+ /**
+ * Tooltip message for actions button
+ *
+ * @return {string}
+ */
+ actionsTooltip() {
+ return t('files_sharing', 'Actions for "{title}"', { title: this.title })
+ },
+
+ /**
+ * Tooltip message for copy button
+ *
+ * @return {string}
+ */
+ copyLinkTooltip() {
+ if (this.copied) {
+ if (this.copySuccess) {
+ return ''
+ }
+ return t('files_sharing', 'Cannot copy, please copy the link manually')
+ }
+ return t('files_sharing', 'Copy public link of "{title}"', { title: this.title })
+ },
+
+ /**
+ * External additionnai actions for the menu
+ *
+ * @deprecated use OCA.Sharing.ExternalShareActions
+ * @return {Array}
+ */
+ externalLegacyLinkActions() {
+ return this.ExternalLegacyLinkActions.actions
+ },
+
+ /**
+ * 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 registered actions for said link
+ return this.ExternalShareActions.actions
+ .filter(filterValidAction)
+ },
+
+ isPasswordPolicyEnabled() {
+ return typeof this.config.passwordPolicy === 'object'
+ },
+
+ canChangeHideDownload() {
+ const hasDisabledDownload = (shareAttribute) => shareAttribute.scope === 'permissions' && shareAttribute.key === 'download' && shareAttribute.value === false
+ return this.fileInfo.shareAttributes.some(hasDisabledDownload)
+ },
+
+ isFileRequest() {
+ return this.share.isFileRequest
+ },
+ },
+ mounted() {
+ this.defaultExpirationDateEnabled = this.config.defaultExpirationDate instanceof Date
+ if (this.share && this.isNewShare) {
+ this.share.expireDate = this.defaultExpirationDateEnabled ? this.formatDateToString(this.config.defaultExpirationDate) : ''
+ }
+ },
+
+ methods: {
+ /**
+ * Check if the share requires review
+ *
+ * @param {boolean} shareReviewComplete if the share was reviewed
+ * @return {boolean}
+ */
+ shareRequiresReview(shareReviewComplete) {
+ // If a user clicks 'Create share' it means they have reviewed the share
+ if (shareReviewComplete) {
+ return false
+ }
+ return this.defaultExpirationDateEnabled || this.config.enableLinkPasswordByDefault
+ },
+ /**
+ * Create a new share link and append it to the list
+ * @param {boolean} shareReviewComplete if the share was reviewed
+ */
+ async onNewLinkShare(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: 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)
+ }
+
+ 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
+
+ logger.info('Share policy requires a review or has mandated properties (password, expirationDate)...')
+
+ // ELSE, show the pending popovermenu
+ // 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)
+ })
+
+ // open the menu on the
+ // freshly created share component
+ this.open = false
+ this.pending = false
+ component.open = true
+
+ // 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
+ }
+ },
+
+ /**
+ * Push a new link share to the server
+ * And update or append to the list
+ * accordingly
+ *
+ * @param {Share} share the new share
+ * @param {boolean} [update] do we update the current share ?
+ */
+ async pushNewLinkShare(share, update) {
+ try {
+ // do nothing if we're already pending creation
+ if (this.loading) {
+ return true
+ }
+
+ this.loading = true
+ this.errors = {}
+
+ const path = (this.fileInfo.path + '/' + this.fileInfo.name).replace('//', '/')
+ const options = {
+ path,
+ shareType: ShareType.Link,
+ password: share.password,
+ 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 requests
+ // (currently not supported on create, only update)
+ }
+
+ console.debug('Creating link share with options', options)
+ const newShare = await this.createShare(options)
+
+ this.open = false
+ this.shareCreationComplete = true
+ console.debug('Link share created', newShare)
+ // if share already exists, copy link directly on next tick
+ let component
+ if (update) {
+ component = await new Promise(resolve => {
+ this.$emit('update:share', newShare, resolve)
+ })
+ } else {
+ // adding new share to the array and copying link to clipboard
+ // using promise so that we can copy link in the same click function
+ // and avoid firefox copy permissions issue
+ component = await new Promise(resolve => {
+ this.$emit('add:share', newShare, resolve)
+ })
+ }
+
+ await this.getNode()
+ emit('files:node:updated', this.node)
+
+ // Execute the copy link method
+ // freshly created share component
+ // ! somehow does not works on firefox !
+ if (!this.config.enforcePasswordForPublicLink) {
+ // Only copy the link when the password was not forced,
+ // otherwise the user needs to copy/paste the password before finishing the share.
+ component.copyLink()
+ }
+ showSuccess(t('files_sharing', 'Link share created'))
+
+ } catch (data) {
+ const message = data?.response?.data?.ocs?.meta?.message
+ if (!message) {
+ showError(t('files_sharing', 'Error while creating the share'))
+ console.error(data)
+ return
+ }
+
+ if (message.match(/password/i)) {
+ this.onSyncError('password', message)
+ } else if (message.match(/date/i)) {
+ this.onSyncError('expireDate', message)
+ } else {
+ this.onSyncError('pending', message)
+ }
+ throw data
+
+ } finally {
+ this.loading = false
+ this.shareCreationComplete = true
+ }
+ },
+ async copyLink() {
+ try {
+ await navigator.clipboard.writeText(this.shareLink)
+ showSuccess(t('files_sharing', 'Link copied'))
+ // focus and show the tooltip
+ this.$refs.copyButton.$el.focus()
+ this.copySuccess = true
+ this.copied = true
+ } catch (error) {
+ this.copySuccess = false
+ this.copied = true
+ console.error(error)
+ } finally {
+ setTimeout(() => {
+ this.copySuccess = false
+ this.copied = false
+ }, 4000)
+ }
+ },
+
+ /**
+ * Update newPassword values
+ * of share. If password is set but not newPassword
+ * then the user did not changed the password
+ * If both co-exists, the password have changed and
+ * we show it in plain text.
+ * Then on submit (or menu close), we sync it.
+ *
+ * @param {string} password the changed password
+ */
+ onPasswordChange(password) {
+ this.$set(this.share, 'newPassword', password)
+ },
+
+ /**
+ * Uncheck password protection
+ * We need this method because @update:checked
+ * is ran simultaneously as @uncheck, so we
+ * cannot ensure data is up-to-date
+ */
+ onPasswordDisable() {
+ this.share.password = ''
+
+ // reset password state after sync
+ this.$delete(this.share, 'newPassword')
+
+ // only update if valid share.
+ if (this.share.id) {
+ this.queueUpdate('password')
+ }
+ },
+
+ /**
+ * Menu have been closed or password has been submitted.
+ * The only property that does not get
+ * synced automatically is the password
+ * So let's check if we have an unsaved
+ * password.
+ * expireDate is saved on datepicker pick
+ * or close.
+ */
+ onPasswordSubmit() {
+ if (this.hasUnsavedPassword) {
+ this.share.newPassword = this.share.newPassword.trim()
+ this.queueUpdate('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() {
+ if (this.hasUnsavedPassword) {
+ this.share.newPassword = this.share.newPassword.trim()
+ }
+
+ this.queueUpdate('sendPasswordByTalk', 'password')
+ },
+
+ /**
+ * Save potential changed data on menu close
+ */
+ onMenuClose() {
+ this.onPasswordSubmit()
+ this.onNoteSubmit()
+ },
+
+ /**
+ * @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
+ */
+ onCancel() {
+ // this.share already exists at this point,
+ // but is incomplete as not pushed to server
+ // YET. We can safely delete the share :)
+ if (!this.shareCreationComplete) {
+ this.$emit('remove:share', this.share)
+ }
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+.sharing-entry {
+ display: flex;
+ align-items: center;
+ min-height: 44px;
+
+ &__summary {
+ padding: 8px;
+ padding-inline-start: 10px;
+ display: flex;
+ justify-content: space-between;
+ 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;
+ }
+ }
+
+ &__actions {
+ display: flex;
+ align-items: center;
+ margin-inline-start: auto;
+ }
+
+ &:not(.sharing-entry--share) &__actions {
+ .new-share-link {
+ border-top: 1px solid var(--color-border);
+ }
+ }
+
+ :deep(.avatar-link-share) {
+ background-color: var(--color-primary-element);
+ }
+
+ .sharing-entry__action--public-upload {
+ border-bottom: 1px solid var(--color-border);
+ }
+
+ &__loading {
+ width: 44px;
+ height: 44px;
+ margin: 0;
+ padding: 14px;
+ margin-inline-start: auto;
+ }
+
+ // put menus to the left
+ // but only the first one
+ .action-item {
+
+ ~.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
new file mode 100644
index 00000000000..a00333ba0ce
--- /dev/null
+++ b/apps/files_sharing/src/components/SharingEntrySimple.vue
@@ -0,0 +1,92 @@
+<!--
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <li class="sharing-entry">
+ <slot name="avatar" />
+ <div class="sharing-entry__desc">
+ <span class="sharing-entry__title">{{ title }}</span>
+ <p v-if="subtitle">
+ {{ subtitle }}
+ </p>
+ </div>
+ <NcActions v-if="$slots['default']"
+ ref="actionsComponent"
+ class="sharing-entry__actions"
+ menu-align="right"
+ :aria-expanded="ariaExpandedValue">
+ <slot />
+ </NcActions>
+ </li>
+</template>
+
+<script>
+import NcActions from '@nextcloud/vue/components/NcActions'
+
+export default {
+ name: 'SharingEntrySimple',
+
+ components: {
+ NcActions,
+ },
+
+ props: {
+ title: {
+ type: String,
+ default: '',
+ required: true,
+ },
+ subtitle: {
+ type: String,
+ default: '',
+ },
+ isUnique: {
+ type: Boolean,
+ default: true,
+ },
+ ariaExpanded: {
+ type: Boolean,
+ default: null,
+ },
+ },
+
+ computed: {
+ ariaExpandedValue() {
+ if (this.ariaExpanded === null) {
+ return this.ariaExpanded
+ }
+ return this.ariaExpanded ? 'true' : 'false'
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+.sharing-entry {
+ display: flex;
+ align-items: center;
+ min-height: 44px;
+ &__desc {
+ padding: 8px;
+ padding-inline-start: 10px;
+ line-height: 1.2em;
+ position: relative;
+ flex: 1 1;
+ min-width: 0;
+ p {
+ color: var(--color-text-maxcontrast);
+ }
+ }
+ &__title {
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ max-width: inherit;
+ }
+ &__actions {
+ margin-inline-start: auto !important;
+ }
+}
+</style>
diff --git a/apps/files_sharing/src/components/SharingInput.vue b/apps/files_sharing/src/components/SharingInput.vue
new file mode 100644
index 00000000000..6fb33aba6b2
--- /dev/null
+++ b/apps/files_sharing/src/components/SharingInput.vue
@@ -0,0 +1,530 @@
+<!--
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <div class="sharing-search">
+ <label class="hidden-visually" :for="shareInputId">
+ {{ isExternal ? t('files_sharing', 'Enter external recipients')
+ : t('files_sharing', 'Search for internal recipients') }}
+ </label>
+ <NcSelect ref="select"
+ v-model="value"
+ :input-id="shareInputId"
+ class="sharing-search__input"
+ :disabled="!canReshare"
+ :loading="loading"
+ :filterable="false"
+ :placeholder="inputPlaceholder"
+ :clear-search-on-blur="() => false"
+ :user-select="true"
+ :options="options"
+ :label-outside="true"
+ @search="asyncFind"
+ @option:selected="onSelected">
+ <template #no-options="{ search }">
+ {{ search ? noResultText : placeholder }}
+ </template>
+ </NcSelect>
+ </div>
+</template>
+
+<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/components/NcSelect'
+
+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',
+
+ components: {
+ NcSelect,
+ },
+
+ mixins: [ShareRequests, ShareDetails],
+
+ props: {
+ shares: {
+ type: Array,
+ default: () => [],
+ required: true,
+ },
+ linkShares: {
+ type: Array,
+ default: () => [],
+ required: true,
+ },
+ fileInfo: {
+ type: Object,
+ default: () => {},
+ required: true,
+ },
+ reshare: {
+ type: Share,
+ default: null,
+ },
+ canReshare: {
+ type: Boolean,
+ required: true,
+ },
+ isExternal: {
+ type: Boolean,
+ default: false,
+ },
+ placeholder: {
+ type: String,
+ default: '',
+ },
+ },
+
+ setup() {
+ return {
+ shareInputId: `share-input-${Math.random().toString(36).slice(2, 7)}`,
+ }
+ },
+
+ data() {
+ return {
+ config: new Config(),
+ loading: false,
+ query: '',
+ recommendations: [],
+ ShareSearch: OCA.Sharing.ShareSearch.state,
+ suggestions: [],
+ value: null,
+ }
+ },
+
+ computed: {
+ /**
+ * Implement ShareSearch
+ * allows external appas to inject new
+ * results into the autocomplete dropdown
+ * Used for the guests app
+ *
+ * @return {Array}
+ */
+ externalResults() {
+ return this.ShareSearch.results
+ },
+ inputPlaceholder() {
+ const allowRemoteSharing = this.config.isRemoteShareAllowed
+
+ 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 …')
+ }
+
+ return t('files_sharing', 'Name, email, or Federated Cloud ID …')
+ },
+
+ isValidQuery() {
+ return this.query && this.query.trim() !== '' && this.query.length > this.config.minSearchStringLength
+ },
+
+ options() {
+ if (this.isValidQuery) {
+ return this.suggestions
+ }
+ return this.recommendations
+ },
+
+ noResultText() {
+ if (this.loading) {
+ return t('files_sharing', 'Searching …')
+ }
+ return t('files_sharing', 'No elements found.')
+ },
+ },
+
+ mounted() {
+ 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
+ this.query = query.trim()
+ if (this.isValidQuery) {
+ // start loading now to have proper ux feedback
+ // during the debounce
+ this.loading = true
+ await this.debounceGetSuggestions(query)
+ }
+ },
+
+ /**
+ * Get suggestions
+ *
+ * @param {string} search the search query
+ * @param {boolean} [lookup] search on lookup server
+ */
+ async getSuggestions(search, lookup = false) {
+ this.loading = true
+
+ if (getCapabilities().files_sharing.sharee.query_lookup_default === true) {
+ lookup = true
+ }
+
+ 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
+ try {
+ request = await axios.get(generateOcsUrl('apps/files_sharing/api/v1/sharees'), {
+ params: {
+ format: 'json',
+ itemType: this.fileInfo.type === 'dir' ? 'folder' : 'file',
+ search,
+ lookup,
+ perPage: this.config.maxAutocompleteResults,
+ shareType,
+ },
+ })
+ } catch (error) {
+ console.error('Error fetching suggestions', error)
+ return
+ }
+
+ const { exact, ...data } = request.data.ocs.data
+ // flatten array of arrays
+ 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)
+ .map(share => this.formatForMultiselect(share))
+ // sort by type so we can get user&groups first...
+ .sort((a, b) => a.shareType - b.shareType)
+ const suggestions = this.filterOutExistingShares(rawSuggestions)
+ .map(share => this.formatForMultiselect(share))
+ // sort by type so we can get user&groups first...
+ .sort((a, b) => a.shareType - b.shareType)
+
+ // lookup clickable entry
+ // show if enabled and not already requested
+ const lookupEntry = []
+ if (data.lookupEnabled && !lookup) {
+ lookupEntry.push({
+ id: 'global-lookup',
+ isNoUser: true,
+ displayName: t('files_sharing', 'Search everywhere'),
+ lookup: true,
+ })
+ }
+
+ // if there is a condition specified, filter it
+ const externalResults = this.externalResults.filter(result => !result.condition || result.condition(this))
+
+ const allSuggestions = exactSuggestions.concat(suggestions).concat(externalResults).concat(lookupEntry)
+
+ // Count occurrences of display names in order to provide a distinguishable description if needed
+ const nameCounts = allSuggestions.reduce((nameCounts, result) => {
+ if (!result.displayName) {
+ return nameCounts
+ }
+ if (!nameCounts[result.displayName]) {
+ nameCounts[result.displayName] = 0
+ }
+ nameCounts[result.displayName]++
+ return nameCounts
+ }, {})
+
+ this.suggestions = allSuggestions.map(item => {
+ // Make sure that items with duplicate displayName get the shareWith applied as a description
+ if (nameCounts[item.displayName] > 1 && !item.desc) {
+ return { ...item, desc: item.shareWithDisplayNameUnique }
+ }
+ return item
+ })
+
+ this.loading = false
+ console.info('suggestions', this.suggestions)
+ },
+
+ /**
+ * Debounce getSuggestions
+ *
+ * @param {...*} args the arguments
+ */
+ debounceGetSuggestions: debounce(function(...args) {
+ this.getSuggestions(...args)
+ }, 300),
+
+ /**
+ * Get the sharing recommendations
+ */
+ async getRecommendations() {
+ this.loading = true
+
+ let request = null
+ try {
+ request = await axios.get(generateOcsUrl('apps/files_sharing/api/v1/sharees_recommended'), {
+ params: {
+ format: 'json',
+ itemType: this.fileInfo.type,
+ },
+ })
+ } catch (error) {
+ console.error('Error fetching recommendations', error)
+ return
+ }
+
+ // Add external results from the OCA.Sharing.ShareSearch api
+ const externalResults = this.externalResults.filter(result => !result.condition || result.condition(this))
+
+ // flatten array of arrays
+ const rawRecommendations = Object.values(request.data.ocs.data.exact)
+ .reduce((arr, elem) => arr.concat(elem), [])
+
+ // remove invalid data and format to user-select layout
+ this.recommendations = this.filterOutExistingShares(rawRecommendations)
+ .map(share => this.formatForMultiselect(share))
+ .concat(externalResults)
+
+ this.loading = false
+ console.info('recommendations', this.recommendations)
+ },
+
+ /**
+ * Filter out existing shares from
+ * the provided shares search results
+ *
+ * @param {object[]} shares the array of shares object
+ * @return {object[]}
+ */
+ filterOutExistingShares(shares) {
+ return shares.reduce((arr, share) => {
+ // only check proper objects
+ if (typeof share !== 'object') {
+ return arr
+ }
+ try {
+ if (share.value.shareType === ShareType.User) {
+ // filter out current user
+ if (share.value.shareWith === getCurrentUser().uid) {
+ return arr
+ }
+
+ // filter out the owner of the share
+ if (this.reshare && share.value.shareWith === this.reshare.owner) {
+ return arr
+ }
+ }
+
+ // filter out existing mail shares
+ if (share.value.shareType === 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
+ }
+ } else { // filter out existing shares
+ // creating an object of uid => type
+ const sharesObj = this.shares.reduce((obj, elem) => {
+ obj[elem.shareWith] = elem.type
+ return obj
+ }, {})
+
+ // if shareWith is the same and the share type too, ignore it
+ const key = share.value.shareWith.trim()
+ if (key in sharesObj
+ && sharesObj[key] === share.value.shareType) {
+ return arr
+ }
+ }
+
+ // ALL GOOD
+ // let's add the suggestion
+ arr.push(share)
+ } catch {
+ return arr
+ }
+ return arr
+ }, [])
+ },
+
+ /**
+ * Get the icon based on the share type
+ *
+ * @param {number} type the share type
+ * @return {string} the icon class
+ */
+ shareTypeToIcon(type) {
+ switch (type) {
+ 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 ShareType.Remote:
+ // case ShareType.User:
+ return {
+ icon: 'icon-user',
+ iconTitle: t('files_sharing', 'Guest'),
+ }
+ case ShareType.RemoteGroup:
+ case ShareType.Group:
+ return {
+ icon: 'icon-group',
+ iconTitle: t('files_sharing', 'Group'),
+ }
+ case ShareType.Email:
+ return {
+ icon: 'icon-mail',
+ iconTitle: t('files_sharing', 'Email'),
+ }
+ case ShareType.Team:
+ return {
+ icon: 'icon-teams',
+ iconTitle: t('files_sharing', 'Team'),
+ }
+ case ShareType.Room:
+ return {
+ icon: 'icon-room',
+ iconTitle: t('files_sharing', 'Talk conversation'),
+ }
+ 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 {}
+ }
+ },
+
+ /**
+ * Format shares for the multiselect options
+ *
+ * @param {object} result select entry item
+ * @return {object}
+ */
+ formatForMultiselect(result) {
+ 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 {
+ subname = result.shareWithDescription ?? ''
+ }
+
+ return {
+ shareWith: result.value.shareWith,
+ shareType: result.value.shareType,
+ user: result.uuid || result.value.shareWith,
+ isNoUser: result.value.shareType !== ShareType.User,
+ displayName,
+ subname,
+ shareWithDisplayNameUnique: result.shareWithDisplayNameUnique || '',
+ ...this.shareTypeToIcon(result.value.shareType),
+ }
+ },
+ },
+}
+</script>
+
+<style lang="scss">
+.sharing-search {
+ display: flex;
+ flex-direction: column;
+ margin-bottom: 4px;
+
+ label[for="sharing-search-input"] {
+ margin-bottom: 2px;
+ }
+
+ &__input {
+ width: 100%;
+ margin: 10px 0;
+ }
+}
+
+.vs__dropdown-menu {
+ // properly style the lookup entry
+ span[lookup] {
+ .avatardiv {
+ background-image: var(--icon-search-white);
+ background-repeat: no-repeat;
+ background-position: center;
+ background-color: var(--color-text-maxcontrast) !important;
+ .avatardiv__initials-wrapper {
+ display: none;
+ }
+ }
+ }
+}
+</style>
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_tab.js b/apps/files_sharing/src/files_sharing_tab.js
new file mode 100644
index 00000000000..6afcfa76717
--- /dev/null
+++ b/apps/files_sharing/src/files_sharing_tab.js
@@ -0,0 +1,71 @@
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import Vue from 'vue'
+import { getCSPNonce } from '@nextcloud/auth'
+import { t, n } from '@nextcloud/l10n'
+
+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 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 = {}
+}
+Object.assign(window.OCA.Sharing, { ShareSearch: new ShareSearch() })
+Object.assign(window.OCA.Sharing, { ExternalLinkActions: new ExternalLinkActions() })
+Object.assign(window.OCA.Sharing, { ExternalShareActions: new ExternalShareActions() })
+Object.assign(window.OCA.Sharing, { ShareTabSections: new TabSections() })
+
+Vue.prototype.t = t
+Vue.prototype.n = n
+
+// Init Sharing tab component
+let TabInstance = null
+
+window.addEventListener('DOMContentLoaded', function() {
+ if (OCA.Files && OCA.Files.Sidebar) {
+ OCA.Files.Sidebar.registerTab(new OCA.Files.Sidebar.Tab({
+ id: 'sharing',
+ name: t('files_sharing', 'Sharing'),
+ iconSvg: ShareVariant,
+
+ async mount(el, fileInfo, context) {
+ const SharingTab = (await import('./views/SharingTab.vue')).default
+ const View = Vue.extend(SharingTab)
+
+ if (TabInstance) {
+ TabInstance.$destroy()
+ }
+ TabInstance = new View({
+ // Better integration with vue parent component
+ parent: context,
+ })
+ // Only mount after we have all the info we need
+ await TabInstance.update(fileInfo)
+ TabInstance.$mount(el)
+ },
+
+ update(fileInfo) {
+ TabInstance.update(fileInfo)
+ },
+
+ destroy() {
+ 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/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
new file mode 100644
index 00000000000..797645ae04d
--- /dev/null
+++ b/apps/files_sharing/src/lib/SharePermissionsToolBox.js
@@ -0,0 +1,107 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+export const ATOMIC_PERMISSIONS = {
+ NONE: 0,
+ READ: 1,
+ UPDATE: 2,
+ CREATE: 4,
+ DELETE: 8,
+ SHARE: 16,
+}
+
+export const BUNDLED_PERMISSIONS = {
+ READ_ONLY: ATOMIC_PERMISSIONS.READ,
+ 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,
+}
+
+/**
+ * Return whether a given permissions set contains some permissions.
+ *
+ * @param {number} initialPermissionSet - the permissions set.
+ * @param {number} permissionsToCheck - the permissions to check.
+ * @return {boolean}
+ */
+export function hasPermissions(initialPermissionSet, permissionsToCheck) {
+ return initialPermissionSet !== ATOMIC_PERMISSIONS.NONE && (initialPermissionSet & permissionsToCheck) === permissionsToCheck
+}
+
+/**
+ * Return whether a given permissions set is valid.
+ *
+ * @param {number} permissionsSet - the permissions set.
+ *
+ * @return {boolean}
+ */
+export function permissionsSetIsValid(permissionsSet) {
+ // Must have at least READ or CREATE permission.
+ if (!hasPermissions(permissionsSet, ATOMIC_PERMISSIONS.READ) && !hasPermissions(permissionsSet, ATOMIC_PERMISSIONS.CREATE)) {
+ return false
+ }
+
+ // Must have READ permission if have UPDATE or DELETE.
+ if (!hasPermissions(permissionsSet, ATOMIC_PERMISSIONS.READ) && (
+ hasPermissions(permissionsSet, ATOMIC_PERMISSIONS.UPDATE) || hasPermissions(permissionsSet, ATOMIC_PERMISSIONS.DELETE)
+ )) {
+ return false
+ }
+
+ return true
+}
+
+/**
+ * Add some permissions to an initial set of permissions.
+ *
+ * @param {number} initialPermissionSet - the initial permissions.
+ * @param {number} permissionsToAdd - the permissions to add.
+ *
+ * @return {number}
+ */
+export function addPermissions(initialPermissionSet, permissionsToAdd) {
+ return initialPermissionSet | permissionsToAdd
+}
+
+/**
+ * Remove some permissions from an initial set of permissions.
+ *
+ * @param {number} initialPermissionSet - the initial permissions.
+ * @param {number} permissionsToSubtract - the permissions to remove.
+ *
+ * @return {number}
+ */
+export function subtractPermissions(initialPermissionSet, permissionsToSubtract) {
+ return initialPermissionSet & ~permissionsToSubtract
+}
+
+/**
+ * Toggle some permissions from an initial set of permissions.
+ *
+ * @param {number} initialPermissionSet - the permissions set.
+ * @param {number} permissionsToToggle - the permissions to toggle.
+ *
+ * @return {number}
+ */
+export function togglePermissions(initialPermissionSet, permissionsToToggle) {
+ if (hasPermissions(initialPermissionSet, permissionsToToggle)) {
+ return subtractPermissions(initialPermissionSet, permissionsToToggle)
+ } else {
+ return addPermissions(initialPermissionSet, permissionsToToggle)
+ }
+}
+
+/**
+ * Return whether some given permissions can be toggled from a permission set.
+ *
+ * @param {number} permissionSet - the initial permissions set.
+ * @param {number} permissionsToToggle - the permissions to toggle.
+ *
+ * @return {boolean}
+ */
+export function canTogglePermissions(permissionSet, permissionsToToggle) {
+ return permissionsSetIsValid(togglePermissions(permissionSet, permissionsToToggle))
+}
diff --git a/apps/files_sharing/src/lib/SharePermissionsToolBox.spec.js b/apps/files_sharing/src/lib/SharePermissionsToolBox.spec.js
new file mode 100644
index 00000000000..a58552063d8
--- /dev/null
+++ b/apps/files_sharing/src/lib/SharePermissionsToolBox.spec.js
@@ -0,0 +1,80 @@
+/**
+ * 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,
+ BUNDLED_PERMISSIONS,
+ addPermissions,
+ subtractPermissions,
+ hasPermissions,
+ permissionsSetIsValid,
+ togglePermissions,
+ canTogglePermissions,
+} from '../lib/SharePermissionsToolBox.js'
+
+describe('SharePermissionsToolBox', () => {
+ test('Adding permissions', () => {
+ expect(addPermissions(ATOMIC_PERMISSIONS.NONE, ATOMIC_PERMISSIONS.NONE)).toBe(ATOMIC_PERMISSIONS.NONE)
+ expect(addPermissions(ATOMIC_PERMISSIONS.NONE, ATOMIC_PERMISSIONS.READ)).toBe(ATOMIC_PERMISSIONS.READ)
+ expect(addPermissions(ATOMIC_PERMISSIONS.READ, ATOMIC_PERMISSIONS.READ)).toBe(ATOMIC_PERMISSIONS.READ)
+ expect(addPermissions(ATOMIC_PERMISSIONS.READ, ATOMIC_PERMISSIONS.UPDATE)).toBe(ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.UPDATE)
+ expect(addPermissions(ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.UPDATE, ATOMIC_PERMISSIONS.CREATE | ATOMIC_PERMISSIONS.DELETE | ATOMIC_PERMISSIONS.SHARE)).toBe(BUNDLED_PERMISSIONS.ALL)
+ expect(addPermissions(BUNDLED_PERMISSIONS.ALL, ATOMIC_PERMISSIONS.READ)).toBe(BUNDLED_PERMISSIONS.ALL)
+ expect(addPermissions(BUNDLED_PERMISSIONS.ALL, ATOMIC_PERMISSIONS.NONE)).toBe(BUNDLED_PERMISSIONS.ALL)
+ })
+
+ test('Subtract permissions', () => {
+ expect(subtractPermissions(ATOMIC_PERMISSIONS.READ, ATOMIC_PERMISSIONS.NONE)).toBe(ATOMIC_PERMISSIONS.READ)
+ expect(subtractPermissions(ATOMIC_PERMISSIONS.READ, ATOMIC_PERMISSIONS.READ)).toBe(ATOMIC_PERMISSIONS.NONE)
+ expect(subtractPermissions(ATOMIC_PERMISSIONS.READ, ATOMIC_PERMISSIONS.UPDATE)).toBe(ATOMIC_PERMISSIONS.READ)
+ expect(subtractPermissions(ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.UPDATE, ATOMIC_PERMISSIONS.UPDATE)).toBe(ATOMIC_PERMISSIONS.READ)
+ expect(subtractPermissions(ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.UPDATE, ATOMIC_PERMISSIONS.CREATE | ATOMIC_PERMISSIONS.DELETE)).toBe(ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.UPDATE)
+ expect(subtractPermissions(ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.UPDATE, ATOMIC_PERMISSIONS.UPDATE | ATOMIC_PERMISSIONS.DELETE)).toBe(ATOMIC_PERMISSIONS.READ)
+ expect(subtractPermissions(BUNDLED_PERMISSIONS.ALL, ATOMIC_PERMISSIONS.READ)).toBe(ATOMIC_PERMISSIONS.UPDATE | ATOMIC_PERMISSIONS.CREATE | ATOMIC_PERMISSIONS.DELETE | ATOMIC_PERMISSIONS.SHARE)
+ })
+
+ test('Has permissions', () => {
+ expect(hasPermissions(ATOMIC_PERMISSIONS.NONE, ATOMIC_PERMISSIONS.READ)).toBe(false)
+ expect(hasPermissions(ATOMIC_PERMISSIONS.READ, ATOMIC_PERMISSIONS.NONE)).toBe(true)
+ expect(hasPermissions(BUNDLED_PERMISSIONS.READ_ONLY, ATOMIC_PERMISSIONS.READ)).toBe(true)
+ expect(hasPermissions(BUNDLED_PERMISSIONS.READ_ONLY, ATOMIC_PERMISSIONS.UPDATE)).toBe(false)
+ expect(hasPermissions(BUNDLED_PERMISSIONS.READ_ONLY, ATOMIC_PERMISSIONS.DELETE)).toBe(false)
+ expect(hasPermissions(BUNDLED_PERMISSIONS.ALL, ATOMIC_PERMISSIONS.DELETE)).toBe(true)
+ })
+
+ test('Toggle permissions', () => {
+ expect(togglePermissions(BUNDLED_PERMISSIONS.ALL, BUNDLED_PERMISSIONS.UPLOAD_AND_UPDATE)).toBe(ATOMIC_PERMISSIONS.SHARE)
+ expect(togglePermissions(BUNDLED_PERMISSIONS.ALL, BUNDLED_PERMISSIONS.FILE_DROP)).toBe(ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.UPDATE | ATOMIC_PERMISSIONS.DELETE | ATOMIC_PERMISSIONS.SHARE)
+ expect(togglePermissions(BUNDLED_PERMISSIONS.ALL, ATOMIC_PERMISSIONS.NONE)).toBe(BUNDLED_PERMISSIONS.ALL)
+ expect(togglePermissions(ATOMIC_PERMISSIONS.NONE, BUNDLED_PERMISSIONS.ALL)).toBe(BUNDLED_PERMISSIONS.ALL)
+ expect(togglePermissions(ATOMIC_PERMISSIONS.READ, BUNDLED_PERMISSIONS.ALL)).toBe(BUNDLED_PERMISSIONS.ALL)
+ })
+
+ test('Permissions set is valid', () => {
+ expect(permissionsSetIsValid(ATOMIC_PERMISSIONS.NONE)).toBe(false)
+ expect(permissionsSetIsValid(ATOMIC_PERMISSIONS.READ)).toBe(true)
+ expect(permissionsSetIsValid(ATOMIC_PERMISSIONS.CREATE)).toBe(true)
+ expect(permissionsSetIsValid(ATOMIC_PERMISSIONS.UPDATE)).toBe(false)
+ expect(permissionsSetIsValid(ATOMIC_PERMISSIONS.DELETE)).toBe(false)
+ expect(permissionsSetIsValid(ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.UPDATE)).toBe(true)
+ expect(permissionsSetIsValid(ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.DELETE)).toBe(true)
+ expect(permissionsSetIsValid(ATOMIC_PERMISSIONS.CREATE | ATOMIC_PERMISSIONS.UPDATE)).toBe(false)
+ expect(permissionsSetIsValid(ATOMIC_PERMISSIONS.CREATE | ATOMIC_PERMISSIONS.DELETE)).toBe(false)
+ expect(permissionsSetIsValid(ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.CREATE | ATOMIC_PERMISSIONS.UPDATE)).toBe(true)
+ expect(permissionsSetIsValid(ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.CREATE | ATOMIC_PERMISSIONS.DELETE)).toBe(true)
+ })
+
+ test('Toggle permissions', () => {
+ expect(canTogglePermissions(ATOMIC_PERMISSIONS.READ, ATOMIC_PERMISSIONS.READ)).toBe(false)
+ expect(canTogglePermissions(ATOMIC_PERMISSIONS.CREATE, ATOMIC_PERMISSIONS.READ)).toBe(true)
+ expect(canTogglePermissions(ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.UPDATE, ATOMIC_PERMISSIONS.READ)).toBe(false)
+ expect(canTogglePermissions(ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.DELETE, ATOMIC_PERMISSIONS.READ)).toBe(false)
+ expect(canTogglePermissions(ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.CREATE | ATOMIC_PERMISSIONS.UPDATE, ATOMIC_PERMISSIONS.READ)).toBe(false)
+ expect(canTogglePermissions(ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.CREATE | ATOMIC_PERMISSIONS.DELETE, ATOMIC_PERMISSIONS.READ)).toBe(false)
+ expect(canTogglePermissions(ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.CREATE | ATOMIC_PERMISSIONS.UPDATE, ATOMIC_PERMISSIONS.CREATE)).toBe(true)
+ expect(canTogglePermissions(ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.CREATE | ATOMIC_PERMISSIONS.DELETE, ATOMIC_PERMISSIONS.CREATE)).toBe(true)
+ })
+})
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
new file mode 100644
index 00000000000..2c33fa3b0c7
--- /dev/null
+++ b/apps/files_sharing/src/mixins/ShareRequests.js
@@ -0,0 +1,112 @@
+/**
+ * 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.ts'
+
+const shareUrl = generateOcsUrl('apps/files_sharing/api/v1/shares')
+
+export default {
+ methods: {
+ /**
+ * Create a new share
+ *
+ * @param {object} data destructuring object
+ * @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] allow public upload to a public shared folder
+ * @param {string} [data.password] password to protect public link Share with
+ * @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, note, attributes }) {
+ try {
+ const request = await axios.post(shareUrl, { path, permissions, shareType, shareWith, publicUpload, password, sendPasswordByTalk, expireDate, label, note, attributes })
+ if (!request?.data?.ocs) {
+ throw request
+ }
+ 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
+ showError(
+ errorMessage ? t('files_sharing', 'Error creating the share: {errorMessage}', { errorMessage }) : t('files_sharing', 'Error creating the share'),
+ { type: 'error' },
+ )
+ throw error
+ }
+ },
+
+ /**
+ * Delete a share
+ *
+ * @param {number} id share id
+ * @throws {Error}
+ */
+ async deleteShare(id) {
+ try {
+ const request = await axios.delete(shareUrl + `/${id}`)
+ 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' },
+ )
+ throw error
+ }
+ },
+
+ /**
+ * Update a share
+ *
+ * @param {number} id share id
+ * @param {object} properties key-value object of the properties to update
+ */
+ 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 {
+ return request.data.ocs.data
+ }
+ } catch (error) {
+ console.error('Error while updating share', error)
+ if (error.response.status !== 400) {
+ 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' },
+ )
+ }
+ const message = error.response.data.ocs.meta.message
+ throw new Error(message)
+ }
+ },
+ },
+}
diff --git a/apps/files_sharing/src/mixins/SharesMixin.js b/apps/files_sharing/src/mixins/SharesMixin.js
new file mode 100644
index 00000000000..a461da56d85
--- /dev/null
+++ b/apps/files_sharing/src/mixins/SharesMixin.js
@@ -0,0 +1,448 @@
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { getCurrentUser } from '@nextcloud/auth'
+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 GeneratePassword from '../utils/GeneratePassword.ts'
+import Share from '../models/Share.ts'
+import SharesRequests from './ShareRequests.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],
+
+ props: {
+ fileInfo: {
+ type: Object,
+ default: () => { },
+ required: true,
+ },
+ share: {
+ type: Share,
+ default: null,
+ },
+ isUnique: {
+ type: Boolean,
+ default: true,
+ },
+ },
+
+ data() {
+ return {
+ config: new Config(),
+ node: null,
+ ShareType,
+
+ // errors helpers
+ errors: {},
+
+ // component status toggles
+ loading: false,
+ saving: false,
+ open: false,
+
+ // concurrency management queue
+ // we want one queue per share
+ updateQueue: new PQueue({ concurrency: 1 }),
+
+ /**
+ * ! This allow vue to make the Share class state reactive
+ * ! do not remove it ot you'll lose all reactivity here
+ */
+ reactiveState: this.share?.state,
+ }
+ },
+
+ computed: {
+ path() {
+ return (this.fileInfo.path + '/' + this.fileInfo.name).replace('//', '/')
+ },
+ /**
+ * Does the current share have a note
+ *
+ * @return {boolean}
+ */
+ hasNote: {
+ get() {
+ return this.share.note !== ''
+ },
+ set(enabled) {
+ this.share.note = enabled
+ ? null // enabled but user did not changed the content yet
+ : '' // empty = no note = disabled
+ },
+ },
+
+ dateTomorrow() {
+ return new Date(new Date().setDate(new Date().getDate() + 1))
+ },
+
+ // Datepicker language
+ lang() {
+ const weekdaysShort = window.dayNamesShort
+ ? window.dayNamesShort // provided by Nextcloud
+ : ['Sun.', 'Mon.', 'Tue.', 'Wed.', 'Thu.', 'Fri.', 'Sat.']
+ const monthsShort = window.monthNamesShort
+ ? window.monthNamesShort // provided by Nextcloud
+ : ['Jan.', 'Feb.', 'Mar.', 'Apr.', 'May.', 'Jun.', 'Jul.', 'Aug.', 'Sep.', 'Oct.', 'Nov.', 'Dec.']
+ const firstDayOfWeek = window.firstDay ? window.firstDay : 0
+
+ return {
+ formatLocale: {
+ firstDayOfWeek,
+ monthsShort,
+ weekdaysMin: weekdaysShort,
+ weekdaysShort,
+ },
+ 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
+ *
+ * @param {Share} share the share to check
+ * @return {boolean}
+ */
+ checkShare(share) {
+ if (share.password) {
+ if (typeof share.password !== 'string' || share.password.trim() === '') {
+ return false
+ }
+ }
+ if (share.expirationDate) {
+ const date = share.expirationDate
+ if (!date.isValid()) {
+ return false
+ }
+ }
+ return true
+ },
+
+ /**
+ * @param {Date} date the date to format
+ * @return {string} date a date with YYYY-MM-DD format
+ */
+ formatDateToString(date) {
+ // Force utc time. Drop time information to be timezone-less
+ const utcDate = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
+ // Format to YYYY-MM-DD
+ return utcDate.toISOString().split('T')[0]
+ },
+
+ /**
+ * Save given value to expireDate and trigger queueUpdate
+ *
+ * @param {Date} date
+ */
+ onExpirationChange(date) {
+ 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)
+ },
+
+ /**
+ * Note changed, let's save it to a different key
+ *
+ * @param {string} note the share note
+ */
+ onNoteChange(note) {
+ this.$set(this.share, 'newNote', note.trim())
+ },
+
+ /**
+ * When the note change, we trim, save and dispatch
+ *
+ */
+ onNoteSubmit() {
+ if (this.share.newNote) {
+ this.share.note = this.share.newNote
+ this.$delete(this.share, 'newNote')
+ this.queueUpdate('note')
+ }
+ },
+
+ /**
+ * Delete share button handler
+ */
+ async onDelete() {
+ try {
+ this.loading = true
+ this.open = false
+ await this.deleteShare(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
+ } finally {
+ this.loading = false
+ }
+ },
+
+ /**
+ * Send an update of the share to the queue
+ *
+ * @param {Array<string>} propertyNames the properties to sync
+ */
+ queueUpdate(...propertyNames) {
+ if (propertyNames.length === 0) {
+ // Nothing to update
+ return
+ }
+
+ if (this.share.id) {
+ const properties = {}
+ // force value to string because that is what our
+ // share api controller accepts
+ 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()
+ }
+ }
+
+ return this.updateQueue.add(async () => {
+ this.saving = true
+ this.errors = {}
+ try {
+ const updatedShare = await this.updateShare(this.share.id, properties)
+
+ 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
+ this.share.passwordExpirationTime = updatedShare.password_expiration_time
+ }
+
+ // clear any previous errors
+ 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 !== '') {
+ 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
+ }
+ })
+ }
+
+ // This share does not exists on the server yet
+ console.debug('Updated local share', this.share)
+ },
+
+ /**
+ * @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) {
+ case 'password':
+ case 'pending':
+ case 'expireDate':
+ case 'label':
+ case 'note': {
+ // show error
+ this.$set(this.errors, property, message)
+
+ let propertyEl = this.$refs[property]
+ if (propertyEl) {
+ if (propertyEl.$el) {
+ propertyEl = propertyEl.$el
+ }
+ // focus if there is a focusable action element
+ const focusable = propertyEl.querySelector('.focusable')
+ if (focusable) {
+ focusable.focus()
+ }
+ }
+ break
+ }
+ case 'sendPasswordByTalk': {
+ // show error
+ this.$set(this.errors, property, message)
+
+ // Restore previous state
+ this.share.sendPasswordByTalk = !this.share.sendPasswordByTalk
+ break
+ }
+ }
+ },
+ /**
+ * Debounce queueUpdate to avoid requests spamming
+ * more importantly for text data
+ *
+ * @param {string} property the property to sync
+ */
+ debounceQueueUpdate: debounce(function(property) {
+ this.queueUpdate(property)
+ }, 500),
+ },
+}
diff --git a/apps/files_sharing/src/models/Share.ts b/apps/files_sharing/src/models/Share.ts
new file mode 100644
index 00000000000..b0638b29448
--- /dev/null
+++ b/apps/files_sharing/src/models/Share.ts
@@ -0,0 +1,496 @@
+/**
+ * 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
+
+ /**
+ * Create the share object
+ *
+ * @param {object} ocsData ocs request response
+ */
+ constructor(ocsData) {
+ if (ocsData.ocs && ocsData.ocs.data && ocsData.ocs.data[0]) {
+ 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 && 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)
+ }
+ }
+ ocsData.attributes = ocsData.attributes ?? []
+
+ // store state
+ this._share = ocsData
+ }
+
+ /**
+ * Get the share state
+ * ! used for reactivity purpose
+ * Do not remove. It allow vuejs to
+ * inject its watchers into the #share
+ * state and make the whole class reactive
+ *
+ * @return {object} the share raw state
+ */
+ get state() {
+ return this._share
+ }
+
+ /**
+ * get the share id
+ */
+ get id(): number {
+ return this._share.id
+ }
+
+ /**
+ * Get the share type
+ */
+ get type(): ShareType {
+ return this._share.share_type
+ }
+
+ /**
+ * Get the share permissions
+ * See window.OC.PERMISSION_* variables
+ */
+ get permissions(): number {
+ return this._share.permissions
+ }
+
+ /**
+ * Get the share attributes
+ */
+ get attributes(): Array<ShareAttribute> {
+ return this._share.attributes || []
+ }
+
+ /**
+ * Set the share permissions
+ * See window.OC.PERMISSION_* variables
+ */
+ set permissions(permissions: number) {
+ this._share.permissions = permissions
+ }
+
+ // SHARE OWNER --------------------------------------------------
+ /**
+ * Get the share owner uid
+ */
+ get owner(): string {
+ return this._share.uid_owner
+ }
+
+ /**
+ * Get the share owner's display name
+ */
+ get ownerDisplayName(): string {
+ return this._share.displayname_owner
+ }
+
+ // SHARED WITH --------------------------------------------------
+ /**
+ * Get the share with entity uid
+ */
+ get shareWith(): string {
+ return this._share.share_with
+ }
+
+ /**
+ * Get the share with entity display name
+ * fallback to its uid if none
+ */
+ get shareWithDisplayName(): string {
+ return this._share.share_with_displayname
+ || this._share.share_with
+ }
+
+ /**
+ * Unique display name in case of multiple
+ * duplicates results with the same name.
+ */
+ get shareWithDisplayNameUnique(): string {
+ return this._share.share_with_displayname_unique
+ || this._share.share_with
+ }
+
+ /**
+ * Get the share with entity link
+ */
+ get shareWithLink(): string {
+ return this._share.share_with_link
+ }
+
+ /**
+ * Get the share with avatar if any
+ */
+ get shareWithAvatar(): string {
+ return this._share.share_with_avatar
+ }
+
+ // SHARED FILE OR FOLDER OWNER ----------------------------------
+ /**
+ * Get the shared item owner uid
+ */
+ get uidFileOwner(): string {
+ return this._share.uid_file_owner
+ }
+
+ /**
+ * Get the shared item display name
+ * fallback to its uid if none
+ */
+ get displaynameFileOwner(): string {
+ return this._share.displayname_file_owner
+ || this._share.uid_file_owner
+ }
+
+ // TIME DATA ----------------------------------------------------
+ /**
+ * Get the share creation timestamp
+ */
+ get createdTime(): number {
+ return this._share.stime
+ }
+
+ /**
+ * Get the expiration date
+ * @return {string} date with YYYY-MM-DD format
+ */
+ get expireDate(): string {
+ return this._share.expiration
+ }
+
+ /**
+ * Set the expiration date
+ * @param {string} date the share expiration date with YYYY-MM-DD format
+ */
+ set expireDate(date: string) {
+ this._share.expiration = date
+ }
+
+ // EXTRA DATA ---------------------------------------------------
+ /**
+ * Get the public share 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
+ */
+ get note(): string {
+ return this._share.note
+ }
+
+ /**
+ * Set the share note if any
+ */
+ set note(note: string) {
+ this._share.note = note
+ }
+
+ /**
+ * Get the share label if any
+ * Should only exist on link shares
+ */
+ get label(): string {
+ return this._share.label ?? ''
+ }
+
+ /**
+ * Set the share label if any
+ * Should only be set on link shares
+ */
+ set label(label: string) {
+ this._share.label = label
+ }
+
+ /**
+ * Have a mail been sent
+ */
+ get mailSend(): boolean {
+ return this._share.mail_send === true
+ }
+
+ /**
+ * Hide the download button on public page
+ */
+ 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
+ */
+ 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
+ */
+ get password():string {
+ return this._share.password
+ }
+
+ /**
+ * Password protection of the share
+ */
+ set password(password: string) {
+ this._share.password = password
+ }
+
+ /**
+ * Password expiration time
+ * @return {string} date with YYYY-MM-DD format
+ */
+ get passwordExpirationTime(): string {
+ return this._share.password_expiration_time
+ }
+
+ /**
+ * Password expiration time
+ * @param {string} passwordExpirationTime date with YYYY-MM-DD format
+ */
+ set passwordExpirationTime(passwordExpirationTime: string) {
+ this._share.password_expiration_time = passwordExpirationTime
+ }
+
+ /**
+ * Password protection by Talk of the share
+ */
+ 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
+ */
+ set sendPasswordByTalk(sendPasswordByTalk: boolean) {
+ this._share.send_password_by_talk = sendPasswordByTalk
+ }
+
+ // SHARED ITEM DATA ---------------------------------------------
+ /**
+ * Get the shared item absolute full path
+ */
+ get path(): string {
+ return this._share.path
+ }
+
+ /**
+ * Return the item type: file or folder
+ * @return {string} 'folder' | 'file'
+ */
+ get itemType(): string {
+ return this._share.item_type
+ }
+
+ /**
+ * Get the shared item mimetype
+ */
+ get mimetype(): string {
+ return this._share.mimetype
+ }
+
+ /**
+ * Get the shared item id
+ */
+ get fileSource(): number {
+ return this._share.file_source
+ }
+
+ /**
+ * 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
+ */
+ get fileTarget(): string {
+ return this._share.file_target
+ }
+
+ /**
+ * Get the parent folder id if any
+ */
+ get fileParent(): number {
+ return this._share.file_parent
+ }
+
+ // PERMISSIONS Shortcuts
+
+ /**
+ * Does this share have READ permissions
+ */
+ get hasReadPermission(): boolean {
+ return !!((this.permissions & window.OC.PERMISSION_READ))
+ }
+
+ /**
+ * Does this share have CREATE permissions
+ */
+ get hasCreatePermission(): boolean {
+ return !!((this.permissions & window.OC.PERMISSION_CREATE))
+ }
+
+ /**
+ * Does this share have DELETE permissions
+ */
+ get hasDeletePermission(): boolean {
+ return !!((this.permissions & window.OC.PERMISSION_DELETE))
+ }
+
+ /**
+ * Does this share have UPDATE permissions
+ */
+ get hasUpdatePermission(): boolean {
+ return !!((this.permissions & window.OC.PERMISSION_UPDATE))
+ }
+
+ /**
+ * Does this share have SHARE permissions
+ */
+ get hasSharePermission(): boolean {
+ return !!((this.permissions & window.OC.PERMISSION_SHARE))
+ }
+
+ /**
+ * Does this share have download permissions
+ */
+ get hasDownloadPermission(): boolean {
+ const hasDisabledDownload = (attribute) => {
+ return attribute.scope === 'permissions' && attribute.key === 'download' && attribute.value === false
+ }
+ return this.attributes.some(hasDisabledDownload)
+ }
+
+ /**
+ * 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, value) {
+ const attrUpdate = {
+ scope,
+ key,
+ 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.splice(i, 1, attrUpdate)
+ return
+ }
+ }
+
+ this._share.attributes.push(attrUpdate)
+ }
+
+ // PERMISSIONS Shortcuts for the CURRENT USER
+ // ! the permissions above are the share settings,
+ // ! meaning the permissions for the recipient
+ /**
+ * Can the current user EDIT this share ?
+ */
+ get canEdit(): boolean {
+ return this._share.can_edit === true
+ }
+
+ /**
+ * Can the current user DELETE this share ?
+ */
+ get canDelete(): boolean {
+ return this._share.can_delete === true
+ }
+
+ /**
+ * Top level accessible shared folder fileid for the current user
+ */
+ get viaFileid(): string {
+ return this._share.via_fileid
+ }
+
+ /**
+ * Top level accessible shared folder path for the current user
+ */
+ get viaPath(): string {
+ return this._share.via_path
+ }
+
+ // TODO: SORT THOSE PROPERTIES
+
+ get parent() {
+ return this._share.parent
+ }
+
+ get storageId(): string {
+ return this._share.storage_id
+ }
+
+ get storage(): number {
+ return this._share.storage
+ }
+
+ get itemSource(): number {
+ return this._share.item_source
+ }
+
+ get status() {
+ 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
new file mode 100644
index 00000000000..e3184f0041e
--- /dev/null
+++ b/apps/files_sharing/src/personal-settings.js
@@ -0,0 +1,17 @@
+/**
+ * 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 PersonalSettings from './components/PersonalSettings.vue'
+
+// eslint-disable-next-line camelcase
+__webpack_nonce__ = getCSPNonce()
+
+Vue.prototype.t = t
+
+const View = Vue.extend(PersonalSettings)
+new View().$mount('#files-sharing-personal-settings')
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.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
new file mode 100644
index 00000000000..fe5130fbb49
--- /dev/null
+++ b/apps/files_sharing/src/services/ExternalLinkActions.js
@@ -0,0 +1,48 @@
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+export default class ExternalLinkActions {
+
+ _state
+
+ constructor() {
+ // init empty state
+ this._state = {}
+
+ // init default values
+ this._state.actions = []
+ console.debug('OCA.Sharing.ExternalLinkActions initialized')
+ }
+
+ /**
+ * Get the state
+ *
+ * @readonly
+ * @memberof ExternalLinkActions
+ * @return {object} the data state
+ */
+ get state() {
+ return this._state
+ }
+
+ /**
+ * Register a new action for the link share
+ * Mostly used by the social sharing app.
+ *
+ * @param {object} action new action component to register
+ * @return {boolean}
+ */
+ registerAction(action) {
+ 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)
+ return true
+ }
+ console.error('Invalid action provided', action)
+ return false
+ }
+
+}
diff --git a/apps/files_sharing/src/services/ExternalShareActions.js b/apps/files_sharing/src/services/ExternalShareActions.js
new file mode 100644
index 00000000000..6ffd7014fe2
--- /dev/null
+++ b/apps/files_sharing/src/services/ExternalShareActions.js
@@ -0,0 +1,69 @@
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+export default class ExternalShareActions {
+
+ _state
+
+ constructor() {
+ // init empty state
+ this._state = {}
+
+ // init default values
+ this._state.actions = []
+ console.debug('OCA.Sharing.ExternalShareActions initialized')
+ }
+
+ /**
+ * Get the state
+ *
+ * @readonly
+ * @memberof ExternalLinkActions
+ * @return {object} the data state
+ */
+ get state() {
+ return this._state
+ }
+
+ /**
+ * @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 {(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}
+ */
+ registerAction(action) {
+ // Validate action
+ if (typeof action !== 'object'
+ || typeof action.id !== 'string'
+ || typeof action.data !== 'function' // () => {disabled: true}
+ || !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)
+ return false
+ }
+
+ // Check duplicates
+ const hasDuplicate = this._state.actions.findIndex(check => check.id === action.id) > -1
+ if (hasDuplicate) {
+ console.error(`An action with the same id ${action.id} already exists`, action)
+ return false
+ }
+
+ this._state.actions.push(action)
+ return true
+ }
+
+}
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
new file mode 100644
index 00000000000..eff209aad2b
--- /dev/null
+++ b/apps/files_sharing/src/services/ShareSearch.js
@@ -0,0 +1,54 @@
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+export default class ShareSearch {
+
+ _state
+
+ constructor() {
+ // init empty state
+ this._state = {}
+
+ // init default values
+ this._state.results = []
+ console.debug('OCA.Sharing.ShareSearch initialized')
+ }
+
+ /**
+ * Get the state
+ *
+ * @readonly
+ * @memberof ShareSearch
+ * @return {object} the data state
+ */
+ get state() {
+ return this._state
+ }
+
+ /**
+ * Register a new result
+ * Mostly used by the guests app.
+ * We should consider deprecation and add results via php ?
+ *
+ * @param {object} result entry to append
+ * @param {string} [result.user] entry user
+ * @param {string} result.displayName entry first line
+ * @param {string} [result.desc] entry second line
+ * @param {string} [result.icon] entry icon
+ * @param {Function} result.handler function to run on entry selection
+ * @param {Function} [result.condition] condition to add entry or not
+ * @return {boolean}
+ */
+ addNewResult(result) {
+ if (result.displayName.trim() !== ''
+ && typeof result.handler === 'function') {
+ this._state.results.push(result)
+ return true
+ }
+ console.error('Invalid search result provided', result)
+ return false
+ }
+
+}
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
new file mode 100644
index 00000000000..ab1237e7044
--- /dev/null
+++ b/apps/files_sharing/src/services/TabSections.js
@@ -0,0 +1,33 @@
+/**
+ * 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 {
+
+ _sections
+
+ constructor() {
+ this._sections = []
+ }
+
+ /**
+ * @param {registerSectionCallback} section To be called to mount the section to the sharing sidebar
+ */
+ registerSection(section) {
+ this._sections.push(section)
+ }
+
+ getSections() {
+ return this._sections
+ }
+
+}
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
new file mode 100644
index 00000000000..cdc3c917dfa
--- /dev/null
+++ b/apps/files_sharing/src/share.js
@@ -0,0 +1,505 @@
+/**
+ * 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 { ShareType } from '@nextcloud/sharing'
+import { getCapabilities } from '@nextcloud/capabilities'
+
+(function() {
+
+ _.extend(OC.Files.Client, {
+ PROPERTY_SHARE_TYPES: '{' + OC.Files.Client.NS_OWNCLOUD + '}share-types',
+ PROPERTY_OWNER_ID: '{' + OC.Files.Client.NS_OWNCLOUD + '}owner-id',
+ PROPERTY_OWNER_DISPLAY_NAME: '{' + OC.Files.Client.NS_OWNCLOUD + '}owner-display-name'
+ })
+
+ if (!OCA.Sharing) {
+ OCA.Sharing = {}
+ }
+
+ /**
+ * @namespace
+ */
+ OCA.Sharing.Util = {
+
+ /**
+ * Regular expression for splitting parts of remote share owners:
+ * "user@example.com/"
+ * "user@example.com/path/to/owncloud"
+ * "user@anotherexample.com@example.com/path/to/owncloud
+ */
+ _REMOTE_OWNER_REGEXP: new RegExp('^(([^@]*)@(([^@^/\\s]*)@)?)((https://)?[^[\\s/]*)([/](.*))?$'),
+
+ /**
+ * Initialize the sharing plugin.
+ *
+ * Registers the "Share" file action and adds additional
+ * DOM attributes for the sharing file info.
+ *
+ * @param {OCA.Files.FileList} fileList file list to be extended
+ */
+ attach: function(fileList) {
+ // core sharing is disabled/not loaded
+ if (!getCapabilities().files_sharing?.api_enabled) {
+ return
+ }
+ if (fileList.id === 'trashbin' || fileList.id === 'files.public') {
+ return
+ }
+ var fileActions = fileList.fileActions
+ var oldCreateRow = fileList._createRow
+ fileList._createRow = function(fileData) {
+
+ var tr = oldCreateRow.apply(this, arguments)
+ var sharePermissions = OCA.Sharing.Util.getSharePermissions(fileData)
+
+ if (fileData.permissions === 0) {
+ // no permission, disabling sidebar
+ delete fileActions.actions.all.Comment
+ delete fileActions.actions.all.Details
+ delete fileActions.actions.all.Goto
+ }
+ if (_.isFunction(fileData.canDownload) && !fileData.canDownload()) {
+ delete fileActions.actions.all.Download
+ if ((fileData.permissions & OC.PERMISSION_UPDATE) === 0) {
+ // neither move nor copy is allowed, remove the action completely
+ delete fileActions.actions.all.MoveCopy
+ }
+ }
+ tr.attr('data-share-permissions', sharePermissions)
+ tr.attr('data-share-attributes', JSON.stringify(fileData.shareAttributes))
+ if (fileData.shareOwner) {
+ tr.attr('data-share-owner', fileData.shareOwner)
+ tr.attr('data-share-owner-id', fileData.shareOwnerId)
+ // user should always be able to rename a mount point
+ if (fileData.mountType === 'shared-root') {
+ tr.attr('data-permissions', fileData.permissions | OC.PERMISSION_UPDATE)
+ }
+ }
+ if (fileData.recipientData && !_.isEmpty(fileData.recipientData)) {
+ tr.attr('data-share-recipient-data', JSON.stringify(fileData.recipientData))
+ }
+ if (fileData.shareTypes) {
+ tr.attr('data-share-types', fileData.shareTypes.join(','))
+ }
+ return tr
+ }
+
+ var oldElementToFile = fileList.elementToFile
+ fileList.elementToFile = function($el) {
+ var fileInfo = oldElementToFile.apply(this, arguments)
+ fileInfo.shareAttributes = JSON.parse($el.attr('data-share-attributes') || '[]')
+ fileInfo.sharePermissions = $el.attr('data-share-permissions') || undefined
+ fileInfo.shareOwner = $el.attr('data-share-owner') || undefined
+ fileInfo.shareOwnerId = $el.attr('data-share-owner-id') || undefined
+
+ if ($el.attr('data-share-types')) {
+ fileInfo.shareTypes = $el.attr('data-share-types').split(',')
+ }
+
+ if ($el.attr('data-expiration')) {
+ var expirationTimestamp = parseInt($el.attr('data-expiration'))
+ fileInfo.shares = []
+ fileInfo.shares.push({ expiration: expirationTimestamp })
+ }
+
+ return fileInfo
+ }
+
+ var oldGetWebdavProperties = fileList._getWebdavProperties
+ fileList._getWebdavProperties = function() {
+ var props = oldGetWebdavProperties.apply(this, arguments)
+ props.push(OC.Files.Client.PROPERTY_OWNER_ID)
+ props.push(OC.Files.Client.PROPERTY_OWNER_DISPLAY_NAME)
+ props.push(OC.Files.Client.PROPERTY_SHARE_TYPES)
+ return props
+ }
+
+ fileList.filesClient.addFileInfoParser(function(response) {
+ var data = {}
+ var props = response.propStat[0].properties
+ var permissionsProp = props[OC.Files.Client.PROPERTY_PERMISSIONS]
+
+ if (permissionsProp && permissionsProp.indexOf('S') >= 0) {
+ data.shareOwner = props[OC.Files.Client.PROPERTY_OWNER_DISPLAY_NAME]
+ data.shareOwnerId = props[OC.Files.Client.PROPERTY_OWNER_ID]
+ }
+
+ var shareTypesProp = props[OC.Files.Client.PROPERTY_SHARE_TYPES]
+ if (shareTypesProp) {
+ data.shareTypes = _.chain(shareTypesProp).filter(function(xmlvalue) {
+ return (xmlvalue.namespaceURI === OC.Files.Client.NS_OWNCLOUD && xmlvalue.nodeName.split(':')[1] === 'share-type')
+ }).map(function(xmlvalue) {
+ return parseInt(xmlvalue.textContent || xmlvalue.text, 10)
+ }).value()
+ }
+
+ return data
+ })
+
+ // use delegate to catch the case with multiple file lists
+ fileList.$el.on('fileActionsReady', function(ev) {
+ var $files = ev.$files
+
+ _.each($files, function(file) {
+ var $tr = $(file)
+ var shareTypesStr = $tr.attr('data-share-types') || ''
+ var shareOwner = $tr.attr('data-share-owner')
+ if (shareTypesStr || shareOwner) {
+ var hasLink = false
+ var hasShares = false
+ _.each(shareTypesStr.split(',') || [], function(shareTypeStr) {
+ let shareType = parseInt(shareTypeStr, 10)
+ if (shareType === ShareType.Link) {
+ hasLink = true
+ } else if (shareType === ShareType.Email) {
+ hasLink = true
+ } else if (shareType === ShareType.User) {
+ hasShares = true
+ } else if (shareType === ShareType.Group) {
+ hasShares = true
+ } else if (shareType === ShareType.Remote) {
+ hasShares = true
+ } else if (shareType === ShareType.RemoteGroup) {
+ hasShares = true
+ } else if (shareType === ShareType.Team) {
+ hasShares = true
+ } else if (shareType === ShareType.Room) {
+ hasShares = true
+ } else if (shareType === ShareType.Deck) {
+ hasShares = true
+ }
+ })
+ OCA.Sharing.Util._updateFileActionIcon($tr, hasShares, hasLink)
+ }
+ })
+ })
+
+ fileList.$el.on('changeDirectory', function() {
+ OCA.Sharing.sharesLoaded = false
+ })
+
+ fileActions.registerAction({
+ name: 'Share',
+ displayName: function(context) {
+ if (context && context.$file) {
+ var shareType = parseInt(context.$file.data('share-types'), 10)
+ var shareOwner = context.$file.data('share-owner-id')
+ if (shareType >= 0 || shareOwner) {
+ return t('files_sharing', 'Shared')
+ }
+ }
+ return t('files_sharing', 'Share')
+ },
+ altText: t('files_sharing', 'Share'),
+ mime: 'all',
+ order: -150,
+ permissions: OC.PERMISSION_ALL,
+ iconClass: function(fileName, context) {
+ var shareType = parseInt(context.$file.data('share-types'), 10)
+ if (shareType === ShareType.Email
+ || shareType === ShareType.Link) {
+ return 'icon-public'
+ }
+ return 'icon-shared'
+ },
+ icon: function(fileName, context) {
+ var shareOwner = context.$file.data('share-owner-id')
+ if (shareOwner) {
+ return OC.generateUrl(`/avatar/${shareOwner}/32`)
+ }
+ },
+ type: OCA.Files.FileActions.TYPE_INLINE,
+ actionHandler: function(fileName, context) {
+ // details view disabled in some share lists
+ if (!fileList._detailsView) {
+ return
+ }
+ // do not open sidebar if permission is set and equal to 0
+ var permissions = parseInt(context.$file.data('share-permissions'), 10)
+ if (isNaN(permissions) || permissions > 0) {
+ fileList.showDetailsView(fileName, 'sharing')
+ }
+ },
+ render: function(actionSpec, isDefault, context) {
+ var permissions = parseInt(context.$file.data('permissions'), 10)
+ // if no share permissions but share owner exists, still show the link
+ if ((permissions & OC.PERMISSION_SHARE) !== 0 || context.$file.attr('data-share-owner')) {
+ return fileActions._defaultRenderAction.call(fileActions, actionSpec, isDefault, context)
+ }
+ // don't render anything
+ return null
+ }
+ })
+
+ // register share breadcrumbs component
+ var breadCrumbSharingDetailView = new OCA.Sharing.ShareBreadCrumbView()
+ fileList.registerBreadCrumbDetailView(breadCrumbSharingDetailView)
+ },
+
+ /**
+ * Update file list data attributes
+ */
+ _updateFileListDataAttributes: function(fileList, $tr, shareModel) {
+ // files app current cannot show recipients on load, so we don't update the
+ // icon when changed for consistency
+ if (fileList.id === 'files') {
+ return
+ }
+ var recipients = _.pluck(shareModel.get('shares'), 'share_with_displayname')
+ // note: we only update the data attribute because updateIcon()
+ if (recipients.length) {
+ var recipientData = _.mapObject(shareModel.get('shares'), function(share) {
+ return { shareWith: share.share_with, shareWithDisplayName: share.share_with_displayname }
+ })
+ $tr.attr('data-share-recipient-data', JSON.stringify(recipientData))
+ } else {
+ $tr.removeAttr('data-share-recipient-data')
+ }
+ },
+
+ /**
+ * Update the file action share icon for the given file
+ *
+ * @param $tr file element of the file to update
+ * @param {boolean} hasUserShares true if a user share exists
+ * @param {boolean} hasLinkShares true if a link share exists
+ *
+ * @returns {boolean} true if the icon was set, false otherwise
+ */
+ _updateFileActionIcon: function($tr, hasUserShares, hasLinkShares) {
+ // if the statuses are loaded already, use them for the icon
+ // (needed when scrolling to the next page)
+ if (hasUserShares || hasLinkShares || $tr.attr('data-share-recipient-data') || $tr.attr('data-share-owner')) {
+ OCA.Sharing.Util._markFileAsShared($tr, true, hasLinkShares)
+ return true
+ }
+ return false
+ },
+
+ /**
+ * Marks/unmarks a given file as shared by changing its action icon
+ * and folder icon.
+ *
+ * @param $tr file element to mark as shared
+ * @param hasShares whether shares are available
+ * @param hasLink whether link share is available
+ */
+ _markFileAsShared: function($tr, hasShares, hasLink) {
+ var action = $tr.find('.fileactions .action[data-action="Share"]')
+ var type = $tr.data('type')
+ var icon = action.find('.icon')
+ var message, recipients, avatars
+ var ownerId = $tr.attr('data-share-owner-id')
+ var owner = $tr.attr('data-share-owner')
+ var mountType = $tr.attr('data-mounttype')
+ var shareFolderIcon
+ var iconClass = 'icon-shared'
+ action.removeClass('shared-style')
+ // update folder icon
+ 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) {
+ shareFolderIcon = OC.MimeType.getIconUrl('dir-public')
+ } else {
+ shareFolderIcon = OC.MimeType.getIconUrl('dir-shared')
+ }
+ $tr.find('.filename .thumbnail').css('background-image', 'url(' + shareFolderIcon + ')')
+ $tr.attr('data-icon', shareFolderIcon)
+ } else if (type === 'dir') {
+ // FIXME: duplicate of FileList._createRow logic for external folder,
+ // need to refactor the icon logic into a single code path eventually
+ if (mountType && mountType.indexOf('external') === 0) {
+ shareFolderIcon = OC.MimeType.getIconUrl('dir-external')
+ $tr.attr('data-icon', shareFolderIcon)
+ } else {
+ shareFolderIcon = OC.MimeType.getIconUrl('dir')
+ // back to default
+ $tr.removeAttr('data-icon')
+ }
+ $tr.find('.filename .thumbnail').css('background-image', 'url(' + shareFolderIcon + ')')
+ }
+ // update share action text / icon
+ if (hasShares || ownerId) {
+ recipients = $tr.data('share-recipient-data')
+ action.addClass('shared-style')
+
+ avatars = '<span>' + t('files_sharing', 'Shared') + '</span>'
+ // even if reshared, only show "Shared by"
+ if (ownerId) {
+ message = t('files_sharing', 'Shared by')
+ avatars = OCA.Sharing.Util._formatRemoteShare(ownerId, owner, message)
+ } else if (recipients) {
+ avatars = OCA.Sharing.Util._formatShareList(recipients)
+ }
+ action.html(avatars).prepend(icon)
+
+ if (ownerId || recipients) {
+ var avatarElement = action.find('.avatar')
+ avatarElement.each(function() {
+ $(this).avatar($(this).data('username'), 32)
+ })
+ }
+ } else {
+ action.html('<span class="hidden-visually">' + t('files_sharing', 'Shared') + '</span>').prepend(icon)
+ }
+ if (hasLink) {
+ iconClass = 'icon-public'
+ }
+ icon.removeClass('icon-shared icon-public').addClass(iconClass)
+ },
+ /**
+ * Format a remote address
+ *
+ * @param {String} shareWith userid, full remote share, or whatever
+ * @param {String} shareWithDisplayName
+ * @param {String} message
+ * @returns {String} HTML code to display
+ */
+ _formatRemoteShare: function(shareWith, shareWithDisplayName, message) {
+ var parts = OCA.Sharing.Util._REMOTE_OWNER_REGEXP.exec(shareWith)
+ if (!parts || !parts[7]) {
+ // display avatar of the user
+ var avatar = '<span class="avatar" data-username="' + escapeHTML(shareWith) + '" title="' + message + ' ' + escapeHTML(shareWithDisplayName) + '"></span>'
+ var hidden = '<span class="hidden-visually">' + message + ' ' + escapeHTML(shareWithDisplayName) + '</span> '
+ return avatar + hidden
+ }
+
+ var userName = parts[2]
+ var userDomain = parts[4]
+ var server = parts[5]
+ var protocol = parts[6]
+ var serverPath = parts[8] ? parts[7] : ''; // no trailing slash on root
+
+ var tooltip = message + ' ' + userName
+ if (userDomain) {
+ tooltip += '@' + userDomain
+ }
+ if (server) {
+ tooltip += '@' + server.replace(protocol, '') + serverPath
+ }
+
+ var html = '<span class="remoteAddress" title="' + escapeHTML(tooltip) + '">'
+ html += '<span class="username">' + escapeHTML(userName) + '</span>'
+ if (userDomain) {
+ html += '<span class="userDomain">@' + escapeHTML(userDomain) + '</span>'
+ }
+ html += '</span> '
+ return html
+ },
+ /**
+ * Loop over all recipients in the list and format them using
+ * all kind of fancy magic.
+ *
+ * @param {Object} recipients array of all the recipients
+ * @returns {String[]} modified list of recipients
+ */
+ _formatShareList: function(recipients) {
+ var _parent = this
+ recipients = _.toArray(recipients)
+ recipients.sort(function(a, b) {
+ return a.shareWithDisplayName.localeCompare(b.shareWithDisplayName)
+ })
+ return $.map(recipients, function(recipient) {
+ return _parent._formatRemoteShare(recipient.shareWith, recipient.shareWithDisplayName, t('files_sharing', 'Shared with'))
+ })
+ },
+
+ /**
+ * Marks/unmarks a given file as shared by changing its action icon
+ * and folder icon.
+ *
+ * @param $tr file element to mark as shared
+ * @param hasShares whether shares are available
+ * @param hasLink whether link share is available
+ */
+ markFileAsShared: function($tr, hasShares, hasLink) {
+ var action = $tr.find('.fileactions .action[data-action="Share"]')
+ var type = $tr.data('type')
+ var icon = action.find('.icon')
+ var message, recipients, avatars
+ var ownerId = $tr.attr('data-share-owner-id')
+ var owner = $tr.attr('data-share-owner')
+ var mountType = $tr.attr('data-mounttype')
+ var shareFolderIcon
+ var iconClass = 'icon-shared'
+ action.removeClass('shared-style')
+ // update folder icon
+ if (type === 'dir' && (hasShares || hasLink || ownerId)) {
+ if (typeof mountType !== 'undefined' && mountType !== 'shared-root' && mountType !== 'shared') {
+ shareFolderIcon = OC.MimeType.getIconUrl('dir-' + mountType)
+ } else if (hasLink) {
+ shareFolderIcon = OC.MimeType.getIconUrl('dir-public')
+ } else {
+ shareFolderIcon = OC.MimeType.getIconUrl('dir-shared')
+ }
+ $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) {
+ shareFolderIcon = OC.MimeType.getIconUrl('dir-external')
+ $tr.attr('data-icon', shareFolderIcon)
+ } else {
+ shareFolderIcon = OC.MimeType.getIconUrl('dir')
+ // back to default
+ $tr.removeAttr('data-icon')
+ }
+ $tr.find('.filename .thumbnail').css('background-image', 'url(' + shareFolderIcon + ')')
+ }
+ // update share action text / icon
+ if (hasShares || ownerId) {
+ recipients = $tr.data('share-recipient-data')
+ action.addClass('shared-style')
+
+ avatars = '<span>' + t('files_sharing', 'Shared') + '</span>'
+ // even if reshared, only show "Shared by"
+ if (ownerId) {
+ message = t('files_sharing', 'Shared by')
+ avatars = this._formatRemoteShare(ownerId, owner, message)
+ } else if (recipients) {
+ avatars = this._formatShareList(recipients)
+ }
+ action.html(avatars).prepend(icon)
+
+ if (ownerId || recipients) {
+ var avatarElement = action.find('.avatar')
+ avatarElement.each(function() {
+ $(this).avatar($(this).data('username'), 32)
+ })
+ }
+ } else {
+ action.html('<span class="hidden-visually">' + t('files_sharing', 'Shared') + '</span>').prepend(icon)
+ }
+ if (hasLink) {
+ iconClass = 'icon-public'
+ }
+ icon.removeClass('icon-shared icon-public').addClass(iconClass)
+ },
+
+ /**
+ * @param {Array} fileData
+ * @returns {String}
+ */
+ getSharePermissions: function(fileData) {
+ return fileData.sharePermissions
+ }
+ }
+})()
+
+OC.Plugins.register('OCA.Files.FileList', OCA.Sharing.Util)
diff --git a/apps/files_sharing/src/sharebreadcrumbview.js b/apps/files_sharing/src/sharebreadcrumbview.js
new file mode 100644
index 00000000000..68ea75d4df9
--- /dev/null
+++ b/apps/files_sharing/src/sharebreadcrumbview.js
@@ -0,0 +1,62 @@
+/**
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { ShareType } from '@nextcloud/sharing'
+
+(function() {
+ 'use strict'
+
+ const BreadCrumbView = OC.Backbone.View.extend({
+ tagName: 'span',
+ events: {
+ click: '_onClick',
+ },
+ _dirInfo: undefined,
+
+ render(data) {
+ this._dirInfo = data.dirInfo || null
+
+ if (this._dirInfo !== null && (this._dirInfo.path !== '/' || this._dirInfo.name !== '')) {
+ const isShared = data.dirInfo && data.dirInfo.shareTypes && data.dirInfo.shareTypes.length > 0
+ this.$el.removeClass('shared icon-public icon-shared')
+ if (isShared) {
+ this.$el.addClass('shared')
+ if (data.dirInfo.shareTypes.indexOf(ShareType.Link) !== -1) {
+ this.$el.addClass('icon-public')
+ } else {
+ this.$el.addClass('icon-shared')
+ }
+ } else {
+ this.$el.addClass('icon-shared')
+ }
+ this.$el.show()
+ this.delegateEvents()
+ } else {
+ this.$el.removeClass('shared icon-public icon-shared')
+ this.$el.hide()
+ }
+
+ return this
+ },
+ _onClick(e) {
+ e.preventDefault()
+ e.stopPropagation()
+
+ const fileInfoModel = new OCA.Files.FileInfoModel(this._dirInfo)
+ const self = this
+ fileInfoModel.on('change', function() {
+ self.render({
+ dirInfo: self._dirInfo,
+ })
+ })
+
+ const path = fileInfoModel.attributes.path + '/' + fileInfoModel.attributes.name
+ OCA.Files.Sidebar.open(path)
+ OCA.Files.Sidebar.setActiveTab('sharing')
+ },
+ })
+
+ OCA.Sharing.ShareBreadCrumbView = BreadCrumbView
+})()
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
new file mode 100644
index 00000000000..6ee05c45306
--- /dev/null
+++ b/apps/files_sharing/src/style/sharebreadcrumb.scss
@@ -0,0 +1,17 @@
+/*!
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+li.crumb span.icon-shared,
+li.crumb span.icon-public {
+ display: inline-block;
+ cursor: pointer;
+ opacity: 0.2;
+ margin-inline-end: 6px;
+}
+
+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.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
new file mode 100644
index 00000000000..2f63932bfbe
--- /dev/null
+++ b/apps/files_sharing/src/utils/SharedWithMe.js
@@ -0,0 +1,65 @@
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { ShareType } from '@nextcloud/sharing'
+
+const shareWithTitle = function(share) {
+ if (share.type === ShareType.Group) {
+ return t(
+ 'files_sharing',
+ 'Shared with you and the group {group} by {owner}',
+ {
+ group: share.shareWithDisplayName,
+ owner: share.ownerDisplayName,
+ },
+ undefined,
+ { escape: false },
+ )
+ } else if (share.type === ShareType.Team) {
+ return t(
+ 'files_sharing',
+ 'Shared with you and {circle} by {owner}',
+ {
+ circle: share.shareWithDisplayName,
+ owner: share.ownerDisplayName,
+ },
+ undefined,
+ { escape: false },
+ )
+ } else if (share.type === ShareType.Room) {
+ if (share.shareWithDisplayName) {
+ return t(
+ 'files_sharing',
+ 'Shared with you and the conversation {conversation} by {owner}',
+ {
+ conversation: share.shareWithDisplayName,
+ owner: share.ownerDisplayName,
+ },
+ undefined,
+ { escape: false },
+ )
+ } else {
+ return t(
+ 'files_sharing',
+ 'Shared with you in a conversation by {owner}',
+ {
+ owner: share.ownerDisplayName,
+ },
+ undefined,
+ { escape: false },
+ )
+ }
+ } else {
+ return t(
+ 'files_sharing',
+ 'Shared with you by {owner}',
+ { owner: share.ownerDisplayName },
+ undefined,
+ { escape: false },
+ )
+ }
+}
+
+export { shareWithTitle }
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
new file mode 100644
index 00000000000..809de522d93
--- /dev/null
+++ b/apps/files_sharing/src/views/SharingInherited.vue
@@ -0,0 +1,164 @@
+<!--
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <ul v-if="shares.length" id="sharing-inherited-shares">
+ <!-- Main collapsible entry -->
+ <SharingEntrySimple class="sharing-entry__inherited"
+ :title="mainTitle"
+ :subtitle="subTitle"
+ :aria-expanded="showInheritedShares">
+ <template #avatar>
+ <div class="avatar-shared icon-more-white" />
+ </template>
+ <NcActionButton :icon="showInheritedSharesIcon"
+ :aria-label="toggleTooltip"
+ :title="toggleTooltip"
+ @click.prevent.stop="toggleInheritedShares" />
+ </SharingEntrySimple>
+
+ <!-- Inherited shares list -->
+ <SharingEntryInherited v-for="share in shares"
+ :key="share.id"
+ :file-info="fileInfo"
+ :share="share"
+ @remove:share="removeShare" />
+ </ul>
+</template>
+
+<script>
+import { generateOcsUrl } from '@nextcloud/router'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import axios from '@nextcloud/axios'
+
+import Share from '../models/Share.ts'
+import SharingEntryInherited from '../components/SharingEntryInherited.vue'
+import SharingEntrySimple from '../components/SharingEntrySimple.vue'
+
+export default {
+ name: 'SharingInherited',
+
+ components: {
+ NcActionButton,
+ SharingEntryInherited,
+ SharingEntrySimple,
+ },
+
+ props: {
+ fileInfo: {
+ type: Object,
+ default: () => {},
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ loaded: false,
+ loading: false,
+ showInheritedShares: false,
+ shares: [],
+ }
+ },
+ computed: {
+ showInheritedSharesIcon() {
+ if (this.loading) {
+ return 'icon-loading-small'
+ }
+ if (this.showInheritedShares) {
+ return 'icon-triangle-n'
+ }
+ return 'icon-triangle-s'
+ },
+ mainTitle() {
+ return t('files_sharing', 'Others with access')
+ },
+ subTitle() {
+ return (this.showInheritedShares && this.shares.length === 0)
+ ? t('files_sharing', 'No other accounts with access found')
+ : ''
+ },
+ toggleTooltip() {
+ return this.fileInfo.type === 'dir'
+ ? t('files_sharing', 'Toggle list of others with access to this directory')
+ : t('files_sharing', 'Toggle list of others with access to this file')
+ },
+ fullPath() {
+ const path = `${this.fileInfo.path}/${this.fileInfo.name}`
+ return path.replace('//', '/')
+ },
+ },
+ watch: {
+ fileInfo() {
+ this.resetState()
+ },
+ },
+ methods: {
+ /**
+ * Toggle the list view and fetch/reset the state
+ */
+ toggleInheritedShares() {
+ this.showInheritedShares = !this.showInheritedShares
+ if (this.showInheritedShares) {
+ this.fetchInheritedShares()
+ } else {
+ this.resetState()
+ }
+ },
+ /**
+ * Fetch the Inherited Shares array
+ */
+ async fetchInheritedShares() {
+ this.loading = true
+ try {
+ const url = generateOcsUrl('apps/files_sharing/api/v1/shares/inherited?format=json&path={path}', { path: this.fullPath })
+ const shares = await axios.get(url)
+ this.shares = shares.data.ocs.data
+ .map(share => new Share(share))
+ .sort((a, b) => b.createdTime - a.createdTime)
+ console.info(this.shares)
+ this.loaded = true
+ } catch (error) {
+ OC.Notification.showTemporary(t('files_sharing', 'Unable to fetch inherited shares'), { type: 'error' })
+ } finally {
+ this.loading = false
+ }
+ },
+ /**
+ * Reset current component state
+ */
+ resetState() {
+ this.loaded = false
+ this.loading = false
+ this.showInheritedShares = false
+ this.shares = []
+ },
+ /**
+ * 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>
+
+<style lang="scss" scoped>
+.sharing-entry__inherited {
+ .avatar-shared {
+ width: 32px;
+ height: 32px;
+ line-height: 32px;
+ font-size: 18px;
+ background-color: var(--color-text-maxcontrast);
+ border-radius: 50%;
+ flex-shrink: 0;
+ }
+}
+</style>
diff --git a/apps/files_sharing/src/views/SharingLinkList.vue b/apps/files_sharing/src/views/SharingLinkList.vue
new file mode 100644
index 00000000000..c3d9a7f83dc
--- /dev/null
+++ b/apps/files_sharing/src/views/SharingLinkList.vue
@@ -0,0 +1,142 @@
+<!--
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <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 -->
+ <SharingEntryLink v-for="(share, index) in shares"
+ :key="share.id"
+ :index="shares.length > 1 ? index + 1 : null"
+ :can-reshare="canReshare"
+ :share.sync="shares[index]"
+ :file-info="fileInfo"
+ @add:share="addShare(...arguments)"
+ @update:share="awaitForShare(...arguments)"
+ @remove:share="removeShare"
+ @open-sharing-details="openSharingDetails(share)" />
+ </template>
+
+ <!-- If no link shares, show the add link default entry -->
+ <SharingEntryLink v-if="!hasLinkShares && canReshare"
+ :can-reshare="canReshare"
+ :file-info="fileInfo"
+ @add:share="addShare" />
+ </ul>
+</template>
+
+<script>
+import { getCapabilities } from '@nextcloud/capabilities'
+
+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',
+
+ components: {
+ SharingEntryLink,
+ },
+
+ mixins: [ShareDetails],
+
+ props: {
+ fileInfo: {
+ type: Object,
+ default: () => {},
+ required: true,
+ },
+ shares: {
+ type: Array,
+ default: () => [],
+ required: true,
+ },
+ canReshare: {
+ type: Boolean,
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ canLinkShare: getCapabilities().files_sharing.public.enabled,
+ }
+ },
+
+ computed: {
+ /**
+ * Do we have link shares?
+ * Using this to still show the `new link share`
+ * button regardless of mail shares
+ *
+ * @return {Array}
+ */
+ hasLinkShares() {
+ return this.shares.filter(share => share.type === ShareType.Link).length > 0
+ },
+
+ /**
+ * Do we have any link or email shares?
+ *
+ * @return {boolean}
+ */
+ hasShares() {
+ return this.shares.length > 0
+ },
+ },
+
+ methods: {
+ t,
+
+ /**
+ * Add a new share into the link shares list
+ * and return the newly created share component
+ *
+ * @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) {
+ // eslint-disable-next-line vue/no-mutating-props
+ this.shares.push(share)
+ this.awaitForShare(share, resolve)
+ },
+
+ /**
+ * Await for next tick and render after the list updated
+ * Then resolve with the matched vue component of the
+ * provided share object
+ *
+ * @param {Share} share newly created share
+ * @param {Function} resolve a function to execute after
+ */
+ awaitForShare(share, resolve) {
+ this.$nextTick(() => {
+ const newShare = this.$children.find(component => component.share === share)
+ if (newShare) {
+ resolve(newShare)
+ }
+ })
+ },
+
+ /**
+ * 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/SharingList.vue b/apps/files_sharing/src/views/SharingList.vue
new file mode 100644
index 00000000000..2167059772e
--- /dev/null
+++ b/apps/files_sharing/src/views/SharingList.vue
@@ -0,0 +1,63 @@
+<!--
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <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)"
+ @open-sharing-details="openSharingDetails(share)" />
+ </ul>
+</template>
+
+<script>
+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',
+
+ components: {
+ SharingEntry,
+ },
+
+ mixins: [ShareDetails],
+
+ props: {
+ fileInfo: {
+ type: Object,
+ default: () => { },
+ required: true,
+ },
+ shares: {
+ type: Array,
+ default: () => [],
+ required: true,
+ },
+ },
+
+ setup() {
+ return {
+ t,
+ }
+ },
+ computed: {
+ hasShares() {
+ return this.shares.length === 0
+ },
+ isUnique() {
+ return (share) => {
+ return [...this.shares].filter((item) => {
+ return share.type === ShareType.User && share.shareWithDisplayName === item.shareWithDisplayName
+ }).length <= 1
+ }
+ },
+ },
+}
+</script>
diff --git a/apps/files_sharing/src/views/SharingTab.vue b/apps/files_sharing/src/views/SharingTab.vue
new file mode 100644
index 00000000000..2ed44a4b5ad
--- /dev/null
+++ b/apps/files_sharing/src/views/SharingTab.vue
@@ -0,0 +1,627 @@
+<!--
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <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" />
+ <h2>{{ error }}</h2>
+ </div>
+
+ <!-- shares content -->
+ <div v-show="!showSharingDetailsView"
+ class="sharingTab__content">
+ <!-- shared with me information -->
+ <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>
+
+ <!-- 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 { 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 { 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 moment from '@nextcloud/moment'
+
+import { shareWithTitle } from '../utils/SharedWithMe.js'
+
+import Config from '../services/ConfigService.ts'
+import Share from '../models/Share.ts'
+import SharingEntryInternal from '../components/SharingEntryInternal.vue'
+import SharingEntrySimple from '../components/SharingEntrySimple.vue'
+import SharingInput from '../components/SharingInput.vue'
+
+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,
+ NcButton,
+ NcCollectionList,
+ NcPopover,
+ SharingEntryInternal,
+ SharingEntrySimple,
+ SharingInherited,
+ SharingInput,
+ SharingLinkList,
+ SharingList,
+ SharingDetailsTab,
+ },
+ mixins: [ShareDetails],
+
+ data() {
+ return {
+ config: new Config(),
+ deleteEvent: null,
+ error: '',
+ expirationInterval: null,
+ loading: true,
+
+ fileInfo: null,
+
+ // reshare Share object
+ reshare: null,
+ 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.'),
+ }
+ },
+
+ computed: {
+ /**
+ * Is this share shared with me?
+ *
+ * @return {boolean}
+ */
+ isSharedWithMe() {
+ 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
+ *
+ * @param {object} fileInfo the current file FileInfo
+ */
+ async update(fileInfo) {
+ this.fileInfo = fileInfo
+ this.resetState()
+ this.getShares()
+ },
+ /**
+ * Get the existing shares infos
+ */
+ async getShares() {
+ try {
+ this.loading = true
+
+ // init params
+ const shareUrl = generateOcsUrl('apps/files_sharing/api/v1/shares')
+ const format = 'json'
+ // TODO: replace with proper getFUllpath implementation of our own FileInfo model
+ const path = (this.fileInfo.path + '/' + this.fileInfo.name).replace('//', '/')
+
+ // fetch shares
+ const fetchShares = axios.get(shareUrl, {
+ params: {
+ format,
+ path,
+ reshares: true,
+ },
+ })
+ const fetchSharedWithMe = axios.get(shareUrl, {
+ params: {
+ format,
+ path,
+ shared_with_me: true,
+ },
+ })
+
+ // wait for data
+ const [shares, sharedWithMe] = await Promise.all([fetchShares, fetchSharedWithMe])
+ this.loading = false
+
+ // process results
+ this.processSharedWithMe(sharedWithMe)
+ this.processShares(shares)
+ } catch (error) {
+ 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')
+ }
+ this.loading = false
+ console.error('Error loading the shares list', error)
+ }
+ },
+
+ /**
+ * Reset the current view to its default state
+ */
+ resetState() {
+ clearInterval(this.expirationInterval)
+ this.loading = true
+ this.error = ''
+ this.sharedWithMe = {}
+ this.shares = []
+ this.linkShares = []
+ this.showSharingDetailsView = false
+ this.shareDetailsData = {}
+ },
+
+ /**
+ * Update sharedWithMe.subtitle with the appropriate
+ * expiration time left
+ *
+ * @param {Share} share the sharedWith Share object
+ */
+ updateExpirationSubtitle(share) {
+ const expiration = moment(share.expireDate).unix()
+ this.$set(this.sharedWithMe, 'subtitle', t('files_sharing', 'Expires {relativetime}', {
+ relativetime: moment(expiration * 1000).fromNow(),
+ }))
+
+ // share have expired
+ if (moment().unix() > expiration) {
+ clearInterval(this.expirationInterval)
+ // TODO: clear ui if share is expired
+ this.$set(this.sharedWithMe, 'subtitle', t('files_sharing', 'this share just expired.'))
+ }
+ },
+
+ /**
+ * Process the current shares data
+ * and init shares[]
+ *
+ * @param {object} share the share ocs api request data
+ * @param {object} share.data the request data
+ */
+ processShares({ data }) {
+ if (data.ocs && data.ocs.data && data.ocs.data.length > 0) {
+ 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)
+ }
+ }
+
+ 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)`)
+ }
+ },
+
+ /**
+ * Process the sharedWithMe share data
+ * and init sharedWithMe
+ *
+ * @param {object} share the share ocs api request data
+ * @param {object} share.data the request data
+ */
+ processSharedWithMe({ data }) {
+ if (data.ocs && data.ocs.data && data.ocs.data[0]) {
+ const share = new Share(data)
+ const title = shareWithTitle(share)
+ const displayName = share.ownerDisplayName
+ const user = share.owner
+
+ this.sharedWithMe = {
+ displayName,
+ title,
+ user,
+ }
+ this.reshare = share
+
+ // If we have an expiration date, use it as subtitle
+ // Refresh the status every 10s and clear if expired
+ if (share.expireDate && moment(share.expireDate).unix() > moment().unix()) {
+ // first update
+ this.updateExpirationSubtitle(share)
+ // interval update
+ this.expirationInterval = setInterval(this.updateExpirationSubtitle, 10000, share)
+ }
+ } 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,
+ title: t(
+ 'files_sharing',
+ 'Shared with you by {owner}',
+ { owner: this.fileInfo.shareOwner },
+ undefined,
+ { escape: false },
+ ),
+ user: this.fileInfo.shareOwnerId,
+ }
+ }
+ },
+
+ /**
+ * Add a new share into the shares list
+ * and return the newly created share component
+ *
+ * @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 = () => { }) {
+ // only catching share type MAIL as link shares are added differently
+ // meaning: not from the ShareInput
+ 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
+ * provided share object
+ *
+ * @param {Share} share newly created share
+ * @param {Function} resolve a function to execute after
+ */
+ awaitForShare(share, resolve) {
+ 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>
+
+<style scoped lang="scss">
+.emptyContentWithSections {
+ margin: 1rem auto;
+}
+
+.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>