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.js3
-rw-r--r--apps/files_sharing/src/collaborationresourceshandler.js3
-rw-r--r--apps/files_sharing/src/components/ExternalShareAction.vue2
-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/SelectShareFolderDialogue.vue8
-rw-r--r--apps/files_sharing/src/components/ShareExpiryTime.vue91
-rw-r--r--apps/files_sharing/src/components/SharingEntry.vue48
-rw-r--r--apps/files_sharing/src/components/SharingEntryInherited.vue12
-rw-r--r--apps/files_sharing/src/components/SharingEntryInternal.vue13
-rw-r--r--apps/files_sharing/src/components/SharingEntryLink.vue308
-rw-r--r--apps/files_sharing/src/components/SharingEntryQuickShareSelect.vue28
-rw-r--r--apps/files_sharing/src/components/SharingEntrySimple.vue6
-rw-r--r--apps/files_sharing/src/components/SharingInput.vue235
-rw-r--r--apps/files_sharing/src/eventbus.d.ts15
-rw-r--r--apps/files_sharing/src/files_actions/acceptShareAction.spec.ts (renamed from apps/files_sharing/src/actions/acceptShareAction.spec.ts)49
-rw-r--r--apps/files_sharing/src/files_actions/acceptShareAction.ts (renamed from apps/files_sharing/src/actions/acceptShareAction.ts)2
-rw-r--r--apps/files_sharing/src/files_actions/openInFilesAction.spec.ts (renamed from apps/files_sharing/src/actions/openInFilesAction.spec.ts)13
-rw-r--r--apps/files_sharing/src/files_actions/openInFilesAction.ts (renamed from apps/files_sharing/src/actions/openInFilesAction.ts)24
-rw-r--r--apps/files_sharing/src/files_actions/rejectShareAction.spec.ts (renamed from apps/files_sharing/src/actions/rejectShareAction.spec.ts)52
-rw-r--r--apps/files_sharing/src/files_actions/rejectShareAction.ts (renamed from apps/files_sharing/src/actions/rejectShareAction.ts)7
-rw-r--r--apps/files_sharing/src/files_actions/restoreShareAction.spec.ts (renamed from apps/files_sharing/src/actions/restoreShareAction.spec.ts)44
-rw-r--r--apps/files_sharing/src/files_actions/restoreShareAction.ts (renamed from apps/files_sharing/src/actions/restoreShareAction.ts)2
-rw-r--r--apps/files_sharing/src/files_actions/sharingStatusAction.scss (renamed from apps/files_sharing/src/actions/sharingStatusAction.scss)13
-rw-r--r--apps/files_sharing/src/files_actions/sharingStatusAction.ts (renamed from apps/files_sharing/src/actions/sharingStatusAction.ts)84
-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.js14
-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.ts (renamed from apps/files_sharing/src/views/shares.spec.ts)53
-rw-r--r--apps/files_sharing/src/files_views/shares.ts (renamed from apps/files_sharing/src/views/shares.ts)67
-rw-r--r--apps/files_sharing/src/init-public.ts63
-rw-r--r--apps/files_sharing/src/init.ts29
-rw-r--r--apps/files_sharing/src/lib/SharePermissionsToolBox.spec.js1
-rw-r--r--apps/files_sharing/src/main.ts4
-rw-r--r--apps/files_sharing/src/mixins/ShareDetails.js33
-rw-r--r--apps/files_sharing/src/mixins/ShareRequests.js12
-rw-r--r--apps/files_sharing/src/mixins/ShareTypes.js14
-rw-r--r--apps/files_sharing/src/mixins/SharesMixin.js179
-rw-r--r--apps/files_sharing/src/models/Share.ts (renamed from apps/files_sharing/src/models/Share.js)351
-rw-r--r--apps/files_sharing/src/personal-settings.js4
-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.js322
-rw-r--r--apps/files_sharing/src/services/ConfigService.ts333
-rw-r--r--apps/files_sharing/src/services/ExternalShareActions.js2
-rw-r--r--apps/files_sharing/src/services/GuestNameValidity.ts45
-rw-r--r--apps/files_sharing/src/services/SharingService.spec.ts234
-rw-r--r--apps/files_sharing/src/services/SharingService.ts96
-rw-r--r--apps/files_sharing/src/services/TabSections.js8
-rw-r--r--apps/files_sharing/src/services/TokenService.ts20
-rw-r--r--apps/files_sharing/src/share.js26
-rw-r--r--apps/files_sharing/src/sharebreadcrumbview.js4
-rw-r--r--apps/files_sharing/src/sharing.d.ts10
-rw-r--r--apps/files_sharing/src/style/sharebreadcrumb.scss2
-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.js44
-rw-r--r--apps/files_sharing/src/utils/GeneratePassword.ts66
-rw-r--r--apps/files_sharing/src/utils/NodeShareUtils.ts10
-rw-r--r--apps/files_sharing/src/utils/SharedWithMe.js8
-rw-r--r--apps/files_sharing/src/views/CollaborationView.vue36
-rw-r--r--apps/files_sharing/src/views/FilesHeaderNoteToRecipient.vue73
-rw-r--r--apps/files_sharing/src/views/FilesViewFileDropEmptyContent.vue136
-rw-r--r--apps/files_sharing/src/views/SharingDetailsTab.vue461
-rw-r--r--apps/files_sharing/src/views/SharingInherited.vue6
-rw-r--r--apps/files_sharing/src/views/SharingLinkList.vue29
-rw-r--r--apps/files_sharing/src/views/SharingList.vue16
-rw-r--r--apps/files_sharing/src/views/SharingTab.vue351
75 files changed, 4580 insertions, 1538 deletions
diff --git a/apps/files_sharing/src/additionalScripts.js b/apps/files_sharing/src/additionalScripts.js
index 18812eec537..e8807a7325e 100644
--- a/apps/files_sharing/src/additionalScripts.js
+++ b/apps/files_sharing/src/additionalScripts.js
@@ -2,6 +2,7 @@
* 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'
@@ -9,6 +10,6 @@ import './style/sharebreadcrumb.scss'
import './collaborationresourceshandler.js'
// eslint-disable-next-line camelcase
-__webpack_nonce__ = btoa(OC.requestToken)
+__webpack_nonce__ = getCSPNonce()
window.OCA.Sharing = OCA.Sharing
diff --git a/apps/files_sharing/src/collaborationresourceshandler.js b/apps/files_sharing/src/collaborationresourceshandler.js
index adb9cb02fbc..6f3645385b7 100644
--- a/apps/files_sharing/src/collaborationresourceshandler.js
+++ b/apps/files_sharing/src/collaborationresourceshandler.js
@@ -2,9 +2,10 @@
* SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
+import { getCSPNonce } from '@nextcloud/auth'
// eslint-disable-next-line camelcase
-__webpack_nonce__ = btoa(OC.requestToken)
+__webpack_nonce__ = getCSPNonce()
window.OCP.Collaboration.registerType('file', {
action: () => {
diff --git a/apps/files_sharing/src/components/ExternalShareAction.vue b/apps/files_sharing/src/components/ExternalShareAction.vue
index 0cfb295ff63..c2c86cc8679 100644
--- a/apps/files_sharing/src/components/ExternalShareAction.vue
+++ b/apps/files_sharing/src/components/ExternalShareAction.vue
@@ -12,7 +12,7 @@
</template>
<script>
-import Share from '../models/Share.js'
+import Share from '../models/Share.ts'
export default {
name: 'ExternalShareAction',
diff --git a/apps/files_sharing/src/components/FileListFilterAccount.vue b/apps/files_sharing/src/components/FileListFilterAccount.vue
new file mode 100644
index 00000000000..150516e139b
--- /dev/null
+++ b/apps/files_sharing/src/components/FileListFilterAccount.vue
@@ -0,0 +1,138 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <FileListFilter class="file-list-filter-accounts"
+ :is-active="selectedAccounts.length > 0"
+ :filter-name="t('files_sharing', 'People')"
+ @reset-filter="resetFilter">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiAccountMultipleOutline" />
+ </template>
+ <NcActionInput v-if="availableAccounts.length > 1"
+ :label="t('files_sharing', 'Filter accounts')"
+ :label-outside="false"
+ :show-trailing-button="false"
+ type="search"
+ :value.sync="accountFilter" />
+ <NcActionButton v-for="account of shownAccounts"
+ :key="account.id"
+ class="file-list-filter-accounts__item"
+ type="radio"
+ :model-value="selectedAccounts.includes(account)"
+ :value="account.id"
+ @click="toggleAccount(account.id)">
+ <template #icon>
+ <NcAvatar class="file-list-filter-accounts__avatar"
+ v-bind="account"
+ :size="24"
+ disable-menu
+ :show-user-status="false" />
+ </template>
+ {{ account.displayName }}
+ </NcActionButton>
+ </FileListFilter>
+</template>
+
+<script setup lang="ts">
+import type { IAccountData } from '../files_filters/AccountFilter.ts'
+
+import { translate as t } from '@nextcloud/l10n'
+import { mdiAccountMultipleOutline } from '@mdi/js'
+import { computed, ref, watch } from 'vue'
+
+import FileListFilter from '../../../files/src/components/FileListFilter/FileListFilter.vue'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcActionInput from '@nextcloud/vue/components/NcActionInput'
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+
+interface IUserSelectData {
+ id: string
+ user: string
+ displayName: string
+}
+
+const emit = defineEmits<{
+ (event: 'update:accounts', value: IAccountData[]): void
+}>()
+
+const accountFilter = ref('')
+const availableAccounts = ref<IUserSelectData[]>([])
+const selectedAccounts = ref<IUserSelectData[]>([])
+
+/**
+ * Currently shown accounts (filtered)
+ */
+const shownAccounts = computed(() => {
+ if (!accountFilter.value) {
+ return availableAccounts.value
+ }
+ const queryParts = accountFilter.value.toLocaleLowerCase().trim().split(' ')
+ return availableAccounts.value.filter((account) =>
+ queryParts.every((part) =>
+ account.user.toLocaleLowerCase().includes(part)
+ || account.displayName.toLocaleLowerCase().includes(part),
+ ),
+ )
+})
+
+/**
+ * Toggle an account as selected
+ * @param accountId The account to toggle
+ */
+function toggleAccount(accountId: string) {
+ const account = availableAccounts.value.find(({ id }) => id === accountId)
+ if (account && selectedAccounts.value.includes(account)) {
+ selectedAccounts.value = selectedAccounts.value.filter(({ id }) => id !== accountId)
+ } else {
+ if (account) {
+ selectedAccounts.value = [...selectedAccounts.value, account]
+ }
+ }
+}
+
+// Watch selected account, on change we emit the new account data to the filter instance
+watch(selectedAccounts, () => {
+ // Emit selected accounts as account data
+ const accounts = selectedAccounts.value.map(({ id: uid, displayName }) => ({ uid, displayName }))
+ emit('update:accounts', accounts)
+})
+
+/**
+ * Reset this filter
+ */
+function resetFilter() {
+ selectedAccounts.value = []
+ accountFilter.value = ''
+}
+
+/**
+ * Update list of available accounts in current view.
+ *
+ * @param accounts - Accounts to use
+ */
+function setAvailableAccounts(accounts: IAccountData[]): void {
+ availableAccounts.value = accounts.map(({ uid, displayName }) => ({ displayName, id: uid, user: uid }))
+}
+
+defineExpose({
+ resetFilter,
+ setAvailableAccounts,
+ toggleAccount,
+})
+</script>
+
+<style scoped lang="scss">
+.file-list-filter-accounts {
+ &__item {
+ min-width: 250px;
+ }
+
+ &__avatar {
+ // 24px is the avatar size
+ margin: calc((var(--default-clickable-area) - 24px) / 2)
+ }
+}
+</style>
diff --git a/apps/files_sharing/src/components/NewFileRequestDialog.vue b/apps/files_sharing/src/components/NewFileRequestDialog.vue
new file mode 100644
index 00000000000..392f286e104
--- /dev/null
+++ b/apps/files_sharing/src/components/NewFileRequestDialog.vue
@@ -0,0 +1,468 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <NcDialog can-close
+ class="file-request-dialog"
+ data-cy-file-request-dialog
+ :close-on-click-outside="false"
+ :name="currentStep !== STEP.LAST ? t('files_sharing', 'Create a file request') : t('files_sharing', 'File request created')"
+ size="normal"
+ @closing="onCancel">
+ <!-- Header -->
+ <NcNoteCard v-show="currentStep === STEP.FIRST" type="info" class="file-request-dialog__header">
+ <p id="file-request-dialog-description" class="file-request-dialog__description">
+ {{ t('files_sharing', 'Collect files from others even if they do not have an account.') }}
+ {{ t('files_sharing', 'To ensure you can receive files, verify you have enough storage available.') }}
+ </p>
+ </NcNoteCard>
+
+ <!-- Main form -->
+ <form ref="form"
+ class="file-request-dialog__form"
+ aria-describedby="file-request-dialog-description"
+ :aria-label="t('files_sharing', 'File request')"
+ aria-live="polite"
+ data-cy-file-request-dialog-form
+ @submit.prevent.stop="">
+ <FileRequestIntro v-show="currentStep === STEP.FIRST"
+ :context="context"
+ :destination.sync="destination"
+ :disabled="loading"
+ :label.sync="label"
+ :note.sync="note" />
+
+ <FileRequestDatePassword v-show="currentStep === STEP.SECOND"
+ :disabled="loading"
+ :expiration-date.sync="expirationDate"
+ :password.sync="password" />
+
+ <FileRequestFinish v-if="share"
+ v-show="currentStep === STEP.LAST"
+ :emails="emails"
+ :is-share-by-mail-enabled="isShareByMailEnabled"
+ :share="share"
+ @add-email="email => emails.push(email)"
+ @remove-email="onRemoveEmail" />
+ </form>
+
+ <!-- Controls -->
+ <template #actions>
+ <!-- Back -->
+ <NcButton v-show="currentStep === STEP.SECOND"
+ :aria-label="t('files_sharing', 'Previous step')"
+ :disabled="loading"
+ data-cy-file-request-dialog-controls="back"
+ type="tertiary"
+ @click="currentStep = STEP.FIRST">
+ {{ t('files_sharing', 'Previous step') }}
+ </NcButton>
+
+ <!-- Align right -->
+ <span class="dialog__actions-separator" />
+
+ <!-- Cancel the creation -->
+ <NcButton v-if="currentStep !== STEP.LAST"
+ :aria-label="t('files_sharing', 'Cancel')"
+ :disabled="loading"
+ :title="t('files_sharing', 'Cancel the file request creation')"
+ data-cy-file-request-dialog-controls="cancel"
+ type="tertiary"
+ @click="onCancel">
+ {{ t('files_sharing', 'Cancel') }}
+ </NcButton>
+
+ <!-- Cancel email and just close -->
+ <NcButton v-else-if="emails.length !== 0"
+ :aria-label="t('files_sharing', 'Close without sending emails')"
+ :disabled="loading"
+ :title="t('files_sharing', 'Close without sending emails')"
+ data-cy-file-request-dialog-controls="cancel"
+ type="tertiary"
+ @click="onCancel">
+ {{ t('files_sharing', 'Close') }}
+ </NcButton>
+
+ <!-- Next -->
+ <NcButton v-if="currentStep !== STEP.LAST"
+ :aria-label="t('files_sharing', 'Continue')"
+ :disabled="loading"
+ data-cy-file-request-dialog-controls="next"
+ @click="onPageNext">
+ <template #icon>
+ <NcLoadingIcon v-if="loading" />
+ <IconNext v-else :size="20" />
+ </template>
+ {{ t('files_sharing', 'Continue') }}
+ </NcButton>
+
+ <!-- Finish -->
+ <NcButton v-else
+ :aria-label="finishButtonLabel"
+ :disabled="loading"
+ data-cy-file-request-dialog-controls="finish"
+ type="primary"
+ @click="onFinish">
+ <template #icon>
+ <NcLoadingIcon v-if="loading" />
+ <IconCheck v-else :size="20" />
+ </template>
+ {{ finishButtonLabel }}
+ </NcButton>
+ </template>
+ </NcDialog>
+</template>
+
+<script lang="ts">
+import type { AxiosError } from '@nextcloud/axios'
+import type { Folder, Node } from '@nextcloud/files'
+import type { OCSResponse } from '@nextcloud/typings/ocs'
+import type { PropType } from 'vue'
+
+import { defineComponent } from 'vue'
+import { emit } from '@nextcloud/event-bus'
+import { generateOcsUrl } from '@nextcloud/router'
+import { Permission } from '@nextcloud/files'
+import { ShareType } from '@nextcloud/sharing'
+import { showError, showSuccess } from '@nextcloud/dialogs'
+import { n, t } from '@nextcloud/l10n'
+import axios from '@nextcloud/axios'
+
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcDialog from '@nextcloud/vue/components/NcDialog'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+
+import IconCheck from 'vue-material-design-icons/Check.vue'
+import IconNext from 'vue-material-design-icons/ArrowRight.vue'
+
+import Config from '../services/ConfigService'
+import FileRequestDatePassword from './NewFileRequestDialog/NewFileRequestDialogDatePassword.vue'
+import FileRequestFinish from './NewFileRequestDialog/NewFileRequestDialogFinish.vue'
+import FileRequestIntro from './NewFileRequestDialog/NewFileRequestDialogIntro.vue'
+import logger from '../services/logger'
+import Share from '../models/Share.ts'
+
+enum STEP {
+ FIRST = 0,
+ SECOND = 1,
+ LAST = 2,
+}
+
+const sharingConfig = new Config()
+
+export default defineComponent({
+ name: 'NewFileRequestDialog',
+
+ components: {
+ FileRequestDatePassword,
+ FileRequestFinish,
+ FileRequestIntro,
+ IconCheck,
+ IconNext,
+ NcButton,
+ NcDialog,
+ NcLoadingIcon,
+ NcNoteCard,
+ },
+
+ props: {
+ context: {
+ type: Object as PropType<Folder>,
+ required: true,
+ },
+ content: {
+ type: Array as PropType<Node[]>,
+ required: true,
+ },
+ },
+
+ setup() {
+ return {
+ STEP,
+ n,
+ t,
+
+ isShareByMailEnabled: sharingConfig.isMailShareAllowed,
+ }
+ },
+
+ data() {
+ return {
+ currentStep: STEP.FIRST,
+ loading: false,
+
+ destination: this.context.path || '/',
+ label: '',
+ note: '',
+
+ expirationDate: null as Date | null,
+ password: null as string | null,
+
+ share: null as Share | null,
+ emails: [] as string[],
+ }
+ },
+
+ computed: {
+ finishButtonLabel() {
+ if (this.emails.length === 0) {
+ return t('files_sharing', 'Close')
+ }
+ return n('files_sharing', 'Send email and close', 'Send {count} emails and close', this.emails.length, { count: this.emails.length })
+ },
+ },
+
+ methods: {
+ onPageNext() {
+ const form = this.$refs.form as HTMLFormElement
+
+ // Reset custom validity
+ form.querySelectorAll('input').forEach(input => input.setCustomValidity(''))
+
+ // custom destination validation
+ // cannot share root
+ if (this.destination === '/' || this.destination === '') {
+ const destinationInput = form.querySelector('input[name="destination"]') as HTMLInputElement
+ destinationInput?.setCustomValidity(t('files_sharing', 'Please select a folder, you cannot share the root directory.'))
+ form.reportValidity()
+ return
+ }
+
+ // If the form is not valid, show the error message
+ if (!form.checkValidity()) {
+ form.reportValidity()
+ return
+ }
+
+ if (this.currentStep === STEP.FIRST) {
+ this.currentStep = STEP.SECOND
+ return
+ }
+
+ this.createShare()
+ },
+
+ onRemoveEmail(email: string) {
+ const index = this.emails.indexOf(email)
+ this.emails.splice(index, 1)
+ },
+
+ onCancel() {
+ this.$emit('close')
+ },
+
+ async onFinish() {
+ if (this.emails.length === 0 || this.isShareByMailEnabled === false) {
+ showSuccess(t('files_sharing', 'File request created'))
+ this.$emit('close')
+ return
+ }
+
+ if (sharingConfig.isMailShareAllowed && this.emails.length > 0) {
+ await this.setShareEmails()
+ await this.sendEmails()
+ showSuccess(n('files_sharing', 'File request created and email sent', 'File request created and {count} emails sent', this.emails.length, { count: this.emails.length }))
+ } else {
+ showSuccess(t('files_sharing', 'File request created'))
+ }
+
+ this.$emit('close')
+ },
+
+ async createShare() {
+ this.loading = true
+
+ let expireDate = ''
+ if (this.expirationDate) {
+ const year = this.expirationDate.getFullYear()
+ const month = (this.expirationDate.getMonth() + 1).toString().padStart(2, '0')
+ const day = this.expirationDate.getDate().toString().padStart(2, '0')
+
+ // Format must be YYYY-MM-DD
+ expireDate = `${year}-${month}-${day}`
+ }
+ const shareUrl = generateOcsUrl('apps/files_sharing/api/v1/shares')
+ try {
+ const request = await axios.post<OCSResponse>(shareUrl, {
+ // Always create a file request, but without mail share
+ // permissions, only a share link will be created.
+ shareType: sharingConfig.isMailShareAllowed ? ShareType.Email : ShareType.Link,
+ permissions: Permission.CREATE,
+
+ label: this.label,
+ path: this.destination,
+ note: this.note,
+
+ password: this.password || '',
+ expireDate: expireDate || '',
+
+ // Empty string
+ shareWith: '',
+ attributes: JSON.stringify([{
+ value: true,
+ key: 'enabled',
+ scope: 'fileRequest',
+ }]),
+ })
+
+ // If not an ocs request
+ if (!request?.data?.ocs) {
+ throw request
+ }
+
+ const share = new Share(request.data.ocs.data)
+ this.share = share
+
+ logger.info('New file request created', { share })
+ emit('files_sharing:share:created', { share })
+
+ // Move to the last page
+ this.currentStep = STEP.LAST
+ } catch (error) {
+ const errorMessage = (error as AxiosError<OCSResponse>)?.response?.data?.ocs?.meta?.message
+ showError(
+ errorMessage
+ ? t('files_sharing', 'Error creating the share: {errorMessage}', { errorMessage })
+ : t('files_sharing', 'Error creating the share'),
+ )
+ logger.error('Error while creating share', { error, errorMessage })
+ throw error
+ } finally {
+ this.loading = false
+ }
+ },
+
+ async setShareEmails() {
+ this.loading = true
+
+ // This should never happen™
+ if (!this.share || !this.share?.id) {
+ throw new Error('Share ID is missing')
+ }
+
+ const shareUrl = generateOcsUrl('apps/files_sharing/api/v1/shares/{id}', { id: this.share.id })
+ try {
+ // Convert link share to email share
+ const request = await axios.put<OCSResponse>(shareUrl, {
+ attributes: JSON.stringify([{
+ value: this.emails,
+ key: 'emails',
+ scope: 'shareWith',
+ },
+ {
+ value: true,
+ key: 'enabled',
+ scope: 'fileRequest',
+ }]),
+ })
+
+ // If not an ocs request
+ if (!request?.data?.ocs) {
+ throw request
+ }
+ } catch (error) {
+ this.onEmailSendError(error)
+ throw error
+ } finally {
+ this.loading = false
+ }
+ },
+
+ async sendEmails() {
+ this.loading = true
+
+ // This should never happen™
+ if (!this.share || !this.share?.id) {
+ throw new Error('Share ID is missing')
+ }
+
+ const shareUrl = generateOcsUrl('apps/files_sharing/api/v1/shares/{id}/send-email', { id: this.share.id })
+ try {
+ // Convert link share to email share
+ const request = await axios.post<OCSResponse>(shareUrl, {
+ password: this.password || undefined,
+ })
+
+ // If not an ocs request
+ if (!request?.data?.ocs) {
+ throw request
+ }
+ } catch (error) {
+ this.onEmailSendError(error)
+ throw error
+ } finally {
+ this.loading = false
+ }
+ },
+
+ onEmailSendError(error: AxiosError<OCSResponse>) {
+ const errorMessage = error.response?.data?.ocs?.meta?.message
+ showError(
+ errorMessage
+ ? t('files_sharing', 'Error sending emails: {errorMessage}', { errorMessage })
+ : t('files_sharing', 'Error sending emails'),
+ )
+ logger.error('Error while sending emails', { error, errorMessage })
+ },
+ },
+})
+</script>
+
+<style lang="scss">
+.file-request-dialog {
+ --margin: 18px;
+
+ &__header {
+ margin: 0 var(--margin);
+ }
+
+ &__form {
+ position: relative;
+ overflow: auto;
+ padding: var(--margin) var(--margin);
+ // overlap header bottom padding
+ margin-top: calc(-1 * var(--margin));
+ }
+
+ fieldset {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ margin-top: var(--margin);
+
+ legend {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ }
+ }
+
+ // Using a NcNoteCard was a bit much sometimes.
+ // Using a simple paragraph instead does it.
+ &__info {
+ color: var(--color-text-maxcontrast);
+ padding-block: 4px;
+ display: flex;
+ align-items: center;
+ .file-request-dialog__info-icon {
+ margin-inline-end: 8px;
+ }
+ }
+
+ .dialog__actions {
+ width: auto;
+ margin-inline: 12px;
+ span.dialog__actions-separator {
+ margin-inline-start: auto;
+ }
+ }
+
+ .input-field__helper-text-message {
+ // reduce helper text standing out
+ color: var(--color-text-maxcontrast);
+ }
+}
+</style>
diff --git a/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogDatePassword.vue b/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogDatePassword.vue
new file mode 100644
index 00000000000..7e6d56e8794
--- /dev/null
+++ b/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogDatePassword.vue
@@ -0,0 +1,258 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <div>
+ <!-- Password and expiration summary -->
+ <NcNoteCard v-if="passwordAndExpirationSummary" type="success">
+ {{ passwordAndExpirationSummary }}
+ </NcNoteCard>
+
+ <!-- Expiration date -->
+ <fieldset class="file-request-dialog__expiration" data-cy-file-request-dialog-fieldset="expiration">
+ <!-- Enable expiration -->
+ <legend>{{ t('files_sharing', 'When should the request expire?') }}</legend>
+ <NcCheckboxRadioSwitch v-show="!isExpirationDateEnforced"
+ :checked="isExpirationDateEnforced || expirationDate !== null"
+ :disabled="disabled || isExpirationDateEnforced"
+ @update:checked="onToggleDeadline">
+ {{ t('files_sharing', 'Set a submission expiration date') }}
+ </NcCheckboxRadioSwitch>
+
+ <!-- Date picker -->
+ <NcDateTimePickerNative v-if="expirationDate !== null"
+ id="file-request-dialog-expirationDate"
+ :disabled="disabled"
+ :hide-label="true"
+ :label="t('files_sharing', 'Expiration date')"
+ :max="maxDate"
+ :min="minDate"
+ :placeholder="t('files_sharing', 'Select a date')"
+ :required="defaultExpireDateEnforced"
+ :value="expirationDate"
+ name="expirationDate"
+ type="date"
+ @input="$emit('update:expirationDate', $event)" />
+
+ <p v-if="defaultExpireDateEnforced" class="file-request-dialog__info">
+ <IconInfo :size="18" class="file-request-dialog__info-icon" />
+ {{ t('files_sharing', 'Your administrator has enforced a {count} days expiration policy.', { count: defaultExpireDate }) }}
+ </p>
+ </fieldset>
+
+ <!-- Password -->
+ <fieldset class="file-request-dialog__password" data-cy-file-request-dialog-fieldset="password">
+ <!-- Enable password -->
+ <legend>{{ t('files_sharing', 'What password should be used for the request?') }}</legend>
+ <NcCheckboxRadioSwitch v-show="!isPasswordEnforced"
+ :checked="isPasswordEnforced || password !== null"
+ :disabled="disabled || isPasswordEnforced"
+ @update:checked="onTogglePassword">
+ {{ t('files_sharing', 'Set a password') }}
+ </NcCheckboxRadioSwitch>
+
+ <div v-if="password !== null" class="file-request-dialog__password-field">
+ <NcPasswordField ref="passwordField"
+ :check-password-strength="true"
+ :disabled="disabled"
+ :label="t('files_sharing', 'Password')"
+ :placeholder="t('files_sharing', 'Enter a valid password')"
+ :required="enforcePasswordForPublicLink"
+ :value="password"
+ name="password"
+ @update:value="$emit('update:password', $event)" />
+ <NcButton :aria-label="t('files_sharing', 'Generate a new password')"
+ :title="t('files_sharing', 'Generate a new password')"
+ type="tertiary-no-background"
+ @click="onGeneratePassword">
+ <template #icon>
+ <IconPasswordGen :size="20" />
+ </template>
+ </NcButton>
+ </div>
+
+ <p v-if="enforcePasswordForPublicLink" class="file-request-dialog__info">
+ <IconInfo :size="18" class="file-request-dialog__info-icon" />
+ {{ t('files_sharing', 'Your administrator has enforced a password protection.') }}
+ </p>
+ </fieldset>
+ </div>
+</template>
+
+<script lang="ts">
+import { defineComponent, type PropType } from 'vue'
+import { t } from '@nextcloud/l10n'
+
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
+import NcDateTimePickerNative from '@nextcloud/vue/components/NcDateTimePickerNative'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
+
+import IconInfo from 'vue-material-design-icons/Information.vue'
+import IconPasswordGen from 'vue-material-design-icons/AutoFix.vue'
+
+import Config from '../../services/ConfigService'
+import GeneratePassword from '../../utils/GeneratePassword'
+
+const sharingConfig = new Config()
+
+export default defineComponent({
+ name: 'NewFileRequestDialogDatePassword',
+
+ components: {
+ IconInfo,
+ IconPasswordGen,
+ NcButton,
+ NcCheckboxRadioSwitch,
+ NcDateTimePickerNative,
+ NcNoteCard,
+ NcPasswordField,
+ },
+
+ props: {
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ expirationDate: {
+ type: Date as PropType<Date | null>,
+ required: false,
+ default: null,
+ },
+ password: {
+ type: String as PropType<string | null>,
+ required: false,
+ default: null,
+ },
+ },
+
+ emits: [
+ 'update:expirationDate',
+ 'update:password',
+ ],
+
+ setup() {
+ return {
+ t,
+
+ // Default expiration date if defaultExpireDateEnabled is true
+ defaultExpireDate: sharingConfig.defaultExpireDate,
+ // Default expiration date is enabled for public links (can be disabled)
+ defaultExpireDateEnabled: sharingConfig.isDefaultExpireDateEnabled,
+ // Default expiration date is enforced for public links (can't be disabled)
+ defaultExpireDateEnforced: sharingConfig.isDefaultExpireDateEnforced,
+
+ // Default password protection is enabled for public links (can be disabled)
+ enableLinkPasswordByDefault: sharingConfig.enableLinkPasswordByDefault,
+ // Password protection is enforced for public links (can't be disabled)
+ enforcePasswordForPublicLink: sharingConfig.enforcePasswordForPublicLink,
+ }
+ },
+
+ data() {
+ return {
+ maxDate: null as Date | null,
+ minDate: new Date(new Date().setDate(new Date().getDate() + 1)),
+ }
+ },
+
+ computed: {
+ passwordAndExpirationSummary(): string {
+ if (this.expirationDate && this.password) {
+ return t('files_sharing', 'The request will expire on {date} at midnight and will be password protected.', {
+ date: this.expirationDate.toLocaleDateString(),
+ })
+ }
+
+ if (this.expirationDate) {
+ return t('files_sharing', 'The request will expire on {date} at midnight.', {
+ date: this.expirationDate.toLocaleDateString(),
+ })
+ }
+
+ if (this.password) {
+ return t('files_sharing', 'The request will be password protected.')
+ }
+
+ return ''
+ },
+
+ isExpirationDateEnforced(): boolean {
+ // Both fields needs to be enabled in the settings
+ return this.defaultExpireDateEnabled
+ && this.defaultExpireDateEnforced
+ },
+
+ isPasswordEnforced(): boolean {
+ // Both fields needs to be enabled in the settings
+ return this.enableLinkPasswordByDefault
+ && this.enforcePasswordForPublicLink
+ },
+ },
+
+ mounted() {
+ // If defined, we set the default expiration date
+ if (this.defaultExpireDate) {
+ this.$emit('update:expirationDate', sharingConfig.defaultExpirationDate)
+ }
+
+ // If enforced, we cannot set a date before the default expiration days (see admin settings)
+ if (this.isExpirationDateEnforced) {
+ this.maxDate = sharingConfig.defaultExpirationDate
+ }
+
+ // If enabled by default, we generate a valid password
+ if (this.isPasswordEnforced) {
+ this.generatePassword()
+ }
+ },
+
+ methods: {
+ onToggleDeadline(checked: boolean) {
+ this.$emit('update:expirationDate', checked ? (this.maxDate || this.minDate) : null)
+ },
+
+ async onTogglePassword(checked: boolean) {
+ if (checked) {
+ this.generatePassword()
+ return
+ }
+ this.$emit('update:password', null)
+ },
+
+ async onGeneratePassword() {
+ await this.generatePassword()
+ this.showPassword()
+ },
+
+ async generatePassword() {
+ await GeneratePassword().then(password => {
+ this.$emit('update:password', password)
+ })
+ },
+
+ showPassword() {
+ // @ts-expect-error isPasswordHidden is private
+ this.$refs.passwordField.isPasswordHidden = false
+ },
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.file-request-dialog__password-field {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ // Compensate label gab with legend
+ margin-top: 12px;
+ > div {
+ // Force margin to 0 as we handle it above
+ margin: 0;
+ }
+}
+</style>
diff --git a/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogFinish.vue b/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogFinish.vue
new file mode 100644
index 00000000000..7826aab581e
--- /dev/null
+++ b/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogFinish.vue
@@ -0,0 +1,236 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <div>
+ <!-- Request note -->
+ <NcNoteCard type="success">
+ {{ t('files_sharing', 'You can now share the link below to allow people to upload files to your directory.') }}
+ </NcNoteCard>
+
+ <!-- Copy share link -->
+ <NcInputField ref="clipboard"
+ :value="shareLink"
+ :label="t('files_sharing', 'Share link')"
+ :readonly="true"
+ :show-trailing-button="true"
+ :trailing-button-label="t('files_sharing', 'Copy')"
+ data-cy-file-request-dialog-fieldset="link"
+ @click="copyShareLink"
+ @trailing-button-click="copyShareLink">
+ <template #trailing-button-icon>
+ <IconCheck v-if="isCopied" :size="20" />
+ <IconClipboard v-else :size="20" />
+ </template>
+ </NcInputField>
+
+ <template v-if="isShareByMailEnabled">
+ <!-- Email share-->
+ <NcTextField :value.sync="email"
+ :label="t('files_sharing', 'Send link via email')"
+ :placeholder="t('files_sharing', 'Enter an email address or paste a list')"
+ data-cy-file-request-dialog-fieldset="email"
+ type="email"
+ @keypress.enter.stop="addNewEmail"
+ @paste.stop.prevent="onPasteEmails"
+ @focusout.native="addNewEmail" />
+
+ <!-- Email list -->
+ <div v-if="emails.length > 0" class="file-request-dialog__emails">
+ <NcChip v-for="mail in emails"
+ :key="mail"
+ :aria-label-close="t('files_sharing', 'Remove email')"
+ :text="mail"
+ @close="$emit('remove-email', mail)">
+ <template #icon>
+ <NcAvatar :disable-menu="true"
+ :disable-tooltip="true"
+ :display-name="mail"
+ :is-no-user="true"
+ :show-user-status="false"
+ :size="24" />
+ </template>
+ </NcChip>
+ </div>
+ </template>
+ </div>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+import Share from '../../models/Share.ts'
+
+import { defineComponent } from 'vue'
+import { generateUrl, getBaseUrl } from '@nextcloud/router'
+import { showError, showSuccess } from '@nextcloud/dialogs'
+import { n, t } from '@nextcloud/l10n'
+
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcInputField from '@nextcloud/vue/components/NcInputField'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+import NcChip from '@nextcloud/vue/components/NcChip'
+
+import IconCheck from 'vue-material-design-icons/Check.vue'
+import IconClipboard from 'vue-material-design-icons/ClipboardText.vue'
+
+export default defineComponent({
+ name: 'NewFileRequestDialogFinish',
+
+ components: {
+ IconCheck,
+ IconClipboard,
+ NcAvatar,
+ NcInputField,
+ NcNoteCard,
+ NcTextField,
+ NcChip,
+ },
+
+ props: {
+ share: {
+ type: Object as PropType<Share>,
+ required: true,
+ },
+ emails: {
+ type: Array as PropType<string[]>,
+ required: true,
+ },
+ isShareByMailEnabled: {
+ type: Boolean,
+ required: true,
+ },
+ },
+
+ emits: ['add-email', 'remove-email'],
+
+ setup() {
+ return {
+ n, t,
+ }
+ },
+
+ data() {
+ return {
+ isCopied: false,
+ email: '',
+ }
+ },
+
+ computed: {
+ shareLink() {
+ return generateUrl('/s/{token}', { token: this.share.token }, { baseURL: getBaseUrl() })
+ },
+ },
+
+ methods: {
+ async copyShareLink(event: MouseEvent) {
+ if (this.isCopied) {
+ this.isCopied = false
+ return
+ }
+
+ if (!navigator.clipboard) {
+ // Clipboard API not available
+ window.prompt(t('files_sharing', 'Automatically copying failed, please copy the share link manually'), this.shareLink)
+ return
+ }
+
+ await navigator.clipboard.writeText(this.shareLink)
+
+ showSuccess(t('files_sharing', 'Link copied'))
+ this.isCopied = true
+ event.target?.select?.()
+
+ setTimeout(() => {
+ this.isCopied = false
+ }, 3000)
+ },
+
+ addNewEmail(e: KeyboardEvent) {
+ if (this.email.trim() === '') {
+ return
+ }
+
+ if (e.target instanceof HTMLInputElement) {
+ // Reset the custom validity
+ e.target.setCustomValidity('')
+
+ // Check if the field is valid
+ if (e.target.checkValidity() === false) {
+ e.target.reportValidity()
+ return
+ }
+
+ // The email is already in the list
+ if (this.emails.includes(this.email.trim())) {
+ e.target.setCustomValidity(t('files_sharing', 'Email already added'))
+ e.target.reportValidity()
+ return
+ }
+
+ // Check if the email is valid
+ if (!this.isValidEmail(this.email.trim())) {
+ e.target.setCustomValidity(t('files_sharing', 'Invalid email address'))
+ e.target.reportValidity()
+ return
+ }
+
+ this.$emit('add-email', this.email.trim())
+ this.email = ''
+ }
+ },
+
+ // Handle dumping a list of emails
+ onPasteEmails(e: ClipboardEvent) {
+ const clipboardData = e.clipboardData
+ if (!clipboardData) {
+ return
+ }
+
+ const pastedText = clipboardData.getData('text')
+ const emails = pastedText.split(/[\s,;]+/).filter(Boolean).map((email) => email.trim())
+
+ const duplicateEmails = emails.filter((email) => this.emails.includes(email))
+ const validEmails = emails.filter((email) => this.isValidEmail(email) && !duplicateEmails.includes(email))
+ const invalidEmails = emails.filter((email) => !this.isValidEmail(email))
+ validEmails.forEach((email) => this.$emit('add-email', email))
+
+ // Warn about invalid emails
+ if (invalidEmails.length > 0) {
+ showError(n('files_sharing', 'The following email address is not valid: {emails}', 'The following email addresses are not valid: {emails}', invalidEmails.length, { emails: invalidEmails.join(', ') }))
+ }
+
+ // Warn about duplicate emails
+ if (duplicateEmails.length > 0) {
+ showError(n('files_sharing', '{count} email address already added', '{count} email addresses already added', duplicateEmails.length, { count: duplicateEmails.length }))
+ }
+
+ if (validEmails.length > 0) {
+ showSuccess(n('files_sharing', '{count} email address added', '{count} email addresses added', validEmails.length, { count: validEmails.length }))
+ }
+
+ this.email = ''
+ },
+
+ // No need to have a fancy regex, just check for an @
+ isValidEmail(email: string): boolean {
+ return email.includes('@')
+ },
+ },
+})
+</script>
+<style scoped>
+.input-field,
+.file-request-dialog__emails {
+ margin-top: var(--margin);
+}
+
+.file-request-dialog__emails {
+ display: flex;
+ gap: var(--default-grid-baseline);
+ flex-wrap: wrap;
+}
+</style>
diff --git a/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogIntro.vue b/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogIntro.vue
new file mode 100644
index 00000000000..5ac60c37e29
--- /dev/null
+++ b/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogIntro.vue
@@ -0,0 +1,166 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <div>
+ <!-- Request label -->
+ <fieldset class="file-request-dialog__label" data-cy-file-request-dialog-fieldset="label">
+ <legend>
+ {{ t('files_sharing', 'What are you requesting?') }}
+ </legend>
+ <NcTextField :value="label"
+ :disabled="disabled"
+ :label="t('files_sharing', 'Request subject')"
+ :placeholder="t('files_sharing', 'Birthday party photos, History assignment…')"
+ :required="false"
+ name="label"
+ @update:value="$emit('update:label', $event)" />
+ </fieldset>
+
+ <!-- Request destination -->
+ <fieldset class="file-request-dialog__destination" data-cy-file-request-dialog-fieldset="destination">
+ <legend>
+ {{ t('files_sharing', 'Where should these files go?') }}
+ </legend>
+ <NcTextField :value="destination"
+ :disabled="disabled"
+ :label="t('files_sharing', 'Upload destination')"
+ :minlength="2/* cannot share root */"
+ :placeholder="t('files_sharing', 'Select a destination')"
+ :readonly="false /* cannot validate a readonly input */"
+ :required="true /* cannot be empty */"
+ :show-trailing-button="destination !== context.path"
+ :trailing-button-icon="'undo'"
+ :trailing-button-label="t('files_sharing', 'Revert to default')"
+ name="destination"
+ @click="onPickDestination"
+ @keypress.prevent.stop="/* prevent typing in the input, we use the picker */"
+ @paste.prevent.stop="/* prevent pasting in the input, we use the picker */"
+ @trailing-button-click="$emit('update:destination', '')">
+ <IconFolder :size="18" />
+ </NcTextField>
+
+ <p class="file-request-dialog__info">
+ <IconLock :size="18" class="file-request-dialog__info-icon" />
+ {{ t('files_sharing', 'The uploaded files are visible only to you unless you choose to share them.') }}
+ </p>
+ </fieldset>
+
+ <!-- Request note -->
+ <fieldset class="file-request-dialog__note" data-cy-file-request-dialog-fieldset="note">
+ <legend>
+ {{ t('files_sharing', 'Add a note') }}
+ </legend>
+ <NcTextArea :value="note"
+ :disabled="disabled"
+ :label="t('files_sharing', 'Note for recipient')"
+ :placeholder="t('files_sharing', 'Add a note to help people understand what you are requesting.')"
+ :required="false"
+ name="note"
+ @update:value="$emit('update:note', $event)" />
+
+ <p class="file-request-dialog__info">
+ <IconInfo :size="18" class="file-request-dialog__info-icon" />
+ {{ t('files_sharing', 'You can add links, date or any other information that will help the recipient understand what you are requesting.') }}
+ </p>
+ </fieldset>
+ </div>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+import type { Folder, Node } from '@nextcloud/files'
+
+import { defineComponent } from 'vue'
+import { getFilePickerBuilder } from '@nextcloud/dialogs'
+import { t } from '@nextcloud/l10n'
+
+import IconFolder from 'vue-material-design-icons/Folder.vue'
+import IconInfo from 'vue-material-design-icons/InformationOutline.vue'
+import IconLock from 'vue-material-design-icons/Lock.vue'
+import NcTextArea from '@nextcloud/vue/components/NcTextArea'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+
+export default defineComponent({
+ name: 'NewFileRequestDialogIntro',
+
+ components: {
+ IconFolder,
+ IconInfo,
+ IconLock,
+ NcTextArea,
+ NcTextField,
+ },
+
+ props: {
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ context: {
+ type: Object as PropType<Folder>,
+ required: true,
+ },
+ label: {
+ type: String,
+ required: true,
+ },
+ destination: {
+ type: String,
+ required: true,
+ },
+ note: {
+ type: String,
+ required: true,
+ },
+ },
+
+ emits: [
+ 'update:destination',
+ 'update:label',
+ 'update:note',
+ ],
+
+ setup() {
+ return {
+ t,
+ }
+ },
+
+ methods: {
+ onPickDestination() {
+ const filepicker = getFilePickerBuilder(t('files_sharing', 'Select a destination'))
+ .addMimeTypeFilter('httpd/unix-directory')
+ .allowDirectories(true)
+ .addButton({
+ label: t('files_sharing', 'Select'),
+ callback: this.onPickedDestination,
+ })
+ .setFilter(node => node.path !== '/')
+ .startAt(this.destination)
+ .build()
+ try {
+ filepicker.pick()
+ } catch (e) {
+ // ignore cancel
+ }
+ },
+
+ onPickedDestination(nodes: Node[]) {
+ const node = nodes[0]
+ if (node) {
+ this.$emit('update:destination', node.path)
+ }
+ },
+ },
+})
+</script>
+<style scoped>
+.file-request-dialog__note :deep(textarea) {
+ width: 100% !important;
+ min-height: 80px;
+}
+</style>
diff --git a/apps/files_sharing/src/components/SelectShareFolderDialogue.vue b/apps/files_sharing/src/components/SelectShareFolderDialogue.vue
index 1b717da8b67..959fecaa4a4 100644
--- a/apps/files_sharing/src/components/SelectShareFolderDialogue.vue
+++ b/apps/files_sharing/src/components/SelectShareFolderDialogue.vue
@@ -29,7 +29,7 @@ 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/dist/Components/NcTextField.js'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
const defaultDirectory = loadState('files_sharing', 'default_share_folder', '/')
const directory = loadState('files_sharing', 'share_folder', defaultDirectory)
@@ -57,7 +57,7 @@ export default {
async pickFolder() {
// Setup file picker
- const picker = getFilePickerBuilder(t('files', 'Choose a default folder for accepted shares'))
+ const picker = getFilePickerBuilder(t('files_sharing', 'Choose a default folder for accepted shares'))
.startAt(this.readableDirectory)
.setMultiSelect(false)
.setType(1)
@@ -69,7 +69,7 @@ export default {
// Init user folder picking
const dir = await picker.pick() || '/'
if (!dir.startsWith('/')) {
- throw new Error(t('files', 'Invalid path selected'))
+ throw new Error(t('files_sharing', 'Invalid path selected'))
}
// Fix potential path issues and save results
@@ -78,7 +78,7 @@ export default {
shareFolder: this.directory,
})
} catch (error) {
- showError(error.message || t('files', 'Unknown error'))
+ showError(error.message || t('files_sharing', 'Unknown error'))
}
},
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
index 23127a6fe16..342b40ce384 100644
--- a/apps/files_sharing/src/components/SharingEntry.vue
+++ b/apps/files_sharing/src/components/SharingEntry.vue
@@ -6,7 +6,7 @@
<template>
<li class="sharing-entry">
<NcAvatar class="sharing-entry__avatar"
- :is-no-user="share.type !== SHARE_TYPES.SHARE_TYPE_USER"
+ :is-no-user="share.type !== ShareType.User"
:user="share.shareWith"
:display-name="share.shareWithDisplayName"
:menu-position="'left'"
@@ -19,8 +19,9 @@
:href="share.shareWithLink"
class="sharing-entry__summary__desc">
<span>{{ title }}
- <span v-if="!isUnique" class="sharing-entry__summary__desc-unique"> ({{
- share.shareWithDisplayNameUnique }})</span>
+ <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>
@@ -28,7 +29,9 @@
:file-info="fileInfo"
@open-sharing-details="openShareDetailsForCustomSettings(share)" />
</div>
- <NcButton class="sharing-entry__action"
+ <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"
@@ -41,11 +44,14 @@
</template>
<script>
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
-import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
+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'
@@ -59,6 +65,7 @@ export default {
NcAvatar,
DotsHorizontalIcon,
NcSelect,
+ ShareExpiryTime,
SharingEntryQuickShareSelect,
},
@@ -67,17 +74,26 @@ export default {
computed: {
title() {
let title = this.share.shareWithDisplayName
- if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_GROUP) {
+
+ const showAsInternal = this.config.showFederatedSharesAsInternal
+ || (this.share.isTrustedServer && this.config.showFederatedSharesToTrustedServersAsInternal)
+
+ if (this.share.type === ShareType.Group || (this.share.type === ShareType.RemoteGroup && showAsInternal)) {
title += ` (${t('files_sharing', 'group')})`
- } else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_ROOM) {
+ } else if (this.share.type === ShareType.Room) {
title += ` (${t('files_sharing', 'conversation')})`
- } else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_REMOTE) {
+ } else if (this.share.type === ShareType.Remote && !showAsInternal) {
title += ` (${t('files_sharing', 'remote')})`
- } else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP) {
+ } else if (this.share.type === ShareType.RemoteGroup) {
title += ` (${t('files_sharing', 'remote group')})`
- } else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_GUEST) {
+ } else if (this.share.type === ShareType.Guest) {
title += ` (${t('files_sharing', 'guest')})`
}
+ if (!this.isShareOwner && this.share.ownerDisplayName) {
+ title += ' ' + t('files_sharing', 'by {initiator}', {
+ initiator: this.share.ownerDisplayName,
+ })
+ }
return title
},
tooltip() {
@@ -88,9 +104,9 @@ export default {
user: this.share.shareWithDisplayName,
owner: this.share.ownerDisplayName,
}
- if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_GROUP) {
+ if (this.share.type === ShareType.Group) {
return t('files_sharing', 'Shared with the group {user} by {owner}', data)
- } else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_ROOM) {
+ } else if (this.share.type === ShareType.Room) {
return t('files_sharing', 'Shared with the conversation {user} by {owner}', data)
}
@@ -103,7 +119,7 @@ export default {
* @return {boolean}
*/
hasStatus() {
- if (this.share.type !== this.SHARE_TYPES.SHARE_TYPE_USER) {
+ if (this.share.type !== ShareType.User) {
return false
}
@@ -129,7 +145,7 @@ export default {
height: 44px;
&__summary {
padding: 8px;
- padding-left: 10px;
+ padding-inline-start: 10px;
display: flex;
flex-direction: column;
justify-content: center;
diff --git a/apps/files_sharing/src/components/SharingEntryInherited.vue b/apps/files_sharing/src/components/SharingEntryInherited.vue
index fec2bcf6f63..e7dfffd5776 100644
--- a/apps/files_sharing/src/components/SharingEntryInherited.vue
+++ b/apps/files_sharing/src/components/SharingEntryInherited.vue
@@ -31,10 +31,10 @@
<script>
import { generateUrl } from '@nextcloud/router'
import { basename } from '@nextcloud/paths'
-import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
-import NcActionLink from '@nextcloud/vue/dist/Components/NcActionLink.js'
-import NcActionText from '@nextcloud/vue/dist/Components/NcActionText.js'
+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'
@@ -85,14 +85,14 @@ export default {
flex-direction: column;
justify-content: space-between;
padding: 8px;
- padding-left: 10px;
+ padding-inline-start: 10px;
line-height: 1.2em;
p {
color: var(--color-text-maxcontrast);
}
}
&__actions {
- margin-left: auto;
+ margin-inline-start: auto;
}
}
</style>
diff --git a/apps/files_sharing/src/components/SharingEntryInternal.vue b/apps/files_sharing/src/components/SharingEntryInternal.vue
index a045612cbcc..027d2a3d5c3 100644
--- a/apps/files_sharing/src/components/SharingEntryInternal.vue
+++ b/apps/files_sharing/src/components/SharingEntryInternal.vue
@@ -29,10 +29,10 @@
<script>
import { generateUrl } from '@nextcloud/router'
import { showSuccess } from '@nextcloud/dialogs'
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
-import CheckIcon from 'vue-material-design-icons/CheckBold.vue'
-import ClipboardIcon from 'vue-material-design-icons/ClipboardFlow.vue'
+import CheckIcon from 'vue-material-design-icons/Check.vue'
+import ClipboardIcon from 'vue-material-design-icons/ContentCopy.vue'
import SharingEntrySimple from './SharingEntrySimple.vue'
@@ -83,14 +83,11 @@ export default {
}
return t('files_sharing', 'Cannot copy, please copy the link manually')
}
- return t('files_sharing', 'Copy internal link to clipboard')
+ return t('files_sharing', 'Copy internal link')
},
internalLinkSubtitle() {
- if (this.fileInfo.type === 'dir') {
- return t('files_sharing', 'Only works for people with access to this folder')
- }
- return t('files_sharing', 'Only works for people with access to this file')
+ return t('files_sharing', 'For people who already have access')
},
},
diff --git a/apps/files_sharing/src/components/SharingEntryLink.vue b/apps/files_sharing/src/components/SharingEntryLink.vue
index 6413b8226b6..6865af1b864 100644
--- a/apps/files_sharing/src/components/SharingEntryLink.vue
+++ b/apps/files_sharing/src/components/SharingEntryLink.vue
@@ -24,23 +24,30 @@
@open-sharing-details="openShareDetailsForCustomSettings(share)" />
</div>
- <!-- clipboard -->
- <NcActions v-if="share && !isEmailShareType && share.token" ref="copyButton" class="sharing-entry__copy">
- <NcActionButton :title="copyLinkTooltip"
- :aria-label="copyLinkTooltip"
- @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 class="sharing-entry__actions">
+ <ShareExpiryTime v-if="share && share.expireDate" :share="share" />
+
+ <!-- clipboard -->
+ <div>
+ <NcActions v-if="share && (!isEmailShareType || isFileRequest) && share.token" ref="copyButton" class="sharing-entry__copy">
+ <NcActionButton :aria-label="copyLinkTooltip"
+ :title="copyLinkTooltip"
+ :href="shareLink"
+ @click.prevent="copyLink">
+ <template #icon>
+ <CheckIcon v-if="copied && copySuccess"
+ :size="20"
+ class="icon-checkmark-color" />
+ <ClipboardIcon v-else :size="20" />
+ </template>
+ </NcActionButton>
+ </NcActions>
+ </div>
+ </div>
</div>
<!-- pending actions -->
- <NcActions v-if="!pending && (pendingPassword || pendingEnforcedPassword || pendingExpirationDate)"
+ <NcActions v-if="!pending && pendingDataIsMissing"
class="sharing-entry__actions"
:aria-label="actionsTooltip"
menu-align="right"
@@ -59,36 +66,41 @@
</NcActionText>
<!-- password -->
- <NcActionText v-if="pendingEnforcedPassword">
- <LockIcon :size="20" />
- {{ t('files_sharing', 'Password protection (enforced)') }}
- </NcActionText>
- <NcActionCheckbox v-else-if="pendingPassword"
+ <NcActionCheckbox v-if="pendingPassword"
:checked.sync="isPasswordProtected"
:disabled="config.enforcePasswordForPublicLink || saving"
class="share-link-password-checkbox"
@uncheck="onPasswordDisable">
- {{ t('files_sharing', 'Password protection') }}
+ {{ config.enforcePasswordForPublicLink ? t('files_sharing', 'Password protection (enforced)') : t('files_sharing', 'Password protection') }}
</NcActionCheckbox>
- <NcActionInput v-if="pendingEnforcedPassword || share.password"
+ <NcActionInput v-if="pendingEnforcedPassword || isPasswordProtected"
class="share-link-password"
- :value.sync="share.password"
+ :label="t('files_sharing', 'Enter a password')"
+ :value.sync="share.newPassword"
:disabled="saving"
:required="config.enableLinkPasswordByDefault || config.enforcePasswordForPublicLink"
:minlength="isPasswordPolicyEnabled && config.passwordPolicy.minLength"
- icon=""
autocomplete="new-password"
- @submit="onNewLinkShare">
- {{ t('files_sharing', 'Enter a password') }}
+ @submit="onNewLinkShare(true)">
+ <template #icon>
+ <LockIcon :size="20" />
+ </template>
</NcActionInput>
+ <NcActionCheckbox v-if="pendingDefaultExpirationDate"
+ :checked.sync="defaultExpirationDateEnabled"
+ :disabled="pendingEnforcedExpirationDate || saving"
+ class="share-link-expiration-date-checkbox"
+ @update:model-value="onExpirationDateToggleUpdate">
+ {{ config.isDefaultExpireDateEnforced ? t('files_sharing', 'Enable link expiration (enforced)') : t('files_sharing', 'Enable link expiration') }}
+ </NcActionCheckbox>
+
<!-- expiration date -->
- <NcActionText v-if="pendingExpirationDate" icon="icon-calendar-dark">
- {{ t('files_sharing', 'Expiration date (enforced)') }}
- </NcActionText>
- <NcActionInput v-if="pendingExpirationDate"
+ <NcActionInput v-if="(pendingDefaultExpirationDate || pendingEnforcedExpirationDate) && defaultExpirationDateEnabled"
+ data-cy-files-sharing-expiration-date-input
class="share-link-expire-date"
+ :label="pendingEnforcedExpirationDate ? t('files_sharing', 'Enter expiration date (enforced)') : t('files_sharing', 'Enter expiration date')"
:disabled="saving"
:is-native-picker="true"
:hide-label="true"
@@ -96,13 +108,15 @@
type="date"
:min="dateTomorrow"
:max="maxExpirationDateEnforced"
- @input="onExpirationChange">
- <!-- let's not submit when picked, the user
- might want to still edit or copy the password -->
- {{ t('files_sharing', 'Enter a date') }}
+ @update:model-value="onExpirationChange"
+ @change="expirationDateChanged">
+ <template #icon>
+ <IconCalendarBlank :size="20" />
+ </template>
</NcActionInput>
- <NcActionButton @click.prevent.stop="onNewLinkShare">
+ <NcActionButton :disabled="pendingEnforcedPassword && !share.newPassword"
+ @click.prevent.stop="onNewLinkShare(true)">
<template #icon>
<CheckIcon :size="20" />
</template>
@@ -134,7 +148,7 @@
{{ t('files_sharing', 'Customize link') }}
</NcActionButton>
</template>
-
+
<NcActionButton :close-after-click="true"
@click.prevent="showQRCode = true">
<template #icon>
@@ -154,8 +168,8 @@
:share="share" />
<!-- external legacy sharing via url (social...) -->
- <NcActionLink v-for="({ icon, url, name }, index) in externalLegacyLinkActions"
- :key="index"
+ <NcActionLink v-for="({ icon, url, name }, actionIndex) in externalLegacyLinkActions"
+ :key="actionIndex"
:href="url(shareLink)"
:icon="icon"
target="_blank">
@@ -210,38 +224,43 @@
</template>
<script>
-import { generateUrl } from '@nextcloud/router'
import { showError, showSuccess } from '@nextcloud/dialogs'
-import { Type as ShareTypes } from '@nextcloud/sharing'
-import Vue from 'vue'
-import VueQrcode from '@chenfengyuan/vue-qrcode';
-
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
-import NcActionInput from '@nextcloud/vue/dist/Components/NcActionInput.js'
-import NcActionLink from '@nextcloud/vue/dist/Components/NcActionLink.js'
-import NcActionText from '@nextcloud/vue/dist/Components/NcActionText.js'
-import NcActionSeparator from '@nextcloud/vue/dist/Components/NcActionSeparator.js'
-import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
-import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
-import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js'
+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/Lock.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/ClipboardFlow.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.js'
-import Share from '../models/Share.js'
+import GeneratePassword from '../utils/GeneratePassword.ts'
+import Share from '../models/Share.ts'
import SharesMixin from '../mixins/SharesMixin.js'
import ShareDetails from '../mixins/ShareDetails.js'
-import { getLoggerBuilder } from '@nextcloud/logger'
+import logger from '../services/logger.ts'
export default {
name: 'SharingEntryLink',
@@ -250,6 +269,7 @@ export default {
ExternalShareAction,
NcActions,
NcActionButton,
+ NcActionCheckbox,
NcActionInput,
NcActionLink,
NcActionText,
@@ -258,6 +278,7 @@ export default {
NcDialog,
VueQrcode,
Tune,
+ IconCalendarBlank,
IconQr,
ErrorIcon,
LockIcon,
@@ -266,6 +287,7 @@ export default {
CloseIcon,
PlusIcon,
SharingEntryQuickShareSelect,
+ ShareExpiryTime,
},
mixins: [SharesMixin, ShareDetails],
@@ -286,16 +308,13 @@ export default {
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,
- logger: getLoggerBuilder()
- .setApp('files_sharing')
- .detectUser()
- .build(),
// tracks whether modal should be opened or not
showQRCode: false,
@@ -309,6 +328,8 @@ export default {
* @return {string}
*/
title() {
+ const l10nOptions = { escape: false /* no escape as this string is already escaped by Vue */ }
+
// if we have a valid existing share (not pending)
if (this.share && this.share.id) {
if (!this.isShareOwner && this.share.ownerDisplayName) {
@@ -316,30 +337,46 @@ export default {
return t('files_sharing', '{shareWith} by {initiator}', {
shareWith: this.share.shareWith,
initiator: this.share.ownerDisplayName,
- })
+ }, l10nOptions)
}
return t('files_sharing', 'Shared via link by {initiator}', {
initiator: this.share.ownerDisplayName,
- })
+ }, l10nOptions)
}
if (this.share.label && this.share.label.trim() !== '') {
if (this.isEmailShareType) {
+ if (this.isFileRequest) {
+ return t('files_sharing', 'File request ({label})', {
+ label: this.share.label.trim(),
+ }, l10nOptions)
+ }
return t('files_sharing', 'Mail share ({label})', {
label: this.share.label.trim(),
- })
+ }, l10nOptions)
}
return t('files_sharing', 'Share link ({label})', {
label: this.share.label.trim(),
- })
+ }, l10nOptions)
}
if (this.isEmailShareType) {
+ if (!this.share.shareWith || this.share.shareWith.trim() === '') {
+ return this.isFileRequest
+ ? t('files_sharing', 'File request')
+ : t('files_sharing', 'Mail share')
+ }
return this.share.shareWith
}
+
+ if (this.index === null) {
+ return t('files_sharing', 'Share link')
+ }
}
- if (this.index > 1) {
+
+ if (this.index >= 1) {
return t('files_sharing', 'Share link ({index})', { index: this.index })
}
- return t('files_sharing', 'Share link')
+
+ return t('files_sharing', 'Create public link')
},
/**
@@ -354,22 +391,6 @@ export default {
}
return null
},
- /**
- * Is the current share password protected ?
- *
- * @return {boolean}
- */
- isPasswordProtected: {
- get() {
- return this.config.enforcePasswordForPublicLink
- || !!this.share.password
- },
- async set(enabled) {
- // TODO: directly save after generation to make sure the share is always protected
- Vue.set(this.share, 'password', enabled ? await GeneratePassword() : '')
- Vue.set(this.share, 'newPassword', this.share.password)
- },
- },
passwordExpirationTime() {
if (this.share.passwordExpirationTime === null) {
@@ -424,7 +445,7 @@ export default {
*/
isEmailShareType() {
return this.share
- ? this.share.type === this.SHARE_TYPES.SHARE_TYPE_EMAIL
+ ? this.share.type === ShareType.Email
: false
},
@@ -449,29 +470,37 @@ export default {
*
* @return {boolean}
*/
+ pendingDataIsMissing() {
+ return this.pendingPassword || this.pendingEnforcedPassword || this.pendingDefaultExpirationDate || this.pendingEnforcedExpirationDate
+ },
pendingPassword() {
- return this.config.enableLinkPasswordByDefault && this.share && !this.share.id
+ return this.config.enableLinkPasswordByDefault && this.isPendingShare
},
pendingEnforcedPassword() {
- return this.config.enforcePasswordForPublicLink && this.share && !this.share.id
+ return this.config.enforcePasswordForPublicLink && this.isPendingShare
},
- pendingExpirationDate() {
- return this.config.isDefaultExpireDateEnforced && this.share && !this.share.id
+ pendingEnforcedExpirationDate() {
+ return this.config.isDefaultExpireDateEnforced && this.isPendingShare
},
-
- sharePolicyHasRequiredProperties() {
+ 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
},
- requiredPropertiesMissing() {
+ enforcedPropertiesMissing() {
// Ensure share exist and the share policy has required properties
- if (!this.sharePolicyHasRequiredProperties) {
+ 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
+ return true
}
// If share has ID, then this is an incoming link share created from the existing link share
@@ -497,7 +526,7 @@ export default {
* @return {string}
*/
shareLink() {
- return window.location.protocol + '//' + window.location.host + generateUrl('/s/') + this.share.token
+ return generateUrl('/s/{token}', { token: this.share.token }, { baseURL: getBaseUrl() })
},
/**
@@ -521,7 +550,7 @@ export default {
}
return t('files_sharing', 'Cannot copy, please copy the link manually')
}
- return t('files_sharing', 'Copy public link of "{title}" to clipboard', { title: this.title })
+ return t('files_sharing', 'Copy public link of "{title}"', { title: this.title })
},
/**
@@ -540,7 +569,7 @@ export default {
* @return {Array}
*/
externalLinkActions() {
- const filterValidAction = (action) => (action.shareType.includes(ShareTypes.SHARE_TYPE_LINK) || action.shareType.includes(ShareTypes.SHARE_TYPE_EMAIL)) && !action.advanced
+ 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)
@@ -551,24 +580,48 @@ export default {
},
canChangeHideDownload() {
- const hasDisabledDownload = (shareAttribute) => shareAttribute.key === 'download' && shareAttribute.scope === 'permissions' && shareAttribute.enabled === false
+ const hasDisabledDownload = (shareAttribute) => shareAttribute.scope === 'permissions' && shareAttribute.key === 'download' && shareAttribute.value === false
return this.fileInfo.shareAttributes.some(hasDisabledDownload)
},
+
+ isFileRequest() {
+ return this.share.isFileRequest
+ },
+ },
+ mounted() {
+ this.defaultExpirationDateEnabled = this.config.defaultExpirationDate instanceof Date
+ if (this.share && this.isNewShare) {
+ this.share.expireDate = this.defaultExpirationDateEnabled ? this.formatDateToString(this.config.defaultExpirationDate) : ''
+ }
},
methods: {
/**
+ * Check if the share requires review
+ *
+ * @param {boolean} shareReviewComplete if the share was reviewed
+ * @return {boolean}
+ */
+ shareRequiresReview(shareReviewComplete) {
+ // If a user clicks 'Create share' it means they have reviewed the share
+ if (shareReviewComplete) {
+ return false
+ }
+ return this.defaultExpirationDateEnabled || this.config.enableLinkPasswordByDefault
+ },
+ /**
* Create a new share link and append it to the list
+ * @param {boolean} shareReviewComplete if the share was reviewed
*/
- async onNewLinkShare() {
- this.logger.debug('onNewLinkShare called (with this.share)', this.share)
+ async onNewLinkShare(shareReviewComplete = false) {
+ logger.debug('onNewLinkShare called (with this.share)', this.share)
// do not run again if already loading
if (this.loading) {
return
}
const shareDefaults = {
- share_type: ShareTypes.SHARE_TYPE_LINK,
+ share_type: ShareType.Link,
}
if (this.config.isDefaultExpireDateEnforced) {
// default is empty string if not set
@@ -576,22 +629,25 @@ export default {
shareDefaults.expiration = this.formatDateToString(this.config.defaultExpirationDate)
}
- this.logger.debug('Missing required properties?', this.requiredPropertiesMissing)
- // do not push yet if we need a password or an expiration date: show pending menu
- if (this.sharePolicyHasRequiredProperties && this.requiredPropertiesMissing) {
+ 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
- this.logger.info('Share policy requires mandated properties (password)...')
+ 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()
+ 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)
})
@@ -610,13 +666,13 @@ export default {
// if the share is valid, create it on the server
if (this.checkShare(this.share)) {
try {
- this.logger.info('Sending existing share to server', this.share)
+ logger.info('Sending existing share to server', this.share)
await this.pushNewLinkShare(this.share, true)
this.shareCreationComplete = true
- this.logger.info('Share created on server', this.share)
+ logger.info('Share created on server', this.share)
} catch (e) {
this.pending = false
- this.logger.error('Error creating share', e)
+ logger.error('Error creating share', e)
return false
}
return true
@@ -654,14 +710,14 @@ export default {
const path = (this.fileInfo.path + '/' + this.fileInfo.name).replace('//', '/')
const options = {
path,
- shareType: ShareTypes.SHARE_TYPE_LINK,
+ shareType: ShareType.Link,
password: share.password,
- expireDate: share.expireDate,
+ expireDate: share.expireDate ?? '',
attributes: JSON.stringify(this.fileInfo.shareAttributes),
// we do not allow setting the publicUpload
// before the share creation.
// Todo: We also need to fix the createShare method in
- // lib/Controller/ShareAPIController.php to allow file drop
+ // lib/Controller/ShareAPIController.php to allow file requests
// (currently not supported on create, only update)
}
@@ -686,6 +742,9 @@ export default {
})
}
+ await this.getNode()
+ emit('files:node:updated', this.node)
+
// Execute the copy link method
// freshly created share component
// ! somehow does not works on firefox !
@@ -781,7 +840,7 @@ export default {
*/
onPasswordSubmit() {
if (this.hasUnsavedPassword) {
- this.share.password = this.share.newPassword.trim()
+ this.share.newPassword = this.share.newPassword.trim()
this.queueUpdate('password')
}
},
@@ -796,7 +855,7 @@ export default {
*/
onPasswordProtectedByTalkChange() {
if (this.hasUnsavedPassword) {
- this.share.password = this.share.newPassword.trim()
+ this.share.newPassword = this.share.newPassword.trim()
}
this.queueUpdate('sendPasswordByTalk', 'password')
@@ -811,6 +870,19 @@ export default {
},
/**
+ * @param enabled True if expiration is enabled
+ */
+ onExpirationDateToggleUpdate(enabled) {
+ this.share.expireDate = enabled ? this.formatDateToString(this.config.defaultExpirationDate) : ''
+ },
+
+ expirationDateChanged(event) {
+ const value = event?.target?.value
+ const isValid = !!value && !isNaN(new Date(value).getTime())
+ this.defaultExpirationDateEnabled = isValid
+ },
+
+ /**
* Cancel the share creation
* Used in the pending popover
*/
@@ -834,7 +906,7 @@ export default {
&__summary {
padding: 8px;
- padding-left: 10px;
+ padding-inline-start: 10px;
display: flex;
justify-content: space-between;
flex: 1 0;
@@ -857,13 +929,19 @@ export default {
}
}
+ &__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);
}
}
- ::v-deep .avatar-link-share {
+ :deep(.avatar-link-share) {
background-color: var(--color-primary-element);
}
@@ -876,7 +954,7 @@ export default {
height: 44px;
margin: 0;
padding: 14px;
- margin-left: auto;
+ margin-inline-start: auto;
}
// put menus to the left
@@ -885,7 +963,7 @@ export default {
~.action-item,
~.sharing-entry__loading {
- margin-left: 0;
+ margin-inline-start: 0;
}
}
diff --git a/apps/files_sharing/src/components/SharingEntryQuickShareSelect.vue b/apps/files_sharing/src/components/SharingEntryQuickShareSelect.vue
index e7599516eed..102eea63cb6 100644
--- a/apps/files_sharing/src/components/SharingEntryQuickShareSelect.vue
+++ b/apps/files_sharing/src/components/SharingEntryQuickShareSelect.vue
@@ -8,6 +8,7 @@
:menu-name="selectedOption"
:aria-label="ariaLabel"
type="tertiary-no-background"
+ :disabled="!share.canEdit"
force-name>
<template #icon>
<DropdownIcon :size="15" />
@@ -27,14 +28,15 @@
</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 ShareTypes from '../mixins/ShareTypes.js'
-import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.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/Pencil.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'
@@ -52,7 +54,7 @@ export default {
NcActionButton,
},
- mixins: [SharesMixin, ShareDetails, ShareTypes],
+ mixins: [SharesMixin, ShareDetails],
props: {
share: {
@@ -80,7 +82,7 @@ export default {
return t('files_sharing', 'Can edit')
},
fileDropText() {
- return t('files_sharing', 'File drop')
+ return t('files_sharing', 'File request')
},
customPermissionsText() {
return t('files_sharing', 'Custom permissions')
@@ -122,7 +124,7 @@ export default {
supportsFileDrop() {
if (this.isFolder && this.config.isPublicUploadEnabled) {
const shareType = this.share.type ?? this.share.shareType
- return [this.SHARE_TYPES.SHARE_TYPE_LINK, this.SHARE_TYPES.SHARE_TYPE_EMAIL].includes(shareType)
+ return [ShareType.Link, ShareType.Email].includes(shareType)
}
return false
},
@@ -144,7 +146,17 @@ export default {
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
diff --git a/apps/files_sharing/src/components/SharingEntrySimple.vue b/apps/files_sharing/src/components/SharingEntrySimple.vue
index 5e7ebf2dd93..a00333ba0ce 100644
--- a/apps/files_sharing/src/components/SharingEntrySimple.vue
+++ b/apps/files_sharing/src/components/SharingEntrySimple.vue
@@ -23,7 +23,7 @@
</template>
<script>
-import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
+import NcActions from '@nextcloud/vue/components/NcActions'
export default {
name: 'SharingEntrySimple',
@@ -70,7 +70,7 @@ export default {
min-height: 44px;
&__desc {
padding: 8px;
- padding-left: 10px;
+ padding-inline-start: 10px;
line-height: 1.2em;
position: relative;
flex: 1 1;
@@ -86,7 +86,7 @@ export default {
max-width: inherit;
}
&__actions {
- margin-left: auto !important;
+ margin-inline-start: auto !important;
}
}
</style>
diff --git a/apps/files_sharing/src/components/SharingInput.vue b/apps/files_sharing/src/components/SharingInput.vue
index 90cd8143b91..6fb33aba6b2 100644
--- a/apps/files_sharing/src/components/SharingInput.vue
+++ b/apps/files_sharing/src/components/SharingInput.vue
@@ -5,10 +5,13 @@
<template>
<div class="sharing-search">
- <label for="sharing-search-input">{{ t('files_sharing', 'Search for share recipients') }}</label>
+ <label class="hidden-visually" :for="shareInputId">
+ {{ isExternal ? t('files_sharing', 'Enter external recipients')
+ : t('files_sharing', 'Search for internal recipients') }}
+ </label>
<NcSelect ref="select"
v-model="value"
- input-id="sharing-search-input"
+ :input-id="shareInputId"
class="sharing-search__input"
:disabled="!canReshare"
:loading="loading"
@@ -17,10 +20,11 @@
:clear-search-on-blur="() => false"
:user-select="true"
:options="options"
+ :label-outside="true"
@search="asyncFind"
@option:selected="onSelected">
<template #no-options="{ search }">
- {{ search ? noResultText : t('files_sharing', 'No recommendations. Start typing.') }}
+ {{ search ? noResultText : placeholder }}
</template>
</NcSelect>
</div>
@@ -32,14 +36,13 @@ import { getCurrentUser } from '@nextcloud/auth'
import { getCapabilities } from '@nextcloud/capabilities'
import axios from '@nextcloud/axios'
import debounce from 'debounce'
-import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
+import NcSelect from '@nextcloud/vue/components/NcSelect'
-import Config from '../services/ConfigService.js'
-import GeneratePassword from '../utils/GeneratePassword.js'
-import Share from '../models/Share.js'
+import Config from '../services/ConfigService.ts'
+import Share from '../models/Share.ts'
import ShareRequests from '../mixins/ShareRequests.js'
-import ShareTypes from '../mixins/ShareTypes.js'
import ShareDetails from '../mixins/ShareDetails.js'
+import { ShareType } from '@nextcloud/sharing'
export default {
name: 'SharingInput',
@@ -48,7 +51,7 @@ export default {
NcSelect,
},
- mixins: [ShareTypes, ShareRequests, ShareDetails],
+ mixins: [ShareRequests, ShareDetails],
props: {
shares: {
@@ -74,6 +77,20 @@ export default {
type: Boolean,
required: true,
},
+ isExternal: {
+ type: Boolean,
+ default: false,
+ },
+ placeholder: {
+ type: String,
+ default: '',
+ },
+ },
+
+ setup() {
+ return {
+ shareInputId: `share-input-${Math.random().toString(36).slice(2, 7)}`,
+ }
},
data() {
@@ -106,6 +123,10 @@ export default {
if (!this.canReshare) {
return t('files_sharing', 'Resharing is not allowed')
}
+ if (this.placeholder) {
+ return this.placeholder
+ }
+
// We can always search with email addresses for users too
if (!allowRemoteSharing) {
return t('files_sharing', 'Name or email …')
@@ -134,7 +155,10 @@ export default {
},
mounted() {
- this.getRecommendations()
+ if (!this.isExternal) {
+ // We can only recommend users, groups etc for internal shares
+ this.getRecommendations()
+ }
},
methods: {
@@ -168,20 +192,37 @@ export default {
lookup = true
}
- const shareType = [
- this.SHARE_TYPES.SHARE_TYPE_USER,
- this.SHARE_TYPES.SHARE_TYPE_GROUP,
- this.SHARE_TYPES.SHARE_TYPE_REMOTE,
- this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP,
- this.SHARE_TYPES.SHARE_TYPE_CIRCLE,
- this.SHARE_TYPES.SHARE_TYPE_ROOM,
- this.SHARE_TYPES.SHARE_TYPE_GUEST,
- this.SHARE_TYPES.SHARE_TYPE_DECK,
- this.SHARE_TYPES.SHARE_TYPE_SCIENCEMESH,
- ]
-
- if (getCapabilities().files_sharing.public.enabled === true) {
- shareType.push(this.SHARE_TYPES.SHARE_TYPE_EMAIL)
+ const remoteTypes = [ShareType.Remote, ShareType.RemoteGroup]
+ const shareType = []
+
+ const showFederatedAsInternal = this.config.showFederatedSharesAsInternal
+ || this.config.showFederatedSharesToTrustedServersAsInternal
+
+ // For internal users, add remote types if config says to show them as internal
+ const shouldAddRemoteTypes = (!this.isExternal && showFederatedAsInternal)
+ // For external users, add them if config *doesn't* say to show them as internal
+ || (this.isExternal && !showFederatedAsInternal)
+ // Edge case: federated-to-trusted is a separate "add" trigger for external users
+ || (this.isExternal && this.config.showFederatedSharesToTrustedServersAsInternal)
+
+ if (this.isExternal) {
+ if (getCapabilities().files_sharing.public.enabled === true) {
+ shareType.push(ShareType.Email)
+ }
+ } else {
+ shareType.push(
+ ShareType.User,
+ ShareType.Group,
+ ShareType.Team,
+ ShareType.Room,
+ ShareType.Guest,
+ ShareType.Deck,
+ ShareType.ScienceMesh,
+ )
+ }
+
+ if (shouldAddRemoteTypes) {
+ shareType.push(...remoteTypes)
}
let request = null
@@ -201,13 +242,10 @@ export default {
return
}
- const data = request.data.ocs.data
- const exact = request.data.ocs.data.exact
- data.exact = [] // removing exact from general results
-
+ const { exact, ...data } = request.data.ocs.data
// flatten array of arrays
- const rawExactSuggestions = Object.values(exact).reduce((arr, elem) => arr.concat(elem), [])
- const rawSuggestions = Object.values(data).reduce((arr, elem) => arr.concat(elem), [])
+ const rawExactSuggestions = Object.values(exact).flat()
+ const rawSuggestions = Object.values(data).flat()
// remove invalid data and format to user-select layout
const exactSuggestions = this.filterOutExistingShares(rawExactSuggestions)
@@ -226,7 +264,7 @@ export default {
lookupEntry.push({
id: 'global-lookup',
isNoUser: true,
- displayName: t('files_sharing', 'Search globally'),
+ displayName: t('files_sharing', 'Search everywhere'),
lookup: true,
})
}
@@ -318,7 +356,7 @@ export default {
return arr
}
try {
- if (share.value.shareType === this.SHARE_TYPES.SHARE_TYPE_USER) {
+ if (share.value.shareType === ShareType.User) {
// filter out current user
if (share.value.shareWith === getCurrentUser().uid) {
return arr
@@ -331,7 +369,12 @@ export default {
}
// filter out existing mail shares
- if (share.value.shareType === this.SHARE_TYPES.SHARE_TYPE_EMAIL) {
+ if (share.value.shareType === ShareType.Email) {
+ // When sharing internally, we don't want to suggest email addresses
+ // that the user previously created shares to
+ if (!this.isExternal) {
+ return arr
+ }
const emails = this.linkShares.map(elem => elem.shareWith)
if (emails.indexOf(share.value.shareWith.trim()) !== -1) {
return arr
@@ -369,42 +412,42 @@ export default {
*/
shareTypeToIcon(type) {
switch (type) {
- case this.SHARE_TYPES.SHARE_TYPE_GUEST:
+ case ShareType.Guest:
// default is a user, other icons are here to differentiate
// themselves from it, so let's not display the user icon
- // case this.SHARE_TYPES.SHARE_TYPE_REMOTE:
- // case this.SHARE_TYPES.SHARE_TYPE_USER:
+ // case ShareType.Remote:
+ // case ShareType.User:
return {
icon: 'icon-user',
iconTitle: t('files_sharing', 'Guest'),
}
- case this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP:
- case this.SHARE_TYPES.SHARE_TYPE_GROUP:
+ case ShareType.RemoteGroup:
+ case ShareType.Group:
return {
icon: 'icon-group',
iconTitle: t('files_sharing', 'Group'),
}
- case this.SHARE_TYPES.SHARE_TYPE_EMAIL:
+ case ShareType.Email:
return {
icon: 'icon-mail',
iconTitle: t('files_sharing', 'Email'),
}
- case this.SHARE_TYPES.SHARE_TYPE_CIRCLE:
+ case ShareType.Team:
return {
icon: 'icon-teams',
iconTitle: t('files_sharing', 'Team'),
}
- case this.SHARE_TYPES.SHARE_TYPE_ROOM:
+ case ShareType.Room:
return {
icon: 'icon-room',
iconTitle: t('files_sharing', 'Talk conversation'),
}
- case this.SHARE_TYPES.SHARE_TYPE_DECK:
+ case ShareType.Deck:
return {
icon: 'icon-deck',
iconTitle: t('files_sharing', 'Deck board'),
}
- case this.SHARE_TYPES.SHARE_TYPE_SCIENCEMESH:
+ case ShareType.Sciencemesh:
return {
icon: 'icon-sciencemesh',
iconTitle: t('files_sharing', 'ScienceMesh'),
@@ -421,105 +464,35 @@ export default {
* @return {object}
*/
formatForMultiselect(result) {
- let subtitle
- if (result.value.shareType === this.SHARE_TYPES.SHARE_TYPE_USER && this.config.shouldAlwaysShowUnique) {
- subtitle = result.shareWithDisplayNameUnique ?? ''
- } else if ((result.value.shareType === this.SHARE_TYPES.SHARE_TYPE_REMOTE
- || result.value.shareType === this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP
- ) && result.value.server) {
- subtitle = t('files_sharing', 'on {server}', { server: result.value.server })
- } else if (result.value.shareType === this.SHARE_TYPES.SHARE_TYPE_EMAIL) {
- subtitle = result.value.shareWith
+ let subname
+ let displayName = result.name || result.label
+
+ if (result.value.shareType === ShareType.User && this.config.shouldAlwaysShowUnique) {
+ subname = result.shareWithDisplayNameUnique ?? ''
+ } else if (result.value.shareType === ShareType.Email) {
+ subname = result.value.shareWith
+ } else if (result.value.shareType === ShareType.Remote || result.value.shareType === ShareType.RemoteGroup) {
+ if (this.config.showFederatedSharesAsInternal) {
+ subname = result.extra?.email?.value ?? ''
+ displayName = result.extra?.name?.value ?? displayName
+ } else if (result.value.server) {
+ subname = t('files_sharing', 'on {server}', { server: result.value.server })
+ }
} else {
- subtitle = result.shareWithDescription ?? ''
+ subname = result.shareWithDescription ?? ''
}
return {
shareWith: result.value.shareWith,
shareType: result.value.shareType,
user: result.uuid || result.value.shareWith,
- isNoUser: result.value.shareType !== this.SHARE_TYPES.SHARE_TYPE_USER,
- displayName: result.name || result.label,
- subtitle,
+ isNoUser: result.value.shareType !== ShareType.User,
+ displayName,
+ subname,
shareWithDisplayNameUnique: result.shareWithDisplayNameUnique || '',
...this.shareTypeToIcon(result.value.shareType),
}
},
-
- /**
- * Process the new share request
- *
- * @param {object} value the multiselect option
- */
- async addShare(value) {
- // Clear the displayed selection
- this.value = null
-
- if (value.lookup) {
- await this.getSuggestions(this.query, true)
-
- this.$nextTick(() => {
- // open the dropdown again
- this.$refs.select.$children[0].open = true
- })
- return true
- }
-
- // handle externalResults from OCA.Sharing.ShareSearch
- if (value.handler) {
- const share = await value.handler(this)
- this.$emit('add:share', new Share(share))
- return true
- }
-
- this.loading = true
- console.debug('Adding a new share from the input for', value)
- try {
- let password = null
-
- if (this.config.enforcePasswordForPublicLink
- && value.shareType === this.SHARE_TYPES.SHARE_TYPE_EMAIL) {
- password = await GeneratePassword()
- }
-
- const path = (this.fileInfo.path + '/' + this.fileInfo.name).replace('//', '/')
- const share = await this.createShare({
- path,
- shareType: value.shareType,
- shareWith: value.shareWith,
- password,
- permissions: this.fileInfo.sharePermissions & getCapabilities().files_sharing.default_permissions,
- attributes: JSON.stringify(this.fileInfo.shareAttributes),
- })
-
- // If we had a password, we need to show it to the user as it was generated
- if (password) {
- share.newPassword = password
- // Wait for the newly added share
- const component = await new Promise(resolve => {
- this.$emit('add:share', share, resolve)
- })
-
- // open the menu on the
- // freshly created share component
- component.open = true
- } else {
- // Else we just add it normally
- this.$emit('add:share', share)
- }
-
- await this.getRecommendations()
- } catch (error) {
- this.$nextTick(() => {
- // open the dropdown again on error
- this.$refs.select.$children[0].open = true
- })
- this.query = value.shareWith
- console.error('Error while adding new share', error)
- } finally {
- this.loading = false
- }
- },
},
}
</script>
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/actions/acceptShareAction.spec.ts b/apps/files_sharing/src/files_actions/acceptShareAction.spec.ts
index 14ff88fa885..4003e0799ac 100644
--- a/apps/files_sharing/src/actions/acceptShareAction.spec.ts
+++ b/apps/files_sharing/src/files_actions/acceptShareAction.spec.ts
@@ -2,12 +2,17 @@
* 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 { expect } from '@jest/globals'
import { File, Permission, View, FileAction } from '@nextcloud/files'
-import eventBus from '@nextcloud/event-bus'
+import { ShareType } from '@nextcloud/sharing'
+import * as eventBus from '@nextcloud/event-bus'
import axios from '@nextcloud/axios'
-import '../main'
+
+import '../main.ts'
+
+vi.mock('@nextcloud/axios')
const view = {
id: 'files',
@@ -38,7 +43,7 @@ describe('Accept share action conditions tests', () => {
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('accept-share')
expect(action.displayName([file], pendingShareView)).toBe('Accept share')
- expect(action.iconSvgInline([file], pendingShareView)).toBe('<svg>SvgMock</svg>')
+ expect(action.iconSvgInline([file], pendingShareView)).toMatch(/<svg.+<\/svg>/)
expect(action.default).toBeUndefined()
expect(action.order).toBe(1)
expect(action.inline).toBeDefined()
@@ -91,9 +96,11 @@ describe('Accept share action enabled tests', () => {
})
describe('Accept share action execute tests', () => {
+ beforeEach(() => { vi.resetAllMocks() })
+
test('Accept share action', async () => {
- jest.spyOn(axios, 'post')
- jest.spyOn(eventBus, 'emit')
+ vi.spyOn(axios, 'post')
+ vi.spyOn(eventBus, 'emit')
const file = new File({
id: 1,
@@ -103,7 +110,7 @@ describe('Accept share action execute tests', () => {
permissions: Permission.READ,
attributes: {
id: 123,
- share_type: window.OC.Share.SHARE_TYPE_USER,
+ share_type: ShareType.User,
},
})
@@ -111,15 +118,15 @@ describe('Accept share action execute tests', () => {
expect(exec).toBe(true)
expect(axios.post).toBeCalledTimes(1)
- expect(axios.post).toBeCalledWith('http://localhost/ocs/v2.php/apps/files_sharing/api/v1/shares/pending/123')
+ 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 () => {
- jest.spyOn(axios, 'post')
- jest.spyOn(eventBus, 'emit')
+ vi.spyOn(axios, 'post')
+ vi.spyOn(eventBus, 'emit')
const file = new File({
id: 1,
@@ -130,7 +137,7 @@ describe('Accept share action execute tests', () => {
attributes: {
id: 123,
remote: 3,
- share_type: window.OC.Share.SHARE_TYPE_USER,
+ share_type: ShareType.User,
},
})
@@ -138,15 +145,15 @@ describe('Accept share action execute tests', () => {
expect(exec).toBe(true)
expect(axios.post).toBeCalledTimes(1)
- expect(axios.post).toBeCalledWith('http://localhost/ocs/v2.php/apps/files_sharing/api/v1/remote_shares/pending/123')
+ 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 () => {
- jest.spyOn(axios, 'post')
- jest.spyOn(eventBus, 'emit')
+ vi.spyOn(axios, 'post')
+ vi.spyOn(eventBus, 'emit')
const file1 = new File({
id: 1,
@@ -156,7 +163,7 @@ describe('Accept share action execute tests', () => {
permissions: Permission.READ,
attributes: {
id: 123,
- share_type: window.OC.Share.SHARE_TYPE_USER,
+ share_type: ShareType.User,
},
})
@@ -168,7 +175,7 @@ describe('Accept share action execute tests', () => {
permissions: Permission.READ,
attributes: {
id: 456,
- share_type: window.OC.Share.SHARE_TYPE_USER,
+ share_type: ShareType.User,
},
})
@@ -176,8 +183,8 @@ describe('Accept share action execute tests', () => {
expect(exec).toStrictEqual([true, true])
expect(axios.post).toBeCalledTimes(2)
- expect(axios.post).toHaveBeenNthCalledWith(1, 'http://localhost/ocs/v2.php/apps/files_sharing/api/v1/shares/pending/123')
- expect(axios.post).toHaveBeenNthCalledWith(2, 'http://localhost/ocs/v2.php/apps/files_sharing/api/v1/shares/pending/456')
+ 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)
@@ -185,7 +192,7 @@ describe('Accept share action execute tests', () => {
})
test('Accept fails', async () => {
- jest.spyOn(axios, 'post').mockImplementation(() => { throw new Error('Mock error') })
+ vi.spyOn(axios, 'post').mockImplementation(() => { throw new Error('Mock error') })
const file = new File({
id: 1,
@@ -195,7 +202,7 @@ describe('Accept share action execute tests', () => {
permissions: Permission.READ,
attributes: {
id: 123,
- share_type: window.OC.Share.SHARE_TYPE_USER,
+ share_type: ShareType.User,
},
})
@@ -203,7 +210,7 @@ describe('Accept share action execute tests', () => {
expect(exec).toBe(false)
expect(axios.post).toBeCalledTimes(1)
- expect(axios.post).toBeCalledWith('http://localhost/ocs/v2.php/apps/files_sharing/api/v1/shares/pending/123')
+ 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/actions/acceptShareAction.ts b/apps/files_sharing/src/files_actions/acceptShareAction.ts
index 6584d1860f4..f2177fdec1a 100644
--- a/apps/files_sharing/src/actions/acceptShareAction.ts
+++ b/apps/files_sharing/src/files_actions/acceptShareAction.ts
@@ -11,7 +11,7 @@ import { translatePlural as n } from '@nextcloud/l10n'
import axios from '@nextcloud/axios'
import CheckSvg from '@mdi/svg/svg/check.svg?raw'
-import { pendingSharesViewId } from '../views/shares'
+import { pendingSharesViewId } from '../files_views/shares'
export const action = new FileAction({
id: 'accept-share',
diff --git a/apps/files_sharing/src/actions/openInFilesAction.spec.ts b/apps/files_sharing/src/files_actions/openInFilesAction.spec.ts
index d48e3ac8144..23c0938545c 100644
--- a/apps/files_sharing/src/actions/openInFilesAction.spec.ts
+++ b/apps/files_sharing/src/files_actions/openInFilesAction.spec.ts
@@ -2,12 +2,12 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { expect } from '@jest/globals'
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'
-import { action } from './openInFilesAction'
-import { deletedSharesViewId, pendingSharesViewId, sharedWithOthersViewId, sharedWithYouViewId, sharesViewId, sharingByLinksViewId } from '../views/shares'
const view = {
id: 'files',
@@ -29,7 +29,7 @@ const invalidViews = [
describe('Open in files action conditions tests', () => {
test('Default values', () => {
expect(action).toBeInstanceOf(FileAction)
- expect(action.id).toBe('open-in-files')
+ 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)
@@ -56,7 +56,8 @@ describe('Open in files action enabled tests', () => {
describe('Open in files action execute tests', () => {
test('Open in files', async () => {
- const goToRouteMock = jest.fn()
+ 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({
@@ -72,6 +73,6 @@ describe('Open in files action execute tests', () => {
// Silent action
expect(exec).toBe(null)
expect(goToRouteMock).toBeCalledTimes(1)
- expect(goToRouteMock).toBeCalledWith(null, { fileid: 1, view: 'files' }, { dir: '/Foo', openfile: 'true' })
+ expect(goToRouteMock).toBeCalledWith(null, { fileid: '1', view: 'files' }, { dir: '/Foo', openfile: 'true' })
})
})
diff --git a/apps/files_sharing/src/actions/openInFilesAction.ts b/apps/files_sharing/src/files_actions/openInFilesAction.ts
index 3715e96562e..133b4531bb5 100644
--- a/apps/files_sharing/src/actions/openInFilesAction.ts
+++ b/apps/files_sharing/src/files_actions/openInFilesAction.ts
@@ -2,16 +2,16 @@
* 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 } from '@nextcloud/files'
+import { registerFileAction, FileAction, DefaultType, FileType } from '@nextcloud/files'
import { translate as t } from '@nextcloud/l10n'
-
-import { sharesViewId, sharedWithYouViewId, sharedWithOthersViewId, sharingByLinksViewId } from '../views/shares'
+import { sharesViewId, sharedWithYouViewId, sharedWithOthersViewId, sharingByLinksViewId } from '../files_views/shares'
export const action = new FileAction({
- id: 'open-in-files',
- displayName: () => t('files', 'Open in Files'),
+ id: 'files_sharing:open-in-files',
+ displayName: () => t('files_sharing', 'Open in Files'),
iconSvgInline: () => '',
enabled: (nodes, view) => [
@@ -24,10 +24,20 @@ export const action = new FileAction({
].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: node.fileid },
- { dir: node.dirname, openfile: 'true' },
+ {
+ 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
},
diff --git a/apps/files_sharing/src/actions/rejectShareAction.spec.ts b/apps/files_sharing/src/files_actions/rejectShareAction.spec.ts
index dc4c9a69601..51ded69d1c5 100644
--- a/apps/files_sharing/src/actions/rejectShareAction.spec.ts
+++ b/apps/files_sharing/src/files_actions/rejectShareAction.spec.ts
@@ -2,13 +2,17 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { action } from './rejectShareAction'
-import { expect } from '@jest/globals'
import { File, Folder, Permission, View, FileAction } from '@nextcloud/files'
-import eventBus from '@nextcloud/event-bus'
+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',
@@ -38,7 +42,7 @@ describe('Reject share action conditions tests', () => {
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('reject-share')
expect(action.displayName([file], pendingShareView)).toBe('Reject share')
- expect(action.iconSvgInline([file], pendingShareView)).toBe('<svg>SvgMock</svg>')
+ expect(action.iconSvgInline([file], pendingShareView)).toMatch(/<svg.+<\/svg>/)
expect(action.default).toBeUndefined()
expect(action.order).toBe(2)
expect(action.inline).toBeDefined()
@@ -96,7 +100,7 @@ describe('Reject share action enabled tests', () => {
owner: 'admin',
permissions: Permission.READ,
attributes: {
- share_type: window.OC.Share.SHARE_TYPE_USER,
+ share_type: ShareType.User,
},
})
const folder2 = new Folder({
@@ -106,7 +110,7 @@ describe('Reject share action enabled tests', () => {
permissions: Permission.READ,
attributes: {
remote_id: 1,
- share_type: window.OC.Share.SHARE_TYPE_REMOTE_GROUP,
+ share_type: ShareType.RemoteGroup,
},
})
@@ -118,9 +122,11 @@ describe('Reject share action enabled tests', () => {
})
describe('Reject share action execute tests', () => {
+ beforeEach(() => { vi.resetAllMocks() })
+
test('Reject share action', async () => {
- jest.spyOn(axios, 'delete')
- jest.spyOn(eventBus, 'emit')
+ vi.spyOn(axios, 'delete')
+ vi.spyOn(eventBus, 'emit')
const file = new File({
id: 1,
@@ -130,7 +136,7 @@ describe('Reject share action execute tests', () => {
permissions: Permission.READ,
attributes: {
id: 123,
- share_type: window.OC.Share.SHARE_TYPE_USER,
+ share_type: ShareType.User,
},
})
@@ -138,15 +144,15 @@ describe('Reject share action execute tests', () => {
expect(exec).toBe(true)
expect(axios.delete).toBeCalledTimes(1)
- expect(axios.delete).toBeCalledWith('http://localhost/ocs/v2.php/apps/files_sharing/api/v1/shares/123')
+ 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 () => {
- jest.spyOn(axios, 'delete')
- jest.spyOn(eventBus, 'emit')
+ vi.spyOn(axios, 'delete')
+ vi.spyOn(eventBus, 'emit')
const file = new File({
id: 1,
@@ -157,7 +163,7 @@ describe('Reject share action execute tests', () => {
attributes: {
id: 123,
remote: 3,
- share_type: window.OC.Share.SHARE_TYPE_USER,
+ share_type: ShareType.User,
},
})
@@ -165,15 +171,15 @@ describe('Reject share action execute tests', () => {
expect(exec).toBe(true)
expect(axios.delete).toBeCalledTimes(1)
- expect(axios.delete).toBeCalledWith('http://localhost/ocs/v2.php/apps/files_sharing/api/v1/remote_shares/123')
+ 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 () => {
- jest.spyOn(axios, 'delete')
- jest.spyOn(eventBus, 'emit')
+ vi.spyOn(axios, 'delete')
+ vi.spyOn(eventBus, 'emit')
const file1 = new File({
id: 1,
@@ -183,7 +189,7 @@ describe('Reject share action execute tests', () => {
permissions: Permission.READ,
attributes: {
id: 123,
- share_type: window.OC.Share.SHARE_TYPE_USER,
+ share_type: ShareType.User,
},
})
@@ -195,7 +201,7 @@ describe('Reject share action execute tests', () => {
permissions: Permission.READ,
attributes: {
id: 456,
- share_type: window.OC.Share.SHARE_TYPE_USER,
+ share_type: ShareType.User,
},
})
@@ -203,8 +209,8 @@ describe('Reject share action execute tests', () => {
expect(exec).toStrictEqual([true, true])
expect(axios.delete).toBeCalledTimes(2)
- expect(axios.delete).toHaveBeenNthCalledWith(1, 'http://localhost/ocs/v2.php/apps/files_sharing/api/v1/shares/123')
- expect(axios.delete).toHaveBeenNthCalledWith(2, 'http://localhost/ocs/v2.php/apps/files_sharing/api/v1/shares/456')
+ 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)
@@ -212,7 +218,7 @@ describe('Reject share action execute tests', () => {
})
test('Reject fails', async () => {
- jest.spyOn(axios, 'delete').mockImplementation(() => { throw new Error('Mock error') })
+ vi.spyOn(axios, 'delete').mockImplementation(() => { throw new Error('Mock error') })
const file = new File({
id: 1,
@@ -222,7 +228,7 @@ describe('Reject share action execute tests', () => {
permissions: Permission.READ,
attributes: {
id: 123,
- share_type: window.OC.Share.SHARE_TYPE_USER,
+ share_type: ShareType.User,
},
})
@@ -230,7 +236,7 @@ describe('Reject share action execute tests', () => {
expect(exec).toBe(false)
expect(axios.delete).toBeCalledTimes(1)
- expect(axios.delete).toBeCalledWith('http://localhost/ocs/v2.php/apps/files_sharing/api/v1/shares/123')
+ 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/actions/rejectShareAction.ts b/apps/files_sharing/src/files_actions/rejectShareAction.ts
index 5c3dc619f2c..22f77262ef2 100644
--- a/apps/files_sharing/src/actions/rejectShareAction.ts
+++ b/apps/files_sharing/src/files_actions/rejectShareAction.ts
@@ -8,11 +8,12 @@ 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'
-import { pendingSharesViewId } from '../views/shares'
-
export const action = new FileAction({
id: 'reject-share',
displayName: (nodes: Node[]) => n('files_sharing', 'Reject share', 'Reject shares', nodes.length),
@@ -30,7 +31,7 @@ export const action = new FileAction({
// 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 === window.OC.Share.SHARE_TYPE_REMOTE_GROUP)) {
+ && node.attributes.share_type === ShareType.RemoteGroup)) {
return false
}
diff --git a/apps/files_sharing/src/actions/restoreShareAction.spec.ts b/apps/files_sharing/src/files_actions/restoreShareAction.spec.ts
index f35d0d0ef18..015aa8aa95d 100644
--- a/apps/files_sharing/src/actions/restoreShareAction.spec.ts
+++ b/apps/files_sharing/src/files_actions/restoreShareAction.spec.ts
@@ -2,12 +2,17 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { action } from './restoreShareAction'
-import { expect } from '@jest/globals'
import { File, Permission, View, FileAction } from '@nextcloud/files'
-import eventBus from '@nextcloud/event-bus'
+import { ShareType } from '@nextcloud/sharing'
+import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'
+
import axios from '@nextcloud/axios'
-import '../main'
+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',
@@ -38,7 +43,7 @@ describe('Restore share action conditions tests', () => {
expect(action).toBeInstanceOf(FileAction)
expect(action.id).toBe('restore-share')
expect(action.displayName([file], deletedShareView)).toBe('Restore share')
- expect(action.iconSvgInline([file], deletedShareView)).toBe('<svg>SvgMock</svg>')
+ expect(action.iconSvgInline([file], deletedShareView)).toMatch(/<svg.+<\/svg>/)
expect(action.default).toBeUndefined()
expect(action.order).toBe(1)
expect(action.inline).toBeDefined()
@@ -91,9 +96,11 @@ describe('Restore share action enabled tests', () => {
})
describe('Restore share action execute tests', () => {
+ beforeEach(() => { vi.resetAllMocks() })
+
test('Restore share action', async () => {
- jest.spyOn(axios, 'post')
- jest.spyOn(eventBus, 'emit')
+ vi.spyOn(axios, 'post')
+ vi.spyOn(eventBus, 'emit')
const file = new File({
id: 1,
@@ -103,7 +110,7 @@ describe('Restore share action execute tests', () => {
permissions: Permission.READ,
attributes: {
id: 123,
- share_type: window.OC.Share.SHARE_TYPE_USER,
+ share_type: ShareType.User,
},
})
@@ -111,15 +118,15 @@ describe('Restore share action execute tests', () => {
expect(exec).toBe(true)
expect(axios.post).toBeCalledTimes(1)
- expect(axios.post).toBeCalledWith('http://localhost/ocs/v2.php/apps/files_sharing/api/v1/deletedshares/123')
+ 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 () => {
- jest.spyOn(axios, 'post')
- jest.spyOn(eventBus, 'emit')
+ vi.spyOn(axios, 'post')
+ vi.spyOn(eventBus, 'emit')
const file1 = new File({
id: 1,
@@ -129,7 +136,7 @@ describe('Restore share action execute tests', () => {
permissions: Permission.READ,
attributes: {
id: 123,
- share_type: window.OC.Share.SHARE_TYPE_USER,
+ share_type: ShareType.User,
},
})
@@ -141,7 +148,7 @@ describe('Restore share action execute tests', () => {
permissions: Permission.READ,
attributes: {
id: 456,
- share_type: window.OC.Share.SHARE_TYPE_USER,
+ share_type: ShareType.User,
},
})
@@ -149,8 +156,8 @@ describe('Restore share action execute tests', () => {
expect(exec).toStrictEqual([true, true])
expect(axios.post).toBeCalledTimes(2)
- expect(axios.post).toHaveBeenNthCalledWith(1, 'http://localhost/ocs/v2.php/apps/files_sharing/api/v1/deletedshares/123')
- expect(axios.post).toHaveBeenNthCalledWith(2, 'http://localhost/ocs/v2.php/apps/files_sharing/api/v1/deletedshares/456')
+ 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)
@@ -158,7 +165,8 @@ describe('Restore share action execute tests', () => {
})
test('Restore fails', async () => {
- jest.spyOn(axios, 'post').mockImplementation(() => { throw new Error('Mock error') })
+ vi.spyOn(axios, 'post')
+ .mockImplementation(() => { throw new Error('Mock error') })
const file = new File({
id: 1,
@@ -168,7 +176,7 @@ describe('Restore share action execute tests', () => {
permissions: Permission.READ,
attributes: {
id: 123,
- share_type: window.OC.Share.SHARE_TYPE_USER,
+ share_type: ShareType.User,
},
})
@@ -176,7 +184,7 @@ describe('Restore share action execute tests', () => {
expect(exec).toBe(false)
expect(axios.post).toBeCalledTimes(1)
- expect(axios.post).toBeCalledWith('http://localhost/ocs/v2.php/apps/files_sharing/api/v1/deletedshares/123')
+ 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/actions/restoreShareAction.ts b/apps/files_sharing/src/files_actions/restoreShareAction.ts
index 17de09ba349..2d51de387ee 100644
--- a/apps/files_sharing/src/actions/restoreShareAction.ts
+++ b/apps/files_sharing/src/files_actions/restoreShareAction.ts
@@ -11,7 +11,7 @@ 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 '../views/shares'
+import { deletedSharesViewId } from '../files_views/shares'
export const action = new FileAction({
id: 'restore-share',
diff --git a/apps/files_sharing/src/actions/sharingStatusAction.scss b/apps/files_sharing/src/files_actions/sharingStatusAction.scss
index 33366c42be1..3a6690f40f1 100644
--- a/apps/files_sharing/src/actions/sharingStatusAction.scss
+++ b/apps/files_sharing/src/files_actions/sharingStatusAction.scss
@@ -6,8 +6,8 @@
.action-items > .files-list__row-action-sharing-status {
// put icon at the end of the button
direction: rtl;
- // align icons with textless inline actions
- padding-right: 0 !important;
+ // align icons with text-less inline actions
+ padding-inline-end: 0 !important;
}
svg.sharing-status__avatar {
@@ -18,3 +18,12 @@ svg.sharing-status__avatar {
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/actions/sharingStatusAction.ts b/apps/files_sharing/src/files_actions/sharingStatusAction.ts
index 55e2bf21e8f..18fa46d2781 100644
--- a/apps/files_sharing/src/actions/sharingStatusAction.ts
+++ b/apps/files_sharing/src/files_actions/sharingStatusAction.ts
@@ -2,46 +2,35 @@
* 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 { Type } from '@nextcloud/sharing'
+import { ShareType } from '@nextcloud/sharing'
+import { isPublicShare } from '@nextcloud/sharing/public'
-import AccountGroupSvg from '@mdi/svg/svg/account-group.svg?raw'
-import AccountPlusSvg from '@mdi/svg/svg/account-plus.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 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 { generateUrl } from '@nextcloud/router'
-import { getCurrentUser } from '@nextcloud/auth'
+import { generateAvatarSvg } from '../utils/AccountIcon'
import './sharingStatusAction.scss'
-const isDarkMode = window?.matchMedia?.('(prefers-color-scheme: dark)')?.matches === true
- || document.querySelector('[data-themes*=dark]') !== null
-
-const generateAvatarSvg = (userId: string, isGuest = false) => {
- const url = isDarkMode ? '/avatar/{userId}/32/dark' : '/avatar/{userId}/32'
- const avatarUrl = generateUrl(isGuest ? url : url + '?guestFallback=true', { 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>`
-}
-
const isExternal = (node: Node) => {
- return node.attributes.remote_id !== undefined
+ return node.attributes?.['is-federated'] ?? false
}
+export const ACTION_SHARING_STATUS = 'sharing-status'
export const action = new FileAction({
- id: 'sharing-status',
+ id: ACTION_SHARING_STATUS,
displayName(nodes: Node[]) {
const node = nodes[0]
const shareTypes = Object.values(node?.attributes?.['share-types'] || {}).flat() as number[]
- const ownerId = node?.attributes?.['owner-id']
if (shareTypes.length > 0
- || (ownerId !== getCurrentUser()?.uid || isExternal(node))) {
+ || (node.owner !== getCurrentUser()?.uid || isExternal(node))) {
return t('files_sharing', 'Shared')
}
@@ -50,19 +39,32 @@ export const action = new FileAction({
title(nodes: Node[]) {
const node = nodes[0]
- const ownerId = node?.attributes?.['owner-id']
- const ownerDisplayName = node?.attributes?.['owner-display-name']
- // Mixed share types
- if (Array.isArray(node.attributes?.['share-types']) && node.attributes?.['share-types'].length > 1) {
+ 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')
}
- if (ownerId && (ownerId !== getCurrentUser()?.uid || isExternal(node))) {
- return t('files_sharing', 'Shared by {ownerDisplayName}', { ownerDisplayName })
+ 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')
}
- return t('files_sharing', 'Show 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[]) {
@@ -75,25 +77,24 @@ export const action = new FileAction({
}
// Link shares
- if (shareTypes.includes(Type.SHARE_TYPE_LINK)
- || shareTypes.includes(Type.SHARE_TYPE_EMAIL)) {
+ if (shareTypes.includes(ShareType.Link)
+ || shareTypes.includes(ShareType.Email)) {
return LinkSvg
}
// Group shares
- if (shareTypes.includes(Type.SHARE_TYPE_GROUP)
- || shareTypes.includes(Type.SHARE_TYPE_REMOTE_GROUP)) {
+ if (shareTypes.includes(ShareType.Group)
+ || shareTypes.includes(ShareType.RemoteGroup)) {
return AccountGroupSvg
}
// Circle shares
- if (shareTypes.includes(Type.SHARE_TYPE_CIRCLE)) {
+ if (shareTypes.includes(ShareType.Team)) {
return CircleSvg
}
- const ownerId = node?.attributes?.['owner-id']
- if (ownerId && (ownerId !== getCurrentUser()?.uid || isExternal(node))) {
- return generateAvatarSvg(ownerId, isExternal(node))
+ if (node.owner && (node.owner !== getCurrentUser()?.uid || isExternal(node))) {
+ return generateAvatarSvg(node.owner, isExternal(node))
}
return AccountPlusSvg
@@ -104,9 +105,14 @@ export const action = new FileAction({
return false
}
+ // Do not leak information about users to public shares
+ if (isPublicShare()) {
+ return false
+ }
+
const node = nodes[0]
- const ownerId = node?.attributes?.['owner-id']
- const isMixed = Array.isArray(node.attributes?.['share-types'])
+ 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
@@ -115,7 +121,7 @@ export const action = new FileAction({
}
// If the node is shared by someone else
- if (ownerId && (ownerId !== getCurrentUser()?.uid || isExternal(node))) {
+ if (node.owner !== getCurrentUser()?.uid || isExternal(node)) {
return true
}
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
index 5ecca06a69d..6afcfa76717 100644
--- a/apps/files_sharing/src/files_sharing_tab.js
+++ b/apps/files_sharing/src/files_sharing_tab.js
@@ -4,8 +4,8 @@
*/
import Vue from 'vue'
-import { translate as t, translatePlural as n } from '@nextcloud/l10n'
-import { getRequestToken } from '@nextcloud/auth'
+import { getCSPNonce } from '@nextcloud/auth'
+import { t, n } from '@nextcloud/l10n'
import ShareSearch from './services/ShareSearch.js'
import ExternalLinkActions from './services/ExternalLinkActions.js'
@@ -16,7 +16,7 @@ import TabSections from './services/TabSections.js'
import ShareVariant from '@mdi/svg/svg/share-variant.svg?raw'
// eslint-disable-next-line camelcase
-__webpack_nonce__ = btoa(getRequestToken())
+__webpack_nonce__ = getCSPNonce()
// Init Sharing Tab Service
if (!window.OCA.Sharing) {
@@ -55,12 +55,16 @@ window.addEventListener('DOMContentLoaded', function() {
await TabInstance.update(fileInfo)
TabInstance.$mount(el)
},
+
update(fileInfo) {
TabInstance.update(fileInfo)
},
+
destroy() {
- TabInstance.$destroy()
- TabInstance = null
+ if (TabInstance) {
+ TabInstance.$destroy()
+ TabInstance = null
+ }
},
}))
}
diff --git a/apps/files_sharing/src/files_views/publicFileDrop.ts b/apps/files_sharing/src/files_views/publicFileDrop.ts
new file mode 100644
index 00000000000..65756e83c74
--- /dev/null
+++ b/apps/files_sharing/src/files_views/publicFileDrop.ts
@@ -0,0 +1,60 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { VueConstructor } from 'vue'
+
+import { Folder, Permission, View, getNavigation } from '@nextcloud/files'
+import { defaultRemoteURL, defaultRootPath } from '@nextcloud/files/dav'
+import { loadState } from '@nextcloud/initial-state'
+import { translate as t } from '@nextcloud/l10n'
+import svgCloudUpload from '@mdi/svg/svg/cloud-upload.svg?raw'
+import Vue from 'vue'
+
+export default () => {
+ const foldername = loadState<string>('files_sharing', 'filename')
+
+ let FilesViewFileDropEmptyContent: VueConstructor
+ let fileDropEmptyContentInstance: Vue
+
+ const view = new View({
+ id: 'public-file-drop',
+ name: t('files_sharing', 'File drop'),
+ caption: t('files_sharing', 'Upload files to {foldername}', { foldername }),
+ icon: svgCloudUpload,
+ order: 1,
+
+ emptyView: async (div: HTMLDivElement) => {
+ if (FilesViewFileDropEmptyContent === undefined) {
+ const { default: component } = await import('../views/FilesViewFileDropEmptyContent.vue')
+ FilesViewFileDropEmptyContent = Vue.extend(component)
+ }
+ if (fileDropEmptyContentInstance) {
+ fileDropEmptyContentInstance.$destroy()
+ }
+ fileDropEmptyContentInstance = new FilesViewFileDropEmptyContent({
+ propsData: {
+ foldername,
+ },
+ })
+ fileDropEmptyContentInstance.$mount(div)
+ },
+
+ getContents: async () => {
+ return {
+ contents: [],
+ // Fake a writeonly folder as root
+ folder: new Folder({
+ id: 0,
+ source: `${defaultRemoteURL}${defaultRootPath}`,
+ root: defaultRootPath,
+ owner: null,
+ permissions: Permission.CREATE,
+ }),
+ }
+ },
+ })
+
+ const Navigation = getNavigation()
+ Navigation.register(view)
+}
diff --git a/apps/files_sharing/src/files_views/publicFileShare.ts b/apps/files_sharing/src/files_views/publicFileShare.ts
new file mode 100644
index 00000000000..caa7f862e57
--- /dev/null
+++ b/apps/files_sharing/src/files_views/publicFileShare.ts
@@ -0,0 +1,66 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { FileStat, ResponseDataDetailed } from 'webdav'
+import { Folder, Permission, View, davGetDefaultPropfind, davRemoteURL, davResultToNode, davRootPath, getNavigation } from '@nextcloud/files'
+import { translate as t } from '@nextcloud/l10n'
+import { CancelablePromise } from 'cancelable-promise'
+import LinkSvg from '@mdi/svg/svg/link.svg?raw'
+
+import { client } from '../../../files/src/services/WebdavClient'
+import logger from '../services/logger'
+
+export default () => {
+ const view = new View({
+ id: 'public-file-share',
+ name: t('files_sharing', 'Public file share'),
+ caption: t('files_sharing', 'Publicly shared file.'),
+
+ emptyTitle: t('files_sharing', 'No file'),
+ emptyCaption: t('files_sharing', 'The file shared with you will show up here'),
+
+ icon: LinkSvg,
+ order: 1,
+
+ getContents: () => {
+ return new CancelablePromise(async (resolve, reject, onCancel) => {
+ const abort = new AbortController()
+ onCancel(() => abort.abort())
+ try {
+ const node = await client.stat(
+ davRootPath,
+ {
+ data: davGetDefaultPropfind(),
+ details: true,
+ signal: abort.signal,
+ },
+ ) as ResponseDataDetailed<FileStat>
+
+ resolve({
+ // We only have one file as the content
+ contents: [davResultToNode(node.data)],
+ // Fake a readonly folder as root
+ folder: new Folder({
+ id: 0,
+ source: `${davRemoteURL}${davRootPath}`,
+ root: davRootPath,
+ owner: null,
+ permissions: Permission.READ,
+ attributes: {
+ // Ensure the share note is set on the root
+ note: node.data.props?.note,
+ },
+ }),
+ })
+ } catch (e) {
+ logger.error(e as Error)
+ reject(e as Error)
+ }
+ })
+ },
+ })
+
+ const Navigation = getNavigation()
+ Navigation.register(view)
+}
diff --git a/apps/files_sharing/src/files_views/publicShare.ts b/apps/files_sharing/src/files_views/publicShare.ts
new file mode 100644
index 00000000000..4f5526bc829
--- /dev/null
+++ b/apps/files_sharing/src/files_views/publicShare.ts
@@ -0,0 +1,28 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { translate as t } from '@nextcloud/l10n'
+import { View, getNavigation } from '@nextcloud/files'
+import LinkSvg from '@mdi/svg/svg/link.svg?raw'
+
+import { getContents } from '../../../files/src/services/Files'
+
+export default () => {
+ const view = new View({
+ id: 'public-share',
+ name: t('files_sharing', 'Public share'),
+ caption: t('files_sharing', 'Publicly shared files.'),
+
+ emptyTitle: t('files_sharing', 'No files'),
+ emptyCaption: t('files_sharing', 'Files and folders shared with you will show up here'),
+
+ icon: LinkSvg,
+ order: 1,
+
+ getContents,
+ })
+
+ const Navigation = getNavigation()
+ Navigation.register(view)
+}
diff --git a/apps/files_sharing/src/views/shares.spec.ts b/apps/files_sharing/src/files_views/shares.spec.ts
index cba8ffa94d7..7e5b59e0ad9 100644
--- a/apps/files_sharing/src/views/shares.spec.ts
+++ b/apps/files_sharing/src/files_views/shares.spec.ts
@@ -3,9 +3,12 @@
* 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 { expect } from '@jest/globals'
+
+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'
@@ -20,16 +23,13 @@ declare global {
describe('Sharing views definition', () => {
let Navigation
beforeEach(() => {
+ delete window._nc_navigation
Navigation = getNavigation()
expect(window._nc_navigation).toBeDefined()
})
- afterAll(() => {
- delete window._nc_navigation
- })
-
test('Default values', () => {
- jest.spyOn(Navigation, 'register')
+ vi.spyOn(Navigation, 'register')
expect(Navigation.views.length).toBe(0)
@@ -37,17 +37,17 @@ describe('Sharing views definition', () => {
const shareOverviewView = Navigation.views.find(view => view.id === 'shareoverview') as View
const sharesChildViews = Navigation.views.filter(view => view.parent === 'shareoverview') as View[]
- expect(Navigation.register).toHaveBeenCalledTimes(6)
+ expect(Navigation.register).toHaveBeenCalledTimes(7)
// one main view and no children
- expect(Navigation.views.length).toBe(6)
+ expect(Navigation.views.length).toBe(7)
expect(shareOverviewView).toBeDefined()
- expect(sharesChildViews.length).toBe(5)
+ 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).toBe('<svg>SvgMock</svg>')
+ expect(shareOverviewView?.icon).toMatch(/<svg.+<\/svg>/i)
expect(shareOverviewView?.order).toBe(20)
expect(shareOverviewView?.columns).toStrictEqual([])
expect(shareOverviewView?.getContents).toBeDefined()
@@ -56,6 +56,7 @@ describe('Sharing views definition', () => {
{ 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' },
]
@@ -67,27 +68,45 @@ describe('Sharing views definition', () => {
expect(view?.caption).toBeDefined()
expect(view?.emptyTitle).toBeDefined()
expect(view?.emptyCaption).toBeDefined()
- expect(view?.icon).toBe('<svg>SvgMock</svg>')
+ 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()
})
- afterAll(() => {
- delete window._nc_navigation
- })
-
test('Sharing overview get contents', async () => {
- jest.spyOn(axios, 'get').mockImplementation(async (): Promise<any> => {
+ vi.spyOn(axios, 'get').mockImplementation(async (): Promise<any> => {
return {
data: {
ocs: {
@@ -103,7 +122,7 @@ describe('Sharing views contents', () => {
})
registerSharingViews()
- expect(Navigation.views.length).toBe(6)
+ expect(Navigation.views.length).toBe(7)
Navigation.views.forEach(async (view: View) => {
const content = await view.getContents('/')
expect(content.contents).toStrictEqual([])
diff --git a/apps/files_sharing/src/views/shares.ts b/apps/files_sharing/src/files_views/shares.ts
index e43c75b3a5e..fd5e908638c 100644
--- a/apps/files_sharing/src/views/shares.ts
+++ b/apps/files_sharing/src/files_views/shares.ts
@@ -4,14 +4,17 @@
*/
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.svg?raw'
-import AccountPlusSvg from '@mdi/svg/svg/account-plus.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 } from '../services/SharingService'
+import { getContents, isFileRequest } from '../services/SharingService'
+import { loadState } from '@nextcloud/initial-state'
export const sharesViewId = 'shareoverview'
export const sharedWithYouViewId = 'sharingin'
@@ -19,6 +22,7 @@ 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()
@@ -55,22 +59,26 @@ export default () => {
getContents: () => getContents(true, false, false, false),
}))
- Navigation.register(new View({
- id: sharedWithOthersViewId,
- name: t('files_sharing', 'Shared with others'),
- caption: t('files_sharing', 'List of files that you shared with others.'),
+ // 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'),
+ 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,
+ icon: AccountGroupSvg,
+ order: 2,
+ parent: sharesViewId,
- columns: [],
+ columns: [],
- getContents: () => getContents(false, true, false, false),
- }))
+ getContents: () => getContents(false, true, false, false),
+ }))
+ }
Navigation.register(new View({
id: sharingByLinksViewId,
@@ -86,7 +94,30 @@ export default () => {
columns: [],
- getContents: () => getContents(false, true, false, false, [window.OC.Share.SHARE_TYPE_LINK]),
+ 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({
@@ -98,7 +129,7 @@ export default () => {
emptyCaption: t('files_sharing', 'Shares you have left will show up here'),
icon: DeleteSvg,
- order: 4,
+ order: 5,
parent: sharesViewId,
columns: [],
@@ -115,7 +146,7 @@ export default () => {
emptyCaption: t('files_sharing', 'Shares you have received but not approved will show up here'),
icon: AccountClockSvg,
- order: 5,
+ order: 6,
parent: sharesViewId,
columns: [],
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
index 0c8cd349f1b..f275f3beaf7 100644
--- a/apps/files_sharing/src/init.ts
+++ b/apps/files_sharing/src/init.ts
@@ -2,17 +2,32 @@
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { registerDavProperty } from '@nextcloud/files'
-import registerSharingViews from './views/shares'
+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 './actions/acceptShareAction'
-import './actions/openInFilesAction'
-import './actions/rejectShareAction'
-import './actions/restoreShareAction'
-import './actions/sharingStatusAction'
+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.spec.js b/apps/files_sharing/src/lib/SharePermissionsToolBox.spec.js
index 9ca770a0ac5..a58552063d8 100644
--- a/apps/files_sharing/src/lib/SharePermissionsToolBox.spec.js
+++ b/apps/files_sharing/src/lib/SharePermissionsToolBox.spec.js
@@ -2,6 +2,7 @@
* 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,
diff --git a/apps/files_sharing/src/main.ts b/apps/files_sharing/src/main.ts
index aaa07fddc36..3170fbc2a7b 100644
--- a/apps/files_sharing/src/main.ts
+++ b/apps/files_sharing/src/main.ts
@@ -4,10 +4,6 @@
*/
// register default shares types
-if (!window.OC) {
- window.OC = {}
-}
-
Object.assign(window.OC, {
Share: {
SHARE_TYPE_USER: 0,
diff --git a/apps/files_sharing/src/mixins/ShareDetails.js b/apps/files_sharing/src/mixins/ShareDetails.js
index 6c50440ff24..6ccdf8d63d0 100644
--- a/apps/files_sharing/src/mixins/ShareDetails.js
+++ b/apps/files_sharing/src/mixins/ShareDetails.js
@@ -3,9 +3,10 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-
-import Share from '../models/Share.js'
-import Config from '../services/ConfigService.js'
+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: {
@@ -15,17 +16,30 @@ export default {
// TODO : Better name/interface for handler required
// For example `externalAppCreateShareHook` with proper documentation
if (shareRequestObject.handler) {
+ const handlerInput = {}
if (this.suggestions) {
- shareRequestObject.suggestions = this.suggestions
- shareRequestObject.fileInfo = this.fileInfo
- shareRequestObject.query = this.query
+ handlerInput.suggestions = this.suggestions
+ handlerInput.fileInfo = this.fileInfo
+ handlerInput.query = this.query
}
- share = await shareRequestObject.handler(shareRequestObject)
- share = new Share(share)
+ 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,
@@ -46,11 +60,12 @@ export default {
const share = {
attributes: [
{
- enabled: true,
+ value: true,
key: 'download',
scope: 'permissions',
},
],
+ hideDownload: false,
share_type: shareRequestObject.shareType,
share_with: shareRequestObject.shareWith,
is_no_user: shareRequestObject.isNoUser,
diff --git a/apps/files_sharing/src/mixins/ShareRequests.js b/apps/files_sharing/src/mixins/ShareRequests.js
index f8bd4083d20..2c33fa3b0c7 100644
--- a/apps/files_sharing/src/mixins/ShareRequests.js
+++ b/apps/files_sharing/src/mixins/ShareRequests.js
@@ -6,10 +6,12 @@
// 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.js'
-import { emit } from '@nextcloud/event-bus'
+
+import Share from '../models/Share.ts'
const shareUrl = generateOcsUrl('apps/files_sharing/api/v1/shares')
@@ -26,10 +28,10 @@ export default {
* @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 shareautomatically after
+ * @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 data.note
+ * @param {string} data.note custom note to recipient
* @return {Share} the new share
* @throws {Error}
*/
@@ -45,7 +47,7 @@ export default {
} catch (error) {
console.error('Error while creating share', error)
const errorMessage = error?.response?.data?.ocs?.meta?.message
- OC.Notification.showTemporary(
+ showError(
errorMessage ? t('files_sharing', 'Error creating the share: {errorMessage}', { errorMessage }) : t('files_sharing', 'Error creating the share'),
{ type: 'error' },
)
diff --git a/apps/files_sharing/src/mixins/ShareTypes.js b/apps/files_sharing/src/mixins/ShareTypes.js
deleted file mode 100644
index 4b0746a4849..00000000000
--- a/apps/files_sharing/src/mixins/ShareTypes.js
+++ /dev/null
@@ -1,14 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-import { Type as ShareTypes } from '@nextcloud/sharing'
-
-export default {
- data() {
- return {
- SHARE_TYPES: ShareTypes,
- }
- },
-}
diff --git a/apps/files_sharing/src/mixins/SharesMixin.js b/apps/files_sharing/src/mixins/SharesMixin.js
index 23cc4fd343e..a461da56d85 100644
--- a/apps/files_sharing/src/mixins/SharesMixin.js
+++ b/apps/files_sharing/src/mixins/SharesMixin.js
@@ -3,23 +3,27 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { showError, showSuccess } from '@nextcloud/dialogs'
import { getCurrentUser } from '@nextcloud/auth'
-// eslint-disable-next-line import/no-unresolved, n/no-missing-import
+import { showError, showSuccess } from '@nextcloud/dialogs'
+import { ShareType } from '@nextcloud/sharing'
+import { emit } from '@nextcloud/event-bus'
+
import PQueue from 'p-queue'
import debounce from 'debounce'
-import Share from '../models/Share.js'
+import GeneratePassword from '../utils/GeneratePassword.ts'
+import Share from '../models/Share.ts'
import SharesRequests from './ShareRequests.js'
-import ShareTypes from './ShareTypes.js'
-import Config from '../services/ConfigService.js'
+import Config from '../services/ConfigService.ts'
+import logger from '../services/logger.ts'
import {
BUNDLED_PERMISSIONS,
} from '../lib/SharePermissionsToolBox.js'
+import { fetchNode } from '../../../files/src/services/WebdavClient.ts'
export default {
- mixins: [SharesRequests, ShareTypes],
+ mixins: [SharesRequests],
props: {
fileInfo: {
@@ -40,6 +44,8 @@ export default {
data() {
return {
config: new Config(),
+ node: null,
+ ShareType,
// errors helpers
errors: {},
@@ -62,7 +68,9 @@ export default {
},
computed: {
-
+ path() {
+ return (this.fileInfo.path + '/' + this.fileInfo.name).replace('//', '/')
+ },
/**
* Does the current share have a note
*
@@ -86,10 +94,10 @@ export default {
// Datepicker language
lang() {
const weekdaysShort = window.dayNamesShort
- ? window.dayNamesShort // provided by nextcloud
+ ? window.dayNamesShort // provided by Nextcloud
: ['Sun.', 'Mon.', 'Tue.', 'Wed.', 'Thu.', 'Fri.', 'Sat.']
const monthsShort = window.monthNamesShort
- ? window.monthNamesShort // provided by nextcloud
+ ? window.monthNamesShort // provided by Nextcloud
: ['Jan.', 'Feb.', 'Mar.', 'Apr.', 'May.', 'Jun.', 'Jul.', 'Aug.', 'Sep.', 'Oct.', 'Nov.', 'Dec.']
const firstDayOfWeek = window.firstDay ? window.firstDay : 0
@@ -103,15 +111,18 @@ export default {
monthFormat: 'MMM',
}
},
+ isNewShare() {
+ return !this.share.id
+ },
isFolder() {
return this.fileInfo.type === 'dir'
},
isPublicShare() {
const shareType = this.share.shareType ?? this.share.type
- return [this.SHARE_TYPES.SHARE_TYPE_LINK, this.SHARE_TYPES.SHARE_TYPE_EMAIL].includes(shareType)
+ return [ShareType.Link, ShareType.Email].includes(shareType)
},
isRemoteShare() {
- return this.share.type === this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP || this.share.type === this.SHARE_TYPES.SHARE_TYPE_REMOTE
+ return this.share.type === ShareType.RemoteGroup || this.share.type === ShareType.Remote
},
isShareOwner() {
return this.share && this.share.owner === getCurrentUser().uid
@@ -121,7 +132,7 @@ export default {
return this.config.isDefaultExpireDateEnforced
}
if (this.isRemoteShare) {
- return this.config.isDefaultRemoteExpireDateEnforced
+ return this.config.isDefaultRemoteExpireDateEnforced
}
return this.config.isDefaultInternalExpireDateEnforced
},
@@ -146,10 +157,45 @@ export default {
}
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
*
@@ -172,19 +218,7 @@ export default {
},
/**
- * @param {string} date a date with YYYY-MM-DD format
- * @return {Date} date
- */
- parseDateString(date) {
- if (!date) {
- return
- }
- const regex = /([0-9]{4}-[0-9]{2}-[0-9]{2})/i
- return new Date(date.match(regex)?.pop())
- },
-
- /**
- * @param {Date} date
+ * @param {Date} date the date to format
* @return {string} date a date with YYYY-MM-DD format
*/
formatDateToString(date) {
@@ -199,17 +233,14 @@ export default {
*
* @param {Date} date
*/
- onExpirationChange: debounce(function(date) {
- this.share.expireDate = this.formatDateToString(new Date(date))
- }, 500),
- /**
- * Uncheck expire date
- * We need this method because @update:checked
- * is ran simultaneously as @uncheck, so
- * so we cannot ensure data is up-to-date
- */
- onExpirationDisable() {
- this.share.expireDate = ''
+ 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)
},
/**
@@ -241,12 +272,14 @@ export default {
this.loading = true
this.open = false
await this.deleteShare(this.share.id)
- console.debug('Share deleted', this.share.id)
+ logger.debug('Share deleted', { shareId: this.share.id })
const message = this.share.itemType === 'file'
? t('files_sharing', 'File "{path}" has been unshared', { path: this.share.path })
: t('files_sharing', 'Folder "{path}" has been unshared', { path: this.share.path })
showSuccess(message)
this.$emit('remove:share', this.share)
+ await this.getNode()
+ emit('files:node:updated', this.node)
} catch (error) {
// re-open menu if error
this.open = true
@@ -270,22 +303,30 @@ export default {
const properties = {}
// force value to string because that is what our
// share api controller accepts
- propertyNames.forEach(name => {
- if ((typeof this.share[name]) === 'object') {
+ for (const name of propertyNames) {
+ if (name === 'password') {
+ properties[name] = this.share.newPassword ?? this.share.password
+ continue
+ }
+
+ if (this.share[name] === null || this.share[name] === undefined) {
+ properties[name] = ''
+ } else if ((typeof this.share[name]) === 'object') {
properties[name] = JSON.stringify(this.share[name])
} else {
properties[name] = this.share[name].toString()
}
- })
+ }
- this.updateQueue.add(async () => {
+ return this.updateQueue.add(async () => {
this.saving = true
this.errors = {}
try {
const updatedShare = await this.updateShare(this.share.id, properties)
- if (propertyNames.indexOf('password') >= 0) {
+ if (propertyNames.includes('password')) {
// reset password state after sync
+ this.share.password = this.share.newPassword ?? ''
this.$delete(this.share, 'newPassword')
// updates password expiration time after sync
@@ -293,18 +334,27 @@ export default {
}
// clear any previous errors
- this.$delete(this.errors, propertyNames[0])
- showSuccess(t('files_sharing', 'Share {propertyName} saved', { propertyName: propertyNames[0] }))
- } catch ({ message }) {
+ for (const property of propertyNames) {
+ this.$delete(this.errors, property)
+ }
+ showSuccess(this.updateSuccessMessage(propertyNames))
+ } catch (error) {
+ logger.error('Could not update share', { error, share: this.share, propertyNames })
+
+ const { message } = error
if (message && message !== '') {
- this.onSyncError(propertyNames[0], message)
- showError(t('files_sharing', message))
+ for (const property of propertyNames) {
+ this.onSyncError(property, message)
+ }
+ showError(message)
+ } else {
+ // We do not have information what happened, but we should still inform the user
+ showError(t('files_sharing', 'Could not update share'))
}
} finally {
this.saving = false
}
})
- return
}
// This share does not exists on the server yet
@@ -312,12 +362,45 @@ export default {
},
/**
+ * @param {string[]} names Properties changed
+ */
+ updateSuccessMessage(names) {
+ if (names.length !== 1) {
+ return t('files_sharing', 'Share saved')
+ }
+
+ switch (names[0]) {
+ case 'expireDate':
+ return t('files_sharing', 'Share expiry date saved')
+ case 'hideDownload':
+ return t('files_sharing', 'Share hide-download state saved')
+ case 'label':
+ return t('files_sharing', 'Share label saved')
+ case 'note':
+ return t('files_sharing', 'Share note for recipient saved')
+ case 'password':
+ return t('files_sharing', 'Share password saved')
+ case 'permissions':
+ return t('files_sharing', 'Share permissions saved')
+ default:
+ return t('files_sharing', 'Share saved')
+ }
+ },
+
+ /**
* Manage sync errors
*
* @param {string} property the errored property, e.g. 'password'
* @param {string} message the error message
*/
onSyncError(property, message) {
+ if (property === 'password' && this.share.newPassword) {
+ if (this.share.newPassword === this.share.password) {
+ this.share.password = ''
+ }
+ this.$delete(this.share, 'newPassword')
+ }
+
// re-open menu if closed
this.open = true
switch (property) {
diff --git a/apps/files_sharing/src/models/Share.js b/apps/files_sharing/src/models/Share.ts
index 72e85773855..b0638b29448 100644
--- a/apps/files_sharing/src/models/Share.js
+++ b/apps/files_sharing/src/models/Share.ts
@@ -3,6 +3,10 @@
* 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
@@ -17,6 +21,10 @@ export default class Share {
ocsData = ocsData.ocs.data[0]
}
+ // string to int
+ if (typeof ocsData.id === 'string') {
+ ocsData.id = Number.parseInt(ocsData.id)
+ }
// convert int into boolean
ocsData.hide_download = !!ocsData.hide_download
ocsData.mail_send = !!ocsData.mail_send
@@ -42,8 +50,6 @@ export default class Share {
* state and make the whole class reactive
*
* @return {object} the share raw state
- * @readonly
- * @memberof Sidebar
*/
get state() {
return this._share
@@ -51,104 +57,69 @@ export default class Share {
/**
* get the share id
- *
- * @return {number}
- * @readonly
- * @memberof Share
*/
- get id() {
+ get id(): number {
return this._share.id
}
/**
* Get the share type
- *
- * @return {number}
- * @readonly
- * @memberof Share
*/
- get type() {
+ get type(): ShareType {
return this._share.share_type
}
/**
* Get the share permissions
- * See OC.PERMISSION_* variables
- *
- * @return {number}
- * @readonly
- * @memberof Share
+ * See window.OC.PERMISSION_* variables
*/
- get permissions() {
+ get permissions(): number {
return this._share.permissions
}
/**
* Get the share attributes
- *
- * @return {Array}
- * @readonly
- * @memberof Share
*/
- get attributes() {
- return this._share.attributes
+ get attributes(): Array<ShareAttribute> {
+ return this._share.attributes || []
}
/**
* Set the share permissions
- * See OC.PERMISSION_* variables
- *
- * @param {number} permissions valid permission, See OC.PERMISSION_* variables
- * @memberof Share
+ * See window.OC.PERMISSION_* variables
*/
- set permissions(permissions) {
+ set permissions(permissions: number) {
this._share.permissions = permissions
}
// SHARE OWNER --------------------------------------------------
/**
* Get the share owner uid
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get owner() {
+ get owner(): string {
return this._share.uid_owner
}
/**
* Get the share owner's display name
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get ownerDisplayName() {
+ get ownerDisplayName(): string {
return this._share.displayname_owner
}
// SHARED WITH --------------------------------------------------
/**
* Get the share with entity uid
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get shareWith() {
+ get shareWith(): string {
return this._share.share_with
}
/**
* Get the share with entity display name
* fallback to its uid if none
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get shareWithDisplayName() {
+ get shareWithDisplayName(): string {
return this._share.share_with_displayname
|| this._share.share_with
}
@@ -156,59 +127,39 @@ export default class Share {
/**
* Unique display name in case of multiple
* duplicates results with the same name.
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get shareWithDisplayNameUnique() {
+ get shareWithDisplayNameUnique(): string {
return this._share.share_with_displayname_unique
|| this._share.share_with
}
/**
* Get the share with entity link
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get shareWithLink() {
+ get shareWithLink(): string {
return this._share.share_with_link
}
/**
* Get the share with avatar if any
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get shareWithAvatar() {
+ get shareWithAvatar(): string {
return this._share.share_with_avatar
}
// SHARED FILE OR FOLDER OWNER ----------------------------------
/**
* Get the shared item owner uid
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get uidFileOwner() {
+ get uidFileOwner(): string {
return this._share.uid_file_owner
}
/**
* Get the shared item display name
* fallback to its uid if none
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get displaynameFileOwner() {
+ get displaynameFileOwner(): string {
return this._share.displayname_file_owner
|| this._share.uid_file_owner
}
@@ -216,230 +167,176 @@ export default class Share {
// TIME DATA ----------------------------------------------------
/**
* Get the share creation timestamp
- *
- * @return {number}
- * @readonly
- * @memberof Share
*/
- get createdTime() {
+ get createdTime(): number {
return this._share.stime
}
/**
* Get the expiration date
- *
* @return {string} date with YYYY-MM-DD format
- * @readonly
- * @memberof Share
*/
- get expireDate() {
+ get expireDate(): string {
return this._share.expiration
}
/**
* Set the expiration date
- *
* @param {string} date the share expiration date with YYYY-MM-DD format
- * @memberof Share
*/
- set expireDate(date) {
+ set expireDate(date: string) {
this._share.expiration = date
}
// EXTRA DATA ---------------------------------------------------
/**
* Get the public share token
- *
- * @return {string} the token
- * @readonly
- * @memberof Share
*/
- get token() {
+ get token(): string {
return this._share.token
}
/**
+ * Set the public share token
+ */
+ set token(token: string) {
+ this._share.token = token
+ }
+
+ /**
* Get the share note if any
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get note() {
+ get note(): string {
return this._share.note
}
/**
* Set the share note if any
- *
- * @param {string} note the note
- * @memberof Share
*/
- set note(note) {
+ set note(note: string) {
this._share.note = note
}
/**
* Get the share label if any
* Should only exist on link shares
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get label() {
+ get label(): string {
return this._share.label ?? ''
}
/**
* Set the share label if any
* Should only be set on link shares
- *
- * @param {string} label the label
- * @memberof Share
*/
- set label(label) {
+ set label(label: string) {
this._share.label = label
}
/**
* Have a mail been sent
- *
- * @return {boolean}
- * @readonly
- * @memberof Share
*/
- get mailSend() {
+ get mailSend(): boolean {
return this._share.mail_send === true
}
/**
* Hide the download button on public page
- *
- * @return {boolean}
- * @readonly
- * @memberof Share
*/
- get hideDownload() {
+ get hideDownload(): boolean {
return this._share.hide_download === true
+ || this.attributes.find?.(({ scope, key, value }) => scope === 'permissions' && key === 'download' && !value) !== undefined
}
/**
* Hide the download button on public page
- *
- * @param {boolean} state hide the button ?
- * @memberof Share
*/
- set hideDownload(state) {
+ set hideDownload(state: boolean) {
+ // disabling hide-download also enables the download permission
+ // needed for regression in Nextcloud 31.0.0 until (incl.) 31.0.3
+ if (!state) {
+ const attribute = this.attributes.find(({ key, scope }) => key === 'download' && scope === 'permissions')
+ if (attribute) {
+ attribute.value = true
+ }
+ }
+
this._share.hide_download = state === true
}
/**
* Password protection of the share
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get password() {
+ get password():string {
return this._share.password
}
/**
* Password protection of the share
- *
- * @param {string} password the share password
- * @memberof Share
*/
- set password(password) {
+ set password(password: string) {
this._share.password = password
}
/**
* Password expiration time
- *
- * @return {string}
- * @readonly
- * @memberof Share
+ * @return {string} date with YYYY-MM-DD format
*/
- get passwordExpirationTime() {
+ get passwordExpirationTime(): string {
return this._share.password_expiration_time
}
/**
* Password expiration time
- *
- * @param {string} password expiration time
- * @memberof Share
+ * @param {string} passwordExpirationTime date with YYYY-MM-DD format
*/
- set passwordExpirationTime(passwordExpirationTime) {
+ set passwordExpirationTime(passwordExpirationTime: string) {
this._share.password_expiration_time = passwordExpirationTime
}
/**
* Password protection by Talk of the share
- *
- * @return {boolean}
- * @readonly
- * @memberof Share
*/
- get sendPasswordByTalk() {
+ get sendPasswordByTalk(): boolean {
return this._share.send_password_by_talk
}
/**
* Password protection by Talk of the share
*
- * @param {boolean} sendPasswordByTalk whether to send the password by Talk
- * or not
- * @memberof Share
+ * @param {boolean} sendPasswordByTalk whether to send the password by Talk or not
*/
- set sendPasswordByTalk(sendPasswordByTalk) {
+ set sendPasswordByTalk(sendPasswordByTalk: boolean) {
this._share.send_password_by_talk = sendPasswordByTalk
}
// SHARED ITEM DATA ---------------------------------------------
/**
* Get the shared item absolute full path
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get path() {
+ get path(): string {
return this._share.path
}
/**
* Return the item type: file or folder
- *
- * @return {string} 'folder' or 'file'
- * @readonly
- * @memberof Share
+ * @return {string} 'folder' | 'file'
*/
- get itemType() {
+ get itemType(): string {
return this._share.item_type
}
/**
* Get the shared item mimetype
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get mimetype() {
+ get mimetype(): string {
return this._share.mimetype
}
/**
* Get the shared item id
- *
- * @return {number}
- * @readonly
- * @memberof Share
*/
- get fileSource() {
+ get fileSource(): number {
return this._share.file_source
}
@@ -447,23 +344,15 @@ export default class Share {
* Get the target path on the receiving end
* e.g the file /xxx/aaa will be shared in
* the receiving root as /aaa, the fileTarget is /aaa
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get fileTarget() {
+ get fileTarget(): string {
return this._share.file_target
}
/**
* Get the parent folder id if any
- *
- * @return {number}
- * @readonly
- * @memberof Share
*/
- get fileParent() {
+ get fileParent(): number {
return this._share.file_parent
}
@@ -471,86 +360,65 @@ export default class Share {
/**
* Does this share have READ permissions
- *
- * @return {boolean}
- * @readonly
- * @memberof Share
*/
- get hasReadPermission() {
- return !!((this.permissions & OC.PERMISSION_READ))
+ get hasReadPermission(): boolean {
+ return !!((this.permissions & window.OC.PERMISSION_READ))
}
/**
* Does this share have CREATE permissions
- *
- * @return {boolean}
- * @readonly
- * @memberof Share
*/
- get hasCreatePermission() {
- return !!((this.permissions & OC.PERMISSION_CREATE))
+ get hasCreatePermission(): boolean {
+ return !!((this.permissions & window.OC.PERMISSION_CREATE))
}
/**
* Does this share have DELETE permissions
- *
- * @return {boolean}
- * @readonly
- * @memberof Share
*/
- get hasDeletePermission() {
- return !!((this.permissions & OC.PERMISSION_DELETE))
+ get hasDeletePermission(): boolean {
+ return !!((this.permissions & window.OC.PERMISSION_DELETE))
}
/**
* Does this share have UPDATE permissions
- *
- * @return {boolean}
- * @readonly
- * @memberof Share
*/
- get hasUpdatePermission() {
- return !!((this.permissions & OC.PERMISSION_UPDATE))
+ get hasUpdatePermission(): boolean {
+ return !!((this.permissions & window.OC.PERMISSION_UPDATE))
}
/**
* Does this share have SHARE permissions
- *
- * @return {boolean}
- * @readonly
- * @memberof Share
*/
- get hasSharePermission() {
- return !!((this.permissions & OC.PERMISSION_SHARE))
+ get hasSharePermission(): boolean {
+ return !!((this.permissions & window.OC.PERMISSION_SHARE))
}
/**
* Does this share have download permissions
- *
- * @return {boolean}
- * @readonly
- * @memberof Share
*/
- get hasDownloadPermission() {
- for (const i in this._share.attributes) {
- const attr = this._share.attributes[i]
- if (attr.scope === 'permissions' && attr.key === 'download') {
- return attr.enabled
- }
+ get hasDownloadPermission(): boolean {
+ const hasDisabledDownload = (attribute) => {
+ return attribute.scope === 'permissions' && attribute.key === 'download' && attribute.value === false
}
+ return this.attributes.some(hasDisabledDownload)
+ }
- return true
+ /**
+ * Is this mail share a file request ?
+ */
+ get isFileRequest(): boolean {
+ return isFileRequest(JSON.stringify(this.attributes))
}
set hasDownloadPermission(enabled) {
this.setAttribute('permissions', 'download', !!enabled)
}
- setAttribute(scope, key, enabled) {
+ setAttribute(scope, key, value) {
const attrUpdate = {
scope,
key,
- enabled,
+ value,
}
// try and replace existing
@@ -570,45 +438,29 @@ export default class Share {
// ! meaning the permissions for the recipient
/**
* Can the current user EDIT this share ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Share
*/
- get canEdit() {
+ get canEdit(): boolean {
return this._share.can_edit === true
}
/**
* Can the current user DELETE this share ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Share
*/
- get canDelete() {
+ get canDelete(): boolean {
return this._share.can_delete === true
}
/**
* Top level accessible shared folder fileid for the current user
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get viaFileid() {
+ get viaFileid(): string {
return this._share.via_fileid
}
/**
* Top level accessible shared folder path for the current user
- *
- * @return {string}
- * @readonly
- * @memberof Share
*/
- get viaPath() {
+ get viaPath(): string {
return this._share.via_path
}
@@ -618,15 +470,15 @@ export default class Share {
return this._share.parent
}
- get storageId() {
+ get storageId(): string {
return this._share.storage_id
}
- get storage() {
+ get storage(): number {
return this._share.storage
}
- get itemSource() {
+ get itemSource(): number {
return this._share.item_source
}
@@ -634,4 +486,11 @@ export default class Share {
return this._share.status
}
+ /**
+ * Is the share from a trusted server
+ */
+ get isTrustedServer(): boolean {
+ return !!this._share.is_trusted_server
+ }
+
}
diff --git a/apps/files_sharing/src/personal-settings.js b/apps/files_sharing/src/personal-settings.js
index 3f0161b3ce0..e3184f0041e 100644
--- a/apps/files_sharing/src/personal-settings.js
+++ b/apps/files_sharing/src/personal-settings.js
@@ -3,13 +3,13 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
+import { getCSPNonce } from '@nextcloud/auth'
import Vue from 'vue'
-import { getRequestToken } from '@nextcloud/auth'
import PersonalSettings from './components/PersonalSettings.vue'
// eslint-disable-next-line camelcase
-__webpack_nonce__ = btoa(getRequestToken())
+__webpack_nonce__ = getCSPNonce()
Vue.prototype.t = t
diff --git a/apps/files_sharing/src/public-nickname-handler.ts b/apps/files_sharing/src/public-nickname-handler.ts
new file mode 100644
index 00000000000..02bdc641aaf
--- /dev/null
+++ b/apps/files_sharing/src/public-nickname-handler.ts
@@ -0,0 +1,86 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { getBuilder } from '@nextcloud/browser-storage'
+import { getGuestNickname, type NextcloudUser } from '@nextcloud/auth'
+import { getUploader } from '@nextcloud/upload'
+import { loadState } from '@nextcloud/initial-state'
+import { showGuestUserPrompt } from '@nextcloud/dialogs'
+import { t } from '@nextcloud/l10n'
+
+import logger from './services/logger'
+import { subscribe } from '@nextcloud/event-bus'
+
+const storage = getBuilder('files_sharing').build()
+
+// Setup file-request nickname header for the uploader
+const registerFileRequestHeader = (nickname: string) => {
+ const uploader = getUploader()
+ uploader.setCustomHeader('X-NC-Nickname', encodeURIComponent(nickname))
+ logger.debug('Nickname header registered for uploader', { headers: uploader.customHeaders })
+}
+
+// Callback when a nickname was chosen
+const onUserInfoChanged = (guest: NextcloudUser) => {
+ logger.debug('User info changed', { guest })
+ registerFileRequestHeader(guest.displayName ?? '')
+}
+
+// Monitor nickname changes
+subscribe('user:info:changed', onUserInfoChanged)
+
+window.addEventListener('DOMContentLoaded', () => {
+ const nickname = getGuestNickname() ?? ''
+ const dialogShown = storage.getItem('public-auth-prompt-shown') !== null
+
+ // Check if a nickname is mandatory
+ const isFileRequest = loadState('files_sharing', 'isFileRequest', false)
+
+ const owner = loadState('files_sharing', 'owner', '')
+ const ownerDisplayName = loadState('files_sharing', 'ownerDisplayName', '')
+ const label = loadState('files_sharing', 'label', '')
+ const filename = loadState('files_sharing', 'filename', '')
+
+ // If the owner provided a custom label, use it instead of the filename
+ const folder = label || filename
+
+ const options = {
+ nickname,
+ notice: t('files_sharing', 'To upload files to {folder}, you need to provide your name first.', { folder }),
+ subtitle: undefined as string | undefined,
+ title: t('files_sharing', 'Upload files to {folder}', { folder }),
+ }
+
+ // If the guest already has a nickname, we just make them double check
+ if (nickname) {
+ options.notice = t('files_sharing', 'Please confirm your name to upload files to {folder}', { folder })
+ }
+
+ // If the account owner set their name as public,
+ // we show it in the subtitle
+ if (owner) {
+ options.subtitle = t('files_sharing', '{ownerDisplayName} shared a folder with you.', { ownerDisplayName })
+ }
+
+ // If this is a file request, then we need a nickname
+ if (isFileRequest) {
+ // If we don't have a nickname or the public auth prompt hasn't been shown yet, show it
+ // We still show the prompt if the user has a nickname to double check
+ if (!nickname || !dialogShown) {
+ logger.debug('Showing public auth prompt.', { nickname })
+ showGuestUserPrompt(options)
+ }
+ return
+ }
+
+ if (!dialogShown && !nickname) {
+ logger.debug('Public auth prompt not shown yet but nickname is not mandatory.', { nickname })
+ return
+ }
+
+ // Else, we just register the nickname header if any.
+ logger.debug('Public auth prompt already shown.', { nickname })
+ registerFileRequestHeader(nickname)
+})
diff --git a/apps/files_sharing/src/router/index.ts b/apps/files_sharing/src/router/index.ts
new file mode 100644
index 00000000000..fa613dd364f
--- /dev/null
+++ b/apps/files_sharing/src/router/index.ts
@@ -0,0 +1,76 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { RawLocation, Route } from 'vue-router'
+
+import { loadState } from '@nextcloud/initial-state'
+import { generateUrl } from '@nextcloud/router'
+import queryString from 'query-string'
+import Router, { isNavigationFailure, NavigationFailureType } from 'vue-router'
+import Vue from 'vue'
+import logger from '../services/logger'
+
+const view = loadState<string>('files_sharing', 'view')
+const sharingToken = loadState<string>('files_sharing', 'sharingToken')
+
+Vue.use(Router)
+
+// Prevent router from throwing errors when we're already on the page we're trying to go to
+const originalPush = Router.prototype.push
+Router.prototype.push = (function(this: Router, ...args: Parameters<typeof originalPush>) {
+ if (args.length > 1) {
+ return originalPush.call(this, ...args)
+ }
+ return originalPush.call<Router, [RawLocation], Promise<Route>>(this, args[0]).catch(ignoreDuplicateNavigation)
+}) as typeof originalPush
+
+const originalReplace = Router.prototype.replace
+Router.prototype.replace = (function(this: Router, ...args: Parameters<typeof originalReplace>) {
+ if (args.length > 1) {
+ return originalReplace.call(this, ...args)
+ }
+ return originalReplace.call<Router, [RawLocation], Promise<Route>>(this, args[0]).catch(ignoreDuplicateNavigation)
+}) as typeof originalReplace
+
+/**
+ * Ignore duplicated-navigation error but forward real exceptions
+ * @param error The thrown error
+ */
+function ignoreDuplicateNavigation(error: unknown): void {
+ if (isNavigationFailure(error, NavigationFailureType.duplicated)) {
+ logger.debug('Ignoring duplicated navigation from vue-router', { error })
+ } else {
+ throw error
+ }
+}
+
+const router = new Router({
+ mode: 'history',
+
+ // if index.php is in the url AND we got this far, then it's working:
+ // let's keep using index.php in the url
+ base: generateUrl('/s'),
+ linkActiveClass: 'active',
+
+ routes: [
+ {
+ path: '/',
+ // Pretending we're using the default view
+ redirect: { name: 'filelist', params: { view, token: sharingToken } },
+ },
+ {
+ path: '/:token',
+ name: 'filelist',
+ props: true,
+ },
+ ],
+
+ // Custom stringifyQuery to prevent encoding of slashes in the url
+ stringifyQuery(query) {
+ const result = queryString.stringify(query).replace(/%2F/gmi, '/')
+ return result ? ('?' + result) : ''
+ },
+})
+
+export default router
diff --git a/apps/files_sharing/src/services/ConfigService.js b/apps/files_sharing/src/services/ConfigService.js
deleted file mode 100644
index 3d9e949724e..00000000000
--- a/apps/files_sharing/src/services/ConfigService.js
+++ /dev/null
@@ -1,322 +0,0 @@
-/**
- * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-import { getCapabilities } from '@nextcloud/capabilities'
-
-export default class Config {
-
- constructor() {
- this._capabilities = getCapabilities()
- }
-
- /**
- * Get default share permissions, if any
- *
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get defaultPermissions() {
- return this._capabilities.files_sharing?.default_permissions
- }
-
- /**
- * Is public upload allowed on link shares ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get isPublicUploadEnabled() {
- return this._capabilities.files_sharing?.public.upload
- }
-
- /**
- * Are link share allowed ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get isShareWithLinkAllowed() {
- return document.getElementById('allowShareWithLink')
- && document.getElementById('allowShareWithLink').value === 'yes'
- }
-
- /**
- * Get the federated sharing documentation link
- *
- * @return {string}
- * @readonly
- * @memberof Config
- */
- get federatedShareDocLink() {
- return OC.appConfig.core.federatedCloudShareDoc
- }
-
- /**
- * Get the default link share expiration date
- *
- * @return {Date|null}
- * @readonly
- * @memberof Config
- */
- get defaultExpirationDate() {
- if (this.isDefaultExpireDateEnabled) {
- return new Date(new Date().setDate(new Date().getDate() + this.defaultExpireDate))
- }
- return null
- }
-
- /**
- * Get the default internal expiration date
- *
- * @return {Date|null}
- * @readonly
- * @memberof Config
- */
- get defaultInternalExpirationDate() {
- if (this.isDefaultInternalExpireDateEnabled) {
- return new Date(new Date().setDate(new Date().getDate() + this.defaultInternalExpireDate))
- }
- return null
- }
-
- /**
- * Get the default remote expiration date
- *
- * @return {Date|null}
- * @readonly
- * @memberof Config
- */
- get defaultRemoteExpirationDateString() {
- if (this.isDefaultRemoteExpireDateEnabled) {
- return new Date(new Date().setDate(new Date().getDate() + this.defaultRemoteExpireDate))
- }
- return null
- }
-
- /**
- * Are link shares password-enforced ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get enforcePasswordForPublicLink() {
- return OC.appConfig.core.enforcePasswordForPublicLink === true
- }
-
- /**
- * Is password asked by default on link shares ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get enableLinkPasswordByDefault() {
- return OC.appConfig.core.enableLinkPasswordByDefault === true
- }
-
- /**
- * Is link shares expiration enforced ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get isDefaultExpireDateEnforced() {
- return OC.appConfig.core.defaultExpireDateEnforced === true
- }
-
- /**
- * Is there a default expiration date for new link shares ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get isDefaultExpireDateEnabled() {
- return OC.appConfig.core.defaultExpireDateEnabled === true
- }
-
- /**
- * Is internal shares expiration enforced ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get isDefaultInternalExpireDateEnforced() {
- return OC.appConfig.core.defaultInternalExpireDateEnforced === true
- }
-
- /**
- * Is remote shares expiration enforced ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get isDefaultRemoteExpireDateEnforced() {
- return OC.appConfig.core.defaultRemoteExpireDateEnforced === true
- }
-
- /**
- * Is there a default expiration date for new internal shares ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get isDefaultInternalExpireDateEnabled() {
- return OC.appConfig.core.defaultInternalExpireDateEnabled === true
- }
-
- /**
- * Is there a default expiration date for new remote shares ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get isDefaultRemoteExpireDateEnabled() {
- return OC.appConfig.core.defaultRemoteExpireDateEnabled === true
- }
-
- /**
- * Are users on this server allowed to send shares to other servers ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get isRemoteShareAllowed() {
- return OC.appConfig.core.remoteShareAllowed === true
- }
-
- /**
- * Is sharing my mail (link share) enabled ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get isMailShareAllowed() {
- // eslint-disable-next-line camelcase
- return this._capabilities?.files_sharing?.sharebymail !== undefined
- // eslint-disable-next-line camelcase
- && this._capabilities?.files_sharing?.public?.enabled === true
- }
-
- /**
- * Get the default days to link shares expiration
- *
- * @return {number}
- * @readonly
- * @memberof Config
- */
- get defaultExpireDate() {
- return OC.appConfig.core.defaultExpireDate
- }
-
- /**
- * Get the default days to internal shares expiration
- *
- * @return {number}
- * @readonly
- * @memberof Config
- */
- get defaultInternalExpireDate() {
- return OC.appConfig.core.defaultInternalExpireDate
- }
-
- /**
- * Get the default days to remote shares expiration
- *
- * @return {number}
- * @readonly
- * @memberof Config
- */
- get defaultRemoteExpireDate() {
- return OC.appConfig.core.defaultRemoteExpireDate
- }
-
- /**
- * Is resharing allowed ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get isResharingAllowed() {
- return OC.appConfig.core.resharingAllowed === true
- }
-
- /**
- * Is password enforced for mail shares ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get isPasswordForMailSharesRequired() {
- return (this._capabilities.files_sharing.sharebymail === undefined) ? false : this._capabilities.files_sharing.sharebymail.password.enforced
- }
-
- /**
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get shouldAlwaysShowUnique() {
- return (this._capabilities.files_sharing?.sharee?.always_show_unique === true)
- }
-
- /**
- * Is sharing with groups allowed ?
- *
- * @return {boolean}
- * @readonly
- * @memberof Config
- */
- get allowGroupSharing() {
- return OC.appConfig.core.allowGroupSharing === true
- }
-
- /**
- * Get the maximum results of a share search
- *
- * @return {number}
- * @readonly
- * @memberof Config
- */
- get maxAutocompleteResults() {
- return parseInt(OC.config['sharing.maxAutocompleteResults'], 10) || 25
- }
-
- /**
- * Get the minimal string length
- * to initiate a share search
- *
- * @return {number}
- * @readonly
- * @memberof Config
- */
- get minSearchStringLength() {
- return parseInt(OC.config['sharing.minSearchStringLength'], 10) || 0
- }
-
- /**
- * Get the password policy config
- *
- * @return {object}
- * @readonly
- * @memberof Config
- */
- get passwordPolicy() {
- return this._capabilities.password_policy ? this._capabilities.password_policy : {}
- }
-
-}
diff --git a/apps/files_sharing/src/services/ConfigService.ts b/apps/files_sharing/src/services/ConfigService.ts
new file mode 100644
index 00000000000..547038f362d
--- /dev/null
+++ b/apps/files_sharing/src/services/ConfigService.ts
@@ -0,0 +1,333 @@
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { getCapabilities } from '@nextcloud/capabilities'
+import { loadState } from '@nextcloud/initial-state'
+
+type PasswordPolicyCapabilities = {
+ enforceNonCommonPassword: boolean
+ enforceNumericCharacters: boolean
+ enforceSpecialCharacters: boolean
+ enforceUpperLowerCase: boolean
+ minLength: number
+}
+
+type FileSharingCapabilities = {
+ api_enabled: boolean,
+ public: {
+ enabled: boolean,
+ password: {
+ enforced: boolean,
+ askForOptionalPassword: boolean
+ },
+ expire_date: {
+ enabled: boolean,
+ days: number,
+ enforced: boolean
+ },
+ multiple_links: boolean,
+ expire_date_internal: {
+ enabled: boolean
+ },
+ expire_date_remote: {
+ enabled: boolean
+ },
+ send_mail: boolean,
+ upload: boolean,
+ upload_files_drop: boolean,
+ custom_tokens: boolean,
+ },
+ resharing: boolean,
+ user: {
+ send_mail: boolean,
+ expire_date: {
+ enabled: boolean
+ }
+ },
+ group_sharing: boolean,
+ group: {
+ enabled: boolean,
+ expire_date: {
+ enabled: true
+ }
+ },
+ default_permissions: number,
+ federation: {
+ outgoing: boolean,
+ incoming: boolean,
+ expire_date: {
+ enabled: boolean
+ },
+ expire_date_supported: {
+ enabled: boolean
+ }
+ },
+ sharee: {
+ query_lookup_default: boolean,
+ always_show_unique: boolean
+ },
+ sharebymail: {
+ enabled: boolean,
+ send_password_by_mail: boolean,
+ upload_files_drop: {
+ enabled: boolean
+ },
+ password: {
+ enabled: boolean,
+ enforced: boolean
+ },
+ expire_date: {
+ enabled: boolean,
+ enforced: boolean
+ }
+ }
+}
+
+type Capabilities = {
+ files_sharing: FileSharingCapabilities
+ password_policy: PasswordPolicyCapabilities
+}
+
+export default class Config {
+
+ _capabilities: Capabilities
+
+ constructor() {
+ this._capabilities = getCapabilities() as Capabilities
+ }
+
+ /**
+ * Get default share permissions, if any
+ */
+ get defaultPermissions(): number {
+ return this._capabilities.files_sharing?.default_permissions
+ }
+
+ /**
+ * Is public upload allowed on link shares ?
+ * This covers File request and Full upload/edit option.
+ */
+ get isPublicUploadEnabled(): boolean {
+ return this._capabilities.files_sharing?.public?.upload === true
+ }
+
+ /**
+ * Get the federated sharing documentation link
+ */
+ get federatedShareDocLink() {
+ return window.OC.appConfig.core.federatedCloudShareDoc
+ }
+
+ /**
+ * Get the default link share expiration date
+ */
+ get defaultExpirationDate(): Date|null {
+ if (this.isDefaultExpireDateEnabled && this.defaultExpireDate !== null) {
+ return new Date(new Date().setDate(new Date().getDate() + this.defaultExpireDate))
+ }
+ return null
+ }
+
+ /**
+ * Get the default internal expiration date
+ */
+ get defaultInternalExpirationDate(): Date|null {
+ if (this.isDefaultInternalExpireDateEnabled && this.defaultInternalExpireDate !== null) {
+ return new Date(new Date().setDate(new Date().getDate() + this.defaultInternalExpireDate))
+ }
+ return null
+ }
+
+ /**
+ * Get the default remote expiration date
+ */
+ get defaultRemoteExpirationDateString(): Date|null {
+ if (this.isDefaultRemoteExpireDateEnabled && this.defaultRemoteExpireDate !== null) {
+ return new Date(new Date().setDate(new Date().getDate() + this.defaultRemoteExpireDate))
+ }
+ return null
+ }
+
+ /**
+ * Are link shares password-enforced ?
+ */
+ get enforcePasswordForPublicLink(): boolean {
+ return window.OC.appConfig.core.enforcePasswordForPublicLink === true
+ }
+
+ /**
+ * Is password asked by default on link shares ?
+ */
+ get enableLinkPasswordByDefault(): boolean {
+ return window.OC.appConfig.core.enableLinkPasswordByDefault === true
+ }
+
+ /**
+ * Is link shares expiration enforced ?
+ */
+ get isDefaultExpireDateEnforced(): boolean {
+ return window.OC.appConfig.core.defaultExpireDateEnforced === true
+ }
+
+ /**
+ * Is there a default expiration date for new link shares ?
+ */
+ get isDefaultExpireDateEnabled(): boolean {
+ return window.OC.appConfig.core.defaultExpireDateEnabled === true
+ }
+
+ /**
+ * Is internal shares expiration enforced ?
+ */
+ get isDefaultInternalExpireDateEnforced(): boolean {
+ return window.OC.appConfig.core.defaultInternalExpireDateEnforced === true
+ }
+
+ /**
+ * Is there a default expiration date for new internal shares ?
+ */
+ get isDefaultInternalExpireDateEnabled(): boolean {
+ return window.OC.appConfig.core.defaultInternalExpireDateEnabled === true
+ }
+
+ /**
+ * Is remote shares expiration enforced ?
+ */
+ get isDefaultRemoteExpireDateEnforced(): boolean {
+ return window.OC.appConfig.core.defaultRemoteExpireDateEnforced === true
+ }
+
+ /**
+ * Is there a default expiration date for new remote shares ?
+ */
+ get isDefaultRemoteExpireDateEnabled(): boolean {
+ return window.OC.appConfig.core.defaultRemoteExpireDateEnabled === true
+ }
+
+ /**
+ * Are users on this server allowed to send shares to other servers ?
+ */
+ get isRemoteShareAllowed(): boolean {
+ return window.OC.appConfig.core.remoteShareAllowed === true
+ }
+
+ /**
+ * Is federation enabled ?
+ */
+ get isFederationEnabled(): boolean {
+ return this._capabilities?.files_sharing?.federation?.outgoing === true
+ }
+
+ /**
+ * Is public sharing enabled ?
+ */
+ get isPublicShareAllowed(): boolean {
+ return this._capabilities?.files_sharing?.public?.enabled === true
+ }
+
+ /**
+ * Is sharing my mail (link share) enabled ?
+ */
+ get isMailShareAllowed(): boolean {
+ // eslint-disable-next-line camelcase
+ return this._capabilities?.files_sharing?.sharebymail?.enabled === true
+ // eslint-disable-next-line camelcase
+ && this.isPublicShareAllowed === true
+ }
+
+ /**
+ * Get the default days to link shares expiration
+ */
+ get defaultExpireDate(): number|null {
+ return window.OC.appConfig.core.defaultExpireDate
+ }
+
+ /**
+ * Get the default days to internal shares expiration
+ */
+ get defaultInternalExpireDate(): number|null {
+ return window.OC.appConfig.core.defaultInternalExpireDate
+ }
+
+ /**
+ * Get the default days to remote shares expiration
+ */
+ get defaultRemoteExpireDate(): number|null {
+ return window.OC.appConfig.core.defaultRemoteExpireDate
+ }
+
+ /**
+ * Is resharing allowed ?
+ */
+ get isResharingAllowed(): boolean {
+ return window.OC.appConfig.core.resharingAllowed === true
+ }
+
+ /**
+ * Is password enforced for mail shares ?
+ */
+ get isPasswordForMailSharesRequired(): boolean {
+ return this._capabilities.files_sharing?.sharebymail?.password?.enforced === true
+ }
+
+ /**
+ * Always show the email or userid unique sharee label if enabled by the admin
+ */
+ get shouldAlwaysShowUnique(): boolean {
+ return this._capabilities.files_sharing?.sharee?.always_show_unique === true
+ }
+
+ /**
+ * Is sharing with groups allowed ?
+ */
+ get allowGroupSharing(): boolean {
+ return window.OC.appConfig.core.allowGroupSharing === true
+ }
+
+ /**
+ * Get the maximum results of a share search
+ */
+ get maxAutocompleteResults(): number {
+ return parseInt(window.OC.config['sharing.maxAutocompleteResults'], 10) || 25
+ }
+
+ /**
+ * Get the minimal string length
+ * to initiate a share search
+ */
+ get minSearchStringLength(): number {
+ return parseInt(window.OC.config['sharing.minSearchStringLength'], 10) || 0
+ }
+
+ /**
+ * Get the password policy configuration
+ */
+ get passwordPolicy(): PasswordPolicyCapabilities {
+ return this._capabilities?.password_policy || {}
+ }
+
+ /**
+ * Returns true if custom tokens are allowed
+ */
+ get allowCustomTokens(): boolean {
+ return this._capabilities?.files_sharing?.public?.custom_tokens
+ }
+
+ /**
+ * Show federated shares as internal shares
+ * @return {boolean}
+ */
+ get showFederatedSharesAsInternal(): boolean {
+ return loadState('files_sharing', 'showFederatedSharesAsInternal', false)
+ }
+
+ /**
+ * Show federated shares to trusted servers as internal shares
+ * @return {boolean}
+ */
+ get showFederatedSharesToTrustedServersAsInternal(): boolean {
+ return loadState('files_sharing', 'showFederatedSharesToTrustedServersAsInternal', false)
+ }
+
+}
diff --git a/apps/files_sharing/src/services/ExternalShareActions.js b/apps/files_sharing/src/services/ExternalShareActions.js
index ae1f52e30b4..6ffd7014fe2 100644
--- a/apps/files_sharing/src/services/ExternalShareActions.js
+++ b/apps/files_sharing/src/services/ExternalShareActions.js
@@ -48,7 +48,7 @@ export default class ExternalShareActions {
if (typeof action !== 'object'
|| typeof action.id !== 'string'
|| typeof action.data !== 'function' // () => {disabled: true}
- || !Array.isArray(action.shareType) // [\@nextcloud/sharing.Types.SHARE_TYPE_LINK, ...]
+ || !Array.isArray(action.shareType) // [\@nextcloud/sharing.Types.Link, ...]
|| typeof action.handlers !== 'object' // {click: () => {}, ...}
|| !Object.values(action.handlers).every(handler => typeof handler === 'function')) {
console.error('Invalid action provided', action)
diff --git a/apps/files_sharing/src/services/GuestNameValidity.ts b/apps/files_sharing/src/services/GuestNameValidity.ts
new file mode 100644
index 00000000000..0557c5253ca
--- /dev/null
+++ b/apps/files_sharing/src/services/GuestNameValidity.ts
@@ -0,0 +1,45 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { InvalidFilenameError, InvalidFilenameErrorReason, validateFilename } from '@nextcloud/files'
+import { t } from '@nextcloud/l10n'
+
+/**
+ * Get the validity of a filename (empty if valid).
+ * This can be used for `setCustomValidity` on input elements
+ * @param name The filename
+ * @param escape Escape the matched string in the error (only set when used in HTML)
+ */
+export function getGuestNameValidity(name: string, escape = false): string {
+ if (name.trim() === '') {
+ return t('files', 'Names must not be empty.')
+ }
+
+ if (name.startsWith('.')) {
+ return t('files', 'Names must not start with a dot.')
+ }
+
+ try {
+ validateFilename(name)
+ return ''
+ } catch (error) {
+ if (!(error instanceof InvalidFilenameError)) {
+ throw error
+ }
+
+ switch (error.reason) {
+ case InvalidFilenameErrorReason.Character:
+ return t('files', '"{char}" is not allowed inside a name.', { char: error.segment }, undefined, { escape })
+ case InvalidFilenameErrorReason.ReservedName:
+ return t('files', '"{segment}" is a reserved name and not allowed.', { segment: error.segment }, undefined, { escape: false })
+ case InvalidFilenameErrorReason.Extension:
+ if (error.segment.match(/\.[a-z]/i)) {
+ return t('files', '"{extension}" is not an allowed name.', { extension: error.segment }, undefined, { escape: false })
+ }
+ return t('files', 'Names must not end with "{extension}".', { extension: error.segment }, undefined, { escape: false })
+ default:
+ return t('files', 'Invalid name.')
+ }
+ }
+}
diff --git a/apps/files_sharing/src/services/SharingService.spec.ts b/apps/files_sharing/src/services/SharingService.spec.ts
index d756c4755d7..936c1afafc4 100644
--- a/apps/files_sharing/src/services/SharingService.spec.ts
+++ b/apps/files_sharing/src/services/SharingService.spec.ts
@@ -3,28 +3,33 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { OCSResponse } from '@nextcloud/typings/ocs'
-import { expect } from '@jest/globals'
-import { Type } from '@nextcloud/sharing'
-import * as auth from '@nextcloud/auth'
-import axios from '@nextcloud/axios'
-import { getContents } from './SharingService'
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'
-global.window.OC = {
- TAG_FAVORITE: '_$!<Favorite>!$_',
-}
+const TAG_FAVORITE = '_$!<Favorite>!$_'
+
+const axios = vi.hoisted(() => ({ get: vi.fn() }))
+vi.mock('@nextcloud/auth')
+vi.mock('@nextcloud/axios', () => ({ default: axios }))
-// Mock webroot variable
+// Mock TAG
beforeAll(() => {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (window as any)._oc_webroot = ''
+ window.OC = {
+ ...window.OC,
+ TAG_FAVORITE,
+ }
})
describe('SharingService methods definitions', () => {
- beforeAll(() => {
- jest.spyOn(axios, 'get').mockImplementation(async (): Promise<any> => {
+ beforeEach(() => {
+ vi.resetAllMocks()
+ axios.get.mockImplementation(async (): Promise<unknown> => {
return {
data: {
ocs: {
@@ -35,20 +40,16 @@ describe('SharingService methods definitions', () => {
},
data: [],
},
- } as OCSResponse<any>,
+ } as OCSResponse,
}
})
})
- afterAll(() => {
- jest.restoreAllMocks()
- })
-
test('Shared with you', async () => {
await getContents(true, false, false, false, [])
expect(axios.get).toHaveBeenCalledTimes(2)
- expect(axios.get).toHaveBeenNthCalledWith(1, 'http://localhost/ocs/v2.php/apps/files_sharing/api/v1/shares', {
+ expect(axios.get).toHaveBeenNthCalledWith(1, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares', {
headers: {
'Content-Type': 'application/json',
},
@@ -57,7 +58,7 @@ describe('SharingService methods definitions', () => {
include_tags: true,
},
})
- expect(axios.get).toHaveBeenNthCalledWith(2, 'http://localhost/ocs/v2.php/apps/files_sharing/api/v1/remote_shares', {
+ expect(axios.get).toHaveBeenNthCalledWith(2, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/remote_shares', {
headers: {
'Content-Type': 'application/json',
},
@@ -71,7 +72,7 @@ describe('SharingService methods definitions', () => {
await getContents(false, true, false, false, [])
expect(axios.get).toHaveBeenCalledTimes(1)
- expect(axios.get).toHaveBeenCalledWith('http://localhost/ocs/v2.php/apps/files_sharing/api/v1/shares', {
+ expect(axios.get).toHaveBeenCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares', {
headers: {
'Content-Type': 'application/json',
},
@@ -86,7 +87,7 @@ describe('SharingService methods definitions', () => {
await getContents(false, false, true, false, [])
expect(axios.get).toHaveBeenCalledTimes(2)
- expect(axios.get).toHaveBeenNthCalledWith(1, 'http://localhost/ocs/v2.php/apps/files_sharing/api/v1/shares/pending', {
+ expect(axios.get).toHaveBeenNthCalledWith(1, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/pending', {
headers: {
'Content-Type': 'application/json',
},
@@ -94,7 +95,7 @@ describe('SharingService methods definitions', () => {
include_tags: true,
},
})
- expect(axios.get).toHaveBeenNthCalledWith(2, 'http://localhost/ocs/v2.php/apps/files_sharing/api/v1/remote_shares/pending', {
+ expect(axios.get).toHaveBeenNthCalledWith(2, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/remote_shares/pending', {
headers: {
'Content-Type': 'application/json',
},
@@ -108,7 +109,7 @@ describe('SharingService methods definitions', () => {
await getContents(false, true, false, false, [])
expect(axios.get).toHaveBeenCalledTimes(1)
- expect(axios.get).toHaveBeenCalledWith('http://localhost/ocs/v2.php/apps/files_sharing/api/v1/shares', {
+ expect(axios.get).toHaveBeenCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares', {
headers: {
'Content-Type': 'application/json',
},
@@ -120,7 +121,7 @@ describe('SharingService methods definitions', () => {
})
test('Unknown owner', async () => {
- jest.spyOn(auth, 'getCurrentUser').mockReturnValue(null)
+ vi.spyOn(auth, 'getCurrentUser').mockReturnValue(null)
const results = await getContents(false, true, false, false, [])
expect(results.folder.owner).toEqual(null)
@@ -128,8 +129,9 @@ describe('SharingService methods definitions', () => {
})
describe('SharingService filtering', () => {
- beforeAll(() => {
- jest.spyOn(axios, 'get').mockImplementation(async (): Promise<any> => {
+ beforeEach(() => {
+ vi.resetAllMocks()
+ axios.get.mockImplementation(async (): Promise<unknown> => {
return {
data: {
ocs: {
@@ -141,7 +143,7 @@ describe('SharingService filtering', () => {
data: [
{
id: '62',
- share_type: Type.SHARE_TYPE_USER,
+ share_type: ShareType.User,
uid_owner: 'test',
displayname_owner: 'test',
permissions: 31,
@@ -167,12 +169,8 @@ describe('SharingService filtering', () => {
})
})
- afterAll(() => {
- jest.restoreAllMocks()
- })
-
test('Shared with others filtering', async () => {
- const shares = await getContents(false, true, false, false, [Type.SHARE_TYPE_USER])
+ const shares = await getContents(false, true, false, false, [ShareType.User])
expect(axios.get).toHaveBeenCalledTimes(1)
expect(shares.contents).toHaveLength(1)
@@ -181,7 +179,7 @@ describe('SharingService filtering', () => {
})
test('Shared with others filtering empty', async () => {
- const shares = await getContents(false, true, false, false, [Type.SHARE_TYPE_LINK])
+ const shares = await getContents(false, true, false, false, [ShareType.Link])
expect(axios.get).toHaveBeenCalledTimes(1)
expect(shares.contents).toHaveLength(0)
@@ -274,11 +272,65 @@ describe('SharingService share to Node mapping', () => {
mail_send: 0,
hide_download: 0,
attributes: null,
- tags: [window.OC.TAG_FAVORITE],
+ 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 () => {
- jest.spyOn(axios, 'get').mockReturnValueOnce(Promise.resolve({
+ axios.get.mockReturnValueOnce(Promise.resolve({
data: {
ocs: {
data: [shareFile],
@@ -294,7 +346,7 @@ describe('SharingService share to Node mapping', () => {
const file = shares.contents[0] as File
expect(file).toBeInstanceOf(File)
expect(file.fileid).toBe(530936)
- expect(file.source).toBe('http://localhost/remote.php/dav/files/test/document.md')
+ 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)
@@ -303,11 +355,18 @@ describe('SharingService share to Node mapping', () => {
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 () => {
- jest.spyOn(axios, 'get').mockReturnValueOnce(Promise.resolve({
+ axios.get.mockReturnValueOnce(Promise.resolve({
data: {
ocs: {
data: [shareFolder],
@@ -323,7 +382,7 @@ describe('SharingService share to Node mapping', () => {
const folder = shares.contents[0] as Folder
expect(folder).toBeInstanceOf(Folder)
expect(folder.fileid).toBe(531080)
- expect(folder.source).toBe('http://localhost/remote.php/dav/files/test/Folder')
+ 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)
@@ -336,9 +395,98 @@ describe('SharingService share to Node mapping', () => {
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 () => {
- jest.spyOn(logger, 'error').mockImplementationOnce(() => {})
- jest.spyOn(axios, 'get').mockReturnValueOnce(Promise.resolve({
+ vi.spyOn(logger, 'error').mockImplementationOnce(() => {})
+ axios.get.mockReturnValueOnce(Promise.resolve({
data: {
ocs: {
data: [],
@@ -352,8 +500,8 @@ describe('SharingService share to Node mapping', () => {
})
test('Error', async () => {
- jest.spyOn(logger, 'error').mockImplementationOnce(() => {})
- jest.spyOn(axios, 'get').mockReturnValueOnce(Promise.resolve({
+ vi.spyOn(logger, 'error').mockImplementationOnce(() => {})
+ axios.get.mockReturnValueOnce(Promise.resolve({
data: {
ocs: {
data: [null],
diff --git a/apps/files_sharing/src/services/SharingService.ts b/apps/files_sharing/src/services/SharingService.ts
index ad76879257f..41c20f9aa73 100644
--- a/apps/files_sharing/src/services/SharingService.ts
+++ b/apps/files_sharing/src/services/SharingService.ts
@@ -2,19 +2,21 @@
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-/* eslint-disable camelcase, n/no-extraneous-import */
-import type { AxiosPromise } from 'axios'
+// 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 { Folder, File, type ContentsWithRoot, Permission } from '@nextcloud/files'
-import { generateOcsUrl, generateRemoteUrl } from '@nextcloud/router'
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'
-export const rootPath = `/files/${getCurrentUser()?.uid}`
-
const headers = {
'Content-Type': 'application/json',
}
@@ -23,14 +25,27 @@ const ocsEntryToNode = async function(ocsEntry: any): Promise<Folder | File | nu
try {
// Federated share handling
if (ocsEntry?.remote_id !== undefined) {
- 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.mimetype ? 'file' : 'folder'
-
- // Need to set permissions to NONE for federated shares
- ocsEntry.item_permissions = Permission.NONE
- ocsEntry.permissions = Permission.NONE
+ 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
@@ -43,18 +58,30 @@ const ocsEntryToNode = async function(ocsEntry: any): Promise<Folder | File | nu
// 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
- const fileid = ocsEntry.file_source || ocsEntry.id
+ // 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 = generateRemoteUrl(`dav/${rootPath}/${path}`.replaceAll(/\/\//gm, '/'))
+ 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
- let mtime = ocsEntry?.item_mtime ? new Date((ocsEntry.item_mtime) * 1000) : undefined
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,
@@ -63,15 +90,18 @@ const ocsEntryToNode = async function(ocsEntry: any): Promise<Folder | File | nu
mtime,
size: ocsEntry?.item_size,
permissions: ocsEntry?.item_permissions || ocsEntry?.permissions,
- root: rootPath,
+ 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,
- favorite: ocsEntry?.tags?.includes(window.OC.TAG_FAVORITE) ? 1 : 0,
+ 'share-attributes': ocsEntry?.attributes || '[]',
+ sharees,
+ favorite: ocsEntry?.tags?.includes((window.OC as { TAG_FAVORITE: string }).TAG_FAVORITE) ? 1 : 0,
},
})
} catch (error) {
@@ -80,12 +110,12 @@ const ocsEntryToNode = async function(ocsEntry: any): Promise<Folder | File | nu
}
}
-const getShares = function(shared_with_me = false): AxiosPromise<OCSResponse<any>> {
+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,
+ shared_with_me: shareWithMe,
include_tags: true,
},
})
@@ -140,8 +170,28 @@ const getDeletedShares = function(): AxiosPromise<OCSResponse<any>> {
}
/**
+ * 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) {
@@ -186,7 +236,7 @@ export const getContents = async (sharedWithYou = true, sharedWithOthers = true,
return {
folder: new Folder({
id: 0,
- source: generateRemoteUrl('dav' + rootPath),
+ source: `${davRemoteURL}${davRootPath}`,
owner: getCurrentUser()?.uid || null,
}),
contents,
diff --git a/apps/files_sharing/src/services/TabSections.js b/apps/files_sharing/src/services/TabSections.js
index 8578f8f08d5..ab1237e7044 100644
--- a/apps/files_sharing/src/services/TabSections.js
+++ b/apps/files_sharing/src/services/TabSections.js
@@ -3,6 +3,14 @@
* 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
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/share.js b/apps/files_sharing/src/share.js
index f02d357027f..cdc3c917dfa 100644
--- a/apps/files_sharing/src/share.js
+++ b/apps/files_sharing/src/share.js
@@ -7,7 +7,7 @@
/* eslint-disable */
import escapeHTML from 'escape-html'
-import { Type as ShareTypes } from '@nextcloud/sharing'
+import { ShareType } from '@nextcloud/sharing'
import { getCapabilities } from '@nextcloud/capabilities'
(function() {
@@ -155,25 +155,23 @@ import { getCapabilities } from '@nextcloud/capabilities'
var hasShares = false
_.each(shareTypesStr.split(',') || [], function(shareTypeStr) {
let shareType = parseInt(shareTypeStr, 10)
- if (shareType === ShareTypes.SHARE_TYPE_LINK) {
+ if (shareType === ShareType.Link) {
hasLink = true
- } else if (shareType === ShareTypes.SHARE_TYPE_EMAIL) {
+ } else if (shareType === ShareType.Email) {
hasLink = true
- } else if (shareType === ShareTypes.SHARE_TYPE_USER) {
+ } else if (shareType === ShareType.User) {
hasShares = true
- } else if (shareType === ShareTypes.SHARE_TYPE_GROUP) {
+ } else if (shareType === ShareType.Group) {
hasShares = true
- } else if (shareType === ShareTypes.SHARE_TYPE_REMOTE) {
+ } else if (shareType === ShareType.Remote) {
hasShares = true
- } else if (shareType === ShareTypes.SHARE_TYPE_REMOTE_GROUP) {
+ } else if (shareType === ShareType.RemoteGroup) {
hasShares = true
- } else if (shareType === ShareTypes.SHARE_TYPE_CIRCLE) {
+ } else if (shareType === ShareType.Team) {
hasShares = true
- } else if (shareType === ShareTypes.SHARE_TYPE_ROOM) {
+ } else if (shareType === ShareType.Room) {
hasShares = true
- } else if (shareType === ShareTypes.SHARE_TYPE_DECK) {
- hasShares = true
- } else if (shareType === ShareTypes.SHARE_TYPE_SCIENCEMESH) {
+ } else if (shareType === ShareType.Deck) {
hasShares = true
}
})
@@ -204,8 +202,8 @@ import { getCapabilities } from '@nextcloud/capabilities'
permissions: OC.PERMISSION_ALL,
iconClass: function(fileName, context) {
var shareType = parseInt(context.$file.data('share-types'), 10)
- if (shareType === ShareTypes.SHARE_TYPE_EMAIL
- || shareType === ShareTypes.SHARE_TYPE_LINK) {
+ if (shareType === ShareType.Email
+ || shareType === ShareType.Link) {
return 'icon-public'
}
return 'icon-shared'
diff --git a/apps/files_sharing/src/sharebreadcrumbview.js b/apps/files_sharing/src/sharebreadcrumbview.js
index 063881e2fe0..68ea75d4df9 100644
--- a/apps/files_sharing/src/sharebreadcrumbview.js
+++ b/apps/files_sharing/src/sharebreadcrumbview.js
@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { Type as ShareTypes } from '@nextcloud/sharing'
+import { ShareType } from '@nextcloud/sharing'
(function() {
'use strict'
@@ -23,7 +23,7 @@ import { Type as ShareTypes } from '@nextcloud/sharing'
this.$el.removeClass('shared icon-public icon-shared')
if (isShared) {
this.$el.addClass('shared')
- if (data.dirInfo.shareTypes.indexOf(ShareTypes.SHARE_TYPE_LINK) !== -1) {
+ if (data.dirInfo.shareTypes.indexOf(ShareType.Link) !== -1) {
this.$el.addClass('icon-public')
} else {
this.$el.addClass('icon-shared')
diff --git a/apps/files_sharing/src/sharing.d.ts b/apps/files_sharing/src/sharing.d.ts
new file mode 100644
index 00000000000..5c1a211f346
--- /dev/null
+++ b/apps/files_sharing/src/sharing.d.ts
@@ -0,0 +1,10 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+export type ShareAttribute = {
+ value: boolean|string|number|null|object|Array<unknown>
+ key: string
+ scope: string
+}
diff --git a/apps/files_sharing/src/style/sharebreadcrumb.scss b/apps/files_sharing/src/style/sharebreadcrumb.scss
index 944d529b1b3..6ee05c45306 100644
--- a/apps/files_sharing/src/style/sharebreadcrumb.scss
+++ b/apps/files_sharing/src/style/sharebreadcrumb.scss
@@ -8,7 +8,7 @@ li.crumb span.icon-public {
display: inline-block;
cursor: pointer;
opacity: 0.2;
- margin-right: 6px;
+ margin-inline-end: 6px;
}
li.crumb span.icon-shared.shared,
diff --git a/apps/files_sharing/src/utils/AccountIcon.spec.ts b/apps/files_sharing/src/utils/AccountIcon.spec.ts
new file mode 100644
index 00000000000..bbc7f031774
--- /dev/null
+++ b/apps/files_sharing/src/utils/AccountIcon.spec.ts
@@ -0,0 +1,40 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { describe, expect, it, afterEach } from 'vitest'
+import { generateAvatarSvg } from './AccountIcon'
+describe('AccountIcon', () => {
+
+ afterEach(() => {
+ delete document.body.dataset.themes
+ })
+
+ it('should generate regular account avatar svg', () => {
+ const svg = generateAvatarSvg('admin')
+ expect(svg).toContain('/avatar/admin/32')
+ expect(svg).not.toContain('dark')
+ expect(svg).toContain('?guestFallback=true')
+ })
+
+ it('should generate guest account avatar svg', () => {
+ const svg = generateAvatarSvg('admin', true)
+ expect(svg).toContain('/avatar/guest/admin/32')
+ expect(svg).not.toContain('dark')
+ expect(svg).not.toContain('?guestFallback=true')
+ })
+
+ it('should generate dark mode account avatar svg', () => {
+ document.body.dataset.themes = 'dark'
+ const svg = generateAvatarSvg('admin')
+ expect(svg).toContain('/avatar/admin/32/dark')
+ expect(svg).toContain('?guestFallback=true')
+ })
+
+ it('should generate dark mode guest account avatar svg', () => {
+ document.body.dataset.themes = 'dark'
+ const svg = generateAvatarSvg('admin', true)
+ expect(svg).toContain('/avatar/guest/admin/32/dark')
+ expect(svg).not.toContain('?guestFallback=true')
+ })
+})
diff --git a/apps/files_sharing/src/utils/AccountIcon.ts b/apps/files_sharing/src/utils/AccountIcon.ts
new file mode 100644
index 00000000000..21732f08f68
--- /dev/null
+++ b/apps/files_sharing/src/utils/AccountIcon.ts
@@ -0,0 +1,28 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { generateUrl } from '@nextcloud/router'
+
+const isDarkMode = () => {
+ return window?.matchMedia?.('(prefers-color-scheme: dark)')?.matches === true
+ || document.querySelector('[data-themes*=dark]') !== null
+}
+
+export const generateAvatarSvg = (userId: string, isGuest = false) => {
+ // normal avatar url: /avatar/{userId}/32?guestFallback=true
+ // dark avatar url: /avatar/{userId}/32/dark?guestFallback=true
+ // guest avatar url: /avatar/guest/{userId}/32
+ // guest dark avatar url: /avatar/guest/{userId}/32/dark
+ const basePath = isGuest ? `/avatar/guest/${userId}` : `/avatar/${userId}`
+ const darkModePath = isDarkMode() ? '/dark' : ''
+ const guestFallback = isGuest ? '' : '?guestFallback=true'
+
+ const url = `${basePath}/32${darkModePath}${guestFallback}`
+ const avatarUrl = generateUrl(url, { userId })
+
+ return `<svg width="32" height="32" viewBox="0 0 32 32"
+ xmlns="http://www.w3.org/2000/svg" class="sharing-status__avatar">
+ <image href="${avatarUrl}" height="32" width="32" />
+ </svg>`
+}
diff --git a/apps/files_sharing/src/utils/GeneratePassword.js b/apps/files_sharing/src/utils/GeneratePassword.js
deleted file mode 100644
index e40b12c53d1..00000000000
--- a/apps/files_sharing/src/utils/GeneratePassword.js
+++ /dev/null
@@ -1,44 +0,0 @@
-/**
- * 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.js'
-import { showError, showSuccess } from '@nextcloud/dialogs'
-
-const config = new Config()
-// note: some chars removed on purpose to make them human friendly when read out
-const passwordSet = 'abcdefgijkmnopqrstwxyzABCDEFGHJKLMNPQRSTWXYZ23456789'
-
-/**
- * Generate a valid policy password or
- * request a valid password if password_policy
- * is enabled
- *
- * @return {string} a valid password
- */
-export default async function() {
- // password policy is enabled, let's request a pass
- if (config.passwordPolicy.api && config.passwordPolicy.api.generate) {
- try {
- const request = await axios.get(config.passwordPolicy.api.generate)
- if (request.data.ocs.data.password) {
- showSuccess(t('files_sharing', 'Password created successfully'))
- return request.data.ocs.data.password
- }
- } catch (error) {
- console.info('Error generating password from password_policy', error)
- showError(t('files_sharing', 'Error generating password from password policy'))
- }
- }
-
- const array = new Uint8Array(10)
- const ratio = passwordSet.length / 255
- self.crypto.getRandomValues(array)
- let password = ''
- for (let i = 0; i < array.length; i++) {
- password += passwordSet.charAt(array[i] * ratio)
- }
- return password
-}
diff --git a/apps/files_sharing/src/utils/GeneratePassword.ts b/apps/files_sharing/src/utils/GeneratePassword.ts
new file mode 100644
index 00000000000..82efaaa69d4
--- /dev/null
+++ b/apps/files_sharing/src/utils/GeneratePassword.ts
@@ -0,0 +1,66 @@
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import axios from '@nextcloud/axios'
+import Config from '../services/ConfigService.ts'
+import { showError, showSuccess } from '@nextcloud/dialogs'
+import { translate as t } from '@nextcloud/l10n'
+
+const config = new Config()
+// note: some chars removed on purpose to make them human friendly when read out
+const passwordSet = 'abcdefgijkmnopqrstwxyzABCDEFGHJKLMNPQRSTWXYZ23456789'
+
+/**
+ * Generate a valid policy password or request a valid password if password_policy is enabled
+ *
+ * @param {boolean} verbose If enabled the the status is shown to the user via toast
+ */
+export default async function(verbose = false): Promise<string> {
+ // password policy is enabled, let's request a pass
+ if (config.passwordPolicy.api && config.passwordPolicy.api.generate) {
+ try {
+ const request = await axios.get(config.passwordPolicy.api.generate)
+ if (request.data.ocs.data.password) {
+ if (verbose) {
+ showSuccess(t('files_sharing', 'Password created successfully'))
+ }
+ return request.data.ocs.data.password
+ }
+ } catch (error) {
+ console.info('Error generating password from password_policy', error)
+ if (verbose) {
+ showError(t('files_sharing', 'Error generating password from password policy'))
+ }
+ }
+ }
+
+ const array = new Uint8Array(10)
+ const ratio = passwordSet.length / 255
+ getRandomValues(array)
+ let password = ''
+ for (let i = 0; i < array.length; i++) {
+ password += passwordSet.charAt(array[i] * ratio)
+ }
+ return password
+}
+
+/**
+ * Fills the given array with cryptographically secure random values.
+ * If the crypto API is not available, it falls back to less secure Math.random().
+ * Crypto API is available in modern browsers on secure contexts (HTTPS).
+ *
+ * @param {Uint8Array} array - The array to fill with random values.
+ */
+function getRandomValues(array: Uint8Array): void {
+ if (self?.crypto?.getRandomValues) {
+ self.crypto.getRandomValues(array)
+ return
+ }
+
+ let len = array.length
+ while (len--) {
+ array[len] = Math.floor(Math.random() * 256)
+ }
+}
diff --git a/apps/files_sharing/src/utils/NodeShareUtils.ts b/apps/files_sharing/src/utils/NodeShareUtils.ts
index 7c51e3add69..f14f981e2ad 100644
--- a/apps/files_sharing/src/utils/NodeShareUtils.ts
+++ b/apps/files_sharing/src/utils/NodeShareUtils.ts
@@ -5,7 +5,7 @@
import { getCurrentUser } from '@nextcloud/auth'
import type { Node } from '@nextcloud/files'
-import { Type } from '@nextcloud/sharing'
+import { ShareType } from '@nextcloud/sharing'
type Share = {
/** The recipient display name */
@@ -13,7 +13,7 @@ type Share = {
/** The recipient user id */
id: string
/** The share type */
- type: Type
+ type: ShareType
}
const getSharesAttribute = function(node: Node) {
@@ -31,10 +31,10 @@ export const isNodeSharedWithMe = function(node: Node) {
return shares.length > 0 && (
// If some shares are shared with you as a direct user share
- shares.some(share => share.id === uid && share.type === Type.SHARE_TYPE_USER)
+ 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 === Type.SHARE_TYPE_GROUP)
+ || shares.some(share => share.type === ShareType.Group)
)
}
@@ -49,7 +49,7 @@ export const isNodeSharedWithOthers = function(node: Node) {
return shares.length > 0
// If some shares are shared with you as a direct user share
- && shares.some(share => share.id !== uid && share.type !== Type.SHARE_TYPE_GROUP)
+ && shares.some(share => share.id !== uid && share.type !== ShareType.Group)
}
export const isNodeShared = function(node: Node) {
diff --git a/apps/files_sharing/src/utils/SharedWithMe.js b/apps/files_sharing/src/utils/SharedWithMe.js
index 0b76e925ec9..2f63932bfbe 100644
--- a/apps/files_sharing/src/utils/SharedWithMe.js
+++ b/apps/files_sharing/src/utils/SharedWithMe.js
@@ -3,10 +3,10 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { Type as ShareTypes } from '@nextcloud/sharing'
+import { ShareType } from '@nextcloud/sharing'
const shareWithTitle = function(share) {
- if (share.type === ShareTypes.SHARE_TYPE_GROUP) {
+ if (share.type === ShareType.Group) {
return t(
'files_sharing',
'Shared with you and the group {group} by {owner}',
@@ -17,7 +17,7 @@ const shareWithTitle = function(share) {
undefined,
{ escape: false },
)
- } else if (share.type === ShareTypes.SHARE_TYPE_CIRCLE) {
+ } else if (share.type === ShareType.Team) {
return t(
'files_sharing',
'Shared with you and {circle} by {owner}',
@@ -28,7 +28,7 @@ const shareWithTitle = function(share) {
undefined,
{ escape: false },
)
- } else if (share.type === ShareTypes.SHARE_TYPE_ROOM) {
+ } else if (share.type === ShareType.Room) {
if (share.shareWithDisplayName) {
return t(
'files_sharing',
diff --git a/apps/files_sharing/src/views/CollaborationView.vue b/apps/files_sharing/src/views/CollaborationView.vue
deleted file mode 100644
index b75ad53e1b8..00000000000
--- a/apps/files_sharing/src/views/CollaborationView.vue
+++ /dev/null
@@ -1,36 +0,0 @@
-<!--
- - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- - SPDX-License-Identifier: AGPL-3.0-or-later
--->
-
-<template>
- <CollectionList v-if="fileId"
- :id="fileId"
- type="file"
- :name="filename" />
-</template>
-
-<script>
-import { CollectionList } from 'nextcloud-vue-collections'
-
-export default {
- name: 'CollaborationView',
- components: {
- CollectionList,
- },
- computed: {
- fileId() {
- if (this.$root.model && this.$root.model.id) {
- return '' + this.$root.model.id
- }
- return null
- },
- filename() {
- if (this.$root.model && this.$root.model.name) {
- return '' + this.$root.model.name
- }
- return ''
- },
- },
-}
-</script>
diff --git a/apps/files_sharing/src/views/FilesHeaderNoteToRecipient.vue b/apps/files_sharing/src/views/FilesHeaderNoteToRecipient.vue
new file mode 100644
index 00000000000..ec6348606fb
--- /dev/null
+++ b/apps/files_sharing/src/views/FilesHeaderNoteToRecipient.vue
@@ -0,0 +1,73 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcNoteCard v-if="note.length > 0"
+ class="note-to-recipient"
+ type="info">
+ <p v-if="displayName" class="note-to-recipient__heading">
+ {{ t('files_sharing', 'Note from') }}
+ <NcUserBubble :user="user.id" :display-name="user.displayName" />
+ </p>
+ <p v-else class="note-to-recipient__heading">
+ {{ t('files_sharing', 'Note:') }}
+ </p>
+ <p class="note-to-recipient__text" v-text="note" />
+ </NcNoteCard>
+</template>
+
+<script setup lang="ts">
+import type { Folder } from '@nextcloud/files'
+import { getCurrentUser } from '@nextcloud/auth'
+import { t } from '@nextcloud/l10n'
+import { computed, ref } from 'vue'
+
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+import NcUserBubble from '@nextcloud/vue/components/NcUserBubble'
+
+const folder = ref<Folder>()
+const note = computed<string>(() => folder.value?.attributes.note ?? '')
+const displayName = computed<string>(() => folder.value?.attributes['owner-display-name'] ?? '')
+const user = computed(() => {
+ const id = folder.value?.owner
+ if (id !== getCurrentUser()?.uid) {
+ return {
+ id,
+ displayName: displayName.value,
+ }
+ }
+ return null
+})
+
+/**
+ * Update the current folder
+ * @param newFolder the new folder to show note for
+ */
+function updateFolder(newFolder: Folder) {
+ folder.value = newFolder
+}
+
+defineExpose({ updateFolder })
+</script>
+
+<style scoped>
+.note-to-recipient {
+ margin-inline: var(--row-height)
+}
+
+.note-to-recipient__text {
+ /* respect new lines */
+ white-space: pre-line;
+}
+
+.note-to-recipient__heading {
+ font-weight: bold;
+}
+
+@media screen and (max-width: 512px) {
+ .note-to-recipient {
+ margin-inline: var(--default-grid-baseline);
+ }
+}
+</style>
diff --git a/apps/files_sharing/src/views/FilesViewFileDropEmptyContent.vue b/apps/files_sharing/src/views/FilesViewFileDropEmptyContent.vue
new file mode 100644
index 00000000000..dac22748d8a
--- /dev/null
+++ b/apps/files_sharing/src/views/FilesViewFileDropEmptyContent.vue
@@ -0,0 +1,136 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcEmptyContent class="file-drop-empty-content"
+ data-cy-files-sharing-file-drop
+ :name="name">
+ <template #icon>
+ <NcIconSvgWrapper :svg="svgCloudUpload" />
+ </template>
+ <template #description>
+ <p>
+ {{ shareNote || t('files_sharing', 'Upload files to {foldername}.', { foldername }) }}
+ </p>
+ <p v-if="disclaimer">
+ {{ t('files_sharing', 'By uploading files, you agree to the terms of service.') }}
+ </p>
+ <NcNoteCard v-if="getSortedUploads().length"
+ class="file-drop-empty-content__note-card"
+ type="success">
+ <h2 id="file-drop-empty-content__heading">
+ {{ t('files_sharing', 'Successfully uploaded files') }}
+ </h2>
+ <ul aria-labelledby="file-drop-empty-content__heading" class="file-drop-empty-content__list">
+ <li v-for="file in getSortedUploads()" :key="file">
+ {{ file }}
+ </li>
+ </ul>
+ </NcNoteCard>
+ </template>
+ <template #action>
+ <template v-if="disclaimer">
+ <!-- Terms of service if enabled -->
+ <NcButton type="primary" @click="showDialog = true">
+ {{ t('files_sharing', 'View terms of service') }}
+ </NcButton>
+ <NcDialog close-on-click-outside
+ content-classes="terms-of-service-dialog"
+ :open.sync="showDialog"
+ :name="t('files_sharing', 'Terms of service')"
+ :message="disclaimer" />
+ </template>
+ <UploadPicker allow-folders
+ :content="() => []"
+ no-menu
+ :destination="uploadDestination"
+ multiple />
+ </template>
+ </NcEmptyContent>
+</template>
+
+<script lang="ts">
+/* eslint-disable import/first */
+
+// We need this on module level rather than on the instance as view will be refreshed by the files app after uploading
+const uploads = new Set<string>()
+</script>
+
+<script setup lang="ts">
+import { loadState } from '@nextcloud/initial-state'
+import { translate as t } from '@nextcloud/l10n'
+import { getUploader, UploadPicker, UploadStatus } from '@nextcloud/upload'
+import { ref } from 'vue'
+
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcDialog from '@nextcloud/vue/components/NcDialog'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+import svgCloudUpload from '@mdi/svg/svg/cloud-upload-outline.svg?raw'
+
+defineProps<{
+ foldername: string
+}>()
+
+const disclaimer = loadState<string>('files_sharing', 'disclaimer', '')
+const shareLabel = loadState<string>('files_sharing', 'label', '')
+const shareNote = loadState<string>('files_sharing', 'note', '')
+
+const name = shareLabel || t('files_sharing', 'File drop')
+
+const showDialog = ref(false)
+const uploadDestination = getUploader().destination
+
+getUploader()
+ .addNotifier((upload) => {
+ if (upload.status === UploadStatus.FINISHED && upload.file.name) {
+ // if a upload is finished and is not a meta upload (name is set)
+ // then we add the upload to the list of finished uploads to be shown to the user
+ uploads.add(upload.file.name)
+ }
+ })
+
+/**
+ * Get the previous uploads as sorted list
+ */
+function getSortedUploads() {
+ return [...uploads].sort((a, b) => a.localeCompare(b))
+}
+</script>
+
+<style scoped lang="scss">
+.file-drop-empty-content {
+ margin: auto;
+ max-width: max(50vw, 300px);
+
+ .file-drop-empty-content__note-card {
+ width: fit-content;
+ margin-inline: auto;
+ }
+
+ #file-drop-empty-content__heading {
+ margin-block: 0 10px;
+ font-weight: bold;
+ font-size: 20px;
+ }
+
+ .file-drop-empty-content__list {
+ list-style: inside;
+ max-height: min(350px, 33vh);
+ overflow-y: scroll;
+ padding-inline-end: calc(2 * var(--default-grid-baseline));
+ }
+
+ :deep(.terms-of-service-dialog) {
+ min-height: min(100px, 20vh);
+ }
+
+ /* TODO fix in library */
+ :deep(.empty-content__action) {
+ display: flex;
+ gap: var(--default-grid-baseline);
+ }
+}
+</style>
diff --git a/apps/files_sharing/src/views/SharingDetailsTab.vue b/apps/files_sharing/src/views/SharingDetailsTab.vue
index 9922caf4f4e..b3a3b95d92e 100644
--- a/apps/files_sharing/src/views/SharingDetailsTab.vue
+++ b/apps/files_sharing/src/views/SharingDetailsTab.vue
@@ -8,7 +8,7 @@
<span>
<NcAvatar v-if="isUserShare"
class="sharing-entry__avatar"
- :is-no-user="share.shareType !== SHARE_TYPES.SHARE_TYPE_USER"
+ :is-no-user="share.shareType !== ShareType.User"
:user="share.shareWith"
:display-name="share.shareWithDisplayName"
:menu-position="'left'"
@@ -38,7 +38,7 @@
<NcCheckboxRadioSwitch :button-variant="true"
data-cy-files-sharing-share-permissions-bundle="upload-edit"
:checked.sync="sharingPermission"
- :value="bundledPermissions.ALL.toString()"
+ :value="allPermissions"
name="sharing_permission_radio"
type="radio"
button-variant-grouped="vertical"
@@ -62,7 +62,7 @@
type="radio"
button-variant-grouped="vertical"
@update:checked="toggleCustomPermissions">
- {{ t('files_sharing', 'File drop') }}
+ {{ t('files_sharing', 'File request') }}
<small class="subline">{{ t('files_sharing', 'Upload only') }}</small>
<template #icon>
<UploadIcon :size="20" />
@@ -105,19 +105,33 @@
role="region">
<section>
<NcInputField v-if="isPublicShare"
+ class="sharingTabDetailsView__label"
autocomplete="off"
:label="t('files_sharing', 'Share label')"
:value.sync="share.label" />
+ <NcInputField v-if="config.allowCustomTokens && isPublicShare && !isNewShare"
+ autocomplete="off"
+ :label="t('files_sharing', 'Share link token')"
+ :helper-text="t('files_sharing', 'Set the public share link token to something easy to remember or generate a new token. It is not recommended to use a guessable token for shares which contain sensitive information.')"
+ show-trailing-button
+ :trailing-button-label="loadingToken ? t('files_sharing', 'Generating…') : t('files_sharing', 'Generate new token')"
+ :value.sync="share.token"
+ @trailing-button-click="generateNewToken">
+ <template #trailing-button-icon>
+ <NcLoadingIcon v-if="loadingToken" />
+ <Refresh v-else :size="20" />
+ </template>
+ </NcInputField>
<template v-if="isPublicShare">
<NcCheckboxRadioSwitch :checked.sync="isPasswordProtected" :disabled="isPasswordEnforced">
{{ t('files_sharing', 'Set password') }}
</NcCheckboxRadioSwitch>
<NcPasswordField v-if="isPasswordProtected"
autocomplete="new-password"
- :value="hasUnsavedPassword ? share.newPassword : ''"
+ :value="share.newPassword ?? ''"
:error="passwordError"
- :helper-text="errorPasswordLabel"
- :required="isPasswordEnforced"
+ :helper-text="errorPasswordLabel || passwordHint"
+ :required="isPasswordEnforced && isNewShare"
:label="t('files_sharing', 'Password')"
@update:value="onPasswordChange" />
@@ -144,7 +158,8 @@
:value="new Date(share.expireDate ?? dateTomorrow)"
:min="dateTomorrow"
:max="maxExpirationDateEnforced"
- :hide-label="true"
+ hide-label
+ :label="t('files_sharing', 'Expiration date')"
:placeholder="t('files_sharing', 'Expiration date')"
type="date"
@input="onExpirationChange" />
@@ -154,21 +169,24 @@
@update:checked="queueUpdate('hideDownload')">
{{ t('files_sharing', 'Hide download') }}
</NcCheckboxRadioSwitch>
- <NcCheckboxRadioSwitch v-if="!isPublicShare"
+ <NcCheckboxRadioSwitch v-else
:disabled="!canSetDownload"
:checked.sync="canDownload"
data-cy-files-sharing-share-permissions-checkbox="download">
- {{ t('files_sharing', 'Allow download') }}
+ {{ t('files_sharing', 'Allow download and sync') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch :checked.sync="writeNoteToRecipientIsChecked">
{{ t('files_sharing', 'Note to recipient') }}
</NcCheckboxRadioSwitch>
<template v-if="writeNoteToRecipientIsChecked">
- <label for="share-note-textarea">
- {{ t('files_sharing', 'Enter a note for the share recipient') }}
- </label>
- <textarea id="share-note-textarea" :value="share.note" @input="share.note = $event.target.value" />
+ <NcTextArea :label="t('files_sharing', 'Note to recipient')"
+ :placeholder="t('files_sharing', 'Enter a note for the share recipient')"
+ :value.sync="share.note" />
</template>
+ <NcCheckboxRadioSwitch v-if="isPublicShare && isFolder"
+ :checked.sync="showInGridView">
+ {{ t('files_sharing', 'Show files in grid view') }}
+ </NcCheckboxRadioSwitch>
<ExternalShareAction v-for="action in externalLinkActions"
:id="action.id"
ref="externalLinkActions"
@@ -180,7 +198,7 @@
{{ t('files_sharing', 'Custom permissions') }}
</NcCheckboxRadioSwitch>
<section v-if="setCustomPermissions" class="custom-permissions-group">
- <NcCheckboxRadioSwitch :disabled="!allowsFileDrop && share.type === SHARE_TYPES.SHARE_TYPE_LINK"
+ <NcCheckboxRadioSwitch :disabled="!canRemoveReadPermission"
:checked.sync="hasRead"
data-cy-files-sharing-share-permissions-checkbox="read">
{{ t('files_sharing', 'Read') }}
@@ -196,7 +214,7 @@
data-cy-files-sharing-share-permissions-checkbox="update">
{{ t('files_sharing', 'Edit') }}
</NcCheckboxRadioSwitch>
- <NcCheckboxRadioSwitch v-if="config.isResharingAllowed && share.type !== SHARE_TYPES.SHARE_TYPE_LINK"
+ <NcCheckboxRadioSwitch v-if="resharingIsPossible"
:disabled="!canSetReshare"
:checked.sync="canReshare"
data-cy-files-sharing-share-permissions-checkbox="share">
@@ -208,19 +226,6 @@
{{ t('files_sharing', 'Delete') }}
</NcCheckboxRadioSwitch>
</section>
- <div class="sharingTabDetailsView__delete">
- <NcButton v-if="!isNewShare"
- :aria-label="t('files_sharing', 'Delete share')"
- :disabled="false"
- :readonly="false"
- type="tertiary"
- @click.prevent="removeShare">
- <template #icon>
- <CloseIcon :size="16" />
- </template>
- {{ t('files_sharing', 'Delete share') }}
- </NcButton>
- </div>
</section>
</div>
</div>
@@ -228,11 +233,25 @@
<div class="sharingTabDetailsView__footer">
<div class="button-group">
<NcButton data-cy-files-sharing-share-editor-action="cancel"
- @click="$emit('close-sharing-details')">
+ @click="cancel">
{{ t('files_sharing', 'Cancel') }}
</NcButton>
+ <div class="sharingTabDetailsView__delete">
+ <NcButton v-if="!isNewShare"
+ :aria-label="t('files_sharing', 'Delete share')"
+ :disabled="false"
+ :readonly="false"
+ variant="tertiary"
+ @click.prevent="removeShare">
+ <template #icon>
+ <CloseIcon :size="20" />
+ </template>
+ {{ t('files_sharing', 'Delete share') }}
+ </NcButton>
+ </div>
<NcButton type="primary"
data-cy-files-sharing-share-editor-action="save"
+ :disabled="creating"
@click="saveShare">
{{ shareButtonText }}
<template v-if="creating" #icon>
@@ -245,19 +264,24 @@
</template>
<script>
+import { emit } from '@nextcloud/event-bus'
import { getLanguage } from '@nextcloud/l10n'
-import { Type as ShareType } from '@nextcloud/sharing'
-
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js'
-import NcPasswordField from '@nextcloud/vue/dist/Components/NcPasswordField.js'
-import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
-import NcDateTimePickerNative from '@nextcloud/vue/dist/Components/NcDateTimePickerNative.js'
-import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
-import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
+import { ShareType } from '@nextcloud/sharing'
+import { showError } from '@nextcloud/dialogs'
+import moment from '@nextcloud/moment'
+
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
+import NcDateTimePickerNative from '@nextcloud/vue/components/NcDateTimePickerNative'
+import NcInputField from '@nextcloud/vue/components/NcInputField'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
+import NcTextArea from '@nextcloud/vue/components/NcTextArea'
+
import CircleIcon from 'vue-material-design-icons/CircleOutline.vue'
import CloseIcon from 'vue-material-design-icons/Close.vue'
-import EditIcon from 'vue-material-design-icons/Pencil.vue'
+import EditIcon from 'vue-material-design-icons/PencilOutline.vue'
import EmailIcon from 'vue-material-design-icons/Email.vue'
import LinkIcon from 'vue-material-design-icons/Link.vue'
import GroupIcon from 'vue-material-design-icons/AccountGroup.vue'
@@ -268,14 +292,16 @@ import UploadIcon from 'vue-material-design-icons/Upload.vue'
import MenuDownIcon from 'vue-material-design-icons/MenuDown.vue'
import MenuUpIcon from 'vue-material-design-icons/MenuUp.vue'
import DotsHorizontalIcon from 'vue-material-design-icons/DotsHorizontal.vue'
+import Refresh from 'vue-material-design-icons/Refresh.vue'
import ExternalShareAction from '../components/ExternalShareAction.vue'
-import GeneratePassword from '../utils/GeneratePassword.js'
-import Share from '../models/Share.js'
+import GeneratePassword from '../utils/GeneratePassword.ts'
+import Share from '../models/Share.ts'
import ShareRequests from '../mixins/ShareRequests.js'
-import ShareTypes from '../mixins/ShareTypes.js'
import SharesMixin from '../mixins/SharesMixin.js'
+import { generateToken } from '../services/TokenService.ts'
+import logger from '../services/logger.ts'
import {
ATOMIC_PERMISSIONS,
@@ -288,11 +314,12 @@ export default {
components: {
NcAvatar,
NcButton,
- NcInputField,
- NcPasswordField,
- NcDateTimePickerNative,
NcCheckboxRadioSwitch,
+ NcDateTimePickerNative,
+ NcInputField,
NcLoadingIcon,
+ NcPasswordField,
+ NcTextArea,
CloseIcon,
CircleIcon,
EditIcon,
@@ -306,8 +333,9 @@ export default {
MenuDownIcon,
MenuUpIcon,
DotsHorizontalIcon,
+ Refresh,
},
- mixins: [ShareTypes, ShareRequests, SharesMixin],
+ mixins: [ShareRequests, SharesMixin],
props: {
shareRequestValue: {
type: Object,
@@ -334,6 +362,8 @@ export default {
isFirstComponentLoad: true,
test: false,
creating: false,
+ initialToken: this.share.token,
+ loadingToken: false,
ExternalShareActions: OCA.Sharing.ExternalShareActions.state,
}
@@ -342,34 +372,40 @@ export default {
computed: {
title() {
switch (this.share.type) {
- case this.SHARE_TYPES.SHARE_TYPE_USER:
- return t('files_sharing', 'Share with {userName}', { userName: this.share.shareWithDisplayName })
- case this.SHARE_TYPES.SHARE_TYPE_EMAIL:
+ case ShareType.User:
+ return t('files_sharing', 'Share with {user}', { user: this.share.shareWithDisplayName })
+ case ShareType.Email:
return t('files_sharing', 'Share with email {email}', { email: this.share.shareWith })
- case this.SHARE_TYPES.SHARE_TYPE_LINK:
+ case ShareType.Link:
return t('files_sharing', 'Share link')
- case this.SHARE_TYPES.SHARE_TYPE_GROUP:
+ case ShareType.Group:
return t('files_sharing', 'Share with group')
- case this.SHARE_TYPES.SHARE_TYPE_ROOM:
+ case ShareType.Room:
return t('files_sharing', 'Share in conversation')
- case this.SHARE_TYPES.SHARE_TYPE_REMOTE: {
+ case ShareType.Remote: {
const [user, server] = this.share.shareWith.split('@')
+ if (this.config.showFederatedSharesAsInternal) {
+ return t('files_sharing', 'Share with {user}', { user })
+ }
return t('files_sharing', 'Share with {user} on remote server {server}', { user, server })
}
- case this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP:
+ case ShareType.RemoteGroup:
return t('files_sharing', 'Share with remote group')
- case this.SHARE_TYPES.SHARE_TYPE_GUEST:
+ case ShareType.Guest:
return t('files_sharing', 'Share with guest')
default: {
- if (this.share.id) {
- // Share already exists
- return t('files_sharing', 'Update share')
- } else {
- return t('files_sharing', 'Create share')
- }
+ if (this.share.id) {
+ // Share already exists
+ return t('files_sharing', 'Update share')
+ } else {
+ return t('files_sharing', 'Create share')
+ }
}
}
},
+ allPermissions() {
+ return this.isFolder ? this.bundledPermissions.ALL.toString() : this.bundledPermissions.ALL_FILE.toString()
+ },
/**
* Can the sharee edit the shared file ?
*/
@@ -414,24 +450,34 @@ export default {
this.updateAtomicPermissions({ isReshareChecked: checked })
},
},
+
+ /**
+ * Change the default view for public shares from "list" to "grid"
+ */
+ showInGridView: {
+ get() {
+ return this.getShareAttribute('config', 'grid_view', false)
+ },
+ /** @param {boolean} value If the default view should be changed to "grid" */
+ set(value) {
+ this.setShareAttribute('config', 'grid_view', value)
+ },
+ },
+
/**
* Can the sharee download files or only view them ?
*/
canDownload: {
get() {
- return this.share.attributes.find(attr => attr.key === 'download')?.enabled || false
+ return this.getShareAttribute('permissions', 'download', true)
},
set(checked) {
- // Find the 'download' attribute and update its value
- const downloadAttr = this.share.attributes.find(attr => attr.key === 'download')
- if (downloadAttr) {
- downloadAttr.enabled = checked
- }
+ this.setShareAttribute('permissions', 'download', checked)
},
},
/**
* Is this share readable
- * Needed for some federated shares that might have been added from file drop links
+ * Needed for some federated shares that might have been added from file requests links
*/
hasRead: {
get() {
@@ -457,26 +503,6 @@ export default {
},
},
/**
- * Is the current share password protected ?
- *
- * @return {boolean}
- */
- isPasswordProtected: {
- get() {
- return this.config.enforcePasswordForPublicLink
- || !!this.share.password
- },
- async set(enabled) {
- if (enabled) {
- this.share.password = await GeneratePassword()
- this.$set(this.share, 'newPassword', this.share.password)
- } else {
- this.share.password = ''
- this.$delete(this.share, 'newPassword')
- }
- },
- },
- /**
* Is the current share a folder ?
*
* @return {boolean}
@@ -517,17 +543,14 @@ export default {
return new Date(new Date().setDate(new Date().getDate() + 1))
},
isUserShare() {
- return this.share.type === this.SHARE_TYPES.SHARE_TYPE_USER
+ return this.share.type === ShareType.User
},
isGroupShare() {
- return this.share.type === this.SHARE_TYPES.SHARE_TYPE_GROUP
- },
- isNewShare() {
- return !this.share.id
+ return this.share.type === ShareType.Group
},
allowsFileDrop() {
if (this.isFolder && this.config.isPublicUploadEnabled) {
- if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_LINK || this.share.type === this.SHARE_TYPES.SHARE_TYPE_EMAIL) {
+ if (this.share.type === ShareType.Link || this.share.type === ShareType.Email) {
return true
}
}
@@ -543,6 +566,9 @@ export default {
return t('files_sharing', 'Update share')
},
+ resharingIsPossible() {
+ return this.config.isResharingAllowed && this.share.type !== ShareType.Link && this.share.type !== ShareType.Email
+ },
/**
* Can the sharer set whether the sharee can edit the file ?
*
@@ -600,6 +626,12 @@ export default {
// allowed to revoke it too (but not to grant it again).
return (this.fileInfo.canDownload() || this.canDownload)
},
+ canRemoveReadPermission() {
+ return this.allowsFileDrop && (
+ this.share.type === ShareType.Link
+ || this.share.type === ShareType.Email
+ )
+ },
// if newPassword exists, but is empty, it means
// the user deleted the original password
hasUnsavedPassword() {
@@ -656,7 +688,7 @@ export default {
*/
isEmailShareType() {
return this.share
- ? this.share.type === this.SHARE_TYPES.SHARE_TYPE_EMAIL
+ ? this.share.type === ShareType.Email
: false
},
canTogglePasswordProtectedByTalkAvailable() {
@@ -673,7 +705,7 @@ export default {
return OC.appswebroots.spreed !== undefined
},
canChangeHideDownload() {
- const hasDisabledDownload = (shareAttribute) => shareAttribute.key === 'download' && shareAttribute.scope === 'permissions' && shareAttribute.enabled === false
+ const hasDisabledDownload = (shareAttribute) => shareAttribute.key === 'download' && shareAttribute.scope === 'permissions' && shareAttribute.value === false
return this.fileInfo.shareAttributes.some(hasDisabledDownload)
},
customPermissionsList() {
@@ -686,8 +718,15 @@ export default {
[ATOMIC_PERMISSIONS.DELETE]: this.t('files_sharing', 'Delete'),
}
- return [ATOMIC_PERMISSIONS.READ, ATOMIC_PERMISSIONS.CREATE, ATOMIC_PERMISSIONS.UPDATE, ATOMIC_PERMISSIONS.SHARE, ATOMIC_PERMISSIONS.DELETE]
- .filter((permission) => hasPermissions(this.share.permissions, permission))
+ const permissionsList = [
+ ATOMIC_PERMISSIONS.READ,
+ ...(this.isFolder ? [ATOMIC_PERMISSIONS.CREATE] : []),
+ ATOMIC_PERMISSIONS.UPDATE,
+ ...(this.resharingIsPossible ? [ATOMIC_PERMISSIONS.SHARE] : []),
+ ...(this.isFolder ? [ATOMIC_PERMISSIONS.DELETE] : []),
+ ]
+
+ return permissionsList.filter((permission) => hasPermissions(this.share.permissions, permission))
.map((permission, index) => index === 0
? translatedPermissions[permission]
: translatedPermissions[permission].toLocaleLowerCase(getLanguage()))
@@ -698,18 +737,25 @@ export default {
},
errorPasswordLabel() {
if (this.passwordError) {
- return t('files_sharing', "Password field can't be empty")
+ return t('files_sharing', 'Password field cannot be empty')
}
return undefined
},
+ passwordHint() {
+ if (this.isNewShare || this.hasUnsavedPassword) {
+ return undefined
+ }
+ return t('files_sharing', 'Replace current password')
+ },
+
/**
* Additional actions for the menu
*
* @return {Array}
*/
externalLinkActions() {
- const filterValidAction = (action) => (action.shareType.includes(ShareType.SHARE_TYPE_LINK) || action.shareType.includes(ShareType.SHARE_TYPE_EMAIL)) && action.advanced
+ const filterValidAction = (action) => (action.shareType.includes(ShareType.Link) || action.shareType.includes(ShareType.Email)) && action.advanced
// filter only the advanced registered actions for said link
return this.ExternalShareActions.actions
.filter(filterValidAction)
@@ -727,8 +773,8 @@ export default {
beforeMount() {
this.initializePermissions()
this.initializeAttributes()
- console.debug('shareSentIn', this.share)
- console.debug('config', this.config)
+ logger.debug('Share object received', { share: this.share })
+ logger.debug('Configuration object received', { config: this.config })
},
mounted() {
@@ -736,6 +782,60 @@ export default {
},
methods: {
+ /**
+ * Set a share attribute on the current share
+ * @param {string} scope The attribute scope
+ * @param {string} key The attribute key
+ * @param {boolean} value The value
+ */
+ setShareAttribute(scope, key, value) {
+ if (!this.share.attributes) {
+ this.$set(this.share, 'attributes', [])
+ }
+
+ const attribute = this.share.attributes
+ .find((attr) => attr.scope === scope || attr.key === key)
+
+ if (attribute) {
+ attribute.value = value
+ } else {
+ this.share.attributes.push({
+ scope,
+ key,
+ value,
+ })
+ }
+ },
+
+ /**
+ * Get the value of a share attribute
+ * @param {string} scope The attribute scope
+ * @param {string} key The attribute key
+ * @param {undefined|boolean} fallback The fallback to return if not found
+ */
+ getShareAttribute(scope, key, fallback = undefined) {
+ const attribute = this.share.attributes?.find((attr) => attr.scope === scope && attr.key === key)
+ return attribute?.value ?? fallback
+ },
+
+ async generateNewToken() {
+ if (this.loadingToken) {
+ return
+ }
+ this.loadingToken = true
+ try {
+ this.share.token = await generateToken()
+ } catch (error) {
+ showError(t('files_sharing', 'Failed to generate a new token'))
+ }
+ this.loadingToken = false
+ },
+
+ cancel() {
+ this.share.token = this.initialToken
+ this.$emit('close-sharing-details')
+ },
+
updateAtomicPermissions({
isReadChecked = this.hasRead,
isEditChecked = this.canEdit,
@@ -744,6 +844,13 @@ export default {
isReshareChecked = this.canReshare,
} = {}) {
// calc permissions if checked
+
+ if (!this.isFolder && (isCreateChecked || isDeleteChecked)) {
+ logger.debug('Ignoring create/delete permissions for file share — only available for folders')
+ isCreateChecked = false
+ isDeleteChecked = false
+ }
+
const permissions = 0
| (isReadChecked ? ATOMIC_PERMISSIONS.READ : 0)
| (isCreateChecked ? ATOMIC_PERMISSIONS.CREATE : 0)
@@ -766,8 +873,8 @@ export default {
async initializeAttributes() {
if (this.isNewShare) {
- if (this.isPasswordEnforced && this.isPublicShare) {
- this.$set(this.share, 'newPassword', await GeneratePassword())
+ if ((this.config.enableLinkPasswordByDefault || this.isPasswordEnforced) && this.isPublicShare) {
+ this.$set(this.share, 'newPassword', await GeneratePassword(true))
this.advancedSectionAccordionExpanded = true
}
/* Set default expiration dates if configured */
@@ -800,6 +907,11 @@ export default {
this.advancedSectionAccordionExpanded = true
}
+ if (this.isValidShareAttribute(this.share.note)) {
+ this.writeNoteToRecipientIsChecked = true
+ this.advancedSectionAccordionExpanded = true
+ }
+
},
handleShareType() {
if ('shareType' in this.share) {
@@ -820,6 +932,10 @@ export default {
this.setCustomPermissions = true
}
}
+ // Read permission required for share creation
+ if (!this.canRemoveReadPermission) {
+ this.hasRead = true
+ }
},
handleCustomPermissions() {
if (!this.isNewShare && (this.hasCustomPermissions || this.share.setCustomPermissions)) {
@@ -838,6 +954,9 @@ export default {
async saveShare() {
const permissionsAndAttributes = ['permissions', 'attributes', 'note', 'expireDate']
const publicShareAttributes = ['label', 'password', 'hideDownload']
+ if (this.config.allowCustomTokens) {
+ publicShareAttributes.push('token')
+ }
if (this.isPublicShare) {
permissionsAndAttributes.push(...publicShareAttributes)
}
@@ -856,10 +975,7 @@ export default {
this.share.note = ''
}
if (this.isPasswordProtected) {
- if (this.hasUnsavedPassword && this.isValidShareAttribute(this.share.newPassword)) {
- this.share.password = this.share.newPassword
- this.$delete(this.share, 'newPassword')
- } else if (this.isPasswordEnforced && !this.isValidShareAttribute(this.share.password)) {
+ if (this.isPasswordEnforced && this.isNewShare && !this.isValidShareAttribute(this.share.password)) {
this.passwordError = true
}
} else {
@@ -883,19 +999,45 @@ export default {
incomingShare.expireDate = this.hasExpirationDate ? this.share.expireDate : ''
if (this.isPasswordProtected) {
- incomingShare.password = this.share.password
+ incomingShare.password = this.share.newPassword
+ }
+
+ let share
+ try {
+ this.creating = true
+ share = await this.addShare(incomingShare)
+ } catch (error) {
+ this.creating = false
+ // Error is already handled by ShareRequests mixin
+ return
+ }
+
+ // ugly hack to make code work - we need the id to be set but at the same time we need to keep values we want to update
+ this.share._share.id = share.id
+ await this.queueUpdate(...permissionsAndAttributes)
+ // Also a ugly hack to update the updated permissions
+ for (const prop of permissionsAndAttributes) {
+ if (prop in share && prop in this.share) {
+ try {
+ share[prop] = this.share[prop]
+ } catch {
+ share._share[prop] = this.share[prop]
+ }
+ }
}
- this.creating = true
- const share = await this.addShare(incomingShare, this.fileInfo)
- this.creating = false
this.share = share
+ this.creating = false
this.$emit('add:share', this.share)
} else {
+ // Let's update after creation as some attrs are only available after creation
+ await this.queueUpdate(...permissionsAndAttributes)
this.$emit('update:share', this.share)
- this.queueUpdate(...permissionsAndAttributes)
}
+ await this.getNode()
+ emit('files:node:updated', this.node)
+
if (this.$refs.externalLinkActions?.length > 0) {
await Promise.allSettled(this.$refs.externalLinkActions.map((action) => {
if (typeof action.$children.at(0)?.onSave !== 'function') {
@@ -911,12 +1053,11 @@ export default {
* Process the new share request
*
* @param {Share} share incoming share object
- * @param {object} fileInfo file data
*/
- async addShare(share, fileInfo) {
- console.debug('Adding a new share from the input for', share)
+ async addShare(share) {
+ logger.debug('Adding a new share from the input for', { share })
+ const path = this.path
try {
- const path = (fileInfo.path + '/' + fileInfo.name).replace('//', '/')
const resultingShare = await this.createShare({
path,
shareType: share.shareType,
@@ -929,13 +1070,15 @@ export default {
})
return resultingShare
} catch (error) {
- console.error('Error while adding new share', error)
+ logger.error('Error while adding new share', { error })
} finally {
// this.loading = false // No loader here yet
}
},
async removeShare() {
await this.onDelete()
+ await this.getNode()
+ emit('files:node:updated', this.node)
this.$emit('close-sharing-details')
},
/**
@@ -949,6 +1092,11 @@ export default {
* @param {string} password the changed password
*/
onPasswordChange(password) {
+ if (password === '') {
+ this.$delete(this.share, 'newPassword')
+ this.passwordError = this.isNewShare && this.isPasswordEnforced
+ return
+ }
this.passwordError = !this.isValidShareAttribute(password)
this.$set(this.share, 'newPassword', password)
},
@@ -961,10 +1109,6 @@ export default {
* "sendPasswordByTalk".
*/
onPasswordProtectedByTalkChange() {
- if (this.hasUnsavedPassword) {
- this.share.password = this.share.newPassword.trim()
- }
-
this.queueUpdate('sendPasswordByTalk', 'password')
},
isValidShareAttribute(value) {
@@ -980,22 +1124,22 @@ export default {
},
getShareTypeIcon(type) {
switch (type) {
- case this.SHARE_TYPES.SHARE_TYPE_LINK:
+ case ShareType.Link:
return LinkIcon
- case this.SHARE_TYPES.SHARE_TYPE_GUEST:
+ case ShareType.Guest:
return UserIcon
- case this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP:
- case this.SHARE_TYPES.SHARE_TYPE_GROUP:
+ case ShareType.RemoteGroup:
+ case ShareType.Group:
return GroupIcon
- case this.SHARE_TYPES.SHARE_TYPE_EMAIL:
+ case ShareType.Email:
return EmailIcon
- case this.SHARE_TYPES.SHARE_TYPE_CIRCLE:
+ case ShareType.Team:
return CircleIcon
- case this.SHARE_TYPES.SHARE_TYPE_ROOM:
+ case ShareType.Room:
return ShareIcon
- case this.SHARE_TYPES.SHARE_TYPE_DECK:
+ case ShareType.Deck:
return ShareIcon
- case this.SHARE_TYPES.SHARE_TYPE_SCIENCEMESH:
+ case ShareType.ScienceMesh:
return ShareIcon
default:
return null // Or a default icon component if needed
@@ -1027,7 +1171,7 @@ export default {
h1 {
font-size: 15px;
- padding-left: 0.3em;
+ padding-inline-start: 0.3em;
}
}
@@ -1038,7 +1182,7 @@ export default {
overflow: scroll;
flex-shrink: 1;
padding: 4px;
- padding-right: 12px;
+ padding-inline-end: 12px;
}
&__quick-permissions {
@@ -1060,12 +1204,9 @@ export default {
padding: 0.1em;
}
- ::v-deep label {
-
- span {
- display: flex;
- flex-direction: column;
- }
+ :deep(label span) {
+ display: flex;
+ flex-direction: column;
}
/* Target component based style in NcCheckboxRadioSwitch slot content*/
@@ -1094,8 +1235,8 @@ export default {
&__advanced {
width: 100%;
margin-bottom: 0.5em;
- text-align: left;
- padding-left: 0;
+ text-align: start;
+ padding-inline-start: 0;
section {
@@ -1110,30 +1251,32 @@ export default {
}
/*
- The following style is applied out of the component's scope
- to remove padding from the label.checkbox-radio-switch__label,
- which is used to group radio checkbox items. The use of ::v-deep
- ensures that the padding is modified without being affected by
- the component's scoping.
- Without this achieving left alignment for the checkboxes would not
- be possible.
- */
- span {
- ::v-deep label {
- padding-left: 0 !important;
- background-color: initial !important;
- border: none !important;
- }
+ The following style is applied out of the component's scope
+ to remove padding from the label.checkbox-radio-switch__label,
+ which is used to group radio checkbox items. The use of ::v-deep
+ ensures that the padding is modified without being affected by
+ the component's scoping.
+ Without this achieving left alignment for the checkboxes would not
+ be possible.
+ */
+ span :deep(label) {
+ padding-inline-start: 0 !important;
+ background-color: initial !important;
+ border: none !important;
}
section.custom-permissions-group {
- padding-left: 1.5em;
+ padding-inline-start: 1.5em;
}
}
}
+ &__label {
+ padding-block-end: 6px;
+ }
+
&__delete {
- >button:first-child {
+ > button:first-child {
color: rgb(223, 7, 7);
}
}
@@ -1155,10 +1298,10 @@ export default {
margin-top: 16px;
button {
- margin-left: 16px;
+ margin-inline-start: 16px;
&:first-child {
- margin-left: 0;
+ margin-inline-start: 0;
}
}
}
diff --git a/apps/files_sharing/src/views/SharingInherited.vue b/apps/files_sharing/src/views/SharingInherited.vue
index a9c034256ff..809de522d93 100644
--- a/apps/files_sharing/src/views/SharingInherited.vue
+++ b/apps/files_sharing/src/views/SharingInherited.vue
@@ -4,7 +4,7 @@
-->
<template>
- <ul id="sharing-inherited-shares">
+ <ul v-if="shares.length" id="sharing-inherited-shares">
<!-- Main collapsible entry -->
<SharingEntrySimple class="sharing-entry__inherited"
:title="mainTitle"
@@ -30,10 +30,10 @@
<script>
import { generateOcsUrl } from '@nextcloud/router'
-import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import axios from '@nextcloud/axios'
-import Share from '../models/Share.js'
+import Share from '../models/Share.ts'
import SharingEntryInherited from '../components/SharingEntryInherited.vue'
import SharingEntrySimple from '../components/SharingEntrySimple.vue'
diff --git a/apps/files_sharing/src/views/SharingLinkList.vue b/apps/files_sharing/src/views/SharingLinkList.vue
index b10f41eaa15..c3d9a7f83dc 100644
--- a/apps/files_sharing/src/views/SharingLinkList.vue
+++ b/apps/files_sharing/src/views/SharingLinkList.vue
@@ -4,13 +4,9 @@
-->
<template>
- <ul v-if="canLinkShare" class="sharing-link-list">
- <!-- If no link shares, show the add link default entry -->
- <SharingEntryLink v-if="!hasLinkShares && canReshare"
- :can-reshare="canReshare"
- :file-info="fileInfo"
- @add:share="addShare" />
-
+ <ul v-if="canLinkShare"
+ :aria-label="t('files_sharing', 'Link shares')"
+ class="sharing-link-list">
<!-- Else we display the list -->
<template v-if="hasShares">
<!-- using shares[index] to work with .sync -->
@@ -25,17 +21,24 @@
@remove:share="removeShare"
@open-sharing-details="openSharingDetails(share)" />
</template>
+
+ <!-- If no link shares, show the add link default entry -->
+ <SharingEntryLink v-if="!hasLinkShares && canReshare"
+ :can-reshare="canReshare"
+ :file-info="fileInfo"
+ @add:share="addShare" />
</ul>
</template>
<script>
import { getCapabilities } from '@nextcloud/capabilities'
-// eslint-disable-next-line no-unused-vars
+import { t } from '@nextcloud/l10n'
+
import Share from '../models/Share.js'
-import ShareTypes from '../mixins/ShareTypes.js'
import SharingEntryLink from '../components/SharingEntryLink.vue'
import ShareDetails from '../mixins/ShareDetails.js'
+import { ShareType } from '@nextcloud/sharing'
export default {
name: 'SharingLinkList',
@@ -44,7 +47,7 @@ export default {
SharingEntryLink,
},
- mixins: [ShareTypes, ShareDetails],
+ mixins: [ShareDetails],
props: {
fileInfo: {
@@ -78,7 +81,7 @@ export default {
* @return {Array}
*/
hasLinkShares() {
- return this.shares.filter(share => share.type === this.SHARE_TYPES.SHARE_TYPE_LINK).length > 0
+ return this.shares.filter(share => share.type === ShareType.Link).length > 0
},
/**
@@ -92,6 +95,8 @@ export default {
},
methods: {
+ t,
+
/**
* Add a new share into the link shares list
* and return the newly created share component
@@ -101,7 +106,7 @@ export default {
*/
addShare(share, resolve) {
// eslint-disable-next-line vue/no-mutating-props
- this.shares.unshift(share)
+ this.shares.push(share)
this.awaitForShare(share, resolve)
},
diff --git a/apps/files_sharing/src/views/SharingList.vue b/apps/files_sharing/src/views/SharingList.vue
index 7294294afa4..2167059772e 100644
--- a/apps/files_sharing/src/views/SharingList.vue
+++ b/apps/files_sharing/src/views/SharingList.vue
@@ -4,7 +4,7 @@
-->
<template>
- <ul class="sharing-sharee-list">
+ <ul class="sharing-sharee-list" :aria-label="t('files_sharing', 'Shares')">
<SharingEntry v-for="share in shares"
:key="share.id"
:file-info="fileInfo"
@@ -15,10 +15,10 @@
</template>
<script>
-// eslint-disable-next-line no-unused-vars
+import { t } from '@nextcloud/l10n'
import SharingEntry from '../components/SharingEntry.vue'
-import ShareTypes from '../mixins/ShareTypes.js'
import ShareDetails from '../mixins/ShareDetails.js'
+import { ShareType } from '@nextcloud/sharing'
export default {
name: 'SharingList',
@@ -27,7 +27,7 @@ export default {
SharingEntry,
},
- mixins: [ShareTypes, ShareDetails],
+ mixins: [ShareDetails],
props: {
fileInfo: {
@@ -41,6 +41,12 @@ export default {
required: true,
},
},
+
+ setup() {
+ return {
+ t,
+ }
+ },
computed: {
hasShares() {
return this.shares.length === 0
@@ -48,7 +54,7 @@ export default {
isUnique() {
return (share) => {
return [...this.shares].filter((item) => {
- return share.type === this.SHARE_TYPES.SHARE_TYPE_USER && share.shareWithDisplayName === item.shareWithDisplayName
+ return share.type === ShareType.User && share.shareWithDisplayName === item.shareWithDisplayName
}).length <= 1
}
},
diff --git a/apps/files_sharing/src/views/SharingTab.vue b/apps/files_sharing/src/views/SharingTab.vue
index 7e58cb6401e..2ed44a4b5ad 100644
--- a/apps/files_sharing/src/views/SharingTab.vue
+++ b/apps/files_sharing/src/views/SharingTab.vue
@@ -15,8 +15,8 @@
<div v-show="!showSharingDetailsView"
class="sharingTab__content">
<!-- shared with me information -->
- <ul>
- <SharingEntrySimple v-if="isSharedWithMe" v-bind="sharedWithMe" class="sharing-entry__reshare">
+ <ul v-if="isSharedWithMe">
+ <SharingEntrySimple v-bind="sharedWithMe" class="sharing-entry__reshare">
<template #avatar>
<NcAvatar :user="sharedWithMe.user"
:display-name="sharedWithMe.displayName"
@@ -25,50 +25,123 @@
</SharingEntrySimple>
</ul>
- <!-- add new share input -->
- <SharingInput v-if="!loading"
- :can-reshare="canReshare"
- :file-info="fileInfo"
- :link-shares="linkShares"
- :reshare="reshare"
- :shares="shares"
- @open-sharing-details="toggleShareDetailsView" />
-
- <!-- link shares list -->
- <SharingLinkList v-if="!loading"
- ref="linkShareList"
- :can-reshare="canReshare"
- :file-info="fileInfo"
- :shares="linkShares"
- @open-sharing-details="toggleShareDetailsView" />
-
- <!-- other shares list -->
- <SharingList v-if="!loading"
- ref="shareList"
- :shares="shares"
- :file-info="fileInfo"
- @open-sharing-details="toggleShareDetailsView" />
-
- <!-- inherited shares -->
- <SharingInherited v-if="canReshare && !loading" :file-info="fileInfo" />
-
- <!-- internal link copy -->
- <SharingEntryInternal :file-info="fileInfo" />
-
- <!-- projects -->
- <CollectionList v-if="projectsEnabled && fileInfo"
- :id="`${fileInfo.id}`"
- type="file"
- :name="fileInfo.name" />
- </div>
-
- <!-- additional entries, use it with cautious -->
- <div v-for="(section, index) in sections"
- v-show="!showSharingDetailsView"
- :ref="'section-' + index"
- :key="index"
- class="sharingTab__additionalContent">
- <component :is="section($refs['section-'+index], fileInfo)" :file-info="fileInfo" />
+ <section>
+ <div class="section-header">
+ <h4>{{ t('files_sharing', 'Internal shares') }}</h4>
+ <NcPopover popup-role="dialog">
+ <template #trigger>
+ <NcButton class="hint-icon"
+ type="tertiary-no-background"
+ :aria-label="t('files_sharing', 'Internal shares explanation')">
+ <template #icon>
+ <InfoIcon :size="20" />
+ </template>
+ </NcButton>
+ </template>
+ <p class="hint-body">
+ {{ internalSharesHelpText }}
+ </p>
+ </NcPopover>
+ </div>
+ <!-- add new share input -->
+ <SharingInput v-if="!loading"
+ :can-reshare="canReshare"
+ :file-info="fileInfo"
+ :link-shares="linkShares"
+ :reshare="reshare"
+ :shares="shares"
+ :placeholder="internalShareInputPlaceholder"
+ @open-sharing-details="toggleShareDetailsView" />
+
+ <!-- other shares list -->
+ <SharingList v-if="!loading"
+ ref="shareList"
+ :shares="shares"
+ :file-info="fileInfo"
+ @open-sharing-details="toggleShareDetailsView" />
+
+ <!-- inherited shares -->
+ <SharingInherited v-if="canReshare && !loading" :file-info="fileInfo" />
+
+ <!-- internal link copy -->
+ <SharingEntryInternal :file-info="fileInfo" />
+ </section>
+
+ <section>
+ <div class="section-header">
+ <h4>{{ t('files_sharing', 'External shares') }}</h4>
+ <NcPopover popup-role="dialog">
+ <template #trigger>
+ <NcButton class="hint-icon"
+ type="tertiary-no-background"
+ :aria-label="t('files_sharing', 'External shares explanation')">
+ <template #icon>
+ <InfoIcon :size="20" />
+ </template>
+ </NcButton>
+ </template>
+ <p class="hint-body">
+ {{ externalSharesHelpText }}
+ </p>
+ </NcPopover>
+ </div>
+ <SharingInput v-if="!loading"
+ :can-reshare="canReshare"
+ :file-info="fileInfo"
+ :link-shares="linkShares"
+ :is-external="true"
+ :placeholder="externalShareInputPlaceholder"
+ :reshare="reshare"
+ :shares="shares"
+ @open-sharing-details="toggleShareDetailsView" />
+ <!-- Non link external shares list -->
+ <SharingList v-if="!loading"
+ :shares="externalShares"
+ :file-info="fileInfo"
+ @open-sharing-details="toggleShareDetailsView" />
+ <!-- link shares list -->
+ <SharingLinkList v-if="!loading && isLinkSharingAllowed"
+ ref="linkShareList"
+ :can-reshare="canReshare"
+ :file-info="fileInfo"
+ :shares="linkShares"
+ @open-sharing-details="toggleShareDetailsView" />
+ </section>
+
+ <section v-if="sections.length > 0 && !showSharingDetailsView">
+ <div class="section-header">
+ <h4>{{ t('files_sharing', 'Additional shares') }}</h4>
+ <NcPopover popup-role="dialog">
+ <template #trigger>
+ <NcButton class="hint-icon"
+ type="tertiary-no-background"
+ :aria-label="t('files_sharing', 'Additional shares explanation')">
+ <template #icon>
+ <InfoIcon :size="20" />
+ </template>
+ </NcButton>
+ </template>
+ <p class="hint-body">
+ {{ additionalSharesHelpText }}
+ </p>
+ </NcPopover>
+ </div>
+ <!-- additional entries, use it with cautious -->
+ <div v-for="(component, index) in sectionComponents"
+ :key="index"
+ class="sharingTab__additionalContent">
+ <component :is="component" :file-info="fileInfo" />
+ </div>
+
+ <!-- projects (deprecated as of NC25 (replaced by related_resources) - see instance config "projects.enabled" ; ignore this / remove it / move into own section) -->
+ <div v-if="projectsEnabled"
+ v-show="!showSharingDetailsView && fileInfo"
+ class="sharingTab__additionalContent">
+ <NcCollectionList :id="`${fileInfo.id}`"
+ type="file"
+ :name="fileInfo.name" />
+ </div>
+ </section>
</div>
<!-- share details -->
@@ -82,16 +155,26 @@
</template>
<script>
-import { CollectionList } from 'nextcloud-vue-collections'
+import { getCurrentUser } from '@nextcloud/auth'
+import { getCapabilities } from '@nextcloud/capabilities'
+import { orderBy } from '@nextcloud/files'
+import { loadState } from '@nextcloud/initial-state'
import { generateOcsUrl } from '@nextcloud/router'
-import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
+import { ShareType } from '@nextcloud/sharing'
+
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcCollectionList from '@nextcloud/vue/components/NcCollectionList'
+import NcPopover from '@nextcloud/vue/components/NcPopover'
+import InfoIcon from 'vue-material-design-icons/InformationOutline.vue'
+
import axios from '@nextcloud/axios'
-import { loadState } from '@nextcloud/initial-state'
+import moment from '@nextcloud/moment'
-import Config from '../services/ConfigService.js'
import { shareWithTitle } from '../utils/SharedWithMe.js'
-import Share from '../models/Share.js'
-import ShareTypes from '../mixins/ShareTypes.js'
+
+import Config from '../services/ConfigService.ts'
+import Share from '../models/Share.ts'
import SharingEntryInternal from '../components/SharingEntryInternal.vue'
import SharingEntrySimple from '../components/SharingEntrySimple.vue'
import SharingInput from '../components/SharingInput.vue'
@@ -101,12 +184,18 @@ import SharingLinkList from './SharingLinkList.vue'
import SharingList from './SharingList.vue'
import SharingDetailsTab from './SharingDetailsTab.vue'
+import ShareDetails from '../mixins/ShareDetails.js'
+import logger from '../services/logger.ts'
+
export default {
name: 'SharingTab',
components: {
+ InfoIcon,
NcAvatar,
- CollectionList,
+ NcButton,
+ NcCollectionList,
+ NcPopover,
SharingEntryInternal,
SharingEntrySimple,
SharingInherited,
@@ -115,8 +204,7 @@ export default {
SharingList,
SharingDetailsTab,
},
-
- mixins: [ShareTypes],
+ mixins: [ShareDetails],
data() {
return {
@@ -133,12 +221,17 @@ export default {
sharedWithMe: {},
shares: [],
linkShares: [],
+ externalShares: [],
sections: OCA.Sharing.ShareTabSections.getSections(),
projectsEnabled: loadState('core', 'projects_enabled', false),
showSharingDetailsView: false,
shareDetailsData: {},
returnFocusElement: null,
+
+ internalSharesHelpText: t('files_sharing', 'Share files within your organization. Recipients who can already view the file can also use this link for easy access.'),
+ externalSharesHelpText: t('files_sharing', 'Share files with others outside your organization via public links and email addresses. You can also share to Nextcloud accounts on other instances using their federated cloud ID.'),
+ additionalSharesHelpText: t('files_sharing', 'Shares from apps or other sources which are not included in internal or external shares.'),
}
},
@@ -149,15 +242,54 @@ export default {
* @return {boolean}
*/
isSharedWithMe() {
- return Object.keys(this.sharedWithMe).length > 0
+ return !!this.sharedWithMe?.user
+ },
+
+ /**
+ * Is link sharing allowed for the current user?
+ *
+ * @return {boolean}
+ */
+ isLinkSharingAllowed() {
+ const currentUser = getCurrentUser()
+ if (!currentUser) {
+ return false
+ }
+
+ const capabilities = getCapabilities()
+ const publicSharing = capabilities.files_sharing?.public || {}
+ return publicSharing.enabled === true
},
canReshare() {
return !!(this.fileInfo.permissions & OC.PERMISSION_SHARE)
|| !!(this.reshare && this.reshare.hasSharePermission && this.config.isResharingAllowed)
},
- },
+ internalShareInputPlaceholder() {
+ return this.config.showFederatedSharesAsInternal && this.config.isFederationEnabled
+ // TRANSLATORS: Type as in with a keyboard
+ ? t('files_sharing', 'Type names, teams, federated cloud IDs')
+ // TRANSLATORS: Type as in with a keyboard
+ : t('files_sharing', 'Type names or teams')
+ },
+
+ externalShareInputPlaceholder() {
+ if (!this.isLinkSharingAllowed) {
+ // TRANSLATORS: Type as in with a keyboard
+ return this.config.isFederationEnabled ? t('files_sharing', 'Type a federated cloud ID') : ''
+ }
+ return !this.config.showFederatedSharesAsInternal && !this.config.isFederationEnabled
+ // TRANSLATORS: Type as in with a keyboard
+ ? t('files_sharing', 'Type an email')
+ // TRANSLATORS: Type as in with a keyboard
+ : t('files_sharing', 'Type an email or federated cloud ID')
+ },
+
+ sectionComponents() {
+ return this.sections.map((section) => section(undefined, this.fileInfo))
+ },
+ },
methods: {
/**
* Update current fileInfo and fetch new data
@@ -169,7 +301,6 @@ export default {
this.resetState()
this.getShares()
},
-
/**
* Get the existing shares infos
*/
@@ -207,7 +338,7 @@ export default {
this.processSharedWithMe(sharedWithMe)
this.processShares(shares)
} catch (error) {
- if (error.response.data?.ocs?.meta?.message) {
+ if (error?.response?.data?.ocs?.meta?.message) {
this.error = error.response.data.ocs.meta.message
} else {
this.error = t('files_sharing', 'Unable to load the shares list')
@@ -240,7 +371,7 @@ export default {
updateExpirationSubtitle(share) {
const expiration = moment(share.expireDate).unix()
this.$set(this.sharedWithMe, 'subtitle', t('files_sharing', 'Expires {relativetime}', {
- relativetime: OC.Util.relativeModifiedDate(expiration * 1000),
+ relativetime: moment(expiration * 1000).fromNow(),
}))
// share have expired
@@ -260,16 +391,41 @@ export default {
*/
processShares({ data }) {
if (data.ocs && data.ocs.data && data.ocs.data.length > 0) {
- // create Share objects and sort by newest
- const shares = data.ocs.data
- .map(share => new Share(share))
- .sort((a, b) => b.createdTime - a.createdTime)
-
- this.linkShares = shares.filter(share => share.type === this.SHARE_TYPES.SHARE_TYPE_LINK || share.type === this.SHARE_TYPES.SHARE_TYPE_EMAIL)
- this.shares = shares.filter(share => share.type !== this.SHARE_TYPES.SHARE_TYPE_LINK && share.type !== this.SHARE_TYPES.SHARE_TYPE_EMAIL)
+ const shares = orderBy(
+ data.ocs.data.map(share => new Share(share)),
+ [
+ // First order by the "share with" label
+ (share) => share.shareWithDisplayName,
+ // Then by the label
+ (share) => share.label,
+ // And last resort order by createdTime
+ (share) => share.createdTime,
+ ],
+ )
+
+ for (const share of shares) {
+ if ([ShareType.Link, ShareType.Email].includes(share.type)) {
+ this.linkShares.push(share)
+ } else if ([ShareType.Remote, ShareType.RemoteGroup].includes(share.type)) {
+ if (this.config.showFederatedSharesToTrustedServersAsInternal) {
+ if (share.isTrustedServer) {
+ this.shares.push(share)
+ } else {
+ this.externalShares.push(share)
+ }
+ } else if (this.config.showFederatedSharesAsInternal) {
+ this.shares.push(share)
+ } else {
+ this.externalShares.push(share)
+ }
+ } else {
+ this.shares.push(share)
+ }
+ }
- console.debug('Processed', this.linkShares.length, 'link share(s)')
- console.debug('Processed', this.shares.length, 'share(s)')
+ logger.debug(`Processed ${this.linkShares.length} link share(s)`)
+ logger.debug(`Processed ${this.shares.length} share(s)`)
+ logger.debug(`Processed ${this.externalShares.length} external share(s)`)
}
},
@@ -302,7 +458,7 @@ export default {
// interval update
this.expirationInterval = setInterval(this.updateExpirationSubtitle, 10000, share)
}
- } else if (this.fileInfo && this.fileInfo.shareOwnerId !== undefined ? this.fileInfo.shareOwnerId !== OC.currentUser : false) {
+ } else if (this.fileInfo && this.fileInfo.shareOwnerId !== undefined ? this.fileInfo.shareOwnerId !== getCurrentUser().uid : false) {
// Fallback to compare owner and current user.
this.sharedWithMe = {
displayName: this.fileInfo.shareOwner,
@@ -328,8 +484,18 @@ export default {
addShare(share, resolve = () => { }) {
// only catching share type MAIL as link shares are added differently
// meaning: not from the ShareInput
- if (share.type === this.SHARE_TYPES.SHARE_TYPE_EMAIL) {
+ if (share.type === ShareType.Email) {
this.linkShares.unshift(share)
+ } else if ([ShareType.Remote, ShareType.RemoteGroup].includes(share.type)) {
+ if (this.config.showFederatedSharesAsInternal) {
+ this.shares.unshift(share)
+ } if (this.config.showFederatedSharesToTrustedServersAsInternal) {
+ if (share.isTrustedServer) {
+ this.shares.unshift(share)
+ }
+ } else {
+ this.externalShares.unshift(share)
+ }
} else {
this.shares.unshift(share)
}
@@ -343,8 +509,8 @@ export default {
removeShare(share) {
// Get reference for this.linkShares or this.shares
const shareList
- = share.type === this.SHARE_TYPES.SHARE_TYPE_EMAIL
- || share.type === this.SHARE_TYPES.SHARE_TYPE_LINK
+ = share.type === ShareType.Email
+ || share.type === ShareType.Link
? this.linkShares
: this.shares
const index = shareList.findIndex(item => item.id === share.id)
@@ -365,7 +531,7 @@ export default {
let listComponent = this.$refs.shareList
// Only mail shares comes from the input, link shares
// are managed internally in the SharingLinkList component
- if (share.type === this.SHARE_TYPES.SHARE_TYPE_EMAIL) {
+ if (share.type === ShareType.Email) {
listComponent = this.$refs.linkShareList
}
const newShare = listComponent.$children.find(component => component.share === share)
@@ -415,10 +581,47 @@ export default {
&__content {
padding: 0 6px;
+
+ section {
+ padding-bottom: 16px;
+
+ .section-header {
+ margin-top: 2px;
+ margin-bottom: 2px;
+ display: flex;
+ align-items: center;
+ padding-bottom: 4px;
+
+ h4 {
+ margin: 0;
+ font-size: 16px;
+ }
+
+ .visually-hidden {
+ display: none;
+ }
+
+ .hint-icon {
+ color: var(--color-primary-element);
+ }
+
+ }
+
+ }
+
+ & > section:not(:last-child) {
+ border-bottom: 2px solid var(--color-border);
+ }
+
}
&__additionalContent {
margin: 44px 0;
}
}
+
+.hint-body {
+ max-width: 300px;
+ padding: var(--border-radius-element);
+}
</style>