diff options
Diffstat (limited to 'apps/files_sharing/src')
40 files changed, 894 insertions, 532 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/FileListFilterAccount.vue b/apps/files_sharing/src/components/FileListFilterAccount.vue index f4e4eefb6e2..150516e139b 100644 --- a/apps/files_sharing/src/components/FileListFilterAccount.vue +++ b/apps/files_sharing/src/components/FileListFilterAccount.vue @@ -8,7 +8,7 @@ :filter-name="t('files_sharing', 'People')" @reset-filter="resetFilter"> <template #icon> - <NcIconSvgWrapper :path="mdiAccountMultiple" /> + <NcIconSvgWrapper :path="mdiAccountMultipleOutline" /> </template> <NcActionInput v-if="availableAccounts.length > 1" :label="t('files_sharing', 'Filter accounts')" @@ -36,14 +36,11 @@ </template> <script setup lang="ts"> -import type { IAccountData } from '../filters/AccountFilter.ts' +import type { IAccountData } from '../files_filters/AccountFilter.ts' import { translate as t } from '@nextcloud/l10n' -import { ShareType } from '@nextcloud/sharing' -import { mdiAccountMultiple } from '@mdi/js' -import { useBrowserLocation } from '@vueuse/core' +import { mdiAccountMultipleOutline } from '@mdi/js' import { computed, ref, watch } from 'vue' -import { useNavigation } from '../../../files/src/composables/useNavigation.ts' import FileListFilter from '../../../files/src/components/FileListFilter/FileListFilter.vue' import NcActionButton from '@nextcloud/vue/components/NcActionButton' @@ -61,8 +58,6 @@ const emit = defineEmits<{ (event: 'update:accounts', value: IAccountData[]): void }>() -const { currentView } = useNavigation() -const currentLocation = useBrowserLocation() const accountFilter = ref('') const availableAccounts = ref<IUserSelectData[]>([]) const selectedAccounts = ref<IUserSelectData[]>([]) @@ -106,71 +101,27 @@ watch(selectedAccounts, () => { }) /** - * Update the accounts owning nodes or have nodes shared to them - * @param path The path inside the current view to load for accounts - */ -async function updateAvailableAccounts(path: string = '/') { - availableAccounts.value = [] - if (!currentView.value) { - return - } - - const { contents } = await currentView.value.getContents(path) - const available = new Map<string, IUserSelectData>() - for (const node of contents) { - const owner = node.owner - if (owner && !available.has(owner)) { - available.set(owner, { - id: owner, - user: owner, - displayName: node.attributes['owner-display-name'] ?? node.owner, - }) - } - - const sharees = node.attributes.sharees?.sharee - if (sharees) { - // ensure sharees is an array (if only one share then it is just an object) - 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, { - id: sharee.id, - user: sharee.id, - displayName: sharee['display-name'], - }) - } - } - } - } - availableAccounts.value = [...available.values()] -} - -/** * Reset this filter */ function resetFilter() { selectedAccounts.value = [] accountFilter.value = '' } -defineExpose({ resetFilter, toggleAccount }) -// When the current view changes or the current directory, -// then we need to rebuild the available accounts -watch([currentView, currentLocation], () => { - if (currentView.value) { - // we have no access to the files router here... - const path = (currentLocation.value.search ?? '?dir=/').match(/(?<=&|\?)dir=([^&#]+)/)?.[1] - resetFilter() - updateAvailableAccounts(decodeURIComponent(path ?? '/')) - } -}, { immediate: true }) +/** + * 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"> diff --git a/apps/files_sharing/src/components/NewFileRequestDialog.vue b/apps/files_sharing/src/components/NewFileRequestDialog.vue index 102d1a0fed9..392f286e104 100644 --- a/apps/files_sharing/src/components/NewFileRequestDialog.vue +++ b/apps/files_sharing/src/components/NewFileRequestDialog.vue @@ -296,8 +296,8 @@ export default defineComponent({ path: this.destination, note: this.note, - password: this.password || undefined, - expireDate: expireDate || undefined, + password: this.password || '', + expireDate: expireDate || '', // Empty string shareWith: '', diff --git a/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogDatePassword.vue b/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogDatePassword.vue index 4c14b21e1d5..7e6d56e8794 100644 --- a/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogDatePassword.vue +++ b/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogDatePassword.vue @@ -14,9 +14,9 @@ <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="!defaultExpireDateEnforced" - :checked="defaultExpireDateEnforced || expirationDate !== null" - :disabled="disabled || defaultExpireDateEnforced" + <NcCheckboxRadioSwitch v-show="!isExpirationDateEnforced" + :checked="isExpirationDateEnforced || expirationDate !== null" + :disabled="disabled || isExpirationDateEnforced" @update:checked="onToggleDeadline"> {{ t('files_sharing', 'Set a submission expiration date') }} </NcCheckboxRadioSwitch> @@ -46,9 +46,9 @@ <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="!enforcePasswordForPublicLink" - :checked="enforcePasswordForPublicLink || password !== null" - :disabled="disabled || enforcePasswordForPublicLink" + <NcCheckboxRadioSwitch v-show="!isPasswordEnforced" + :checked="isPasswordEnforced || password !== null" + :disabled="disabled || isPasswordEnforced" @update:checked="onTogglePassword"> {{ t('files_sharing', 'Set a password') }} </NcCheckboxRadioSwitch> @@ -59,7 +59,7 @@ :disabled="disabled" :label="t('files_sharing', 'Password')" :placeholder="t('files_sharing', 'Enter a valid password')" - :required="false" + :required="enforcePasswordForPublicLink" :value="password" name="password" @update:value="$emit('update:password', $event)" /> @@ -180,6 +180,18 @@ export default defineComponent({ 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() { @@ -189,12 +201,12 @@ export default defineComponent({ } // If enforced, we cannot set a date before the default expiration days (see admin settings) - if (this.defaultExpireDateEnforced) { + if (this.isExpirationDateEnforced) { this.maxDate = sharingConfig.defaultExpirationDate } // If enabled by default, we generate a valid password - if (this.enableLinkPasswordByDefault) { + if (this.isPasswordEnforced) { this.generatePassword() } }, diff --git a/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogFinish.vue b/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogFinish.vue index 499fd773edc..7826aab581e 100644 --- a/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogFinish.vue +++ b/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogFinish.vue @@ -16,7 +16,7 @@ :label="t('files_sharing', 'Share link')" :readonly="true" :show-trailing-button="true" - :trailing-button-label="t('files_sharing', 'Copy to clipboard')" + :trailing-button-label="t('files_sharing', 'Copy')" data-cy-file-request-dialog-fieldset="link" @click="copyShareLink" @trailing-button-click="copyShareLink"> @@ -140,7 +140,7 @@ export default defineComponent({ await navigator.clipboard.writeText(this.shareLink) - showSuccess(t('files_sharing', 'Link copied to clipboard')) + showSuccess(t('files_sharing', 'Link copied')) this.isCopied = true event.target?.select?.() diff --git a/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogIntro.vue b/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogIntro.vue index 2d4d8eafa2b..5ac60c37e29 100644 --- a/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogIntro.vue +++ b/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogIntro.vue @@ -78,7 +78,7 @@ 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/Information.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' 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 3f8f03753d8..342b40ce384 100644 --- a/apps/files_sharing/src/components/SharingEntry.vue +++ b/apps/files_sharing/src/components/SharingEntry.vue @@ -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,6 +29,7 @@ :file-info="fileInfo" @open-sharing-details="openShareDetailsForCustomSettings(share)" /> </div> + <ShareExpiryTime v-if="share && share.expireDate" :share="share" /> <NcButton v-if="share.canEdit" class="sharing-entry__action" data-cy-files-sharing-share-actions @@ -49,6 +51,7 @@ 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' @@ -62,6 +65,7 @@ export default { NcAvatar, DotsHorizontalIcon, NcSelect, + ShareExpiryTime, SharingEntryQuickShareSelect, }, @@ -70,11 +74,15 @@ export default { computed: { title() { let title = this.share.shareWithDisplayName - if (this.share.type === ShareType.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 === ShareType.Room) { title += ` (${t('files_sharing', 'conversation')})` - } else if (this.share.type === ShareType.Remote) { + } else if (this.share.type === ShareType.Remote && !showAsInternal) { title += ` (${t('files_sharing', 'remote')})` } else if (this.share.type === ShareType.RemoteGroup) { title += ` (${t('files_sharing', 'remote group')})` diff --git a/apps/files_sharing/src/components/SharingEntryInternal.vue b/apps/files_sharing/src/components/SharingEntryInternal.vue index 2ad1256fa82..027d2a3d5c3 100644 --- a/apps/files_sharing/src/components/SharingEntryInternal.vue +++ b/apps/files_sharing/src/components/SharingEntryInternal.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 e03370bb6e8..6865af1b864 100644 --- a/apps/files_sharing/src/components/SharingEntryLink.vue +++ b/apps/files_sharing/src/components/SharingEntryLink.vue @@ -24,20 +24,26 @@ @open-sharing-details="openShareDetailsForCustomSettings(share)" /> </div> - <!-- clipboard --> - <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 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 --> @@ -68,10 +74,10 @@ {{ 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" :label="t('files_sharing', 'Enter a password')" - :value.sync="share.password" + :value.sync="share.newPassword" :disabled="saving" :required="config.enableLinkPasswordByDefault || config.enforcePasswordForPublicLink" :minlength="isPasswordPolicyEnabled && config.passwordPolicy.minLength" @@ -102,13 +108,15 @@ type="date" :min="dateTomorrow" :max="maxExpirationDateEnforced" - @change="expirationDateChanged($event)"> + @update:model-value="onExpirationChange" + @change="expirationDateChanged"> <template #icon> <IconCalendarBlank :size="20" /> </template> </NcActionInput> - <NcActionButton @click.prevent.stop="onNewLinkShare(true)"> + <NcActionButton :disabled="pendingEnforcedPassword && !share.newPassword" + @click.prevent.stop="onNewLinkShare(true)"> <template #icon> <CheckIcon :size="20" /> </template> @@ -216,14 +224,14 @@ </template> <script> +import { showError, showSuccess } from '@nextcloud/dialogs' import { emit } from '@nextcloud/event-bus' +import { t } from '@nextcloud/l10n' +import moment from '@nextcloud/moment' import { generateUrl, getBaseUrl } from '@nextcloud/router' -import { showError, showSuccess } from '@nextcloud/dialogs' import { ShareType } from '@nextcloud/sharing' -import VueQrcode from '@chenfengyuan/vue-qrcode' -import moment from '@nextcloud/moment' -import Vue from 'vue' +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' @@ -235,23 +243,24 @@ 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/CalendarBlank.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/ContentCopy.vue' import CloseIcon from 'vue-material-design-icons/Close.vue' import PlusIcon from 'vue-material-design-icons/Plus.vue' import SharingEntryQuickShareSelect from './SharingEntryQuickShareSelect.vue' +import ShareExpiryTime from './ShareExpiryTime.vue' import ExternalShareAction from './ExternalShareAction.vue' import GeneratePassword from '../utils/GeneratePassword.ts' import Share from '../models/Share.ts' import SharesMixin from '../mixins/SharesMixin.js' import ShareDetails from '../mixins/ShareDetails.js' -import { getLoggerBuilder } from '@nextcloud/logger' +import logger from '../services/logger.ts' export default { name: 'SharingEntryLink', @@ -278,6 +287,7 @@ export default { CloseIcon, PlusIcon, SharingEntryQuickShareSelect, + ShareExpiryTime, }, mixins: [SharesMixin, ShareDetails], @@ -305,10 +315,6 @@ export default { 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, @@ -322,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) { @@ -329,26 +337,26 @@ 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() === '') { @@ -383,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(true) : '') - Vue.set(this.share, 'newPassword', this.share.password) - }, - }, passwordExpirationTime() { if (this.share.passwordExpirationTime === null) { @@ -558,7 +550,7 @@ export default { } return t('files_sharing', 'Cannot copy, please copy the link manually') } - return t('files_sharing', 'Copy public link of "{title}" to clipboard', { title: this.title }) + return t('files_sharing', 'Copy public link of "{title}"', { title: this.title }) }, /** @@ -622,7 +614,7 @@ export default { * @param {boolean} shareReviewComplete if the share was reviewed */ async onNewLinkShare(shareReviewComplete = false) { - this.logger.debug('onNewLinkShare called (with this.share)', this.share) + logger.debug('onNewLinkShare called (with this.share)', this.share) // do not run again if already loading if (this.loading) { return @@ -637,7 +629,7 @@ export default { shareDefaults.expiration = this.formatDateToString(this.config.defaultExpirationDate) } - this.logger.debug('Missing required properties?', this.enforcedPropertiesMissing) + 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 @@ -645,7 +637,7 @@ export default { this.pending = true this.shareCreationComplete = false - this.logger.info('Share policy requires a review or has mandated properties (password, expirationDate)...') + 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 @@ -655,6 +647,7 @@ export default { // 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) }) @@ -673,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 @@ -847,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') } }, @@ -862,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') @@ -884,9 +877,9 @@ export default { }, expirationDateChanged(event) { - const date = event.target.value - this.onExpirationChange(date) - this.defaultExpirationDateEnabled = !!date + const value = event?.target?.value + const isValid = !!value && !isNaN(new Date(value).getTime()) + this.defaultExpirationDateEnabled = isValid }, /** @@ -936,6 +929,12 @@ 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); diff --git a/apps/files_sharing/src/components/SharingEntryQuickShareSelect.vue b/apps/files_sharing/src/components/SharingEntryQuickShareSelect.vue index 041841201d0..102eea63cb6 100644 --- a/apps/files_sharing/src/components/SharingEntryQuickShareSelect.vue +++ b/apps/files_sharing/src/components/SharingEntryQuickShareSelect.vue @@ -36,7 +36,7 @@ import ShareDetails from '../mixins/ShareDetails.js' import NcActions from '@nextcloud/vue/components/NcActions' import NcActionButton from '@nextcloud/vue/components/NcActionButton' import IconEyeOutline from 'vue-material-design-icons/EyeOutline.vue' -import IconPencil from 'vue-material-design-icons/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' diff --git a/apps/files_sharing/src/components/SharingInput.vue b/apps/files_sharing/src/components/SharingInput.vue index 49a39915e5e..6fb33aba6b2 100644 --- a/apps/files_sharing/src/components/SharingInput.vue +++ b/apps/files_sharing/src/components/SharingInput.vue @@ -192,14 +192,25 @@ export default { lookup = true } - let shareType = [] + 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) { - shareType.push(ShareType.Remote) - shareType.push(ShareType.RemoteGroup) + if (getCapabilities().files_sharing.public.enabled === true) { + shareType.push(ShareType.Email) + } } else { - // Merge shareType array - shareType = shareType.concat([ + shareType.push( ShareType.User, ShareType.Group, ShareType.Team, @@ -207,12 +218,11 @@ export default { ShareType.Guest, ShareType.Deck, ShareType.ScienceMesh, - ]) - + ) } - if (getCapabilities().files_sharing.public.enabled === true && this.isExternal) { - shareType.push(ShareType.Email) + if (shouldAddRemoteTypes) { + shareType.push(...remoteTypes) } let request = null @@ -232,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) @@ -257,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, }) } @@ -363,6 +370,11 @@ export default { // filter out existing mail shares if (share.value.shareType === ShareType.Email) { + // When sharing internally, we don't want to suggest email addresses + // that the user previously created shares to + if (!this.isExternal) { + return arr + } const emails = this.linkShares.map(elem => elem.shareWith) if (emails.indexOf(share.value.shareWith.trim()) !== -1) { return arr @@ -453,14 +465,19 @@ export default { */ formatForMultiselect(result) { let subname + let displayName = result.name || result.label + if (result.value.shareType === ShareType.User && this.config.shouldAlwaysShowUnique) { subname = result.shareWithDisplayNameUnique ?? '' - } else if ((result.value.shareType === ShareType.Remote - || result.value.shareType === ShareType.RemoteGroup - ) && result.value.server) { - subname = t('files_sharing', 'on {server}', { server: result.value.server }) } else if (result.value.shareType === ShareType.Email) { subname = result.value.shareWith + } else if (result.value.shareType === ShareType.Remote || result.value.shareType === ShareType.RemoteGroup) { + if (this.config.showFederatedSharesAsInternal) { + subname = result.extra?.email?.value ?? '' + displayName = result.extra?.name?.value ?? displayName + } else if (result.value.server) { + subname = t('files_sharing', 'on {server}', { server: result.value.server }) + } } else { subname = result.shareWithDescription ?? '' } @@ -470,7 +487,7 @@ export default { shareType: result.value.shareType, user: result.uuid || result.value.shareWith, isNoUser: result.value.shareType !== ShareType.User, - displayName: result.name || result.label, + displayName, subname, shareWithDisplayNameUnique: result.shareWithDisplayNameUnique || '', ...this.shareTypeToIcon(result.value.shareType), diff --git a/apps/files_sharing/src/files_actions/openInFilesAction.spec.ts b/apps/files_sharing/src/files_actions/openInFilesAction.spec.ts index 95bd2812db7..23c0938545c 100644 --- a/apps/files_sharing/src/files_actions/openInFilesAction.spec.ts +++ b/apps/files_sharing/src/files_actions/openInFilesAction.spec.ts @@ -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) diff --git a/apps/files_sharing/src/files_actions/openInFilesAction.ts b/apps/files_sharing/src/files_actions/openInFilesAction.ts index e9e07179fc4..133b4531bb5 100644 --- a/apps/files_sharing/src/files_actions/openInFilesAction.ts +++ b/apps/files_sharing/src/files_actions/openInFilesAction.ts @@ -2,15 +2,15 @@ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ + import type { Node } from '@nextcloud/files' import { registerFileAction, FileAction, DefaultType, FileType } from '@nextcloud/files' import { translate as t } from '@nextcloud/l10n' - import { sharesViewId, sharedWithYouViewId, sharedWithOthersViewId, sharingByLinksViewId } from '../files_views/shares' export const action = new FileAction({ - id: 'open-in-files', + id: 'files_sharing:open-in-files', displayName: () => t('files_sharing', 'Open in Files'), iconSvgInline: () => '', diff --git a/apps/files_sharing/src/files_actions/sharingStatusAction.ts b/apps/files_sharing/src/files_actions/sharingStatusAction.ts index 75fe7d54096..18fa46d2781 100644 --- a/apps/files_sharing/src/files_actions/sharingStatusAction.ts +++ b/apps/files_sharing/src/files_actions/sharingStatusAction.ts @@ -8,8 +8,8 @@ import { translate as t } from '@nextcloud/l10n' import { ShareType } from '@nextcloud/sharing' import { isPublicShare } from '@nextcloud/sharing/public' -import AccountGroupSvg from '@mdi/svg/svg/account-group.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' @@ -53,7 +53,7 @@ export const action = new FileAction({ 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', 'Show sharing options') + return t('files_sharing', 'Sharing options') } const sharee = [sharees].flat()[0] // the property is sometimes weirdly normalized, so we need to compensate diff --git a/apps/files_sharing/src/files_filters/AccountFilter.ts b/apps/files_sharing/src/files_filters/AccountFilter.ts index 8da4d85d67c..4f185d9fd9c 100644 --- a/apps/files_sharing/src/files_filters/AccountFilter.ts +++ b/apps/files_sharing/src/files_filters/AccountFilter.ts @@ -4,27 +4,41 @@ */ 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, toggleAccount: (account: string) => void } +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) { @@ -33,11 +47,11 @@ class AccountFilter extends FileListFilter { } const View = Vue.extend(FileListFilterAccount as never) - this.currentInstance = new View({ - el, - }) - .$on('update:accounts', this.setAccounts.bind(this)) + 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[] { @@ -70,6 +84,11 @@ class AccountFilter extends FileListFilter { 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[] = [] @@ -85,11 +104,59 @@ class AccountFilter extends FileListFilter { 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_newMenu/newFileRequest.ts b/apps/files_sharing/src/files_newMenu/newFileRequest.ts index f7c5cc4057a..1d58e3552a2 100644 --- a/apps/files_sharing/src/files_newMenu/newFileRequest.ts +++ b/apps/files_sharing/src/files_newMenu/newFileRequest.ts @@ -7,7 +7,7 @@ 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.svg?raw' +import FileUploadSvg from '@mdi/svg/svg/file-upload-outline.svg?raw' import Config from '../services/ConfigService' import { isPublicShare } from '@nextcloud/sharing/public' diff --git a/apps/files_sharing/src/files_views/publicFileDrop.ts b/apps/files_sharing/src/files_views/publicFileDrop.ts index 0d782d48fc7..65756e83c74 100644 --- a/apps/files_sharing/src/files_views/publicFileDrop.ts +++ b/apps/files_sharing/src/files_views/publicFileDrop.ts @@ -4,7 +4,8 @@ */ import type { VueConstructor } from 'vue' -import { Folder, Permission, View, davRemoteURL, davRootPath, getNavigation } from '@nextcloud/files' +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' @@ -45,8 +46,8 @@ export default () => { // Fake a writeonly folder as root folder: new Folder({ id: 0, - source: `${davRemoteURL}${davRootPath}`, - root: davRootPath, + source: `${defaultRemoteURL}${defaultRootPath}`, + root: defaultRootPath, owner: null, permissions: Permission.CREATE, }), diff --git a/apps/files_sharing/src/files_views/shares.spec.ts b/apps/files_sharing/src/files_views/shares.spec.ts index 79ef2a09843..7e5b59e0ad9 100644 --- a/apps/files_sharing/src/files_views/shares.spec.ts +++ b/apps/files_sharing/src/files_views/shares.spec.ts @@ -5,8 +5,10 @@ /* eslint-disable n/no-extraneous-import */ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { OCSResponse } from '@nextcloud/typings/ocs' -import { Folder, Navigation, View, getNavigation } from '@nextcloud/files' + 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' @@ -72,6 +74,27 @@ describe('Sharing views definition', () => { 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', () => { diff --git a/apps/files_sharing/src/files_views/shares.ts b/apps/files_sharing/src/files_views/shares.ts index 7aec0dbeafb..fd5e908638c 100644 --- a/apps/files_sharing/src/files_views/shares.ts +++ b/apps/files_sharing/src/files_views/shares.ts @@ -6,14 +6,15 @@ 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.svg?raw' +import FileUploadSvg from '@mdi/svg/svg/file-upload-outline.svg?raw' import LinkSvg from '@mdi/svg/svg/link.svg?raw' import { getContents, isFileRequest } from '../services/SharingService' +import { loadState } from '@nextcloud/initial-state' export const sharesViewId = 'shareoverview' export const sharedWithYouViewId = 'sharingin' @@ -58,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, diff --git a/apps/files_sharing/src/init.ts b/apps/files_sharing/src/init.ts index 5a2b93c6ea3..f275f3beaf7 100644 --- a/apps/files_sharing/src/init.ts +++ b/apps/files_sharing/src/init.ts @@ -2,7 +2,8 @@ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { addNewFileMenuEntry, registerDavProperty } from '@nextcloud/files' +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' @@ -21,6 +22,7 @@ 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' }) diff --git a/apps/files_sharing/src/mixins/ShareDetails.js b/apps/files_sharing/src/mixins/ShareDetails.js index 61cffab86f2..6ccdf8d63d0 100644 --- a/apps/files_sharing/src/mixins/ShareDetails.js +++ b/apps/files_sharing/src/mixins/ShareDetails.js @@ -5,6 +5,8 @@ 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: { @@ -26,6 +28,18 @@ export default { 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, diff --git a/apps/files_sharing/src/mixins/ShareRequests.js b/apps/files_sharing/src/mixins/ShareRequests.js index 24b4b12061e..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.ts' -import { emit } from '@nextcloud/event-bus' const shareUrl = generateOcsUrl('apps/files_sharing/api/v1/shares') @@ -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/SharesMixin.js b/apps/files_sharing/src/mixins/SharesMixin.js index 588b0bf4bc6..a461da56d85 100644 --- a/apps/files_sharing/src/mixins/SharesMixin.js +++ b/apps/files_sharing/src/mixins/SharesMixin.js @@ -11,6 +11,7 @@ import { emit } from '@nextcloud/event-bus' import PQueue from 'p-queue' import debounce from 'debounce' +import GeneratePassword from '../utils/GeneratePassword.ts' import Share from '../models/Share.ts' import SharesRequests from './ShareRequests.js' import Config from '../services/ConfigService.ts' @@ -156,6 +157,26 @@ 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: { @@ -213,8 +234,13 @@ export default { * @param {Date} date */ onExpirationChange(date) { - const formattedDate = date ? this.formatDateToString(new Date(date)) : '' - this.share.expireDate = formattedDate + 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) }, /** @@ -246,7 +272,7 @@ 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 }) @@ -277,7 +303,12 @@ export default { const properties = {} // force value to string because that is what our // share api controller accepts - propertyNames.forEach(name => { + 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') { @@ -285,7 +316,7 @@ export default { } else { properties[name] = this.share[name].toString() } - }) + } return this.updateQueue.add(async () => { this.saving = true @@ -293,8 +324,9 @@ export default { 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 @@ -302,14 +334,18 @@ export default { } // clear any previous errors - this.$delete(this.errors, propertyNames[0]) + 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) + 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 @@ -358,6 +394,13 @@ export default { * @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.ts b/apps/files_sharing/src/models/Share.ts index 39907b03025..b0638b29448 100644 --- a/apps/files_sharing/src/models/Share.ts +++ b/apps/files_sharing/src/models/Share.ts @@ -252,6 +252,15 @@ export default class Share { * Hide the download button on public page */ set hideDownload(state: boolean) { + // disabling hide-download also enables the download permission + // needed for regression in Nextcloud 31.0.0 until (incl.) 31.0.3 + if (!state) { + const attribute = this.attributes.find(({ key, scope }) => key === 'download' && scope === 'permissions') + if (attribute) { + attribute.value = true + } + } + this._share.hide_download = state === true } @@ -477,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/public-file-request.ts b/apps/files_sharing/src/public-file-request.ts deleted file mode 100644 index 1d640c5ea5e..00000000000 --- a/apps/files_sharing/src/public-file-request.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { defineAsyncComponent } from 'vue' -import { getBuilder } from '@nextcloud/browser-storage' -import { getGuestNickname, setGuestNickname } from '@nextcloud/auth' -import { getUploader } from '@nextcloud/upload' -import { spawnDialog } from '@nextcloud/dialogs' - -import logger from './services/logger' - -const storage = getBuilder('files_sharing').build() - -/** - * Setup file-request nickname header for the uploader - * @param nickname The nickname - */ -function 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 - * @param nickname The chosen nickname - */ -function onSetNickname(nickname: string): void { - // Set the nickname - setGuestNickname(nickname) - // Set the dialog as shown - storage.setItem('public-auth-prompt-shown', 'true') - // Register header for uploader - registerFileRequestHeader(nickname) -} - -window.addEventListener('DOMContentLoaded', () => { - const nickname = getGuestNickname() ?? '' - const dialogShown = storage.getItem('public-auth-prompt-shown') !== null - - // 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) { - spawnDialog( - defineAsyncComponent(() => import('./views/PublicAuthPrompt.vue')), - { - nickname, - }, - onSetNickname as (...rest: unknown[]) => void, - ) - } else { - logger.debug('Public auth prompt already shown.', { nickname }) - registerFileRequestHeader(nickname) - } -}) 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/services/ConfigService.ts b/apps/files_sharing/src/services/ConfigService.ts index 09fdca13598..547038f362d 100644 --- a/apps/files_sharing/src/services/ConfigService.ts +++ b/apps/files_sharing/src/services/ConfigService.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ import { getCapabilities } from '@nextcloud/capabilities' +import { loadState } from '@nextcloud/initial-state' type PasswordPolicyCapabilities = { enforceNonCommonPassword: boolean @@ -212,6 +213,13 @@ export default class Config { } /** + * Is federation enabled ? + */ + get isFederationEnabled(): boolean { + return this._capabilities?.files_sharing?.federation?.outgoing === true + } + + /** * Is public sharing enabled ? */ get isPublicShareAllowed(): boolean { @@ -306,4 +314,20 @@ export default class Config { 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/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.ts b/apps/files_sharing/src/services/SharingService.ts index d8c6747f89c..41c20f9aa73 100644 --- a/apps/files_sharing/src/services/SharingService.ts +++ b/apps/files_sharing/src/services/SharingService.ts @@ -94,6 +94,7 @@ const ocsEntryToNode = async function(ocsEntry: any): Promise<Folder | File | nu 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, 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/utils/GeneratePassword.ts b/apps/files_sharing/src/utils/GeneratePassword.ts index 2f3f65c51d8..82efaaa69d4 100644 --- a/apps/files_sharing/src/utils/GeneratePassword.ts +++ b/apps/files_sharing/src/utils/GeneratePassword.ts @@ -38,10 +38,29 @@ export default async function(verbose = false): Promise<string> { const array = new Uint8Array(10) const ratio = passwordSet.length / 255 - self.crypto.getRandomValues(array) + 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/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 index 31b66741698..ec6348606fb 100644 --- a/apps/files_sharing/src/views/FilesHeaderNoteToRecipient.vue +++ b/apps/files_sharing/src/views/FilesHeaderNoteToRecipient.vue @@ -6,7 +6,7 @@ <NcNoteCard v-if="note.length > 0" class="note-to-recipient" type="info"> - <p v-if="user" class="note-to-recipient__heading"> + <p v-if="displayName" class="note-to-recipient__heading"> {{ t('files_sharing', 'Note from') }} <NcUserBubble :user="user.id" :display-name="user.displayName" /> </p> @@ -28,13 +28,13 @@ 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 - const displayName = folder.value?.attributes?.['owner-display-name'] if (id !== getCurrentUser()?.uid) { return { id, - displayName, + displayName: displayName.value, } } return null diff --git a/apps/files_sharing/src/views/FilesViewFileDropEmptyContent.vue b/apps/files_sharing/src/views/FilesViewFileDropEmptyContent.vue index 5571e5e9f5d..dac22748d8a 100644 --- a/apps/files_sharing/src/views/FilesViewFileDropEmptyContent.vue +++ b/apps/files_sharing/src/views/FilesViewFileDropEmptyContent.vue @@ -5,13 +5,29 @@ <template> <NcEmptyContent class="file-drop-empty-content" data-cy-files-sharing-file-drop - :name="t('files_sharing', 'File drop')"> + :name="name"> <template #icon> <NcIconSvgWrapper :svg="svgCloudUpload" /> </template> <template #description> - {{ t('files_sharing', 'Upload files to {foldername}.', { foldername }) }} - {{ disclaimer === '' ? '' : t('files_sharing', 'By uploading files, you agree to the terms of service.') }} + <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"> @@ -34,34 +50,87 @@ </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 } from '@nextcloud/upload' +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 svgCloudUpload from '@mdi/svg/svg/cloud-upload.svg?raw' +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 -</script> -<style scoped> -:deep(.terms-of-service-dialog) { - min-height: min(100px, 20vh); +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)) } -/* TODO fix in library */ -.file-drop-empty-content :deep(.empty-content__action) { - display: flex; - gap: var(--default-grid-baseline); +</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/PublicAuthPrompt.vue b/apps/files_sharing/src/views/PublicAuthPrompt.vue deleted file mode 100644 index afa1e10ac56..00000000000 --- a/apps/files_sharing/src/views/PublicAuthPrompt.vue +++ /dev/null @@ -1,123 +0,0 @@ -<!-- - - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - - SPDX-License-Identifier: AGPL-3.0-or-later ---> - -<template> - <NcDialog :buttons="dialogButtons" - class="public-auth-prompt" - data-cy-public-auth-prompt-dialog - is-form - :can-close="false" - :name="dialogName" - @submit="$emit('close', name)"> - <p v-if="owner" class="public-auth-prompt__subtitle"> - {{ t('files_sharing', '{ownerDisplayName} shared a folder with you.', { ownerDisplayName }) }} - </p> - - <!-- Header --> - <NcNoteCard class="public-auth-prompt__header" - :text="t('files_sharing', 'To upload files, you need to provide your name first.')" - type="info" /> - - <!-- Form --> - <NcTextField ref="input" - class="public-auth-prompt__input" - data-cy-public-auth-prompt-dialog-name - :label="t('files_sharing', 'Nickname')" - :placeholder="t('files_sharing', 'Enter your nickname')" - minlength="2" - name="name" - required - :value.sync="name" /> - </NcDialog> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue' -import { t } from '@nextcloud/l10n' - -import NcDialog from '@nextcloud/vue/components/NcDialog' -import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' -import NcTextField from '@nextcloud/vue/components/NcTextField' -import { loadState } from '@nextcloud/initial-state' - -export default defineComponent({ - name: 'PublicAuthPrompt', - - components: { - NcDialog, - NcNoteCard, - NcTextField, - }, - - props: { - /** - * Preselected nickname - * @default '' No name preselected by default - */ - nickname: { - type: String, - default: '', - }, - }, - - setup() { - return { - t, - - owner: loadState('files_sharing', 'owner', ''), - ownerDisplayName: loadState('files_sharing', 'ownerDisplayName', ''), - label: loadState('files_sharing', 'label', ''), - note: loadState('files_sharing', 'note', ''), - filename: loadState('files_sharing', 'filename', ''), - } - }, - - data() { - return { - name: '', - } - }, - - computed: { - dialogName() { - return this.t('files_sharing', 'Upload files to {folder}', { folder: this.label || this.filename }) - }, - dialogButtons() { - return [{ - label: t('files_sharing', 'Submit name'), - type: 'primary', - nativeType: 'submit', - }] - }, - }, - - watch: { - /** Reset name to pre-selected nickname (e.g. Talk / Collabora ) */ - nickname: { - handler() { - this.name = this.nickname - }, - immediate: true, - }, - }, -}) -</script> -<style scoped lang="scss"> -.public-auth-prompt { - &__subtitle { - // Smaller than dialog title - font-size: 1.25em; - margin-block: 0 calc(3 * var(--default-grid-baseline)); - } - - &__header { - margin-block: 0 calc(3 * var(--default-grid-baseline)); - } - - &__input { - margin-block: calc(4 * var(--default-grid-baseline)) calc(2 * var(--default-grid-baseline)); - } -} -</style> diff --git a/apps/files_sharing/src/views/SharingDetailsTab.vue b/apps/files_sharing/src/views/SharingDetailsTab.vue index 659d65cd70c..b3a3b95d92e 100644 --- a/apps/files_sharing/src/views/SharingDetailsTab.vue +++ b/apps/files_sharing/src/views/SharingDetailsTab.vue @@ -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" @@ -128,7 +128,7 @@ </NcCheckboxRadioSwitch> <NcPasswordField v-if="isPasswordProtected" autocomplete="new-password" - :value="hasUnsavedPassword ? share.newPassword : ''" + :value="share.newPassword ?? ''" :error="passwordError" :helper-text="errorPasswordLabel || passwordHint" :required="isPasswordEnforced && isNewShare" @@ -226,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> @@ -249,8 +236,22 @@ @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> @@ -280,7 +281,7 @@ 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' @@ -372,7 +373,7 @@ export default { title() { switch (this.share.type) { case ShareType.User: - return t('files_sharing', 'Share with {userName}', { userName: this.share.shareWithDisplayName }) + return t('files_sharing', 'Share with {user}', { user: this.share.shareWithDisplayName }) case ShareType.Email: return t('files_sharing', 'Share with email {email}', { email: this.share.shareWith }) case ShareType.Link: @@ -383,6 +384,9 @@ export default { return t('files_sharing', 'Share in conversation') case ShareType.Remote: { const [user, server] = this.share.shareWith.split('@') + if (this.config.showFederatedSharesAsInternal) { + return t('files_sharing', 'Share with {user}', { user }) + } return t('files_sharing', 'Share with {user} on remote server {server}', { user, server }) } case ShareType.RemoteGroup: @@ -399,6 +403,9 @@ export default { } } }, + allPermissions() { + return this.isFolder ? this.bundledPermissions.ALL.toString() : this.bundledPermissions.ALL_FILE.toString() + }, /** * Can the sharee edit the shared file ? */ @@ -496,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(true) - 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} @@ -731,8 +718,15 @@ export default { [ATOMIC_PERMISSIONS.DELETE]: this.t('files_sharing', 'Delete'), } - return [ATOMIC_PERMISSIONS.READ, ATOMIC_PERMISSIONS.CREATE, ATOMIC_PERMISSIONS.UPDATE, ...(this.resharingIsPossible ? [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())) @@ -850,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) @@ -872,7 +873,7 @@ export default { async initializeAttributes() { if (this.isNewShare) { - if (this.isPasswordEnforced && this.isPublicShare) { + if ((this.config.enableLinkPasswordByDefault || this.isPasswordEnforced) && this.isPublicShare) { this.$set(this.share, 'newPassword', await GeneratePassword(true)) this.advancedSectionAccordionExpanded = true } @@ -906,8 +907,9 @@ export default { this.advancedSectionAccordionExpanded = true } - if (this.share.note) { + if (this.isValidShareAttribute(this.share.note)) { this.writeNoteToRecipientIsChecked = true + this.advancedSectionAccordionExpanded = true } }, @@ -973,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.isNewShare && !this.isValidShareAttribute(this.share.password)) { + if (this.isPasswordEnforced && this.isNewShare && !this.isValidShareAttribute(this.share.password)) { this.passwordError = true } } else { @@ -1000,11 +999,19 @@ 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 } - this.creating = true - const share = await this.addShare(incomingShare) // 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) @@ -1018,14 +1025,14 @@ export default { } } } + 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) - emit('update:share', this.share) - this.queueUpdate(...permissionsAndAttributes) } await this.getNode() @@ -1102,10 +1109,6 @@ export default { * "sendPasswordByTalk". */ onPasswordProtectedByTalkChange() { - if (this.hasUnsavedPassword) { - this.share.password = this.share.newPassword.trim() - } - this.queueUpdate('sendPasswordByTalk', 'password') }, isValidShareAttribute(value) { diff --git a/apps/files_sharing/src/views/SharingLinkList.vue b/apps/files_sharing/src/views/SharingLinkList.vue index 3dd6fdf317b..c3d9a7f83dc 100644 --- a/apps/files_sharing/src/views/SharingLinkList.vue +++ b/apps/files_sharing/src/views/SharingLinkList.vue @@ -7,12 +7,6 @@ <ul v-if="canLinkShare" :aria-label="t('files_sharing', 'Link shares')" 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" /> - <!-- Else we display the list --> <template v-if="hasShares"> <!-- using shares[index] to work with .sync --> @@ -27,6 +21,12 @@ @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> diff --git a/apps/files_sharing/src/views/SharingTab.vue b/apps/files_sharing/src/views/SharingTab.vue index 9caa1a0973a..2ed44a4b5ad 100644 --- a/apps/files_sharing/src/views/SharingTab.vue +++ b/apps/files_sharing/src/views/SharingTab.vue @@ -50,7 +50,7 @@ :link-shares="linkShares" :reshare="reshare" :shares="shares" - :placeholder="t('files_sharing', 'Share with accounts and teams')" + :placeholder="internalShareInputPlaceholder" @open-sharing-details="toggleShareDetailsView" /> <!-- other shares list --> @@ -90,12 +90,17 @@ :file-info="fileInfo" :link-shares="linkShares" :is-external="true" - :placeholder="t('files_sharing', 'Email, federated cloud id')" + :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" + <SharingLinkList v-if="!loading && isLinkSharingAllowed" ref="linkShareList" :can-reshare="canReshare" :file-info="fileInfo" @@ -122,18 +127,17 @@ </NcPopover> </div> <!-- additional entries, use it with cautious --> - <div v-for="(section, index) in sections" - :ref="'section-' + index" + <div v-for="(component, index) in sectionComponents" :key="index" class="sharingTab__additionalContent"> - <component :is="section($refs['section-'+index], fileInfo)" :file-info="fileInfo" /> + <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"> - <CollectionList :id="`${fileInfo.id}`" + <NcCollectionList :id="`${fileInfo.id}`" type="file" :name="fileInfo.name" /> </div> @@ -152,19 +156,20 @@ <script> import { getCurrentUser } from '@nextcloud/auth' +import { getCapabilities } from '@nextcloud/capabilities' import { orderBy } from '@nextcloud/files' import { loadState } from '@nextcloud/initial-state' import { generateOcsUrl } from '@nextcloud/router' -import { CollectionList } from 'nextcloud-vue-collections' import { ShareType } from '@nextcloud/sharing' -import InfoIcon from 'vue-material-design-icons/Information.vue' +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcCollectionList from '@nextcloud/vue/components/NcCollectionList' import NcPopover from '@nextcloud/vue/components/NcPopover' +import InfoIcon from 'vue-material-design-icons/InformationOutline.vue' import axios from '@nextcloud/axios' import moment from '@nextcloud/moment' -import NcAvatar from '@nextcloud/vue/components/NcAvatar' -import NcButton from '@nextcloud/vue/components/NcButton' import { shareWithTitle } from '../utils/SharedWithMe.js' @@ -180,15 +185,16 @@ 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: { - CollectionList, InfoIcon, NcAvatar, NcButton, + NcCollectionList, NcPopover, SharingEntryInternal, SharingEntrySimple, @@ -215,6 +221,7 @@ export default { sharedWithMe: {}, shares: [], linkShares: [], + externalShares: [], sections: OCA.Sharing.ShareTabSections.getSections(), projectsEnabled: loadState('core', 'projects_enabled', false), @@ -222,9 +229,9 @@ export default { shareDetailsData: {}, returnFocusElement: null, - internalSharesHelpText: t('files_sharing', 'Use this method to share files with individuals or teams within your organization. If the recipient already has access to the share but cannot locate it, you can send them the internal share link for easy access.'), - externalSharesHelpText: t('files_sharing', 'Use this method to share files with individuals or organizations outside your organization. Files and folders can be shared via public share links and email addresses. You can also share to other Nextcloud accounts hosted on different instances using their federated cloud ID.'), - additionalSharesHelpText: t('files_sharing', 'Shares that are not part of the internal or external shares. This can be shares from apps or other sources.'), + 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.'), } }, @@ -235,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 @@ -255,7 +301,6 @@ export default { this.resetState() this.getShares() }, - /** * Get the existing shares infos */ @@ -358,11 +403,29 @@ export default { ], ) - this.linkShares = shares.filter(share => share.type === ShareType.Link || share.type === ShareType.Email) - this.shares = shares.filter(share => share.type !== ShareType.Link && share.type !== ShareType.Email) + 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)`) } }, @@ -423,6 +486,16 @@ export default { // meaning: not from the ShareInput if (share.type === ShareType.Email) { this.linkShares.unshift(share) + } else if ([ShareType.Remote, ShareType.RemoteGroup].includes(share.type)) { + if (this.config.showFederatedSharesAsInternal) { + this.shares.unshift(share) + } if (this.config.showFederatedSharesToTrustedServersAsInternal) { + if (share.isTrustedServer) { + this.shares.unshift(share) + } + } else { + this.externalShares.unshift(share) + } } else { this.shares.unshift(share) } |