diff options
Diffstat (limited to 'apps/files_sharing/src')
84 files changed, 8138 insertions, 2842 deletions
diff --git a/apps/files_sharing/src/additionalScripts.js b/apps/files_sharing/src/additionalScripts.js index 6cc039a876a..e8807a7325e 100644 --- a/apps/files_sharing/src/additionalScripts.js +++ b/apps/files_sharing/src/additionalScripts.js @@ -1,33 +1,15 @@ /** - * @copyright Copyright (c) 2016 Roeland Jago Douma <roeland@famdouma.nl> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { getCSPNonce } from '@nextcloud/auth' -import './share' -import './sharebreadcrumbview' +import './share.js' +import './sharebreadcrumbview.js' import './style/sharebreadcrumb.scss' import './collaborationresourceshandler.js' // eslint-disable-next-line camelcase -__webpack_nonce__ = btoa(OC.requestToken) +__webpack_nonce__ = getCSPNonce() window.OCA.Sharing = OCA.Sharing diff --git a/apps/files_sharing/src/collaborationresources.js b/apps/files_sharing/src/collaborationresources.js deleted file mode 100644 index 1e6eda02a93..00000000000 --- a/apps/files_sharing/src/collaborationresources.js +++ /dev/null @@ -1,44 +0,0 @@ -/** - * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -import Vue from 'vue' -import Vuex from 'vuex' -import NcPopoverMenu from '@nextcloud/vue/dist/Components/NcPopoverMenu' -import Tooltip from '@nextcloud/vue/dist/Directives/Tooltip' -import ClickOutside from 'vue-click-outside' - -import View from './views/CollaborationView' - -Vue.prototype.t = t -Tooltip.options.defaultHtml = false - -// eslint-disable-next-line vue/match-component-file-name -Vue.component('NcPopoverMenu', NcPopoverMenu) -Vue.directive('ClickOutside', ClickOutside) -Vue.directive('Tooltip', Tooltip) -Vue.use(Vuex) - -export { - Vue, - View, -} diff --git a/apps/files_sharing/src/collaborationresourceshandler.js b/apps/files_sharing/src/collaborationresourceshandler.js index e81b590b2b8..6f3645385b7 100644 --- a/apps/files_sharing/src/collaborationresourceshandler.js +++ b/apps/files_sharing/src/collaborationresourceshandler.js @@ -1,28 +1,11 @@ /** - * @copyright Copyright (c) 2016 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { getCSPNonce } from '@nextcloud/auth' // eslint-disable-next-line camelcase -__webpack_nonce__ = btoa(OC.requestToken) +__webpack_nonce__ = getCSPNonce() window.OCP.Collaboration.registerType('file', { action: () => { diff --git a/apps/files_sharing/src/components/ExternalShareAction.vue b/apps/files_sharing/src/components/ExternalShareAction.vue index 39caa1260c8..c2c86cc8679 100644 --- a/apps/files_sharing/src/components/ExternalShareAction.vue +++ b/apps/files_sharing/src/components/ExternalShareAction.vue @@ -1,24 +1,7 @@ <!-- - - @copyright Copyright (c) 2021 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <Component :is="data.is" @@ -29,7 +12,7 @@ </template> <script> -import Share from '../models/Share' +import Share from '../models/Share.ts' export default { name: 'ExternalShareAction', diff --git a/apps/files_sharing/src/components/FileListFilterAccount.vue b/apps/files_sharing/src/components/FileListFilterAccount.vue new file mode 100644 index 00000000000..150516e139b --- /dev/null +++ b/apps/files_sharing/src/components/FileListFilterAccount.vue @@ -0,0 +1,138 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <FileListFilter class="file-list-filter-accounts" + :is-active="selectedAccounts.length > 0" + :filter-name="t('files_sharing', 'People')" + @reset-filter="resetFilter"> + <template #icon> + <NcIconSvgWrapper :path="mdiAccountMultipleOutline" /> + </template> + <NcActionInput v-if="availableAccounts.length > 1" + :label="t('files_sharing', 'Filter accounts')" + :label-outside="false" + :show-trailing-button="false" + type="search" + :value.sync="accountFilter" /> + <NcActionButton v-for="account of shownAccounts" + :key="account.id" + class="file-list-filter-accounts__item" + type="radio" + :model-value="selectedAccounts.includes(account)" + :value="account.id" + @click="toggleAccount(account.id)"> + <template #icon> + <NcAvatar class="file-list-filter-accounts__avatar" + v-bind="account" + :size="24" + disable-menu + :show-user-status="false" /> + </template> + {{ account.displayName }} + </NcActionButton> + </FileListFilter> +</template> + +<script setup lang="ts"> +import type { IAccountData } from '../files_filters/AccountFilter.ts' + +import { translate as t } from '@nextcloud/l10n' +import { mdiAccountMultipleOutline } from '@mdi/js' +import { computed, ref, watch } from 'vue' + +import FileListFilter from '../../../files/src/components/FileListFilter/FileListFilter.vue' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcActionInput from '@nextcloud/vue/components/NcActionInput' +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' + +interface IUserSelectData { + id: string + user: string + displayName: string +} + +const emit = defineEmits<{ + (event: 'update:accounts', value: IAccountData[]): void +}>() + +const accountFilter = ref('') +const availableAccounts = ref<IUserSelectData[]>([]) +const selectedAccounts = ref<IUserSelectData[]>([]) + +/** + * Currently shown accounts (filtered) + */ +const shownAccounts = computed(() => { + if (!accountFilter.value) { + return availableAccounts.value + } + const queryParts = accountFilter.value.toLocaleLowerCase().trim().split(' ') + return availableAccounts.value.filter((account) => + queryParts.every((part) => + account.user.toLocaleLowerCase().includes(part) + || account.displayName.toLocaleLowerCase().includes(part), + ), + ) +}) + +/** + * Toggle an account as selected + * @param accountId The account to toggle + */ +function toggleAccount(accountId: string) { + const account = availableAccounts.value.find(({ id }) => id === accountId) + if (account && selectedAccounts.value.includes(account)) { + selectedAccounts.value = selectedAccounts.value.filter(({ id }) => id !== accountId) + } else { + if (account) { + selectedAccounts.value = [...selectedAccounts.value, account] + } + } +} + +// Watch selected account, on change we emit the new account data to the filter instance +watch(selectedAccounts, () => { + // Emit selected accounts as account data + const accounts = selectedAccounts.value.map(({ id: uid, displayName }) => ({ uid, displayName })) + emit('update:accounts', accounts) +}) + +/** + * Reset this filter + */ +function resetFilter() { + selectedAccounts.value = [] + accountFilter.value = '' +} + +/** + * Update list of available accounts in current view. + * + * @param accounts - Accounts to use + */ +function setAvailableAccounts(accounts: IAccountData[]): void { + availableAccounts.value = accounts.map(({ uid, displayName }) => ({ displayName, id: uid, user: uid })) +} + +defineExpose({ + resetFilter, + setAvailableAccounts, + toggleAccount, +}) +</script> + +<style scoped lang="scss"> +.file-list-filter-accounts { + &__item { + min-width: 250px; + } + + &__avatar { + // 24px is the avatar size + margin: calc((var(--default-clickable-area) - 24px) / 2) + } +} +</style> diff --git a/apps/files_sharing/src/components/NewFileRequestDialog.vue b/apps/files_sharing/src/components/NewFileRequestDialog.vue new file mode 100644 index 00000000000..392f286e104 --- /dev/null +++ b/apps/files_sharing/src/components/NewFileRequestDialog.vue @@ -0,0 +1,468 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <NcDialog can-close + class="file-request-dialog" + data-cy-file-request-dialog + :close-on-click-outside="false" + :name="currentStep !== STEP.LAST ? t('files_sharing', 'Create a file request') : t('files_sharing', 'File request created')" + size="normal" + @closing="onCancel"> + <!-- Header --> + <NcNoteCard v-show="currentStep === STEP.FIRST" type="info" class="file-request-dialog__header"> + <p id="file-request-dialog-description" class="file-request-dialog__description"> + {{ t('files_sharing', 'Collect files from others even if they do not have an account.') }} + {{ t('files_sharing', 'To ensure you can receive files, verify you have enough storage available.') }} + </p> + </NcNoteCard> + + <!-- Main form --> + <form ref="form" + class="file-request-dialog__form" + aria-describedby="file-request-dialog-description" + :aria-label="t('files_sharing', 'File request')" + aria-live="polite" + data-cy-file-request-dialog-form + @submit.prevent.stop=""> + <FileRequestIntro v-show="currentStep === STEP.FIRST" + :context="context" + :destination.sync="destination" + :disabled="loading" + :label.sync="label" + :note.sync="note" /> + + <FileRequestDatePassword v-show="currentStep === STEP.SECOND" + :disabled="loading" + :expiration-date.sync="expirationDate" + :password.sync="password" /> + + <FileRequestFinish v-if="share" + v-show="currentStep === STEP.LAST" + :emails="emails" + :is-share-by-mail-enabled="isShareByMailEnabled" + :share="share" + @add-email="email => emails.push(email)" + @remove-email="onRemoveEmail" /> + </form> + + <!-- Controls --> + <template #actions> + <!-- Back --> + <NcButton v-show="currentStep === STEP.SECOND" + :aria-label="t('files_sharing', 'Previous step')" + :disabled="loading" + data-cy-file-request-dialog-controls="back" + type="tertiary" + @click="currentStep = STEP.FIRST"> + {{ t('files_sharing', 'Previous step') }} + </NcButton> + + <!-- Align right --> + <span class="dialog__actions-separator" /> + + <!-- Cancel the creation --> + <NcButton v-if="currentStep !== STEP.LAST" + :aria-label="t('files_sharing', 'Cancel')" + :disabled="loading" + :title="t('files_sharing', 'Cancel the file request creation')" + data-cy-file-request-dialog-controls="cancel" + type="tertiary" + @click="onCancel"> + {{ t('files_sharing', 'Cancel') }} + </NcButton> + + <!-- Cancel email and just close --> + <NcButton v-else-if="emails.length !== 0" + :aria-label="t('files_sharing', 'Close without sending emails')" + :disabled="loading" + :title="t('files_sharing', 'Close without sending emails')" + data-cy-file-request-dialog-controls="cancel" + type="tertiary" + @click="onCancel"> + {{ t('files_sharing', 'Close') }} + </NcButton> + + <!-- Next --> + <NcButton v-if="currentStep !== STEP.LAST" + :aria-label="t('files_sharing', 'Continue')" + :disabled="loading" + data-cy-file-request-dialog-controls="next" + @click="onPageNext"> + <template #icon> + <NcLoadingIcon v-if="loading" /> + <IconNext v-else :size="20" /> + </template> + {{ t('files_sharing', 'Continue') }} + </NcButton> + + <!-- Finish --> + <NcButton v-else + :aria-label="finishButtonLabel" + :disabled="loading" + data-cy-file-request-dialog-controls="finish" + type="primary" + @click="onFinish"> + <template #icon> + <NcLoadingIcon v-if="loading" /> + <IconCheck v-else :size="20" /> + </template> + {{ finishButtonLabel }} + </NcButton> + </template> + </NcDialog> +</template> + +<script lang="ts"> +import type { AxiosError } from '@nextcloud/axios' +import type { Folder, Node } from '@nextcloud/files' +import type { OCSResponse } from '@nextcloud/typings/ocs' +import type { PropType } from 'vue' + +import { defineComponent } from 'vue' +import { emit } from '@nextcloud/event-bus' +import { generateOcsUrl } from '@nextcloud/router' +import { Permission } from '@nextcloud/files' +import { ShareType } from '@nextcloud/sharing' +import { showError, showSuccess } from '@nextcloud/dialogs' +import { n, t } from '@nextcloud/l10n' +import axios from '@nextcloud/axios' + +import NcButton from '@nextcloud/vue/components/NcButton' +import NcDialog from '@nextcloud/vue/components/NcDialog' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' + +import IconCheck from 'vue-material-design-icons/Check.vue' +import IconNext from 'vue-material-design-icons/ArrowRight.vue' + +import Config from '../services/ConfigService' +import FileRequestDatePassword from './NewFileRequestDialog/NewFileRequestDialogDatePassword.vue' +import FileRequestFinish from './NewFileRequestDialog/NewFileRequestDialogFinish.vue' +import FileRequestIntro from './NewFileRequestDialog/NewFileRequestDialogIntro.vue' +import logger from '../services/logger' +import Share from '../models/Share.ts' + +enum STEP { + FIRST = 0, + SECOND = 1, + LAST = 2, +} + +const sharingConfig = new Config() + +export default defineComponent({ + name: 'NewFileRequestDialog', + + components: { + FileRequestDatePassword, + FileRequestFinish, + FileRequestIntro, + IconCheck, + IconNext, + NcButton, + NcDialog, + NcLoadingIcon, + NcNoteCard, + }, + + props: { + context: { + type: Object as PropType<Folder>, + required: true, + }, + content: { + type: Array as PropType<Node[]>, + required: true, + }, + }, + + setup() { + return { + STEP, + n, + t, + + isShareByMailEnabled: sharingConfig.isMailShareAllowed, + } + }, + + data() { + return { + currentStep: STEP.FIRST, + loading: false, + + destination: this.context.path || '/', + label: '', + note: '', + + expirationDate: null as Date | null, + password: null as string | null, + + share: null as Share | null, + emails: [] as string[], + } + }, + + computed: { + finishButtonLabel() { + if (this.emails.length === 0) { + return t('files_sharing', 'Close') + } + return n('files_sharing', 'Send email and close', 'Send {count} emails and close', this.emails.length, { count: this.emails.length }) + }, + }, + + methods: { + onPageNext() { + const form = this.$refs.form as HTMLFormElement + + // Reset custom validity + form.querySelectorAll('input').forEach(input => input.setCustomValidity('')) + + // custom destination validation + // cannot share root + if (this.destination === '/' || this.destination === '') { + const destinationInput = form.querySelector('input[name="destination"]') as HTMLInputElement + destinationInput?.setCustomValidity(t('files_sharing', 'Please select a folder, you cannot share the root directory.')) + form.reportValidity() + return + } + + // If the form is not valid, show the error message + if (!form.checkValidity()) { + form.reportValidity() + return + } + + if (this.currentStep === STEP.FIRST) { + this.currentStep = STEP.SECOND + return + } + + this.createShare() + }, + + onRemoveEmail(email: string) { + const index = this.emails.indexOf(email) + this.emails.splice(index, 1) + }, + + onCancel() { + this.$emit('close') + }, + + async onFinish() { + if (this.emails.length === 0 || this.isShareByMailEnabled === false) { + showSuccess(t('files_sharing', 'File request created')) + this.$emit('close') + return + } + + if (sharingConfig.isMailShareAllowed && this.emails.length > 0) { + await this.setShareEmails() + await this.sendEmails() + showSuccess(n('files_sharing', 'File request created and email sent', 'File request created and {count} emails sent', this.emails.length, { count: this.emails.length })) + } else { + showSuccess(t('files_sharing', 'File request created')) + } + + this.$emit('close') + }, + + async createShare() { + this.loading = true + + let expireDate = '' + if (this.expirationDate) { + const year = this.expirationDate.getFullYear() + const month = (this.expirationDate.getMonth() + 1).toString().padStart(2, '0') + const day = this.expirationDate.getDate().toString().padStart(2, '0') + + // Format must be YYYY-MM-DD + expireDate = `${year}-${month}-${day}` + } + const shareUrl = generateOcsUrl('apps/files_sharing/api/v1/shares') + try { + const request = await axios.post<OCSResponse>(shareUrl, { + // Always create a file request, but without mail share + // permissions, only a share link will be created. + shareType: sharingConfig.isMailShareAllowed ? ShareType.Email : ShareType.Link, + permissions: Permission.CREATE, + + label: this.label, + path: this.destination, + note: this.note, + + password: this.password || '', + expireDate: expireDate || '', + + // Empty string + shareWith: '', + attributes: JSON.stringify([{ + value: true, + key: 'enabled', + scope: 'fileRequest', + }]), + }) + + // If not an ocs request + if (!request?.data?.ocs) { + throw request + } + + const share = new Share(request.data.ocs.data) + this.share = share + + logger.info('New file request created', { share }) + emit('files_sharing:share:created', { share }) + + // Move to the last page + this.currentStep = STEP.LAST + } catch (error) { + const errorMessage = (error as AxiosError<OCSResponse>)?.response?.data?.ocs?.meta?.message + showError( + errorMessage + ? t('files_sharing', 'Error creating the share: {errorMessage}', { errorMessage }) + : t('files_sharing', 'Error creating the share'), + ) + logger.error('Error while creating share', { error, errorMessage }) + throw error + } finally { + this.loading = false + } + }, + + async setShareEmails() { + this.loading = true + + // This should never happen™ + if (!this.share || !this.share?.id) { + throw new Error('Share ID is missing') + } + + const shareUrl = generateOcsUrl('apps/files_sharing/api/v1/shares/{id}', { id: this.share.id }) + try { + // Convert link share to email share + const request = await axios.put<OCSResponse>(shareUrl, { + attributes: JSON.stringify([{ + value: this.emails, + key: 'emails', + scope: 'shareWith', + }, + { + value: true, + key: 'enabled', + scope: 'fileRequest', + }]), + }) + + // If not an ocs request + if (!request?.data?.ocs) { + throw request + } + } catch (error) { + this.onEmailSendError(error) + throw error + } finally { + this.loading = false + } + }, + + async sendEmails() { + this.loading = true + + // This should never happen™ + if (!this.share || !this.share?.id) { + throw new Error('Share ID is missing') + } + + const shareUrl = generateOcsUrl('apps/files_sharing/api/v1/shares/{id}/send-email', { id: this.share.id }) + try { + // Convert link share to email share + const request = await axios.post<OCSResponse>(shareUrl, { + password: this.password || undefined, + }) + + // If not an ocs request + if (!request?.data?.ocs) { + throw request + } + } catch (error) { + this.onEmailSendError(error) + throw error + } finally { + this.loading = false + } + }, + + onEmailSendError(error: AxiosError<OCSResponse>) { + const errorMessage = error.response?.data?.ocs?.meta?.message + showError( + errorMessage + ? t('files_sharing', 'Error sending emails: {errorMessage}', { errorMessage }) + : t('files_sharing', 'Error sending emails'), + ) + logger.error('Error while sending emails', { error, errorMessage }) + }, + }, +}) +</script> + +<style lang="scss"> +.file-request-dialog { + --margin: 18px; + + &__header { + margin: 0 var(--margin); + } + + &__form { + position: relative; + overflow: auto; + padding: var(--margin) var(--margin); + // overlap header bottom padding + margin-top: calc(-1 * var(--margin)); + } + + fieldset { + display: flex; + flex-direction: column; + width: 100%; + margin-top: var(--margin); + + legend { + display: flex; + align-items: center; + width: 100%; + } + } + + // Using a NcNoteCard was a bit much sometimes. + // Using a simple paragraph instead does it. + &__info { + color: var(--color-text-maxcontrast); + padding-block: 4px; + display: flex; + align-items: center; + .file-request-dialog__info-icon { + margin-inline-end: 8px; + } + } + + .dialog__actions { + width: auto; + margin-inline: 12px; + span.dialog__actions-separator { + margin-inline-start: auto; + } + } + + .input-field__helper-text-message { + // reduce helper text standing out + color: var(--color-text-maxcontrast); + } +} +</style> diff --git a/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogDatePassword.vue b/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogDatePassword.vue new file mode 100644 index 00000000000..7e6d56e8794 --- /dev/null +++ b/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogDatePassword.vue @@ -0,0 +1,258 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <div> + <!-- Password and expiration summary --> + <NcNoteCard v-if="passwordAndExpirationSummary" type="success"> + {{ passwordAndExpirationSummary }} + </NcNoteCard> + + <!-- Expiration date --> + <fieldset class="file-request-dialog__expiration" data-cy-file-request-dialog-fieldset="expiration"> + <!-- Enable expiration --> + <legend>{{ t('files_sharing', 'When should the request expire?') }}</legend> + <NcCheckboxRadioSwitch v-show="!isExpirationDateEnforced" + :checked="isExpirationDateEnforced || expirationDate !== null" + :disabled="disabled || isExpirationDateEnforced" + @update:checked="onToggleDeadline"> + {{ t('files_sharing', 'Set a submission expiration date') }} + </NcCheckboxRadioSwitch> + + <!-- Date picker --> + <NcDateTimePickerNative v-if="expirationDate !== null" + id="file-request-dialog-expirationDate" + :disabled="disabled" + :hide-label="true" + :label="t('files_sharing', 'Expiration date')" + :max="maxDate" + :min="minDate" + :placeholder="t('files_sharing', 'Select a date')" + :required="defaultExpireDateEnforced" + :value="expirationDate" + name="expirationDate" + type="date" + @input="$emit('update:expirationDate', $event)" /> + + <p v-if="defaultExpireDateEnforced" class="file-request-dialog__info"> + <IconInfo :size="18" class="file-request-dialog__info-icon" /> + {{ t('files_sharing', 'Your administrator has enforced a {count} days expiration policy.', { count: defaultExpireDate }) }} + </p> + </fieldset> + + <!-- Password --> + <fieldset class="file-request-dialog__password" data-cy-file-request-dialog-fieldset="password"> + <!-- Enable password --> + <legend>{{ t('files_sharing', 'What password should be used for the request?') }}</legend> + <NcCheckboxRadioSwitch v-show="!isPasswordEnforced" + :checked="isPasswordEnforced || password !== null" + :disabled="disabled || isPasswordEnforced" + @update:checked="onTogglePassword"> + {{ t('files_sharing', 'Set a password') }} + </NcCheckboxRadioSwitch> + + <div v-if="password !== null" class="file-request-dialog__password-field"> + <NcPasswordField ref="passwordField" + :check-password-strength="true" + :disabled="disabled" + :label="t('files_sharing', 'Password')" + :placeholder="t('files_sharing', 'Enter a valid password')" + :required="enforcePasswordForPublicLink" + :value="password" + name="password" + @update:value="$emit('update:password', $event)" /> + <NcButton :aria-label="t('files_sharing', 'Generate a new password')" + :title="t('files_sharing', 'Generate a new password')" + type="tertiary-no-background" + @click="onGeneratePassword"> + <template #icon> + <IconPasswordGen :size="20" /> + </template> + </NcButton> + </div> + + <p v-if="enforcePasswordForPublicLink" class="file-request-dialog__info"> + <IconInfo :size="18" class="file-request-dialog__info-icon" /> + {{ t('files_sharing', 'Your administrator has enforced a password protection.') }} + </p> + </fieldset> + </div> +</template> + +<script lang="ts"> +import { defineComponent, type PropType } from 'vue' +import { t } from '@nextcloud/l10n' + +import NcButton from '@nextcloud/vue/components/NcButton' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' +import NcDateTimePickerNative from '@nextcloud/vue/components/NcDateTimePickerNative' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' +import NcPasswordField from '@nextcloud/vue/components/NcPasswordField' + +import IconInfo from 'vue-material-design-icons/Information.vue' +import IconPasswordGen from 'vue-material-design-icons/AutoFix.vue' + +import Config from '../../services/ConfigService' +import GeneratePassword from '../../utils/GeneratePassword' + +const sharingConfig = new Config() + +export default defineComponent({ + name: 'NewFileRequestDialogDatePassword', + + components: { + IconInfo, + IconPasswordGen, + NcButton, + NcCheckboxRadioSwitch, + NcDateTimePickerNative, + NcNoteCard, + NcPasswordField, + }, + + props: { + disabled: { + type: Boolean, + required: false, + default: false, + }, + expirationDate: { + type: Date as PropType<Date | null>, + required: false, + default: null, + }, + password: { + type: String as PropType<string | null>, + required: false, + default: null, + }, + }, + + emits: [ + 'update:expirationDate', + 'update:password', + ], + + setup() { + return { + t, + + // Default expiration date if defaultExpireDateEnabled is true + defaultExpireDate: sharingConfig.defaultExpireDate, + // Default expiration date is enabled for public links (can be disabled) + defaultExpireDateEnabled: sharingConfig.isDefaultExpireDateEnabled, + // Default expiration date is enforced for public links (can't be disabled) + defaultExpireDateEnforced: sharingConfig.isDefaultExpireDateEnforced, + + // Default password protection is enabled for public links (can be disabled) + enableLinkPasswordByDefault: sharingConfig.enableLinkPasswordByDefault, + // Password protection is enforced for public links (can't be disabled) + enforcePasswordForPublicLink: sharingConfig.enforcePasswordForPublicLink, + } + }, + + data() { + return { + maxDate: null as Date | null, + minDate: new Date(new Date().setDate(new Date().getDate() + 1)), + } + }, + + computed: { + passwordAndExpirationSummary(): string { + if (this.expirationDate && this.password) { + return t('files_sharing', 'The request will expire on {date} at midnight and will be password protected.', { + date: this.expirationDate.toLocaleDateString(), + }) + } + + if (this.expirationDate) { + return t('files_sharing', 'The request will expire on {date} at midnight.', { + date: this.expirationDate.toLocaleDateString(), + }) + } + + if (this.password) { + return t('files_sharing', 'The request will be password protected.') + } + + return '' + }, + + isExpirationDateEnforced(): boolean { + // Both fields needs to be enabled in the settings + return this.defaultExpireDateEnabled + && this.defaultExpireDateEnforced + }, + + isPasswordEnforced(): boolean { + // Both fields needs to be enabled in the settings + return this.enableLinkPasswordByDefault + && this.enforcePasswordForPublicLink + }, + }, + + mounted() { + // If defined, we set the default expiration date + if (this.defaultExpireDate) { + this.$emit('update:expirationDate', sharingConfig.defaultExpirationDate) + } + + // If enforced, we cannot set a date before the default expiration days (see admin settings) + if (this.isExpirationDateEnforced) { + this.maxDate = sharingConfig.defaultExpirationDate + } + + // If enabled by default, we generate a valid password + if (this.isPasswordEnforced) { + this.generatePassword() + } + }, + + methods: { + onToggleDeadline(checked: boolean) { + this.$emit('update:expirationDate', checked ? (this.maxDate || this.minDate) : null) + }, + + async onTogglePassword(checked: boolean) { + if (checked) { + this.generatePassword() + return + } + this.$emit('update:password', null) + }, + + async onGeneratePassword() { + await this.generatePassword() + this.showPassword() + }, + + async generatePassword() { + await GeneratePassword().then(password => { + this.$emit('update:password', password) + }) + }, + + showPassword() { + // @ts-expect-error isPasswordHidden is private + this.$refs.passwordField.isPasswordHidden = false + }, + }, +}) +</script> + +<style scoped lang="scss"> +.file-request-dialog__password-field { + display: flex; + align-items: flex-start; + gap: 8px; + // Compensate label gab with legend + margin-top: 12px; + > div { + // Force margin to 0 as we handle it above + margin: 0; + } +} +</style> diff --git a/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogFinish.vue b/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogFinish.vue new file mode 100644 index 00000000000..7826aab581e --- /dev/null +++ b/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogFinish.vue @@ -0,0 +1,236 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <div> + <!-- Request note --> + <NcNoteCard type="success"> + {{ t('files_sharing', 'You can now share the link below to allow people to upload files to your directory.') }} + </NcNoteCard> + + <!-- Copy share link --> + <NcInputField ref="clipboard" + :value="shareLink" + :label="t('files_sharing', 'Share link')" + :readonly="true" + :show-trailing-button="true" + :trailing-button-label="t('files_sharing', 'Copy')" + data-cy-file-request-dialog-fieldset="link" + @click="copyShareLink" + @trailing-button-click="copyShareLink"> + <template #trailing-button-icon> + <IconCheck v-if="isCopied" :size="20" /> + <IconClipboard v-else :size="20" /> + </template> + </NcInputField> + + <template v-if="isShareByMailEnabled"> + <!-- Email share--> + <NcTextField :value.sync="email" + :label="t('files_sharing', 'Send link via email')" + :placeholder="t('files_sharing', 'Enter an email address or paste a list')" + data-cy-file-request-dialog-fieldset="email" + type="email" + @keypress.enter.stop="addNewEmail" + @paste.stop.prevent="onPasteEmails" + @focusout.native="addNewEmail" /> + + <!-- Email list --> + <div v-if="emails.length > 0" class="file-request-dialog__emails"> + <NcChip v-for="mail in emails" + :key="mail" + :aria-label-close="t('files_sharing', 'Remove email')" + :text="mail" + @close="$emit('remove-email', mail)"> + <template #icon> + <NcAvatar :disable-menu="true" + :disable-tooltip="true" + :display-name="mail" + :is-no-user="true" + :show-user-status="false" + :size="24" /> + </template> + </NcChip> + </div> + </template> + </div> +</template> + +<script lang="ts"> +import type { PropType } from 'vue' +import Share from '../../models/Share.ts' + +import { defineComponent } from 'vue' +import { generateUrl, getBaseUrl } from '@nextcloud/router' +import { showError, showSuccess } from '@nextcloud/dialogs' +import { n, t } from '@nextcloud/l10n' + +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcInputField from '@nextcloud/vue/components/NcInputField' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' +import NcTextField from '@nextcloud/vue/components/NcTextField' +import NcChip from '@nextcloud/vue/components/NcChip' + +import IconCheck from 'vue-material-design-icons/Check.vue' +import IconClipboard from 'vue-material-design-icons/ClipboardText.vue' + +export default defineComponent({ + name: 'NewFileRequestDialogFinish', + + components: { + IconCheck, + IconClipboard, + NcAvatar, + NcInputField, + NcNoteCard, + NcTextField, + NcChip, + }, + + props: { + share: { + type: Object as PropType<Share>, + required: true, + }, + emails: { + type: Array as PropType<string[]>, + required: true, + }, + isShareByMailEnabled: { + type: Boolean, + required: true, + }, + }, + + emits: ['add-email', 'remove-email'], + + setup() { + return { + n, t, + } + }, + + data() { + return { + isCopied: false, + email: '', + } + }, + + computed: { + shareLink() { + return generateUrl('/s/{token}', { token: this.share.token }, { baseURL: getBaseUrl() }) + }, + }, + + methods: { + async copyShareLink(event: MouseEvent) { + if (this.isCopied) { + this.isCopied = false + return + } + + if (!navigator.clipboard) { + // Clipboard API not available + window.prompt(t('files_sharing', 'Automatically copying failed, please copy the share link manually'), this.shareLink) + return + } + + await navigator.clipboard.writeText(this.shareLink) + + showSuccess(t('files_sharing', 'Link copied')) + this.isCopied = true + event.target?.select?.() + + setTimeout(() => { + this.isCopied = false + }, 3000) + }, + + addNewEmail(e: KeyboardEvent) { + if (this.email.trim() === '') { + return + } + + if (e.target instanceof HTMLInputElement) { + // Reset the custom validity + e.target.setCustomValidity('') + + // Check if the field is valid + if (e.target.checkValidity() === false) { + e.target.reportValidity() + return + } + + // The email is already in the list + if (this.emails.includes(this.email.trim())) { + e.target.setCustomValidity(t('files_sharing', 'Email already added')) + e.target.reportValidity() + return + } + + // Check if the email is valid + if (!this.isValidEmail(this.email.trim())) { + e.target.setCustomValidity(t('files_sharing', 'Invalid email address')) + e.target.reportValidity() + return + } + + this.$emit('add-email', this.email.trim()) + this.email = '' + } + }, + + // Handle dumping a list of emails + onPasteEmails(e: ClipboardEvent) { + const clipboardData = e.clipboardData + if (!clipboardData) { + return + } + + const pastedText = clipboardData.getData('text') + const emails = pastedText.split(/[\s,;]+/).filter(Boolean).map((email) => email.trim()) + + const duplicateEmails = emails.filter((email) => this.emails.includes(email)) + const validEmails = emails.filter((email) => this.isValidEmail(email) && !duplicateEmails.includes(email)) + const invalidEmails = emails.filter((email) => !this.isValidEmail(email)) + validEmails.forEach((email) => this.$emit('add-email', email)) + + // Warn about invalid emails + if (invalidEmails.length > 0) { + showError(n('files_sharing', 'The following email address is not valid: {emails}', 'The following email addresses are not valid: {emails}', invalidEmails.length, { emails: invalidEmails.join(', ') })) + } + + // Warn about duplicate emails + if (duplicateEmails.length > 0) { + showError(n('files_sharing', '{count} email address already added', '{count} email addresses already added', duplicateEmails.length, { count: duplicateEmails.length })) + } + + if (validEmails.length > 0) { + showSuccess(n('files_sharing', '{count} email address added', '{count} email addresses added', validEmails.length, { count: validEmails.length })) + } + + this.email = '' + }, + + // No need to have a fancy regex, just check for an @ + isValidEmail(email: string): boolean { + return email.includes('@') + }, + }, +}) +</script> +<style scoped> +.input-field, +.file-request-dialog__emails { + margin-top: var(--margin); +} + +.file-request-dialog__emails { + display: flex; + gap: var(--default-grid-baseline); + flex-wrap: wrap; +} +</style> diff --git a/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogIntro.vue b/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogIntro.vue new file mode 100644 index 00000000000..5ac60c37e29 --- /dev/null +++ b/apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogIntro.vue @@ -0,0 +1,166 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <div> + <!-- Request label --> + <fieldset class="file-request-dialog__label" data-cy-file-request-dialog-fieldset="label"> + <legend> + {{ t('files_sharing', 'What are you requesting?') }} + </legend> + <NcTextField :value="label" + :disabled="disabled" + :label="t('files_sharing', 'Request subject')" + :placeholder="t('files_sharing', 'Birthday party photos, History assignment…')" + :required="false" + name="label" + @update:value="$emit('update:label', $event)" /> + </fieldset> + + <!-- Request destination --> + <fieldset class="file-request-dialog__destination" data-cy-file-request-dialog-fieldset="destination"> + <legend> + {{ t('files_sharing', 'Where should these files go?') }} + </legend> + <NcTextField :value="destination" + :disabled="disabled" + :label="t('files_sharing', 'Upload destination')" + :minlength="2/* cannot share root */" + :placeholder="t('files_sharing', 'Select a destination')" + :readonly="false /* cannot validate a readonly input */" + :required="true /* cannot be empty */" + :show-trailing-button="destination !== context.path" + :trailing-button-icon="'undo'" + :trailing-button-label="t('files_sharing', 'Revert to default')" + name="destination" + @click="onPickDestination" + @keypress.prevent.stop="/* prevent typing in the input, we use the picker */" + @paste.prevent.stop="/* prevent pasting in the input, we use the picker */" + @trailing-button-click="$emit('update:destination', '')"> + <IconFolder :size="18" /> + </NcTextField> + + <p class="file-request-dialog__info"> + <IconLock :size="18" class="file-request-dialog__info-icon" /> + {{ t('files_sharing', 'The uploaded files are visible only to you unless you choose to share them.') }} + </p> + </fieldset> + + <!-- Request note --> + <fieldset class="file-request-dialog__note" data-cy-file-request-dialog-fieldset="note"> + <legend> + {{ t('files_sharing', 'Add a note') }} + </legend> + <NcTextArea :value="note" + :disabled="disabled" + :label="t('files_sharing', 'Note for recipient')" + :placeholder="t('files_sharing', 'Add a note to help people understand what you are requesting.')" + :required="false" + name="note" + @update:value="$emit('update:note', $event)" /> + + <p class="file-request-dialog__info"> + <IconInfo :size="18" class="file-request-dialog__info-icon" /> + {{ t('files_sharing', 'You can add links, date or any other information that will help the recipient understand what you are requesting.') }} + </p> + </fieldset> + </div> +</template> + +<script lang="ts"> +import type { PropType } from 'vue' +import type { Folder, Node } from '@nextcloud/files' + +import { defineComponent } from 'vue' +import { getFilePickerBuilder } from '@nextcloud/dialogs' +import { t } from '@nextcloud/l10n' + +import IconFolder from 'vue-material-design-icons/Folder.vue' +import IconInfo from 'vue-material-design-icons/InformationOutline.vue' +import IconLock from 'vue-material-design-icons/Lock.vue' +import NcTextArea from '@nextcloud/vue/components/NcTextArea' +import NcTextField from '@nextcloud/vue/components/NcTextField' + +export default defineComponent({ + name: 'NewFileRequestDialogIntro', + + components: { + IconFolder, + IconInfo, + IconLock, + NcTextArea, + NcTextField, + }, + + props: { + disabled: { + type: Boolean, + required: false, + default: false, + }, + context: { + type: Object as PropType<Folder>, + required: true, + }, + label: { + type: String, + required: true, + }, + destination: { + type: String, + required: true, + }, + note: { + type: String, + required: true, + }, + }, + + emits: [ + 'update:destination', + 'update:label', + 'update:note', + ], + + setup() { + return { + t, + } + }, + + methods: { + onPickDestination() { + const filepicker = getFilePickerBuilder(t('files_sharing', 'Select a destination')) + .addMimeTypeFilter('httpd/unix-directory') + .allowDirectories(true) + .addButton({ + label: t('files_sharing', 'Select'), + callback: this.onPickedDestination, + }) + .setFilter(node => node.path !== '/') + .startAt(this.destination) + .build() + try { + filepicker.pick() + } catch (e) { + // ignore cancel + } + }, + + onPickedDestination(nodes: Node[]) { + const node = nodes[0] + if (node) { + this.$emit('update:destination', node.path) + } + }, + }, +}) +</script> +<style scoped> +.file-request-dialog__note :deep(textarea) { + width: 100% !important; + min-height: 80px; +} +</style> diff --git a/apps/files_sharing/src/components/PersonalSettings.vue b/apps/files_sharing/src/components/PersonalSettings.vue index 526bee07324..19c9c2aec87 100644 --- a/apps/files_sharing/src/components/PersonalSettings.vue +++ b/apps/files_sharing/src/components/PersonalSettings.vue @@ -1,24 +1,7 @@ <!-- - - @copyright 2019 Roeland Jago Douma <roeland@famdouma.nl> - - - - @author 2019 Roeland Jago Douma <roeland@famdouma.nl> - - @author Hinrich Mahler <nextcloud@mahlerhome.de> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div v-if="!enforceAcceptShares || allowCustomDirectory" id="files-sharing-personal-settings" class="section"> @@ -29,7 +12,7 @@ class="checkbox" type="checkbox" @change="toggleEnabled"> - <label for="files-sharing-personal-settings-accept">{{ t('files_sharing', 'Accept user and group shares by default') }}</label> + <label for="files-sharing-personal-settings-accept">{{ t('files_sharing', 'Accept shares from other accounts and groups by default') }}</label> </p> <p v-if="allowCustomDirectory"> <SelectShareFolderDialogue /> @@ -43,7 +26,7 @@ import { loadState } from '@nextcloud/initial-state' import { showError } from '@nextcloud/dialogs' import axios from '@nextcloud/axios' -import SelectShareFolderDialogue from './SelectShareFolderDialogue' +import SelectShareFolderDialogue from './SelectShareFolderDialogue.vue' export default { name: 'PersonalSettings', @@ -69,7 +52,7 @@ export default { accept: this.accepting, }) } catch (error) { - showError(t('sharing', 'Error while toggling options')) + showError(t('files_sharing', 'Error while toggling options')) console.error(error) } }, diff --git a/apps/files_sharing/src/components/SelectShareFolderDialogue.vue b/apps/files_sharing/src/components/SelectShareFolderDialogue.vue index 405c6fd16ce..959fecaa4a4 100644 --- a/apps/files_sharing/src/components/SelectShareFolderDialogue.vue +++ b/apps/files_sharing/src/components/SelectShareFolderDialogue.vue @@ -1,32 +1,17 @@ <!-- - - @copyright 2021 Hinrich Mahler <nextcloud@mahlerhome.de> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - --> + - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div class="share-folder"> - <span>{{ t('files_sharing', 'Set default folder for accepted shares') }} </span> - <!-- Folder picking form --> <form class="share-folder__form" @reset.prevent.stop="resetFolder"> - <input class="share-folder__picker" + <NcTextField class="share-folder__picker" type="text" - :placeholder="readableDirectory" - @click.prevent="pickFolder"> + :label="t('files_sharing', 'Set default folder for accepted shares')" + :value="readableDirectory" + @click.prevent="pickFolder" /> <!-- Show reset button if folder is different --> <input v-if="readableDirectory !== defaultDirectory" @@ -44,12 +29,16 @@ import path from 'path' import { generateUrl } from '@nextcloud/router' import { getFilePickerBuilder, showError } from '@nextcloud/dialogs' import { loadState } from '@nextcloud/initial-state' +import NcTextField from '@nextcloud/vue/components/NcTextField' const defaultDirectory = loadState('files_sharing', 'default_share_folder', '/') const directory = loadState('files_sharing', 'share_folder', defaultDirectory) export default { name: 'SelectShareFolderDialogue', + components: { + NcTextField, + }, data() { return { directory, @@ -68,10 +57,9 @@ export default { async pickFolder() { // Setup file picker - const picker = getFilePickerBuilder(t('files', 'Choose a default folder for accepted shares')) + const picker = getFilePickerBuilder(t('files_sharing', 'Choose a default folder for accepted shares')) .startAt(this.readableDirectory) .setMultiSelect(false) - .setModal(true) .setType(1) .setMimeTypeFilter(['httpd/unix-directory']) .allowDirectories() @@ -81,7 +69,7 @@ export default { // Init user folder picking const dir = await picker.pick() || '/' if (!dir.startsWith('/')) { - throw new Error(t('files', 'Invalid path selected')) + throw new Error(t('files_sharing', 'Invalid path selected')) } // Fix potential path issues and save results @@ -90,7 +78,7 @@ export default { shareFolder: this.directory, }) } catch (error) { - showError(error.message || t('files', 'Unknown error')) + showError(error.message || t('files_sharing', 'Unknown error')) } }, @@ -110,7 +98,7 @@ export default { &__picker { cursor: pointer; - min-width: 266px; + max-width: 300px; } // Make the reset button looks like text diff --git a/apps/files_sharing/src/components/ShareExpiryTime.vue b/apps/files_sharing/src/components/ShareExpiryTime.vue new file mode 100644 index 00000000000..939142616e9 --- /dev/null +++ b/apps/files_sharing/src/components/ShareExpiryTime.vue @@ -0,0 +1,91 @@ +<!-- + - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <div class="share-expiry-time"> + <NcPopover popup-role="dialog"> + <template #trigger> + <NcButton v-if="expiryTime" + class="hint-icon" + type="tertiary" + :aria-label="t('files_sharing', 'Share expiration: {date}', { date: new Date(expiryTime).toLocaleString() })"> + <template #icon> + <ClockIcon :size="20" /> + </template> + </NcButton> + </template> + <h3 class="hint-heading"> + {{ t('files_sharing', 'Share Expiration') }} + </h3> + <p v-if="expiryTime" class="hint-body"> + <NcDateTime :timestamp="expiryTime" + :format="timeFormat" + :relative-time="false" /> (<NcDateTime :timestamp="expiryTime" />) + </p> + </NcPopover> + </div> +</template> + +<script> +import NcButton from '@nextcloud/vue/components/NcButton' +import NcPopover from '@nextcloud/vue/components/NcPopover' +import NcDateTime from '@nextcloud/vue/components/NcDateTime' +import ClockIcon from 'vue-material-design-icons/Clock.vue' + +export default { + name: 'ShareExpiryTime', + + components: { + NcButton, + NcPopover, + NcDateTime, + ClockIcon, + }, + + props: { + share: { + type: Object, + required: true, + }, + }, + + computed: { + expiryTime() { + return this.share?.expireDate ? new Date(this.share.expireDate).getTime() : null + }, + timeFormat() { + return { dateStyle: 'full', timeStyle: 'short' } + }, + }, +} +</script> + +<style scoped lang="scss"> +.share-expiry-time { + display: inline-flex; + align-items: center; + justify-content: center; + + .hint-icon { + padding: 0; + margin: 0; + width: 24px; + height: 24px; + } +} + +.hint-heading { + text-align: center; + font-size: 1rem; + margin-top: 8px; + padding-bottom: 8px; + margin-bottom: 0; + border-bottom: 1px solid var(--color-border); +} + +.hint-body { + padding: var(--border-radius-element); + max-width: 300px; +} +</style> diff --git a/apps/files_sharing/src/components/SharePermissionsEditor.vue b/apps/files_sharing/src/components/SharePermissionsEditor.vue deleted file mode 100644 index c5e652b2cda..00000000000 --- a/apps/files_sharing/src/components/SharePermissionsEditor.vue +++ /dev/null @@ -1,294 +0,0 @@ -<!-- - - @copyright Copyright (c) 2022 Louis Chmn <louis@chmn.me> - - - - @author Louis Chmn <louis@chmn.me> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> - -<template> - <li> - <ul> - <!-- file --> - <NcActionCheckbox v-if="!isFolder" - :checked="shareHasPermissions(atomicPermissions.UPDATE)" - :disabled="saving" - @update:checked="toggleSharePermissions(atomicPermissions.UPDATE)"> - {{ t('files_sharing', 'Allow editing') }} - </NcActionCheckbox> - - <!-- folder --> - <template v-if="isFolder && fileHasCreatePermission && config.isPublicUploadEnabled"> - <template v-if="!showCustomPermissionsForm"> - <NcActionRadio :checked="sharePermissionEqual(bundledPermissions.READ_ONLY)" - :value="bundledPermissions.READ_ONLY" - :name="randomFormName" - :disabled="saving" - @change="setSharePermissions(bundledPermissions.READ_ONLY)"> - {{ t('files_sharing', 'Read only') }} - </NcActionRadio> - - <NcActionRadio :checked="sharePermissionEqual(bundledPermissions.UPLOAD_AND_UPDATE)" - :value="bundledPermissions.UPLOAD_AND_UPDATE" - :disabled="saving" - :name="randomFormName" - @change="setSharePermissions(bundledPermissions.UPLOAD_AND_UPDATE)"> - {{ t('files_sharing', 'Allow upload and editing') }} - </NcActionRadio> - <NcActionRadio :checked="sharePermissionEqual(bundledPermissions.FILE_DROP)" - :value="bundledPermissions.FILE_DROP" - :disabled="saving" - :name="randomFormName" - class="sharing-entry__action--public-upload" - @change="setSharePermissions(bundledPermissions.FILE_DROP)"> - {{ t('files_sharing', 'File drop (upload only)') }} - </NcActionRadio> - - <!-- custom permissions button --> - <NcActionButton :title="t('files_sharing', 'Custom permissions')" - @click="showCustomPermissionsForm = true"> - <template #icon> - <Tune /> - </template> - {{ sharePermissionsIsBundle ? "" : sharePermissionsSummary }} - </NcActionButton> - </template> - - <!-- custom permissions --> - <span v-else :class="{error: !sharePermissionsSetIsValid}"> - <NcActionCheckbox :checked="shareHasPermissions(atomicPermissions.READ)" - :disabled="saving || !canToggleSharePermissions(atomicPermissions.READ)" - @update:checked="toggleSharePermissions(atomicPermissions.READ)"> - {{ t('files_sharing', 'Read') }} - </NcActionCheckbox> - <NcActionCheckbox :checked="shareHasPermissions(atomicPermissions.CREATE)" - :disabled="saving || !canToggleSharePermissions(atomicPermissions.CREATE)" - @update:checked="toggleSharePermissions(atomicPermissions.CREATE)"> - {{ t('files_sharing', 'Upload') }} - </NcActionCheckbox> - <NcActionCheckbox :checked="shareHasPermissions(atomicPermissions.UPDATE)" - :disabled="saving || !canToggleSharePermissions(atomicPermissions.UPDATE)" - @update:checked="toggleSharePermissions(atomicPermissions.UPDATE)"> - {{ t('files_sharing', 'Edit') }} - </NcActionCheckbox> - <NcActionCheckbox :checked="shareHasPermissions(atomicPermissions.DELETE)" - :disabled="saving || !canToggleSharePermissions(atomicPermissions.DELETE)" - @update:checked="toggleSharePermissions(atomicPermissions.DELETE)"> - {{ t('files_sharing', 'Delete') }} - </NcActionCheckbox> - - <NcActionButton @click="showCustomPermissionsForm = false"> - <template #icon> - <ChevronLeft /> - </template> - {{ t('files_sharing', 'Bundled permissions') }} - </NcActionButton> - </span> - </template> - </ul> - </li> -</template> - -<script> -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton' -import NcActionRadio from '@nextcloud/vue/dist/Components/NcActionRadio' -import NcActionCheckbox from '@nextcloud/vue/dist/Components/NcActionCheckbox' - -import SharesMixin from '../mixins/SharesMixin' -import { - ATOMIC_PERMISSIONS, - BUNDLED_PERMISSIONS, - hasPermissions, - permissionsSetIsValid, - togglePermissions, - canTogglePermissions, -} from '../lib/SharePermissionsToolBox' - -import Tune from 'vue-material-design-icons/Tune' -import ChevronLeft from 'vue-material-design-icons/ChevronLeft' - -export default { - name: 'SharePermissionsEditor', - - components: { - NcActionButton, - NcActionCheckbox, - NcActionRadio, - Tune, - ChevronLeft, - }, - - mixins: [SharesMixin], - - data() { - return { - randomFormName: Math.random().toString(27).substring(2), - - showCustomPermissionsForm: false, - - atomicPermissions: ATOMIC_PERMISSIONS, - bundledPermissions: BUNDLED_PERMISSIONS, - } - }, - - computed: { - /** - * Return the summary of custom checked permissions. - * - * @return {string} - */ - sharePermissionsSummary() { - return Object.values(this.atomicPermissions) - .filter(permission => this.shareHasPermissions(permission)) - .map(permission => { - switch (permission) { - case this.atomicPermissions.CREATE: - return this.t('files_sharing', 'Upload') - case this.atomicPermissions.READ: - return this.t('files_sharing', 'Read') - case this.atomicPermissions.UPDATE: - return this.t('files_sharing', 'Edit') - case this.atomicPermissions.DELETE: - return this.t('files_sharing', 'Delete') - default: - return null - } - }) - .filter(permissionLabel => permissionLabel !== null) - .join(', ') - }, - - /** - * Return whether the share's permission is a bundle. - * - * @return {boolean} - */ - sharePermissionsIsBundle() { - return Object.values(BUNDLED_PERMISSIONS) - .map(bundle => this.sharePermissionEqual(bundle)) - .filter(isBundle => isBundle) - .length > 0 - }, - - /** - * Return whether the share's permission is valid. - * - * @return {boolean} - */ - sharePermissionsSetIsValid() { - return permissionsSetIsValid(this.share.permissions) - }, - - /** - * Is the current share a folder ? - * TODO: move to a proper FileInfo model? - * - * @return {boolean} - */ - isFolder() { - return this.fileInfo.type === 'dir' - }, - - /** - * Does the current file/folder have create permissions. - * TODO: move to a proper FileInfo model? - * - * @return {boolean} - */ - fileHasCreatePermission() { - return !!(this.fileInfo.permissions & ATOMIC_PERMISSIONS.CREATE) - }, - }, - - mounted() { - // Show the Custom Permissions view on open if the permissions set is not a bundle. - this.showCustomPermissionsForm = !this.sharePermissionsIsBundle - }, - - methods: { - /** - * Return whether the share has the exact given permissions. - * - * @param {number} permissions - the permissions to check. - * - * @return {boolean} - */ - sharePermissionEqual(permissions) { - // We use the share's permission without PERMISSION_SHARE as it is not relevant here. - return (this.share.permissions & ~ATOMIC_PERMISSIONS.SHARE) === permissions - }, - - /** - * Return whether the share has the given permissions. - * - * @param {number} permissions - the permissions to check. - * - * @return {boolean} - */ - shareHasPermissions(permissions) { - return hasPermissions(this.share.permissions, permissions) - }, - - /** - * Set the share permissions to the given permissions. - * - * @param {number} permissions - the permissions to set. - * - * @return {void} - */ - setSharePermissions(permissions) { - this.share.permissions = permissions - this.queueUpdate('permissions') - }, - - /** - * Return whether some given permissions can be toggled. - * - * @param {number} permissionsToToggle - the permissions to toggle. - * - * @return {boolean} - */ - canToggleSharePermissions(permissionsToToggle) { - return canTogglePermissions(this.share.permissions, permissionsToToggle) - }, - - /** - * Toggle a given permission. - * - * @param {number} permissions - the permissions to toggle. - * - * @return {void} - */ - toggleSharePermissions(permissions) { - this.share.permissions = togglePermissions(this.share.permissions, permissions) - - if (!permissionsSetIsValid(this.share.permissions)) { - return - } - - this.queueUpdate('permissions') - }, - }, -} -</script> -<style lang="scss" scoped> -.error { - ::v-deep .action-checkbox__label:before { - border: 1px solid var(--color-error); - } -} -</style> diff --git a/apps/files_sharing/src/components/SharingEntry.vue b/apps/files_sharing/src/components/SharingEntry.vue index b4549112964..342b40ce384 100644 --- a/apps/files_sharing/src/components/SharingEntry.vue +++ b/apps/files_sharing/src/components/SharingEntry.vue @@ -1,192 +1,101 @@ <!-- - - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <li class="sharing-entry"> <NcAvatar class="sharing-entry__avatar" - :is-no-user="share.type !== SHARE_TYPES.SHARE_TYPE_USER" + :is-no-user="share.type !== ShareType.User" :user="share.shareWith" - :title="share.type === SHARE_TYPES.SHARE_TYPE_USER ? share.shareWithDisplayName : ''" + :display-name="share.shareWithDisplayName" :menu-position="'left'" :url="share.shareWithAvatar" /> - <component :is="share.shareWithLink ? 'a' : 'div'" - :title="tooltip" - :aria-label="tooltip" - :href="share.shareWithLink" - class="sharing-entry__desc"> - <span>{{ title }}<span v-if="!isUnique" class="sharing-entry__desc-unique"> ({{ share.shareWithDisplayNameUnique }})</span></span> - <p v-if="hasStatus"> - <span>{{ share.status.icon || '' }}</span> - <span>{{ share.status.message || '' }}</span> - </p> - </component> - <NcActions menu-align="right" - class="sharing-entry__actions" - @close="onMenuClose"> - <template v-if="share.canEdit"> - <!-- edit permission --> - <NcActionCheckbox ref="canEdit" - :checked.sync="canEdit" - :value="permissionsEdit" - :disabled="saving || !canSetEdit"> - {{ t('files_sharing', 'Allow editing') }} - </NcActionCheckbox> - - <!-- create permission --> - <NcActionCheckbox v-if="isFolder" - ref="canCreate" - :checked.sync="canCreate" - :value="permissionsCreate" - :disabled="saving || !canSetCreate"> - {{ t('files_sharing', 'Allow creating') }} - </NcActionCheckbox> - - <!-- delete permission --> - <NcActionCheckbox v-if="isFolder" - ref="canDelete" - :checked.sync="canDelete" - :value="permissionsDelete" - :disabled="saving || !canSetDelete"> - {{ t('files_sharing', 'Allow deleting') }} - </NcActionCheckbox> - - <!-- reshare permission --> - <NcActionCheckbox v-if="config.isResharingAllowed" - ref="canReshare" - :checked.sync="canReshare" - :value="permissionsShare" - :disabled="saving || !canSetReshare"> - {{ t('files_sharing', 'Allow resharing') }} - </NcActionCheckbox> - - <NcActionCheckbox v-if="isSetDownloadButtonVisible" - ref="canDownload" - :checked.sync="canDownload" - :disabled="saving || !canSetDownload"> - {{ allowDownloadText }} - </NcActionCheckbox> - - <!-- expiration date --> - <NcActionCheckbox :checked.sync="hasExpirationDate" - :disabled="config.isDefaultInternalExpireDateEnforced || saving" - @uncheck="onExpirationDisable"> - {{ config.isDefaultInternalExpireDateEnforced - ? t('files_sharing', 'Expiration date enforced') - : t('files_sharing', 'Set expiration date') }} - </NcActionCheckbox> - <NcActionInput v-if="hasExpirationDate" - ref="expireDate" - :is-native-picker="true" - :hide-label="true" - :class="{ error: errors.expireDate}" - :disabled="saving" - :value="new Date(share.expireDate)" - type="date" - :min="dateTomorrow" - :max="dateMaxEnforced" - @input="onExpirationChange"> - {{ t('files_sharing', 'Enter a date') }} - </NcActionInput> - - <!-- note --> - <template v-if="canHaveNote"> - <NcActionCheckbox :checked.sync="hasNote" - :disabled="saving" - @uncheck="queueUpdate('note')"> - {{ t('files_sharing', 'Note to recipient') }} - </NcActionCheckbox> - <NcActionTextEditable v-if="hasNote" - ref="note" - :class="{ error: errors.note}" - :disabled="saving" - :value="share.newNote || share.note" - icon="icon-edit" - @update:value="onNoteChange" - @submit="onNoteSubmit" /> - </template> + <div class="sharing-entry__summary"> + <component :is="share.shareWithLink ? 'a' : 'div'" + :title="tooltip" + :aria-label="tooltip" + :href="share.shareWithLink" + class="sharing-entry__summary__desc"> + <span>{{ title }} + <span v-if="!isUnique" class="sharing-entry__summary__desc-unique"> + ({{ share.shareWithDisplayNameUnique }}) + </span> + <small v-if="hasStatus && share.status.message">({{ share.status.message }})</small> + </span> + </component> + <SharingEntryQuickShareSelect :share="share" + :file-info="fileInfo" + @open-sharing-details="openShareDetailsForCustomSettings(share)" /> + </div> + <ShareExpiryTime v-if="share && share.expireDate" :share="share" /> + <NcButton v-if="share.canEdit" + class="sharing-entry__action" + data-cy-files-sharing-share-actions + :aria-label="t('files_sharing', 'Open Sharing Details')" + type="tertiary" + @click="openSharingDetails(share)"> + <template #icon> + <DotsHorizontalIcon :size="20" /> </template> - - <NcActionButton v-if="share.canDelete" - icon="icon-close" - :disabled="saving" - @click.prevent="onDelete"> - {{ t('files_sharing', 'Unshare') }} - </NcActionButton> - </NcActions> + </NcButton> </li> </template> <script> -import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar' -import NcActions from '@nextcloud/vue/dist/Components/NcActions' -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton' -import NcActionCheckbox from '@nextcloud/vue/dist/Components/NcActionCheckbox' -import NcActionInput from '@nextcloud/vue/dist/Components/NcActionInput' -import NcActionTextEditable from '@nextcloud/vue/dist/Components/NcActionTextEditable' +import { ShareType } from '@nextcloud/sharing' + +import NcButton from '@nextcloud/vue/components/NcButton' +import NcSelect from '@nextcloud/vue/components/NcSelect' +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import DotsHorizontalIcon from 'vue-material-design-icons/DotsHorizontal.vue' + +import ShareExpiryTime from './ShareExpiryTime.vue' +import SharingEntryQuickShareSelect from './SharingEntryQuickShareSelect.vue' import SharesMixin from '../mixins/SharesMixin.js' +import ShareDetails from '../mixins/ShareDetails.js' export default { name: 'SharingEntry', components: { - NcActions, - NcActionButton, - NcActionCheckbox, - NcActionInput, - NcActionTextEditable, + NcButton, NcAvatar, + DotsHorizontalIcon, + NcSelect, + ShareExpiryTime, + SharingEntryQuickShareSelect, }, - mixins: [SharesMixin], - - data() { - return { - permissionsEdit: OC.PERMISSION_UPDATE, - permissionsCreate: OC.PERMISSION_CREATE, - permissionsDelete: OC.PERMISSION_DELETE, - permissionsRead: OC.PERMISSION_READ, - permissionsShare: OC.PERMISSION_SHARE, - } - }, + mixins: [SharesMixin, ShareDetails], computed: { title() { let title = this.share.shareWithDisplayName - if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_GROUP) { + + const showAsInternal = this.config.showFederatedSharesAsInternal + || (this.share.isTrustedServer && this.config.showFederatedSharesToTrustedServersAsInternal) + + if (this.share.type === ShareType.Group || (this.share.type === ShareType.RemoteGroup && showAsInternal)) { title += ` (${t('files_sharing', 'group')})` - } else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_ROOM) { + } else if (this.share.type === ShareType.Room) { title += ` (${t('files_sharing', 'conversation')})` - } else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_REMOTE) { + } else if (this.share.type === ShareType.Remote && !showAsInternal) { title += ` (${t('files_sharing', 'remote')})` - } else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP) { + } else if (this.share.type === ShareType.RemoteGroup) { title += ` (${t('files_sharing', 'remote group')})` - } else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_GUEST) { + } else if (this.share.type === ShareType.Guest) { title += ` (${t('files_sharing', 'guest')})` } + if (!this.isShareOwner && this.share.ownerDisplayName) { + title += ' ' + t('files_sharing', 'by {initiator}', { + initiator: this.share.ownerDisplayName, + }) + } return title }, - tooltip() { if (this.share.owner !== this.share.uidFileOwner) { const data = { @@ -195,9 +104,9 @@ export default { user: this.share.shareWithDisplayName, owner: this.share.ownerDisplayName, } - if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_GROUP) { + if (this.share.type === ShareType.Group) { return t('files_sharing', 'Shared with the group {user} by {owner}', data) - } else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_ROOM) { + } else if (this.share.type === ShareType.Room) { return t('files_sharing', 'Shared with the conversation {user} by {owner}', data) } @@ -206,244 +115,19 @@ export default { return null }, - canHaveNote() { - return !this.isRemote - }, - - isRemote() { - return this.share.type === this.SHARE_TYPES.SHARE_TYPE_REMOTE - || this.share.type === this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP - }, - - /** - * Can the sharer set whether the sharee can edit the file ? - * - * @return {boolean} - */ - canSetEdit() { - // If the owner revoked the permission after the resharer granted it - // the share still has the permission, and the resharer is still - // allowed to revoke it too (but not to grant it again). - return (this.fileInfo.sharePermissions & OC.PERMISSION_UPDATE) || this.canEdit - }, - - /** - * Can the sharer set whether the sharee can create the file ? - * - * @return {boolean} - */ - canSetCreate() { - // If the owner revoked the permission after the resharer granted it - // the share still has the permission, and the resharer is still - // allowed to revoke it too (but not to grant it again). - return (this.fileInfo.sharePermissions & OC.PERMISSION_CREATE) || this.canCreate - }, - - /** - * Can the sharer set whether the sharee can delete the file ? - * - * @return {boolean} - */ - canSetDelete() { - // If the owner revoked the permission after the resharer granted it - // the share still has the permission, and the resharer is still - // allowed to revoke it too (but not to grant it again). - return (this.fileInfo.sharePermissions & OC.PERMISSION_DELETE) || this.canDelete - }, - - /** - * Can the sharer set whether the sharee can reshare the file ? - * - * @return {boolean} - */ - canSetReshare() { - // If the owner revoked the permission after the resharer granted it - // the share still has the permission, and the resharer is still - // allowed to revoke it too (but not to grant it again). - return (this.fileInfo.sharePermissions & OC.PERMISSION_SHARE) || this.canReshare - }, - - /** - * Can the sharer set whether the sharee can download the file ? - * - * @return {boolean} - */ - canSetDownload() { - // If the owner revoked the permission after the resharer granted it - // the share still has the permission, and the resharer is still - // allowed to revoke it too (but not to grant it again). - return (this.fileInfo.canDownload() || this.canDownload) - }, - - /** - * Can the sharee edit the shared file ? - */ - canEdit: { - get() { - return this.share.hasUpdatePermission - }, - set(checked) { - this.updatePermissions({ isEditChecked: checked }) - }, - }, - - /** - * Can the sharee create the shared file ? - */ - canCreate: { - get() { - return this.share.hasCreatePermission - }, - set(checked) { - this.updatePermissions({ isCreateChecked: checked }) - }, - }, - - /** - * Can the sharee delete the shared file ? - */ - canDelete: { - get() { - return this.share.hasDeletePermission - }, - set(checked) { - this.updatePermissions({ isDeleteChecked: checked }) - }, - }, - - /** - * Can the sharee reshare the file ? - */ - canReshare: { - get() { - return this.share.hasSharePermission - }, - set(checked) { - this.updatePermissions({ isReshareChecked: checked }) - }, - }, - - /** - * Can the sharee download files or only view them ? - */ - canDownload: { - get() { - return this.share.hasDownloadPermission - }, - set(checked) { - this.updatePermissions({ isDownloadChecked: checked }) - }, - }, - - /** - * Is this share readable - * Needed for some federated shares that might have been added from file drop links - */ - hasRead: { - get() { - return this.share.hasReadPermission - }, - }, - - /** - * Is the current share a folder ? - * - * @return {boolean} - */ - isFolder() { - return this.fileInfo.type === 'dir' - }, - - /** - * Does the current share have an expiration date - * - * @return {boolean} - */ - hasExpirationDate: { - get() { - return this.config.isDefaultInternalExpireDateEnforced || !!this.share.expireDate - }, - set(enabled) { - const defaultExpirationDate = this.config.defaultInternalExpirationDate - || new Date(new Date().setDate(new Date().getDate() + 1)) - this.share.expireDate = enabled - ? this.formatDateToString(defaultExpirationDate) - : '' - console.debug('Expiration date status', enabled, this.share.expireDate) - }, - }, - - dateMaxEnforced() { - if (!this.isRemote && this.config.isDefaultInternalExpireDateEnforced) { - return new Date(new Date().setDate(new Date().getDate() + 1 + this.config.defaultInternalExpireDate)) - } else if (this.config.isDefaultRemoteExpireDateEnforced) { - return new Date(new Date().setDate(new Date().getDate() + 1 + this.config.defaultRemoteExpireDate)) - } - return null - }, - /** * @return {boolean} */ hasStatus() { - if (this.share.type !== this.SHARE_TYPES.SHARE_TYPE_USER) { + if (this.share.type !== ShareType.User) { return false } return (typeof this.share.status === 'object' && !Array.isArray(this.share.status)) }, - - /** - * @return {string} - */ - allowDownloadText() { - return t('files_sharing', 'Allow download') - }, - - /** - * @return {boolean} - */ - isSetDownloadButtonVisible() { - const allowedMimetypes = [ - // Office documents - 'application/msword', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.ms-powerpoint', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'application/vnd.ms-excel', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.oasis.opendocument.text', - 'application/vnd.oasis.opendocument.spreadsheet', - 'application/vnd.oasis.opendocument.presentation', - ] - - return this.isFolder || allowedMimetypes.includes(this.fileInfo.mimetype) - }, }, methods: { - updatePermissions({ - isEditChecked = this.canEdit, - isCreateChecked = this.canCreate, - isDeleteChecked = this.canDelete, - isReshareChecked = this.canReshare, - isDownloadChecked = this.canDownload, - } = {}) { - // calc permissions if checked - const permissions = 0 - | (this.hasRead ? this.permissionsRead : 0) - | (isCreateChecked ? this.permissionsCreate : 0) - | (isDeleteChecked ? this.permissionsDelete : 0) - | (isEditChecked ? this.permissionsEdit : 0) - | (isReshareChecked ? this.permissionsShare : 0) - - this.share.permissions = permissions - if (this.share.hasDownloadPermission !== isDownloadChecked) { - this.share.hasDownloadPermission = isDownloadChecked - } - this.queueUpdate('permissions', 'attributes') - }, - /** * Save potential changed data on menu close */ @@ -459,21 +143,34 @@ export default { display: flex; align-items: center; height: 44px; - &__desc { + &__summary { + padding: 8px; + padding-inline-start: 10px; display: flex; flex-direction: column; - justify-content: space-between; - padding: 8px; - line-height: 1.2em; - p { - color: var(--color-text-maxcontrast); - } - &-unique { - color: var(--color-text-maxcontrast); + justify-content: center; + align-items: flex-start; + flex: 1 0; + min-width: 0; + + &__desc { + display: inline-block; + padding-bottom: 0; + line-height: 1.2em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + p, + small { + color: var(--color-text-maxcontrast); + } + + &-unique { + color: var(--color-text-maxcontrast); + } } } - &__actions { - margin-left: auto; - } + } </style> diff --git a/apps/files_sharing/src/components/SharingEntryInherited.vue b/apps/files_sharing/src/components/SharingEntryInherited.vue index e4979fdc44d..e7dfffd5776 100644 --- a/apps/files_sharing/src/components/SharingEntryInherited.vue +++ b/apps/files_sharing/src/components/SharingEntryInherited.vue @@ -1,24 +1,7 @@ <!-- - - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <SharingEntrySimple :key="share.id" @@ -26,8 +9,7 @@ :title="share.shareWithDisplayName"> <template #avatar> <NcAvatar :user="share.shareWith" - :aria-label="share.shareWithDisplayName" - :title="share.shareWithDisplayName" + :display-name="share.shareWithDisplayName" class="sharing-entry__avatar" /> </template> <NcActionText icon="icon-user"> @@ -49,15 +31,15 @@ <script> import { generateUrl } from '@nextcloud/router' import { basename } from '@nextcloud/paths' -import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar' -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton' -import NcActionLink from '@nextcloud/vue/dist/Components/NcActionLink' -import NcActionText from '@nextcloud/vue/dist/Components/NcActionText' +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcActionLink from '@nextcloud/vue/components/NcActionLink' +import NcActionText from '@nextcloud/vue/components/NcActionText' // eslint-disable-next-line no-unused-vars -import Share from '../models/Share' -import SharesMixin from '../mixins/SharesMixin' -import SharingEntrySimple from '../components/SharingEntrySimple' +import Share from '../models/Share.js' +import SharesMixin from '../mixins/SharesMixin.js' +import SharingEntrySimple from '../components/SharingEntrySimple.vue' export default { name: 'SharingEntryInherited', @@ -103,13 +85,14 @@ export default { flex-direction: column; justify-content: space-between; padding: 8px; + padding-inline-start: 10px; line-height: 1.2em; p { color: var(--color-text-maxcontrast); } } &__actions { - margin-left: auto; + margin-inline-start: auto; } } </style> diff --git a/apps/files_sharing/src/components/SharingEntryInternal.vue b/apps/files_sharing/src/components/SharingEntryInternal.vue index d3b55d4991c..027d2a3d5c3 100644 --- a/apps/files_sharing/src/components/SharingEntryInternal.vue +++ b/apps/files_sharing/src/components/SharingEntryInternal.vue @@ -1,4 +1,7 @@ - +<!-- + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <ul> <SharingEntrySimple ref="shareEntrySimple" @@ -9,12 +12,16 @@ <div class="avatar-external icon-external-white" /> </template> - <NcActionLink :href="internalLink" + <NcActionButton :title="copyLinkTooltip" :aria-label="copyLinkTooltip" - :title="copyLinkTooltip" - target="_blank" - :icon="copied && copySuccess ? 'icon-checkmark-color' : 'icon-clippy'" - @click.prevent="copyLink" /> + @click="copyLink"> + <template #icon> + <CheckIcon v-if="copied && copySuccess" + :size="20" + class="icon-checkmark-color" /> + <ClipboardIcon v-else :size="20" /> + </template> + </NcActionButton> </SharingEntrySimple> </ul> </template> @@ -22,15 +29,21 @@ <script> import { generateUrl } from '@nextcloud/router' import { showSuccess } from '@nextcloud/dialogs' -import NcActionLink from '@nextcloud/vue/dist/Components/NcActionLink' -import SharingEntrySimple from './SharingEntrySimple' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' + +import CheckIcon from 'vue-material-design-icons/Check.vue' +import ClipboardIcon from 'vue-material-design-icons/ContentCopy.vue' + +import SharingEntrySimple from './SharingEntrySimple.vue' export default { name: 'SharingEntryInternal', components: { - NcActionLink, + NcActionButton, SharingEntrySimple, + CheckIcon, + ClipboardIcon, }, props: { @@ -70,14 +83,11 @@ export default { } return t('files_sharing', 'Cannot copy, please copy the link manually') } - return t('files_sharing', 'Copy internal link to clipboard') + return t('files_sharing', 'Copy internal link') }, internalLinkSubtitle() { - if (this.fileInfo.type === 'dir') { - return t('files_sharing', 'Only works for users with access to this folder') - } - return t('files_sharing', 'Only works for users with access to this file') + return t('files_sharing', 'For people who already have access') }, }, @@ -86,7 +96,6 @@ export default { try { await navigator.clipboard.writeText(this.internalLink) showSuccess(t('files_sharing', 'Link copied')) - // focus and show the tooltip (note: cannot set ref on NcActionLink) this.$refs.shareEntrySimple.$refs.actionsComponent.$el.focus() this.copySuccess = true this.copied = true @@ -118,6 +127,7 @@ export default { } .icon-checkmark-color { opacity: 1; + color: var(--color-success); } } </style> diff --git a/apps/files_sharing/src/components/SharingEntryLink.vue b/apps/files_sharing/src/components/SharingEntryLink.vue index 7622efa6fac..6865af1b864 100644 --- a/apps/files_sharing/src/components/SharingEntryLink.vue +++ b/apps/files_sharing/src/components/SharingEntryLink.vue @@ -1,62 +1,64 @@ <!-- - - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <li :class="{'sharing-entry--share': share}" class="sharing-entry sharing-entry__link"> + <li :class="{ 'sharing-entry--share': share }" + class="sharing-entry sharing-entry__link"> <NcAvatar :is-no-user="true" :icon-class="isEmailShareType ? 'avatar-link-share icon-mail-white' : 'avatar-link-share icon-public-white'" class="sharing-entry__avatar" /> - <div class="sharing-entry__desc"> - <span class="sharing-entry__title" :title="title"> - {{ title }} - </span> - <p v-if="subtitle"> - {{ subtitle }} - </p> - </div> - <!-- clipboard --> - <NcActions v-if="share && !isEmailShareType && share.token" - ref="copyButton" - class="sharing-entry__copy"> - <NcActionLink :href="shareLink" - target="_blank" - :title="copyLinkTooltip" - :aria-label="copyLinkTooltip" - :icon="copied && copySuccess ? 'icon-checkmark-color' : 'icon-clippy'" - @click.stop.prevent="copyLink" /> - </NcActions> + <div class="sharing-entry__summary"> + <div class="sharing-entry__desc"> + <span class="sharing-entry__title" :title="title"> + {{ title }} + </span> + <p v-if="subtitle"> + {{ subtitle }} + </p> + <SharingEntryQuickShareSelect v-if="share && share.permissions !== undefined" + :share="share" + :file-info="fileInfo" + @open-sharing-details="openShareDetailsForCustomSettings(share)" /> + </div> + + <div class="sharing-entry__actions"> + <ShareExpiryTime v-if="share && share.expireDate" :share="share" /> + + <!-- clipboard --> + <div> + <NcActions v-if="share && (!isEmailShareType || isFileRequest) && share.token" ref="copyButton" class="sharing-entry__copy"> + <NcActionButton :aria-label="copyLinkTooltip" + :title="copyLinkTooltip" + :href="shareLink" + @click.prevent="copyLink"> + <template #icon> + <CheckIcon v-if="copied && copySuccess" + :size="20" + class="icon-checkmark-color" /> + <ClipboardIcon v-else :size="20" /> + </template> + </NcActionButton> + </NcActions> + </div> + </div> + </div> <!-- pending actions --> - <NcActions v-if="!pending && (pendingPassword || pendingExpirationDate)" + <NcActions v-if="!pending && pendingDataIsMissing" class="sharing-entry__actions" :aria-label="actionsTooltip" menu-align="right" :open.sync="open" - @close="onNewLinkShare"> + @close="onCancel"> <!-- pending data menu --> <NcActionText v-if="errors.pending" - icon="icon-error" - :class="{ error: errors.pending}"> + class="error"> + <template #icon> + <ErrorIcon :size="20" /> + </template> {{ errors.pending }} </NcActionText> <NcActionText v-else icon="icon-info"> @@ -64,52 +66,66 @@ </NcActionText> <!-- password --> - <NcActionText v-if="pendingPassword" icon="icon-password"> - {{ t('files_sharing', 'Password protection (enforced)') }} - </NcActionText> - <NcActionCheckbox v-else-if="config.enableLinkPasswordByDefault" + <NcActionCheckbox v-if="pendingPassword" :checked.sync="isPasswordProtected" :disabled="config.enforcePasswordForPublicLink || saving" class="share-link-password-checkbox" @uncheck="onPasswordDisable"> - {{ t('files_sharing', 'Password protection') }} + {{ config.enforcePasswordForPublicLink ? t('files_sharing', 'Password protection (enforced)') : t('files_sharing', 'Password protection') }} </NcActionCheckbox> - <NcActionInput v-if="pendingPassword || share.password" + <NcActionInput v-if="pendingEnforcedPassword || isPasswordProtected" class="share-link-password" - :value.sync="share.password" + :label="t('files_sharing', 'Enter a password')" + :value.sync="share.newPassword" :disabled="saving" :required="config.enableLinkPasswordByDefault || config.enforcePasswordForPublicLink" :minlength="isPasswordPolicyEnabled && config.passwordPolicy.minLength" - icon="" autocomplete="new-password" - @submit="onNewLinkShare"> - {{ t('files_sharing', 'Enter a password') }} + @submit="onNewLinkShare(true)"> + <template #icon> + <LockIcon :size="20" /> + </template> </NcActionInput> + <NcActionCheckbox v-if="pendingDefaultExpirationDate" + :checked.sync="defaultExpirationDateEnabled" + :disabled="pendingEnforcedExpirationDate || saving" + class="share-link-expiration-date-checkbox" + @update:model-value="onExpirationDateToggleUpdate"> + {{ config.isDefaultExpireDateEnforced ? t('files_sharing', 'Enable link expiration (enforced)') : t('files_sharing', 'Enable link expiration') }} + </NcActionCheckbox> + <!-- expiration date --> - <NcActionText v-if="pendingExpirationDate" icon="icon-calendar-dark"> - {{ t('files_sharing', 'Expiration date (enforced)') }} - </NcActionText> - <NcActionInput v-if="pendingExpirationDate" + <NcActionInput v-if="(pendingDefaultExpirationDate || pendingEnforcedExpirationDate) && defaultExpirationDateEnabled" + data-cy-files-sharing-expiration-date-input class="share-link-expire-date" + :label="pendingEnforcedExpirationDate ? t('files_sharing', 'Enter expiration date (enforced)') : t('files_sharing', 'Enter expiration date')" :disabled="saving" :is-native-picker="true" :hide-label="true" :value="new Date(share.expireDate)" type="date" :min="dateTomorrow" - :max="dateMaxEnforced" - @input="onExpirationChange"> - <!-- let's not submit when picked, the user - might want to still edit or copy the password --> - {{ t('files_sharing', 'Enter a date') }} + :max="maxExpirationDateEnforced" + @update:model-value="onExpirationChange" + @change="expirationDateChanged"> + <template #icon> + <IconCalendarBlank :size="20" /> + </template> </NcActionInput> - <NcActionButton icon="icon-checkmark" @click.prevent.stop="onNewLinkShare"> + <NcActionButton :disabled="pendingEnforcedPassword && !share.newPassword" + @click.prevent.stop="onNewLinkShare(true)"> + <template #icon> + <CheckIcon :size="20" /> + </template> {{ t('files_sharing', 'Create share') }} </NcActionButton> - <NcActionButton icon="icon-close" @click.prevent.stop="onCancel"> + <NcActionButton @click.prevent.stop="onCancel"> + <template #icon> + <CloseIcon :size="20" /> + </template> {{ t('files_sharing', 'Cancel') }} </NcActionButton> </NcActions> @@ -123,111 +139,24 @@ @close="onMenuClose"> <template v-if="share"> <template v-if="share.canEdit && canReshare"> - <!-- Custom Label --> - <NcActionInput ref="label" - :class="{ error: errors.label }" - :disabled="saving" - :label="t('files_sharing', 'Share label')" - :value="share.newLabel !== undefined ? share.newLabel : share.label" - icon="icon-edit" - maxlength="255" - @update:value="onLabelChange" - @submit="onLabelSubmit" /> - - <SharePermissionsEditor :can-reshare="canReshare" - :share.sync="share" - :file-info="fileInfo" /> - - <NcActionSeparator /> - - <NcActionCheckbox :checked.sync="share.hideDownload" - :disabled="saving || canChangeHideDownload" - @change="queueUpdate('hideDownload')"> - {{ t('files_sharing', 'Hide download') }} - </NcActionCheckbox> - - <!-- password --> - <NcActionCheckbox :checked.sync="isPasswordProtected" - :disabled="config.enforcePasswordForPublicLink || saving" - class="share-link-password-checkbox" - @uncheck="onPasswordDisable"> - {{ config.enforcePasswordForPublicLink - ? t('files_sharing', 'Password protection (enforced)') - : t('files_sharing', 'Password protect') }} - </NcActionCheckbox> - - <NcActionInput v-if="isPasswordProtected" - ref="password" - class="share-link-password" - :class="{ error: errors.password}" - :disabled="saving" - :required="config.enforcePasswordForPublicLink" - :value="hasUnsavedPassword ? share.newPassword : '***************'" - icon="icon-password" - autocomplete="new-password" - :type="hasUnsavedPassword ? 'text': 'password'" - @update:value="onPasswordChange" - @submit="onPasswordSubmit"> - {{ t('files_sharing', 'Enter a password') }} - </NcActionInput> - <NcActionText v-if="isEmailShareType && passwordExpirationTime" icon="icon-info"> - {{ t('files_sharing', 'Password expires {passwordExpirationTime}', {passwordExpirationTime}) }} - </NcActionText> - <NcActionText v-else-if="isEmailShareType && passwordExpirationTime !== null" icon="icon-error"> - {{ t('files_sharing', 'Password expired') }} - </NcActionText> - - <!-- password protected by Talk --> - <NcActionCheckbox v-if="isPasswordProtectedByTalkAvailable" - :checked.sync="isPasswordProtectedByTalk" - :disabled="!canTogglePasswordProtectedByTalkAvailable || saving" - class="share-link-password-talk-checkbox" - @change="onPasswordProtectedByTalkChange"> - {{ t('files_sharing', 'Video verification') }} - </NcActionCheckbox> - - <!-- expiration date --> - <NcActionCheckbox :checked.sync="hasExpirationDate" - :disabled="config.isDefaultExpireDateEnforced || saving" - class="share-link-expire-date-checkbox" - @uncheck="onExpirationDisable"> - {{ config.isDefaultExpireDateEnforced - ? t('files_sharing', 'Expiration date (enforced)') - : t('files_sharing', 'Set expiration date') }} - </NcActionCheckbox> - <NcActionInput v-if="hasExpirationDate" - ref="expireDate" - :is-native-picker="true" - :hide-label="true" - class="share-link-expire-date" - :class="{ error: errors.expireDate}" - :disabled="saving" - :value="new Date(share.expireDate)" - type="date" - :min="dateTomorrow" - :max="dateMaxEnforced" - @input="onExpirationChange"> - {{ t('files_sharing', 'Enter a date') }} - </NcActionInput> - - <!-- note --> - <NcActionCheckbox :checked.sync="hasNote" - :disabled="saving" - @uncheck="queueUpdate('note')"> - {{ t('files_sharing', 'Note to recipient') }} - </NcActionCheckbox> - - <NcActionTextEditable v-if="hasNote" - ref="note" - :class="{ error: errors.note}" - :disabled="saving" - :placeholder="t('files_sharing', 'Enter a note for the share recipient')" - :value="share.newNote || share.note" - icon="icon-edit" - @update:value="onNoteChange" - @submit="onNoteSubmit" /> + <NcActionButton :disabled="saving" + :close-after-click="true" + @click.prevent="openSharingDetails"> + <template #icon> + <Tune :size="20" /> + </template> + {{ t('files_sharing', 'Customize link') }} + </NcActionButton> </template> + <NcActionButton :close-after-click="true" + @click.prevent="showQRCode = true"> + <template #icon> + <IconQr :size="20" /> + </template> + {{ t('files_sharing', 'Generate QR code') }} + </NcActionButton> + <NcActionSeparator /> <!-- external actions --> @@ -239,26 +168,31 @@ :share="share" /> <!-- external legacy sharing via url (social...) --> - <NcActionLink v-for="({icon, url, name}, index) in externalLegacyLinkActions" - :key="index" + <NcActionLink v-for="({ icon, url, name }, actionIndex) in externalLegacyLinkActions" + :key="actionIndex" :href="url(shareLink)" :icon="icon" target="_blank"> {{ name }} </NcActionLink> - <NcActionButton v-if="share.canDelete" - icon="icon-close" - :disabled="saving" - @click.prevent="onDelete"> - {{ t('files_sharing', 'Unshare') }} - </NcActionButton> <NcActionButton v-if="!isEmailShareType && canReshare" class="new-share-link" - icon="icon-add" @click.prevent.stop="onNewLinkShare"> + <template #icon> + <PlusIcon :size="20" /> + </template> {{ t('files_sharing', 'Add another link') }} </NcActionButton> + + <NcActionButton v-if="share.canDelete" + :disabled="saving" + @click.prevent="onDelete"> + <template #icon> + <CloseIcon :size="20" /> + </template> + {{ t('files_sharing', 'Unshare') }} + </NcActionButton> </template> <!-- Create new share --> @@ -272,49 +206,91 @@ <!-- loading indicator to replace the menu --> <div v-else class="icon-loading-small sharing-entry__loading" /> + + <!-- Modal to open whenever we have a QR code --> + <NcDialog v-if="showQRCode" + size="normal" + :open.sync="showQRCode" + :name="title" + :close-on-click-outside="true" + @close="showQRCode = false"> + <div class="qr-code-dialog"> + <VueQrcode tag="img" + :value="shareLink" + class="qr-code-dialog__img" /> + </div> + </NcDialog> </li> </template> <script> -import { generateUrl } from '@nextcloud/router' import { showError, showSuccess } from '@nextcloud/dialogs' -import { Type as ShareTypes } from '@nextcloud/sharing' -import Vue from 'vue' - -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton' -import NcActionCheckbox from '@nextcloud/vue/dist/Components/NcActionCheckbox' -import NcActionInput from '@nextcloud/vue/dist/Components/NcActionInput' -import NcActionLink from '@nextcloud/vue/dist/Components/NcActionLink' -import NcActionText from '@nextcloud/vue/dist/Components/NcActionText' -import NcActionSeparator from '@nextcloud/vue/dist/Components/NcActionSeparator' -import NcActionTextEditable from '@nextcloud/vue/dist/Components/NcActionTextEditable' -import NcActions from '@nextcloud/vue/dist/Components/NcActions' -import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar' +import { emit } from '@nextcloud/event-bus' +import { t } from '@nextcloud/l10n' +import moment from '@nextcloud/moment' +import { generateUrl, getBaseUrl } from '@nextcloud/router' +import { ShareType } from '@nextcloud/sharing' + +import VueQrcode from '@chenfengyuan/vue-qrcode' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcActionCheckbox from '@nextcloud/vue/components/NcActionCheckbox' +import NcActionInput from '@nextcloud/vue/components/NcActionInput' +import NcActionLink from '@nextcloud/vue/components/NcActionLink' +import NcActionText from '@nextcloud/vue/components/NcActionText' +import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcDialog from '@nextcloud/vue/components/NcDialog' + +import Tune from 'vue-material-design-icons/Tune.vue' +import IconCalendarBlank from 'vue-material-design-icons/CalendarBlankOutline.vue' +import IconQr from 'vue-material-design-icons/Qrcode.vue' +import ErrorIcon from 'vue-material-design-icons/Exclamation.vue' +import LockIcon from 'vue-material-design-icons/LockOutline.vue' +import CheckIcon from 'vue-material-design-icons/CheckBold.vue' +import ClipboardIcon from 'vue-material-design-icons/ContentCopy.vue' +import CloseIcon from 'vue-material-design-icons/Close.vue' +import PlusIcon from 'vue-material-design-icons/Plus.vue' + +import SharingEntryQuickShareSelect from './SharingEntryQuickShareSelect.vue' +import ShareExpiryTime from './ShareExpiryTime.vue' import ExternalShareAction from './ExternalShareAction.vue' -import SharePermissionsEditor from './SharePermissionsEditor.vue' -import GeneratePassword from '../utils/GeneratePassword.js' -import Share from '../models/Share.js' +import GeneratePassword from '../utils/GeneratePassword.ts' +import Share from '../models/Share.ts' import SharesMixin from '../mixins/SharesMixin.js' +import ShareDetails from '../mixins/ShareDetails.js' +import logger from '../services/logger.ts' export default { name: 'SharingEntryLink', components: { + ExternalShareAction, NcActions, NcActionButton, NcActionCheckbox, NcActionInput, NcActionLink, NcActionText, - NcActionTextEditable, NcActionSeparator, NcAvatar, - ExternalShareAction, - SharePermissionsEditor, + NcDialog, + VueQrcode, + Tune, + IconCalendarBlank, + IconQr, + ErrorIcon, + LockIcon, + CheckIcon, + ClipboardIcon, + CloseIcon, + PlusIcon, + SharingEntryQuickShareSelect, + ShareExpiryTime, }, - mixins: [SharesMixin], + mixins: [SharesMixin, ShareDetails], props: { canReshare: { @@ -329,14 +305,19 @@ export default { data() { return { + shareCreationComplete: false, copySuccess: true, copied: false, + defaultExpirationDateEnabled: false, // Are we waiting for password/expiration date pending: false, ExternalLegacyLinkActions: OCA.Sharing.ExternalLinkActions.state, ExternalShareActions: OCA.Sharing.ExternalShareActions.state, + + // tracks whether modal should be opened or not + showQRCode: false, } }, @@ -347,6 +328,8 @@ export default { * @return {string} */ title() { + const l10nOptions = { escape: false /* no escape as this string is already escaped by Vue */ } + // if we have a valid existing share (not pending) if (this.share && this.share.id) { if (!this.isShareOwner && this.share.ownerDisplayName) { @@ -354,30 +337,46 @@ export default { return t('files_sharing', '{shareWith} by {initiator}', { shareWith: this.share.shareWith, initiator: this.share.ownerDisplayName, - }) + }, l10nOptions) } return t('files_sharing', 'Shared via link by {initiator}', { initiator: this.share.ownerDisplayName, - }) + }, l10nOptions) } if (this.share.label && this.share.label.trim() !== '') { if (this.isEmailShareType) { + if (this.isFileRequest) { + return t('files_sharing', 'File request ({label})', { + label: this.share.label.trim(), + }, l10nOptions) + } return t('files_sharing', 'Mail share ({label})', { label: this.share.label.trim(), - }) + }, l10nOptions) } return t('files_sharing', 'Share link ({label})', { label: this.share.label.trim(), - }) + }, l10nOptions) } if (this.isEmailShareType) { + if (!this.share.shareWith || this.share.shareWith.trim() === '') { + return this.isFileRequest + ? t('files_sharing', 'File request') + : t('files_sharing', 'Mail share') + } return this.share.shareWith } + + if (this.index === null) { + return t('files_sharing', 'Share link') + } } - if (this.index > 1) { + + if (this.index >= 1) { return t('files_sharing', 'Share link ({index})', { index: this.index }) } - return t('files_sharing', 'Share link') + + return t('files_sharing', 'Create public link') }, /** @@ -393,50 +392,6 @@ export default { return null }, - /** - * Does the current share have an expiration date - * - * @return {boolean} - */ - hasExpirationDate: { - get() { - return this.config.isDefaultExpireDateEnforced - || !!this.share.expireDate - }, - set(enabled) { - const defaultExpirationDate = this.config.defaultExpirationDate - || new Date(new Date().setDate(new Date().getDate() + 1)) - this.share.expireDate = enabled - ? this.formatDateToString(defaultExpirationDate) - : '' - console.debug('Expiration date status', enabled, this.share.expireDate) - }, - }, - - dateMaxEnforced() { - if (this.config.isDefaultExpireDateEnforced) { - return new Date(new Date().setDate(new Date().getDate() + this.config.defaultExpireDate)) - } - return null - }, - - /** - * Is the current share password protected ? - * - * @return {boolean} - */ - isPasswordProtected: { - get() { - return this.config.enforcePasswordForPublicLink - || !!this.share.password - }, - async set(enabled) { - // TODO: directly save after generation to make sure the share is always protected - Vue.set(this.share, 'password', enabled ? await GeneratePassword() : '') - Vue.set(this.share, 'newPassword', this.share.password) - }, - }, - passwordExpirationTime() { if (this.share.passwordExpirationTime === null) { return null @@ -490,7 +445,7 @@ export default { */ isEmailShareType() { return this.share - ? this.share.type === this.SHARE_TYPES.SHARE_TYPE_EMAIL + ? this.share.type === ShareType.Email : false }, @@ -515,13 +470,50 @@ export default { * * @return {boolean} */ + pendingDataIsMissing() { + return this.pendingPassword || this.pendingEnforcedPassword || this.pendingDefaultExpirationDate || this.pendingEnforcedExpirationDate + }, pendingPassword() { - return this.config.enforcePasswordForPublicLink && this.share && !this.share.id + return this.config.enableLinkPasswordByDefault && this.isPendingShare + }, + pendingEnforcedPassword() { + return this.config.enforcePasswordForPublicLink && this.isPendingShare + }, + pendingEnforcedExpirationDate() { + return this.config.isDefaultExpireDateEnforced && this.isPendingShare }, - pendingExpirationDate() { - return this.config.isDefaultExpireDateEnforced && this.share && !this.share.id + pendingDefaultExpirationDate() { + return (this.config.defaultExpirationDate instanceof Date || !isNaN(new Date(this.config.defaultExpirationDate).getTime())) && this.isPendingShare }, + isPendingShare() { + return !!(this.share && !this.share.id) + }, + sharePolicyHasEnforcedProperties() { + return this.config.enforcePasswordForPublicLink || this.config.isDefaultExpireDateEnforced + }, + + enforcedPropertiesMissing() { + // Ensure share exist and the share policy has required properties + if (!this.sharePolicyHasEnforcedProperties) { + return false + } + if (!this.share) { + // if no share, we can't tell if properties are missing or not so we assume properties are missing + return true + } + + // If share has ID, then this is an incoming link share created from the existing link share + // Hence assume required properties + if (this.share.id) { + return true + } + // Check if either password or expiration date is missing and enforced + const isPasswordMissing = this.config.enforcePasswordForPublicLink && !this.share.password + const isExpireDateMissing = this.config.isDefaultExpireDateEnforced && !this.share.expireDate + + return isPasswordMissing || isExpireDateMissing + }, // if newPassword exists, but is empty, it means // the user deleted the original password hasUnsavedPassword() { @@ -534,7 +526,7 @@ export default { * @return {string} */ shareLink() { - return window.location.protocol + '//' + window.location.host + generateUrl('/s/') + this.share.token + return generateUrl('/s/{token}', { token: this.share.token }, { baseURL: getBaseUrl() }) }, /** @@ -558,7 +550,7 @@ export default { } return t('files_sharing', 'Cannot copy, please copy the link manually') } - return t('files_sharing', 'Copy public link of "{title}" to clipboard', { title: this.title }) + return t('files_sharing', 'Copy public link of "{title}"', { title: this.title }) }, /** @@ -577,10 +569,10 @@ export default { * @return {Array} */ externalLinkActions() { + const filterValidAction = (action) => (action.shareType.includes(ShareType.Link) || action.shareType.includes(ShareType.Email)) && !action.advanced // filter only the registered actions for said link return this.ExternalShareActions.actions - .filter(action => action.shareType.includes(ShareTypes.SHARE_TYPE_LINK) - || action.shareType.includes(ShareTypes.SHARE_TYPE_EMAIL)) + .filter(filterValidAction) }, isPasswordPolicyEnabled() { @@ -588,65 +580,74 @@ export default { }, canChangeHideDownload() { - const hasDisabledDownload = (shareAttribute) => shareAttribute.key === 'download' && shareAttribute.scope === 'permissions' && shareAttribute.enabled === false - + const hasDisabledDownload = (shareAttribute) => shareAttribute.scope === 'permissions' && shareAttribute.key === 'download' && shareAttribute.value === false return this.fileInfo.shareAttributes.some(hasDisabledDownload) }, + + isFileRequest() { + return this.share.isFileRequest + }, + }, + mounted() { + this.defaultExpirationDateEnabled = this.config.defaultExpirationDate instanceof Date + if (this.share && this.isNewShare) { + this.share.expireDate = this.defaultExpirationDateEnabled ? this.formatDateToString(this.config.defaultExpirationDate) : '' + } }, methods: { /** + * Check if the share requires review + * + * @param {boolean} shareReviewComplete if the share was reviewed + * @return {boolean} + */ + shareRequiresReview(shareReviewComplete) { + // If a user clicks 'Create share' it means they have reviewed the share + if (shareReviewComplete) { + return false + } + return this.defaultExpirationDateEnabled || this.config.enableLinkPasswordByDefault + }, + /** * Create a new share link and append it to the list + * @param {boolean} shareReviewComplete if the share was reviewed */ - async onNewLinkShare() { + async onNewLinkShare(shareReviewComplete = false) { + logger.debug('onNewLinkShare called (with this.share)', this.share) // do not run again if already loading if (this.loading) { return } const shareDefaults = { - share_type: ShareTypes.SHARE_TYPE_LINK, + share_type: ShareType.Link, } if (this.config.isDefaultExpireDateEnforced) { // default is empty string if not set // expiration is the share object key, not expireDate shareDefaults.expiration = this.formatDateToString(this.config.defaultExpirationDate) } - if (this.config.enableLinkPasswordByDefault) { - shareDefaults.password = await GeneratePassword() - } - // do not push yet if we need a password or an expiration date: show pending menu - if (this.config.enforcePasswordForPublicLink || this.config.isDefaultExpireDateEnforced) { + logger.debug('Missing required properties?', this.enforcedPropertiesMissing) + // Do not push yet if we need a password or an expiration date: show pending menu + // A share would require a review for example is default expiration date is set but not enforced, this allows + // the user to review the share and remove the expiration date if they don't want it + if ((this.sharePolicyHasEnforcedProperties && this.enforcedPropertiesMissing) || this.shareRequiresReview(shareReviewComplete === true)) { this.pending = true + this.shareCreationComplete = false - // if a share already exists, pushing it - if (this.share && !this.share.id) { - // if the share is valid, create it on the server - if (this.checkShare(this.share)) { - try { - await this.pushNewLinkShare(this.share, true) - } catch (e) { - this.pending = false - console.error(e) - return false - } - return true - } else { - this.open = true - OC.Notification.showTemporary(t('files_sharing', 'Error, please enter proper password and/or expiration date')) - return false - } - } + logger.info('Share policy requires a review or has mandated properties (password, expirationDate)...') // ELSE, show the pending popovermenu - // if password enforced, pre-fill with random one - if (this.config.enforcePasswordForPublicLink) { - shareDefaults.password = await GeneratePassword() + // if password default or enforced, pre-fill with random one + if (this.config.enableLinkPasswordByDefault || this.config.enforcePasswordForPublicLink) { + shareDefaults.password = await GeneratePassword(true) } // create share & close menu const share = new Share(shareDefaults) + share.newPassword = share.password const component = await new Promise(resolve => { this.$emit('add:share', share, resolve) }) @@ -657,10 +658,34 @@ export default { this.pending = false component.open = true - // Nothing is enforced, creating share directly + // Nothing is enforced, creating share directly } else { + + // if a share already exists, pushing it + if (this.share && !this.share.id) { + // if the share is valid, create it on the server + if (this.checkShare(this.share)) { + try { + logger.info('Sending existing share to server', this.share) + await this.pushNewLinkShare(this.share, true) + this.shareCreationComplete = true + logger.info('Share created on server', this.share) + } catch (e) { + this.pending = false + logger.error('Error creating share', e) + return false + } + return true + } else { + this.open = true + showError(t('files_sharing', 'Error, please enter proper password and/or expiration date')) + return false + } + } + const share = new Share(shareDefaults) await this.pushNewLinkShare(share) + this.shareCreationComplete = true } }, @@ -670,7 +695,7 @@ export default { * accordingly * * @param {Share} share the new share - * @param {boolean} [update=false] do we update the current share ? + * @param {boolean} [update] do we update the current share ? */ async pushNewLinkShare(share, update) { try { @@ -685,14 +710,14 @@ export default { const path = (this.fileInfo.path + '/' + this.fileInfo.name).replace('//', '/') const options = { path, - shareType: ShareTypes.SHARE_TYPE_LINK, + shareType: ShareType.Link, password: share.password, - expireDate: share.expireDate, + expireDate: share.expireDate ?? '', attributes: JSON.stringify(this.fileInfo.shareAttributes), // we do not allow setting the publicUpload // before the share creation. // Todo: We also need to fix the createShare method in - // lib/Controller/ShareAPIController.php to allow file drop + // lib/Controller/ShareAPIController.php to allow file requests // (currently not supported on create, only update) } @@ -700,8 +725,8 @@ export default { const newShare = await this.createShare(options) this.open = false + this.shareCreationComplete = true console.debug('Link share created', newShare) - // if share already exists, copy link directly on next tick let component if (update) { @@ -717,6 +742,9 @@ export default { }) } + await this.getNode() + emit('files:node:updated', this.node) + // Execute the copy link method // freshly created share component // ! somehow does not works on firefox ! @@ -725,12 +753,12 @@ export default { // otherwise the user needs to copy/paste the password before finishing the share. component.copyLink() } - showSuccess(t('sharing', 'Link share created')) + showSuccess(t('files_sharing', 'Link share created')) } catch (data) { const message = data?.response?.data?.ocs?.meta?.message if (!message) { - showError(t('sharing', 'Error while creating the share')) + showError(t('files_sharing', 'Error while creating the share')) console.error(data) return } @@ -743,28 +771,10 @@ export default { this.onSyncError('pending', message) } throw data + } finally { this.loading = false - } - }, - - /** - * Label changed, let's save it to a different key - * - * @param {string} label the share label - */ - onLabelChange(label) { - this.$set(this.share, 'newLabel', label.trim()) - }, - - /** - * When the note change, we trim, save and dispatch - */ - onLabelSubmit() { - if (typeof this.share.newLabel === 'string') { - this.share.label = this.share.newLabel - this.$delete(this.share, 'newLabel') - this.queueUpdate('label') + this.shareCreationComplete = true } }, async copyLink() { @@ -830,7 +840,7 @@ export default { */ onPasswordSubmit() { if (this.hasUnsavedPassword) { - this.share.password = this.share.newPassword.trim() + this.share.newPassword = this.share.newPassword.trim() this.queueUpdate('password') } }, @@ -845,7 +855,7 @@ export default { */ onPasswordProtectedByTalkChange() { if (this.hasUnsavedPassword) { - this.share.password = this.share.newPassword.trim() + this.share.newPassword = this.share.newPassword.trim() } this.queueUpdate('sendPasswordByTalk', 'password') @@ -860,6 +870,19 @@ export default { }, /** + * @param enabled True if expiration is enabled + */ + onExpirationDateToggleUpdate(enabled) { + this.share.expireDate = enabled ? this.formatDateToString(this.config.defaultExpirationDate) : '' + }, + + expirationDateChanged(event) { + const value = event?.target?.value + const isValid = !!value && !isNaN(new Date(value).getTime()) + this.defaultExpirationDateEnabled = isValid + }, + + /** * Cancel the share creation * Used in the pending popover */ @@ -867,7 +890,9 @@ export default { // this.share already exists at this point, // but is incomplete as not pushed to server // YET. We can safely delete the share :) - this.$emit('remove:share', this.share) + if (!this.shareCreationComplete) { + this.$emit('remove:share', this.share) + } }, }, } @@ -878,23 +903,37 @@ export default { display: flex; align-items: center; min-height: 44px; - &__desc { + + &__summary { + padding: 8px; + padding-inline-start: 10px; display: flex; - flex-direction: column; justify-content: space-between; - padding: 8px; - line-height: 1.2em; - overflow: hidden; + flex: 1 0; + min-width: 0; + } + + &__desc { + display: flex; + flex-direction: column; + line-height: 1.2em; + + p { + color: var(--color-text-maxcontrast); + } + + &__title { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + } - p { - color: var(--color-text-maxcontrast); + &__actions { + display: flex; + align-items: center; + margin-inline-start: auto; } - } - &__title { - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - } &:not(.sharing-entry--share) &__actions { .new-share-link { @@ -902,8 +941,8 @@ export default { } } - ::v-deep .avatar-link-share { - background-color: var(--color-primary); + :deep(.avatar-link-share) { + background-color: var(--color-primary-element); } .sharing-entry__action--public-upload { @@ -915,21 +954,34 @@ export default { height: 44px; margin: 0; padding: 14px; - margin-left: auto; + margin-inline-start: auto; } // put menus to the left // but only the first one .action-item { - margin-left: auto; - ~ .action-item, - ~ .sharing-entry__loading { - margin-left: 0; + + ~.action-item, + ~.sharing-entry__loading { + margin-inline-start: 0; } } .icon-checkmark-color { opacity: 1; + color: var(--color-success); + } +} + +// styling for the qr-code container +.qr-code-dialog { + display: flex; + width: 100%; + justify-content: center; + + &__img { + width: 100%; + height: auto; } } </style> diff --git a/apps/files_sharing/src/components/SharingEntryQuickShareSelect.vue b/apps/files_sharing/src/components/SharingEntryQuickShareSelect.vue new file mode 100644 index 00000000000..102eea63cb6 --- /dev/null +++ b/apps/files_sharing/src/components/SharingEntryQuickShareSelect.vue @@ -0,0 +1,206 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <NcActions ref="quickShareActions" + class="share-select" + :menu-name="selectedOption" + :aria-label="ariaLabel" + type="tertiary-no-background" + :disabled="!share.canEdit" + force-name> + <template #icon> + <DropdownIcon :size="15" /> + </template> + <NcActionButton v-for="option in options" + :key="option.label" + type="radio" + :model-value="option.label === selectedOption" + close-after-click + @click="selectOption(option.label)"> + <template #icon> + <component :is="option.icon" /> + </template> + {{ option.label }} + </NcActionButton> + </NcActions> +</template> + +<script> +import { ShareType } from '@nextcloud/sharing' +import { subscribe, unsubscribe } from '@nextcloud/event-bus' +import DropdownIcon from 'vue-material-design-icons/TriangleSmallDown.vue' +import SharesMixin from '../mixins/SharesMixin.js' +import ShareDetails from '../mixins/ShareDetails.js' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import IconEyeOutline from 'vue-material-design-icons/EyeOutline.vue' +import IconPencil from 'vue-material-design-icons/PencilOutline.vue' +import IconFileUpload from 'vue-material-design-icons/FileUpload.vue' +import IconTune from 'vue-material-design-icons/Tune.vue' + +import { + BUNDLED_PERMISSIONS, + ATOMIC_PERMISSIONS, +} from '../lib/SharePermissionsToolBox.js' + +export default { + name: 'SharingEntryQuickShareSelect', + + components: { + DropdownIcon, + NcActions, + NcActionButton, + }, + + mixins: [SharesMixin, ShareDetails], + + props: { + share: { + type: Object, + required: true, + }, + }, + + emits: ['open-sharing-details'], + + data() { + return { + selectedOption: '', + } + }, + + computed: { + ariaLabel() { + return t('files_sharing', 'Quick share options, the current selected is "{selectedOption}"', { selectedOption: this.selectedOption }) + }, + canViewText() { + return t('files_sharing', 'View only') + }, + canEditText() { + return t('files_sharing', 'Can edit') + }, + fileDropText() { + return t('files_sharing', 'File request') + }, + customPermissionsText() { + return t('files_sharing', 'Custom permissions') + }, + preSelectedOption() { + // We remove the share permission for the comparison as it is not relevant for bundled permissions. + if ((this.share.permissions & ~ATOMIC_PERMISSIONS.SHARE) === BUNDLED_PERMISSIONS.READ_ONLY) { + return this.canViewText + } else if (this.share.permissions === BUNDLED_PERMISSIONS.ALL || this.share.permissions === BUNDLED_PERMISSIONS.ALL_FILE) { + return this.canEditText + } else if ((this.share.permissions & ~ATOMIC_PERMISSIONS.SHARE) === BUNDLED_PERMISSIONS.FILE_DROP) { + return this.fileDropText + } + + return this.customPermissionsText + + }, + options() { + const options = [{ + label: this.canViewText, + icon: IconEyeOutline, + }, { + label: this.canEditText, + icon: IconPencil, + }] + if (this.supportsFileDrop) { + options.push({ + label: this.fileDropText, + icon: IconFileUpload, + }) + } + options.push({ + label: this.customPermissionsText, + icon: IconTune, + }) + + return options + }, + supportsFileDrop() { + if (this.isFolder && this.config.isPublicUploadEnabled) { + const shareType = this.share.type ?? this.share.shareType + return [ShareType.Link, ShareType.Email].includes(shareType) + } + return false + }, + dropDownPermissionValue() { + switch (this.selectedOption) { + case this.canEditText: + return this.isFolder ? BUNDLED_PERMISSIONS.ALL : BUNDLED_PERMISSIONS.ALL_FILE + case this.fileDropText: + return BUNDLED_PERMISSIONS.FILE_DROP + case this.customPermissionsText: + return 'custom' + case this.canViewText: + default: + return BUNDLED_PERMISSIONS.READ_ONLY + } + }, + }, + + created() { + this.selectedOption = this.preSelectedOption + }, + mounted() { + subscribe('update:share', (share) => { + if (share.id === this.share.id) { + this.share.permissions = share.permissions + this.selectedOption = this.preSelectedOption + } + }) + }, + unmounted() { + unsubscribe('update:share') + }, + methods: { + selectOption(optionLabel) { + this.selectedOption = optionLabel + if (optionLabel === this.customPermissionsText) { + this.$emit('open-sharing-details') + } else { + this.share.permissions = this.dropDownPermissionValue + this.queueUpdate('permissions') + // TODO: Add a focus method to NcActions or configurable returnFocus enabling to NcActionButton with closeAfterClick + this.$refs.quickShareActions.$refs.menuButton.$el.focus() + } + }, + }, + +} +</script> + +<style lang="scss" scoped> +.share-select { + display: block; + + // TODO: NcActions should have a slot for custom trigger button like NcPopover + // Overrider NcActionms button to make it small + :deep(.action-item__menutoggle) { + color: var(--color-primary-element) !important; + font-size: 12.5px !important; + height: auto !important; + min-height: auto !important; + + .button-vue__text { + font-weight: normal !important; + } + + .button-vue__icon { + height: 24px !important; + min-height: 24px !important; + width: 24px !important; + min-width: 24px !important; + } + + .button-vue__wrapper { + // Emulate NcButton's alignment=center-reverse + flex-direction: row-reverse !important; + } + } +} +</style> diff --git a/apps/files_sharing/src/components/SharingEntrySimple.vue b/apps/files_sharing/src/components/SharingEntrySimple.vue index 43a6172fb15..a00333ba0ce 100644 --- a/apps/files_sharing/src/components/SharingEntrySimple.vue +++ b/apps/files_sharing/src/components/SharingEntrySimple.vue @@ -1,24 +1,7 @@ <!-- - - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <li class="sharing-entry"> @@ -29,8 +12,8 @@ {{ subtitle }} </p> </div> - <NcActions ref="actionsComponent" - v-if="$slots['default']" + <NcActions v-if="$slots['default']" + ref="actionsComponent" class="sharing-entry__actions" menu-align="right" :aria-expanded="ariaExpandedValue"> @@ -40,7 +23,7 @@ </template> <script> -import NcActions from '@nextcloud/vue/dist/Components/NcActions' +import NcActions from '@nextcloud/vue/components/NcActions' export default { name: 'SharingEntrySimple', @@ -87,6 +70,7 @@ export default { min-height: 44px; &__desc { padding: 8px; + padding-inline-start: 10px; line-height: 1.2em; position: relative; flex: 1 1; @@ -102,7 +86,7 @@ export default { max-width: inherit; } &__actions { - margin-left: auto !important; + margin-inline-start: auto !important; } } </style> diff --git a/apps/files_sharing/src/components/SharingInput.vue b/apps/files_sharing/src/components/SharingInput.vue index 46c495d8279..6fb33aba6b2 100644 --- a/apps/files_sharing/src/components/SharingInput.vue +++ b/apps/files_sharing/src/components/SharingInput.vue @@ -1,30 +1,17 @@ <!-- - - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> <div class="sharing-search"> - <label for="sharing-search-input">{{ t('files_sharing', 'Search for share recipients') }}</label> + <label class="hidden-visually" :for="shareInputId"> + {{ isExternal ? t('files_sharing', 'Enter external recipients') + : t('files_sharing', 'Search for internal recipients') }} + </label> <NcSelect ref="select" - id="sharing-search-input" + v-model="value" + :input-id="shareInputId" class="sharing-search__input" :disabled="!canReshare" :loading="loading" @@ -33,11 +20,11 @@ :clear-search-on-blur="() => false" :user-select="true" :options="options" - v-model="value" + :label-outside="true" @search="asyncFind" - @option:selected="addShare"> + @option:selected="onSelected"> <template #no-options="{ search }"> - {{ search ? noResultText : t('files_sharing', 'No recommendations. Start typing.') }} + {{ search ? noResultText : placeholder }} </template> </NcSelect> </div> @@ -46,15 +33,16 @@ <script> import { generateOcsUrl } from '@nextcloud/router' import { getCurrentUser } from '@nextcloud/auth' +import { getCapabilities } from '@nextcloud/capabilities' import axios from '@nextcloud/axios' import debounce from 'debounce' -import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' +import NcSelect from '@nextcloud/vue/components/NcSelect' -import Config from '../services/ConfigService' -import GeneratePassword from '../utils/GeneratePassword' -import Share from '../models/Share' -import ShareRequests from '../mixins/ShareRequests' -import ShareTypes from '../mixins/ShareTypes' +import Config from '../services/ConfigService.ts' +import Share from '../models/Share.ts' +import ShareRequests from '../mixins/ShareRequests.js' +import ShareDetails from '../mixins/ShareDetails.js' +import { ShareType } from '@nextcloud/sharing' export default { name: 'SharingInput', @@ -63,7 +51,7 @@ export default { NcSelect, }, - mixins: [ShareTypes, ShareRequests], + mixins: [ShareRequests, ShareDetails], props: { shares: { @@ -89,6 +77,20 @@ export default { type: Boolean, required: true, }, + isExternal: { + type: Boolean, + default: false, + }, + placeholder: { + type: String, + default: '', + }, + }, + + setup() { + return { + shareInputId: `share-input-${Math.random().toString(36).slice(2, 7)}`, + } }, data() { @@ -121,6 +123,10 @@ export default { if (!this.canReshare) { return t('files_sharing', 'Resharing is not allowed') } + if (this.placeholder) { + return this.placeholder + } + // We can always search with email addresses for users too if (!allowRemoteSharing) { return t('files_sharing', 'Name or email …') @@ -149,10 +155,18 @@ export default { }, mounted() { - this.getRecommendations() + if (!this.isExternal) { + // We can only recommend users, groups etc for internal shares + this.getRecommendations() + } }, methods: { + onSelected(option) { + this.value = null // Reset selected option + this.openSharingDetails(option) + }, + async asyncFind(query) { // save current query to check if we display // recommendations or search results @@ -169,28 +183,46 @@ export default { * Get suggestions * * @param {string} search the search query - * @param {boolean} [lookup=false] search on lookup server + * @param {boolean} [lookup] search on lookup server */ async getSuggestions(search, lookup = false) { this.loading = true - if (OC.getCapabilities().files_sharing.sharee.query_lookup_default === true) { + if (getCapabilities().files_sharing.sharee.query_lookup_default === true) { lookup = true } - const shareType = [ - this.SHARE_TYPES.SHARE_TYPE_USER, - this.SHARE_TYPES.SHARE_TYPE_GROUP, - this.SHARE_TYPES.SHARE_TYPE_REMOTE, - this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP, - this.SHARE_TYPES.SHARE_TYPE_CIRCLE, - this.SHARE_TYPES.SHARE_TYPE_ROOM, - this.SHARE_TYPES.SHARE_TYPE_GUEST, - this.SHARE_TYPES.SHARE_TYPE_DECK, - ] - - if (OC.getCapabilities().files_sharing.public.enabled === true) { - shareType.push(this.SHARE_TYPES.SHARE_TYPE_EMAIL) + const remoteTypes = [ShareType.Remote, ShareType.RemoteGroup] + const shareType = [] + + const showFederatedAsInternal = this.config.showFederatedSharesAsInternal + || this.config.showFederatedSharesToTrustedServersAsInternal + + // For internal users, add remote types if config says to show them as internal + const shouldAddRemoteTypes = (!this.isExternal && showFederatedAsInternal) + // For external users, add them if config *doesn't* say to show them as internal + || (this.isExternal && !showFederatedAsInternal) + // Edge case: federated-to-trusted is a separate "add" trigger for external users + || (this.isExternal && this.config.showFederatedSharesToTrustedServersAsInternal) + + if (this.isExternal) { + if (getCapabilities().files_sharing.public.enabled === true) { + shareType.push(ShareType.Email) + } + } else { + shareType.push( + ShareType.User, + ShareType.Group, + ShareType.Team, + ShareType.Room, + ShareType.Guest, + ShareType.Deck, + ShareType.ScienceMesh, + ) + } + + if (shouldAddRemoteTypes) { + shareType.push(...remoteTypes) } let request = null @@ -210,13 +242,10 @@ export default { return } - const data = request.data.ocs.data - const exact = request.data.ocs.data.exact - data.exact = [] // removing exact from general results - + const { exact, ...data } = request.data.ocs.data // flatten array of arrays - const rawExactSuggestions = Object.values(exact).reduce((arr, elem) => arr.concat(elem), []) - const rawSuggestions = Object.values(data).reduce((arr, elem) => arr.concat(elem), []) + const rawExactSuggestions = Object.values(exact).flat() + const rawSuggestions = Object.values(data).flat() // remove invalid data and format to user-select layout const exactSuggestions = this.filterOutExistingShares(rawExactSuggestions) @@ -235,7 +264,7 @@ export default { lookupEntry.push({ id: 'global-lookup', isNoUser: true, - displayName: t('files_sharing', 'Search globally'), + displayName: t('files_sharing', 'Search everywhere'), lookup: true, }) } @@ -327,7 +356,7 @@ export default { return arr } try { - if (share.value.shareType === this.SHARE_TYPES.SHARE_TYPE_USER) { + if (share.value.shareType === ShareType.User) { // filter out current user if (share.value.shareWith === getCurrentUser().uid) { return arr @@ -340,7 +369,12 @@ export default { } // filter out existing mail shares - if (share.value.shareType === this.SHARE_TYPES.SHARE_TYPE_EMAIL) { + if (share.value.shareType === ShareType.Email) { + // When sharing internally, we don't want to suggest email addresses + // that the user previously created shares to + if (!this.isExternal) { + return arr + } const emails = this.linkShares.map(elem => elem.shareWith) if (emails.indexOf(share.value.shareWith.trim()) !== -1) { return arr @@ -378,41 +412,46 @@ export default { */ shareTypeToIcon(type) { switch (type) { - case this.SHARE_TYPES.SHARE_TYPE_GUEST: + case ShareType.Guest: // default is a user, other icons are here to differentiate // themselves from it, so let's not display the user icon - // case this.SHARE_TYPES.SHARE_TYPE_REMOTE: - // case this.SHARE_TYPES.SHARE_TYPE_USER: + // case ShareType.Remote: + // case ShareType.User: return { icon: 'icon-user', iconTitle: t('files_sharing', 'Guest'), } - case this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP: - case this.SHARE_TYPES.SHARE_TYPE_GROUP: + case ShareType.RemoteGroup: + case ShareType.Group: return { icon: 'icon-group', iconTitle: t('files_sharing', 'Group'), } - case this.SHARE_TYPES.SHARE_TYPE_EMAIL: + case ShareType.Email: return { icon: 'icon-mail', iconTitle: t('files_sharing', 'Email'), } - case this.SHARE_TYPES.SHARE_TYPE_CIRCLE: + case ShareType.Team: return { - icon: 'icon-circle', - iconTitle: t('files_sharing', 'Circle'), + icon: 'icon-teams', + iconTitle: t('files_sharing', 'Team'), } - case this.SHARE_TYPES.SHARE_TYPE_ROOM: + case ShareType.Room: return { icon: 'icon-room', iconTitle: t('files_sharing', 'Talk conversation'), } - case this.SHARE_TYPES.SHARE_TYPE_DECK: + case ShareType.Deck: return { icon: 'icon-deck', iconTitle: t('files_sharing', 'Deck board'), } + case ShareType.Sciencemesh: + return { + icon: 'icon-sciencemesh', + iconTitle: t('files_sharing', 'ScienceMesh'), + } default: return {} } @@ -425,106 +464,35 @@ export default { * @return {object} */ formatForMultiselect(result) { - let subtitle - if (result.value.shareType === this.SHARE_TYPES.SHARE_TYPE_USER && this.config.shouldAlwaysShowUnique) { - subtitle = result.shareWithDisplayNameUnique ?? '' - } else if ((result.value.shareType === this.SHARE_TYPES.SHARE_TYPE_REMOTE - || result.value.shareType === this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP - ) && result.value.server) { - subtitle = t('files_sharing', 'on {server}', { server: result.value.server }) - } else if (result.value.shareType === this.SHARE_TYPES.SHARE_TYPE_EMAIL) { - subtitle = result.value.shareWith + let subname + let displayName = result.name || result.label + + if (result.value.shareType === ShareType.User && this.config.shouldAlwaysShowUnique) { + subname = result.shareWithDisplayNameUnique ?? '' + } else if (result.value.shareType === ShareType.Email) { + subname = result.value.shareWith + } else if (result.value.shareType === ShareType.Remote || result.value.shareType === ShareType.RemoteGroup) { + if (this.config.showFederatedSharesAsInternal) { + subname = result.extra?.email?.value ?? '' + displayName = result.extra?.name?.value ?? displayName + } else if (result.value.server) { + subname = t('files_sharing', 'on {server}', { server: result.value.server }) + } } else { - subtitle = result.shareWithDescription ?? '' + subname = result.shareWithDescription ?? '' } return { - id: `${result.value.shareType}-${result.value.shareWith}`, shareWith: result.value.shareWith, shareType: result.value.shareType, user: result.uuid || result.value.shareWith, - isNoUser: result.value.shareType !== this.SHARE_TYPES.SHARE_TYPE_USER, - displayName: result.name || result.label, - subtitle, + isNoUser: result.value.shareType !== ShareType.User, + displayName, + subname, shareWithDisplayNameUnique: result.shareWithDisplayNameUnique || '', ...this.shareTypeToIcon(result.value.shareType), } }, - - /** - * Process the new share request - * - * @param {object} value the multiselect option - */ - async addShare(value) { - // Clear the displayed selection - this.value = null - - if (value.lookup) { - await this.getSuggestions(this.query, true) - - this.$nextTick(() => { - // open the dropdown again - this.$refs.select.$children[0].open = true - }) - return true - } - - // handle externalResults from OCA.Sharing.ShareSearch - if (value.handler) { - const share = await value.handler(this) - this.$emit('add:share', new Share(share)) - return true - } - - this.loading = true - console.debug('Adding a new share from the input for', value) - try { - let password = null - - if (this.config.enforcePasswordForPublicLink - && value.shareType === this.SHARE_TYPES.SHARE_TYPE_EMAIL) { - password = await GeneratePassword() - } - - const path = (this.fileInfo.path + '/' + this.fileInfo.name).replace('//', '/') - const share = await this.createShare({ - path, - shareType: value.shareType, - shareWith: value.shareWith, - password, - permissions: this.fileInfo.sharePermissions & OC.getCapabilities().files_sharing.default_permissions, - attributes: JSON.stringify(this.fileInfo.shareAttributes), - }) - - // If we had a password, we need to show it to the user as it was generated - if (password) { - share.newPassword = password - // Wait for the newly added share - const component = await new Promise(resolve => { - this.$emit('add:share', share, resolve) - }) - - // open the menu on the - // freshly created share component - component.open = true - } else { - // Else we just add it normally - this.$emit('add:share', share) - } - - await this.getRecommendations() - } catch (error) { - this.$nextTick(() => { - // open the dropdown again on error - this.$refs.select.$children[0].open = true - }) - this.query = value.shareWith - console.error('Error while adding new share', error) - } finally { - this.loading = false - } - }, }, } </script> @@ -553,7 +521,7 @@ export default { background-repeat: no-repeat; background-position: center; background-color: var(--color-text-maxcontrast) !important; - div { + .avatardiv__initials-wrapper { display: none; } } diff --git a/apps/files_sharing/src/eventbus.d.ts b/apps/files_sharing/src/eventbus.d.ts new file mode 100644 index 00000000000..cc10ff8468f --- /dev/null +++ b/apps/files_sharing/src/eventbus.d.ts @@ -0,0 +1,15 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Folder, Node } from '@nextcloud/files' + +declare module '@nextcloud/event-bus' { + export interface NextcloudEvents { + // mapping of 'event name' => 'event type' + 'files:list:updated': { folder: Folder, contents: Node[] } + 'files:config:updated': { key: string, value: boolean } + } +} + +export {} diff --git a/apps/files_sharing/src/files_actions/acceptShareAction.spec.ts b/apps/files_sharing/src/files_actions/acceptShareAction.spec.ts new file mode 100644 index 00000000000..4003e0799ac --- /dev/null +++ b/apps/files_sharing/src/files_actions/acceptShareAction.spec.ts @@ -0,0 +1,217 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' + +import { action } from './acceptShareAction' +import { File, Permission, View, FileAction } from '@nextcloud/files' +import { ShareType } from '@nextcloud/sharing' +import * as eventBus from '@nextcloud/event-bus' +import axios from '@nextcloud/axios' + +import '../main.ts' + +vi.mock('@nextcloud/axios') + +const view = { + id: 'files', + name: 'Files', +} as View + +const pendingShareView = { + id: 'pendingshares', + name: 'Pending shares', +} as View + +// Mock webroot variable +beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any)._oc_webroot = '' +}) + +describe('Accept share action conditions tests', () => { + test('Default values', () => { + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + }) + + expect(action).toBeInstanceOf(FileAction) + expect(action.id).toBe('accept-share') + expect(action.displayName([file], pendingShareView)).toBe('Accept share') + expect(action.iconSvgInline([file], pendingShareView)).toMatch(/<svg.+<\/svg>/) + expect(action.default).toBeUndefined() + expect(action.order).toBe(1) + expect(action.inline).toBeDefined() + expect(action.inline!(file, pendingShareView)).toBe(true) + }) + + test('Default values for multiple files', () => { + const file1 = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + }) + const file2 = new File({ + id: 2, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + }) + + expect(action.displayName([file1, file2], pendingShareView)).toBe('Accept shares') + }) +}) + +describe('Accept share action enabled tests', () => { + test('Enabled with on pending shares view', () => { + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file], pendingShareView)).toBe(true) + }) + + test('Disabled on wrong view', () => { + expect(action.enabled).toBeDefined() + expect(action.enabled!([], view)).toBe(false) + }) + + test('Disabled without nodes', () => { + expect(action.enabled).toBeDefined() + expect(action.enabled!([], pendingShareView)).toBe(false) + }) +}) + +describe('Accept share action execute tests', () => { + beforeEach(() => { vi.resetAllMocks() }) + + test('Accept share action', async () => { + vi.spyOn(axios, 'post') + vi.spyOn(eventBus, 'emit') + + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.READ, + attributes: { + id: 123, + share_type: ShareType.User, + }, + }) + + const exec = await action.exec(file, pendingShareView, '/') + + expect(exec).toBe(true) + expect(axios.post).toBeCalledTimes(1) + expect(axios.post).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/pending/123') + + expect(eventBus.emit).toBeCalledTimes(1) + expect(eventBus.emit).toBeCalledWith('files:node:deleted', file) + }) + + test('Accept remote share action', async () => { + vi.spyOn(axios, 'post') + vi.spyOn(eventBus, 'emit') + + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.READ, + attributes: { + id: 123, + remote: 3, + share_type: ShareType.User, + }, + }) + + const exec = await action.exec(file, pendingShareView, '/') + + expect(exec).toBe(true) + expect(axios.post).toBeCalledTimes(1) + expect(axios.post).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/remote_shares/pending/123') + + expect(eventBus.emit).toBeCalledTimes(1) + expect(eventBus.emit).toBeCalledWith('files:node:deleted', file) + }) + + test('Accept share action batch', async () => { + vi.spyOn(axios, 'post') + vi.spyOn(eventBus, 'emit') + + const file1 = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.READ, + attributes: { + id: 123, + share_type: ShareType.User, + }, + }) + + const file2 = new File({ + id: 2, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.READ, + attributes: { + id: 456, + share_type: ShareType.User, + }, + }) + + const exec = await action.execBatch!([file1, file2], pendingShareView, '/') + + expect(exec).toStrictEqual([true, true]) + expect(axios.post).toBeCalledTimes(2) + expect(axios.post).toHaveBeenNthCalledWith(1, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/pending/123') + expect(axios.post).toHaveBeenNthCalledWith(2, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/pending/456') + + expect(eventBus.emit).toBeCalledTimes(2) + expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:node:deleted', file1) + expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:node:deleted', file2) + }) + + test('Accept fails', async () => { + vi.spyOn(axios, 'post').mockImplementation(() => { throw new Error('Mock error') }) + + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.READ, + attributes: { + id: 123, + share_type: ShareType.User, + }, + }) + + const exec = await action.exec(file, pendingShareView, '/') + + expect(exec).toBe(false) + expect(axios.post).toBeCalledTimes(1) + expect(axios.post).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/pending/123') + + expect(eventBus.emit).toBeCalledTimes(0) + }) +}) diff --git a/apps/files_sharing/src/files_actions/acceptShareAction.ts b/apps/files_sharing/src/files_actions/acceptShareAction.ts new file mode 100644 index 00000000000..f2177fdec1a --- /dev/null +++ b/apps/files_sharing/src/files_actions/acceptShareAction.ts @@ -0,0 +1,48 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Node, View } from '@nextcloud/files' + +import { emit } from '@nextcloud/event-bus' +import { generateOcsUrl } from '@nextcloud/router' +import { registerFileAction, FileAction } from '@nextcloud/files' +import { translatePlural as n } from '@nextcloud/l10n' +import axios from '@nextcloud/axios' +import CheckSvg from '@mdi/svg/svg/check.svg?raw' + +import { pendingSharesViewId } from '../files_views/shares' + +export const action = new FileAction({ + id: 'accept-share', + displayName: (nodes: Node[]) => n('files_sharing', 'Accept share', 'Accept shares', nodes.length), + iconSvgInline: () => CheckSvg, + + enabled: (nodes, view) => nodes.length > 0 && view.id === pendingSharesViewId, + + async exec(node: Node) { + try { + const isRemote = !!node.attributes.remote + const url = generateOcsUrl('apps/files_sharing/api/v1/{shareBase}/pending/{id}', { + shareBase: isRemote ? 'remote_shares' : 'shares', + id: node.attributes.id, + }) + await axios.post(url) + + // Remove from current view + emit('files:node:deleted', node) + + return true + } catch (error) { + return false + } + }, + async execBatch(nodes: Node[], view: View, dir: string) { + return Promise.all(nodes.map(node => this.exec(node, view, dir))) + }, + + order: 1, + inline: () => true, +}) + +registerFileAction(action) diff --git a/apps/files_sharing/src/files_actions/openInFilesAction.spec.ts b/apps/files_sharing/src/files_actions/openInFilesAction.spec.ts new file mode 100644 index 00000000000..23c0938545c --- /dev/null +++ b/apps/files_sharing/src/files_actions/openInFilesAction.spec.ts @@ -0,0 +1,78 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { File, Permission, View, DefaultType, FileAction } from '@nextcloud/files' +import { describe, expect, test, vi } from 'vitest' +import { deletedSharesViewId, pendingSharesViewId, sharedWithOthersViewId, sharedWithYouViewId, sharesViewId, sharingByLinksViewId } from '../files_views/shares' +import { action } from './openInFilesAction' + +import '../main' + +const view = { + id: 'files', + name: 'Files', +} as View + +const validViews = [ + sharesViewId, + sharedWithYouViewId, + sharedWithOthersViewId, + sharingByLinksViewId, +].map(id => ({ id, name: id })) as View[] + +const invalidViews = [ + deletedSharesViewId, + pendingSharesViewId, +].map(id => ({ id, name: id })) as View[] + +describe('Open in files action conditions tests', () => { + test('Default values', () => { + expect(action).toBeInstanceOf(FileAction) + expect(action.id).toBe('files_sharing:open-in-files') + expect(action.displayName([], validViews[0])).toBe('Open in Files') + expect(action.iconSvgInline([], validViews[0])).toBe('') + expect(action.default).toBe(DefaultType.HIDDEN) + expect(action.order).toBe(-1000) + expect(action.inline).toBeUndefined() + }) +}) + +describe('Open in files action enabled tests', () => { + test('Enabled with on valid view', () => { + validViews.forEach(view => { + expect(action.enabled).toBeDefined() + expect(action.enabled!([], view)).toBe(true) + }) + }) + + test('Disabled on wrong view', () => { + invalidViews.forEach(view => { + expect(action.enabled).toBeDefined() + expect(action.enabled!([], view)).toBe(false) + }) + }) +}) + +describe('Open in files action execute tests', () => { + test('Open in files', async () => { + const goToRouteMock = vi.fn() + // @ts-expect-error We only mock what needed, we do not need Files.Router.goTo or Files.Navigation + window.OCP = { Files: { Router: { goToRoute: goToRouteMock } } } + + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/foobar.txt', + owner: 'admin', + mime: 'text/plain', + root: '/files/admin', + permissions: Permission.READ, + }) + + const exec = await action.exec(file, view, '/') + // Silent action + expect(exec).toBe(null) + expect(goToRouteMock).toBeCalledTimes(1) + expect(goToRouteMock).toBeCalledWith(null, { fileid: '1', view: 'files' }, { dir: '/Foo', openfile: 'true' }) + }) +}) diff --git a/apps/files_sharing/src/files_actions/openInFilesAction.ts b/apps/files_sharing/src/files_actions/openInFilesAction.ts new file mode 100644 index 00000000000..133b4531bb5 --- /dev/null +++ b/apps/files_sharing/src/files_actions/openInFilesAction.ts @@ -0,0 +1,50 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Node } from '@nextcloud/files' + +import { registerFileAction, FileAction, DefaultType, FileType } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import { sharesViewId, sharedWithYouViewId, sharedWithOthersViewId, sharingByLinksViewId } from '../files_views/shares' + +export const action = new FileAction({ + id: 'files_sharing:open-in-files', + displayName: () => t('files_sharing', 'Open in Files'), + iconSvgInline: () => '', + + enabled: (nodes, view) => [ + sharesViewId, + sharedWithYouViewId, + sharedWithOthersViewId, + sharingByLinksViewId, + // Deleted and pending shares are not + // accessible in the files app. + ].includes(view.id), + + async exec(node: Node) { + const isFolder = node.type === FileType.Folder + + window.OCP.Files.Router.goToRoute( + null, // use default route + { + view: 'files', + fileid: String(node.fileid), + }, + { + // If this node is a folder open the folder in files + dir: isFolder ? node.path : node.dirname, + // otherwise if this is a file, we should open it + openfile: isFolder ? undefined : 'true', + }, + ) + return null + }, + + // Before openFolderAction + order: -1000, + default: DefaultType.HIDDEN, +}) + +registerFileAction(action) diff --git a/apps/files_sharing/src/files_actions/rejectShareAction.spec.ts b/apps/files_sharing/src/files_actions/rejectShareAction.spec.ts new file mode 100644 index 00000000000..51ded69d1c5 --- /dev/null +++ b/apps/files_sharing/src/files_actions/rejectShareAction.spec.ts @@ -0,0 +1,243 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { File, Folder, Permission, View, FileAction } from '@nextcloud/files' +import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' +import { ShareType } from '@nextcloud/sharing' +import * as eventBus from '@nextcloud/event-bus' +import axios from '@nextcloud/axios' + +import { action } from './rejectShareAction' +import '../main' + +vi.mock('@nextcloud/axios') + +const view = { + id: 'files', + name: 'Files', +} as View + +const pendingShareView = { + id: 'pendingshares', + name: 'Pending shares', +} as View + +// Mock webroot variable +beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any)._oc_webroot = '' +}) + +describe('Reject share action conditions tests', () => { + test('Default values', () => { + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + }) + + expect(action).toBeInstanceOf(FileAction) + expect(action.id).toBe('reject-share') + expect(action.displayName([file], pendingShareView)).toBe('Reject share') + expect(action.iconSvgInline([file], pendingShareView)).toMatch(/<svg.+<\/svg>/) + expect(action.default).toBeUndefined() + expect(action.order).toBe(2) + expect(action.inline).toBeDefined() + expect(action.inline!(file, pendingShareView)).toBe(true) + }) + + test('Default values for multiple files', () => { + const file1 = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + }) + const file2 = new File({ + id: 2, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + }) + + expect(action.displayName([file1, file2], pendingShareView)).toBe('Reject shares') + }) +}) + +describe('Reject share action enabled tests', () => { + test('Enabled with on pending shares view', () => { + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file], pendingShareView)).toBe(true) + }) + + test('Disabled on wrong view', () => { + expect(action.enabled).toBeDefined() + expect(action.enabled!([], view)).toBe(false) + }) + + test('Disabled without nodes', () => { + expect(action.enabled).toBeDefined() + expect(action.enabled!([], pendingShareView)).toBe(false) + }) + + test('Disabled if some nodes are remote group shares', () => { + const folder1 = new Folder({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/Foo/', + owner: 'admin', + permissions: Permission.READ, + attributes: { + share_type: ShareType.User, + }, + }) + const folder2 = new Folder({ + id: 2, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/Bar/', + owner: 'admin', + permissions: Permission.READ, + attributes: { + remote_id: 1, + share_type: ShareType.RemoteGroup, + }, + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([folder1], pendingShareView)).toBe(true) + expect(action.enabled!([folder2], pendingShareView)).toBe(false) + expect(action.enabled!([folder1, folder2], pendingShareView)).toBe(false) + }) +}) + +describe('Reject share action execute tests', () => { + beforeEach(() => { vi.resetAllMocks() }) + + test('Reject share action', async () => { + vi.spyOn(axios, 'delete') + vi.spyOn(eventBus, 'emit') + + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.READ, + attributes: { + id: 123, + share_type: ShareType.User, + }, + }) + + const exec = await action.exec(file, pendingShareView, '/') + + expect(exec).toBe(true) + expect(axios.delete).toBeCalledTimes(1) + expect(axios.delete).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/123') + + expect(eventBus.emit).toBeCalledTimes(1) + expect(eventBus.emit).toBeCalledWith('files:node:deleted', file) + }) + + test('Reject remote share action', async () => { + vi.spyOn(axios, 'delete') + vi.spyOn(eventBus, 'emit') + + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.READ, + attributes: { + id: 123, + remote: 3, + share_type: ShareType.User, + }, + }) + + const exec = await action.exec(file, pendingShareView, '/') + + expect(exec).toBe(true) + expect(axios.delete).toBeCalledTimes(1) + expect(axios.delete).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/remote_shares/123') + + expect(eventBus.emit).toBeCalledTimes(1) + expect(eventBus.emit).toBeCalledWith('files:node:deleted', file) + }) + + test('Reject share action batch', async () => { + vi.spyOn(axios, 'delete') + vi.spyOn(eventBus, 'emit') + + const file1 = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.READ, + attributes: { + id: 123, + share_type: ShareType.User, + }, + }) + + const file2 = new File({ + id: 2, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.READ, + attributes: { + id: 456, + share_type: ShareType.User, + }, + }) + + const exec = await action.execBatch!([file1, file2], pendingShareView, '/') + + expect(exec).toStrictEqual([true, true]) + expect(axios.delete).toBeCalledTimes(2) + expect(axios.delete).toHaveBeenNthCalledWith(1, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/123') + expect(axios.delete).toHaveBeenNthCalledWith(2, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/456') + + expect(eventBus.emit).toBeCalledTimes(2) + expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:node:deleted', file1) + expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:node:deleted', file2) + }) + + test('Reject fails', async () => { + vi.spyOn(axios, 'delete').mockImplementation(() => { throw new Error('Mock error') }) + + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.READ, + attributes: { + id: 123, + share_type: ShareType.User, + }, + }) + + const exec = await action.exec(file, pendingShareView, '/') + + expect(exec).toBe(false) + expect(axios.delete).toBeCalledTimes(1) + expect(axios.delete).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/123') + + expect(eventBus.emit).toBeCalledTimes(0) + }) +}) diff --git a/apps/files_sharing/src/files_actions/rejectShareAction.ts b/apps/files_sharing/src/files_actions/rejectShareAction.ts new file mode 100644 index 00000000000..22f77262ef2 --- /dev/null +++ b/apps/files_sharing/src/files_actions/rejectShareAction.ts @@ -0,0 +1,66 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Node, View } from '@nextcloud/files' + +import { emit } from '@nextcloud/event-bus' +import { generateOcsUrl } from '@nextcloud/router' +import { registerFileAction, FileAction } from '@nextcloud/files' +import { translatePlural as n } from '@nextcloud/l10n' +import { ShareType } from '@nextcloud/sharing' +import { pendingSharesViewId } from '../files_views/shares' + +import axios from '@nextcloud/axios' +import CloseSvg from '@mdi/svg/svg/close.svg?raw' + +export const action = new FileAction({ + id: 'reject-share', + displayName: (nodes: Node[]) => n('files_sharing', 'Reject share', 'Reject shares', nodes.length), + iconSvgInline: () => CloseSvg, + + enabled: (nodes, view) => { + if (view.id !== pendingSharesViewId) { + return false + } + + if (nodes.length === 0) { + return false + } + + // disable rejecting group shares from the pending list because they anyway + // land back into that same list after rejecting them + if (nodes.some(node => node.attributes.remote_id + && node.attributes.share_type === ShareType.RemoteGroup)) { + return false + } + + return true + }, + + async exec(node: Node) { + try { + const isRemote = !!node.attributes.remote + const url = generateOcsUrl('apps/files_sharing/api/v1/{shareBase}/{id}', { + shareBase: isRemote ? 'remote_shares' : 'shares', + id: node.attributes.id, + }) + await axios.delete(url) + + // Remove from current view + emit('files:node:deleted', node) + + return true + } catch (error) { + return false + } + }, + async execBatch(nodes: Node[], view: View, dir: string) { + return Promise.all(nodes.map(node => this.exec(node, view, dir))) + }, + + order: 2, + inline: () => true, +}) + +registerFileAction(action) diff --git a/apps/files_sharing/src/files_actions/restoreShareAction.spec.ts b/apps/files_sharing/src/files_actions/restoreShareAction.spec.ts new file mode 100644 index 00000000000..015aa8aa95d --- /dev/null +++ b/apps/files_sharing/src/files_actions/restoreShareAction.spec.ts @@ -0,0 +1,191 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { File, Permission, View, FileAction } from '@nextcloud/files' +import { ShareType } from '@nextcloud/sharing' +import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' + +import axios from '@nextcloud/axios' +import * as eventBus from '@nextcloud/event-bus' +import { action } from './restoreShareAction' +import '../main.ts' + +vi.mock('@nextcloud/auth') +vi.mock('@nextcloud/axios') + +const view = { + id: 'files', + name: 'Files', +} as View + +const deletedShareView = { + id: 'deletedshares', + name: 'Deleted shares', +} as View + +// Mock webroot variable +beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any)._oc_webroot = '' +}) + +describe('Restore share action conditions tests', () => { + test('Default values', () => { + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + }) + + expect(action).toBeInstanceOf(FileAction) + expect(action.id).toBe('restore-share') + expect(action.displayName([file], deletedShareView)).toBe('Restore share') + expect(action.iconSvgInline([file], deletedShareView)).toMatch(/<svg.+<\/svg>/) + expect(action.default).toBeUndefined() + expect(action.order).toBe(1) + expect(action.inline).toBeDefined() + expect(action.inline!(file, deletedShareView)).toBe(true) + }) + + test('Default values for multiple files', () => { + const file1 = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + }) + const file2 = new File({ + id: 2, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + }) + + expect(action.displayName([file1, file2], deletedShareView)).toBe('Restore shares') + }) +}) + +describe('Restore share action enabled tests', () => { + test('Enabled with on pending shares view', () => { + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + }) + + expect(action.enabled).toBeDefined() + expect(action.enabled!([file], deletedShareView)).toBe(true) + }) + + test('Disabled on wrong view', () => { + expect(action.enabled).toBeDefined() + expect(action.enabled!([], view)).toBe(false) + }) + + test('Disabled without nodes', () => { + expect(action.enabled).toBeDefined() + expect(action.enabled!([], deletedShareView)).toBe(false) + }) +}) + +describe('Restore share action execute tests', () => { + beforeEach(() => { vi.resetAllMocks() }) + + test('Restore share action', async () => { + vi.spyOn(axios, 'post') + vi.spyOn(eventBus, 'emit') + + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.READ, + attributes: { + id: 123, + share_type: ShareType.User, + }, + }) + + const exec = await action.exec(file, deletedShareView, '/') + + expect(exec).toBe(true) + expect(axios.post).toBeCalledTimes(1) + expect(axios.post).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/deletedshares/123') + + expect(eventBus.emit).toBeCalledTimes(1) + expect(eventBus.emit).toBeCalledWith('files:node:deleted', file) + }) + + test('Restore share action batch', async () => { + vi.spyOn(axios, 'post') + vi.spyOn(eventBus, 'emit') + + const file1 = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foo.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.READ, + attributes: { + id: 123, + share_type: ShareType.User, + }, + }) + + const file2 = new File({ + id: 2, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/bar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.READ, + attributes: { + id: 456, + share_type: ShareType.User, + }, + }) + + const exec = await action.execBatch!([file1, file2], deletedShareView, '/') + + expect(exec).toStrictEqual([true, true]) + expect(axios.post).toBeCalledTimes(2) + expect(axios.post).toHaveBeenNthCalledWith(1, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/deletedshares/123') + expect(axios.post).toHaveBeenNthCalledWith(2, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/deletedshares/456') + + expect(eventBus.emit).toBeCalledTimes(2) + expect(eventBus.emit).toHaveBeenNthCalledWith(1, 'files:node:deleted', file1) + expect(eventBus.emit).toHaveBeenNthCalledWith(2, 'files:node:deleted', file2) + }) + + test('Restore fails', async () => { + vi.spyOn(axios, 'post') + .mockImplementation(() => { throw new Error('Mock error') }) + + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.READ, + attributes: { + id: 123, + share_type: ShareType.User, + }, + }) + + const exec = await action.exec(file, deletedShareView, '/') + + expect(exec).toBe(false) + expect(axios.post).toBeCalledTimes(1) + expect(axios.post).toBeCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/deletedshares/123') + + expect(eventBus.emit).toBeCalledTimes(0) + }) +}) diff --git a/apps/files_sharing/src/files_actions/restoreShareAction.ts b/apps/files_sharing/src/files_actions/restoreShareAction.ts new file mode 100644 index 00000000000..2d51de387ee --- /dev/null +++ b/apps/files_sharing/src/files_actions/restoreShareAction.ts @@ -0,0 +1,47 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Node, View } from '@nextcloud/files' + +import { emit } from '@nextcloud/event-bus' +import { FileAction, registerFileAction } from '@nextcloud/files' +import { generateOcsUrl } from '@nextcloud/router' +import { translatePlural as n } from '@nextcloud/l10n' +import ArrowULeftTopSvg from '@mdi/svg/svg/arrow-u-left-top.svg?raw' +import axios from '@nextcloud/axios' + +import { deletedSharesViewId } from '../files_views/shares' + +export const action = new FileAction({ + id: 'restore-share', + displayName: (nodes: Node[]) => n('files_sharing', 'Restore share', 'Restore shares', nodes.length), + + iconSvgInline: () => ArrowULeftTopSvg, + + enabled: (nodes, view) => nodes.length > 0 && view.id === deletedSharesViewId, + + async exec(node: Node) { + try { + const url = generateOcsUrl('apps/files_sharing/api/v1/deletedshares/{id}', { + id: node.attributes.id, + }) + await axios.post(url) + + // Remove from current view + emit('files:node:deleted', node) + + return true + } catch (error) { + return false + } + }, + async execBatch(nodes: Node[], view: View, dir: string) { + return Promise.all(nodes.map(node => this.exec(node, view, dir))) + }, + + order: 1, + inline: () => true, +}) + +registerFileAction(action) diff --git a/apps/files_sharing/src/files_actions/sharingStatusAction.scss b/apps/files_sharing/src/files_actions/sharingStatusAction.scss new file mode 100644 index 00000000000..3a6690f40f1 --- /dev/null +++ b/apps/files_sharing/src/files_actions/sharingStatusAction.scss @@ -0,0 +1,29 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + // Only when rendered inline, when not enough space, this is put in the menu +.action-items > .files-list__row-action-sharing-status { + // put icon at the end of the button + direction: rtl; + // align icons with text-less inline actions + padding-inline-end: 0 !important; +} + +svg.sharing-status__avatar { + height: 32px !important; + width: 32px !important; + max-height: 32px !important; + max-width: 32px !important; + border-radius: 32px; + overflow: hidden; +} + +.files-list__row-action-sharing-status { + .button-vue__text { + color: var(--color-primary-element); + } + .button-vue__icon { + color: var(--color-primary-element); + } +} diff --git a/apps/files_sharing/src/files_actions/sharingStatusAction.ts b/apps/files_sharing/src/files_actions/sharingStatusAction.ts new file mode 100644 index 00000000000..18fa46d2781 --- /dev/null +++ b/apps/files_sharing/src/files_actions/sharingStatusAction.ts @@ -0,0 +1,144 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { getCurrentUser } from '@nextcloud/auth' +import { Node, View, registerFileAction, FileAction, Permission } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import { ShareType } from '@nextcloud/sharing' +import { isPublicShare } from '@nextcloud/sharing/public' + +import AccountGroupSvg from '@mdi/svg/svg/account-group-outline.svg?raw' +import AccountPlusSvg from '@mdi/svg/svg/account-plus-outline.svg?raw' +import LinkSvg from '@mdi/svg/svg/link.svg?raw' +import CircleSvg from '../../../../core/img/apps/circles.svg?raw' + +import { action as sidebarAction } from '../../../files/src/actions/sidebarAction' +import { generateAvatarSvg } from '../utils/AccountIcon' + +import './sharingStatusAction.scss' + +const isExternal = (node: Node) => { + return node.attributes?.['is-federated'] ?? false +} + +export const ACTION_SHARING_STATUS = 'sharing-status' +export const action = new FileAction({ + id: ACTION_SHARING_STATUS, + displayName(nodes: Node[]) { + const node = nodes[0] + const shareTypes = Object.values(node?.attributes?.['share-types'] || {}).flat() as number[] + + if (shareTypes.length > 0 + || (node.owner !== getCurrentUser()?.uid || isExternal(node))) { + return t('files_sharing', 'Shared') + } + + return '' + }, + + title(nodes: Node[]) { + const node = nodes[0] + + if (node.owner && (node.owner !== getCurrentUser()?.uid || isExternal(node))) { + const ownerDisplayName = node?.attributes?.['owner-display-name'] + return t('files_sharing', 'Shared by {ownerDisplayName}', { ownerDisplayName }) + } + + const shareTypes = Object.values(node?.attributes?.['share-types'] || {}).flat() as number[] + if (shareTypes.length > 1) { + return t('files_sharing', 'Shared multiple times with different people') + } + + const sharees = node.attributes.sharees?.sharee as { id: string, 'display-name': string, type: ShareType }[] | undefined + if (!sharees) { + // No sharees so just show the default message to create a new share + return t('files_sharing', 'Sharing options') + } + + const sharee = [sharees].flat()[0] // the property is sometimes weirdly normalized, so we need to compensate + switch (sharee.type) { + case ShareType.User: + return t('files_sharing', 'Shared with {user}', { user: sharee['display-name'] }) + case ShareType.Group: + return t('files_sharing', 'Shared with group {group}', { group: sharee['display-name'] ?? sharee.id }) + default: + return t('files_sharing', 'Shared with others') + } + }, + + iconSvgInline(nodes: Node[]) { + const node = nodes[0] + const shareTypes = Object.values(node?.attributes?.['share-types'] || {}).flat() as number[] + + // Mixed share types + if (Array.isArray(node.attributes?.['share-types']) && node.attributes?.['share-types'].length > 1) { + return AccountPlusSvg + } + + // Link shares + if (shareTypes.includes(ShareType.Link) + || shareTypes.includes(ShareType.Email)) { + return LinkSvg + } + + // Group shares + if (shareTypes.includes(ShareType.Group) + || shareTypes.includes(ShareType.RemoteGroup)) { + return AccountGroupSvg + } + + // Circle shares + if (shareTypes.includes(ShareType.Team)) { + return CircleSvg + } + + if (node.owner && (node.owner !== getCurrentUser()?.uid || isExternal(node))) { + return generateAvatarSvg(node.owner, isExternal(node)) + } + + return AccountPlusSvg + }, + + enabled(nodes: Node[]) { + if (nodes.length !== 1) { + return false + } + + // Do not leak information about users to public shares + if (isPublicShare()) { + return false + } + + const node = nodes[0] + const shareTypes = node.attributes?.['share-types'] + const isMixed = Array.isArray(shareTypes) && shareTypes.length > 0 + + // If the node is shared multiple times with + // different share types to the current user + if (isMixed) { + return true + } + + // If the node is shared by someone else + if (node.owner !== getCurrentUser()?.uid || isExternal(node)) { + return true + } + + return (node.permissions & Permission.SHARE) !== 0 + }, + + async exec(node: Node, view: View, dir: string) { + // You need read permissions to see the sidebar + if ((node.permissions & Permission.READ) !== 0) { + window.OCA?.Files?.Sidebar?.setActiveTab?.('sharing') + return sidebarAction.exec(node, view, dir) + } + return null + }, + + inline: () => true, + +}) + +registerFileAction(action) diff --git a/apps/files_sharing/src/files_filters/AccountFilter.ts b/apps/files_sharing/src/files_filters/AccountFilter.ts new file mode 100644 index 00000000000..4f185d9fd9c --- /dev/null +++ b/apps/files_sharing/src/files_filters/AccountFilter.ts @@ -0,0 +1,162 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { IFileListFilterChip, INode } from '@nextcloud/files' + +import { subscribe } from '@nextcloud/event-bus' +import { FileListFilter, registerFileListFilter } from '@nextcloud/files' +import { ShareType } from '@nextcloud/sharing' +import Vue from 'vue' + +import FileListFilterAccount from '../components/FileListFilterAccount.vue' +import { isPublicShare } from '@nextcloud/sharing/public' + +export interface IAccountData { + uid: string + displayName: string +} + +type CurrentInstance = Vue & { + resetFilter: () => void + setAvailableAccounts: (accounts: IAccountData[]) => void + toggleAccount: (account: string) => void +} + +/** + * File list filter to filter by owner / sharee + */ +class AccountFilter extends FileListFilter { + + private availableAccounts: IAccountData[] + private currentInstance?: CurrentInstance + private filterAccounts?: IAccountData[] + + constructor() { + super('files_sharing:account', 100) + this.availableAccounts = [] + + subscribe('files:list:updated', ({ contents }) => { + this.updateAvailableAccounts(contents) + }) + } + + public mount(el: HTMLElement) { + if (this.currentInstance) { + this.currentInstance.$destroy() + } + + const View = Vue.extend(FileListFilterAccount as never) + this.currentInstance = new View({ el }) + .$on('update:accounts', (accounts?: IAccountData[]) => this.setAccounts(accounts)) + .$mount() as CurrentInstance + this.currentInstance + .setAvailableAccounts(this.availableAccounts) + } + + public filter(nodes: INode[]): INode[] { + if (!this.filterAccounts || this.filterAccounts.length === 0) { + return nodes + } + + const userIds = this.filterAccounts.map(({ uid }) => uid) + // Filter if the owner of the node is in the list of filtered accounts + return nodes.filter((node) => { + const sharees = node.attributes.sharees?.sharee as { id: string }[] | undefined + // If the node provides no information lets keep it + if (!node.owner && !sharees) { + return true + } + // if the owner matches + if (node.owner && userIds.includes(node.owner)) { + return true + } + // Or any of the sharees (if only one share this will be an object, otherwise an array. So using `.flat()` to make it always an array) + if (sharees && [sharees].flat().some(({ id }) => userIds.includes(id))) { + return true + } + // Not a valid node for the current filter + return false + }) + } + + public reset(): void { + this.currentInstance?.resetFilter() + } + + /** + * Set accounts that should be filtered. + * + * @param accounts - Account to filter or undefined if inactive. + */ + public setAccounts(accounts?: IAccountData[]) { + this.filterAccounts = accounts + let chips: IFileListFilterChip[] = [] + if (this.filterAccounts && this.filterAccounts.length > 0) { + chips = this.filterAccounts.map(({ displayName, uid }) => ({ + text: displayName, + user: uid, + onclick: () => this.currentInstance?.toggleAccount(uid), + })) + } + + this.updateChips(chips) + this.filterUpdated() + } + + /** + * Update the accounts owning nodes or have nodes shared to them. + * + * @param nodes - The current content of the file list. + */ + protected updateAvailableAccounts(nodes: INode[]): void { + const available = new Map<string, IAccountData>() + + for (const node of nodes) { + const owner = node.owner + if (owner && !available.has(owner)) { + available.set(owner, { + uid: owner, + displayName: node.attributes['owner-display-name'] ?? node.owner, + }) + } + + // ensure sharees is an array (if only one share then it is just an object) + const sharees: { id: string, 'display-name': string, type: ShareType }[] = [node.attributes.sharees?.sharee].flat().filter(Boolean) + for (const sharee of [sharees].flat()) { + // Skip link shares and other without user + if (sharee.id === '') { + continue + } + if (sharee.type !== ShareType.User && sharee.type !== ShareType.Remote) { + continue + } + // Add if not already added + if (!available.has(sharee.id)) { + available.set(sharee.id, { + uid: sharee.id, + displayName: sharee['display-name'], + }) + } + } + } + + this.availableAccounts = [...available.values()] + if (this.currentInstance) { + this.currentInstance.setAvailableAccounts(this.availableAccounts) + } + } + +} + +/** + * Register the file list filter by owner or sharees + */ +export function registerAccountFilter() { + if (isPublicShare()) { + // We do not show the filter on public pages - it makes no sense + return + } + + registerFileListFilter(new AccountFilter()) +} diff --git a/apps/files_sharing/src/files_headers/noteToRecipient.ts b/apps/files_sharing/src/files_headers/noteToRecipient.ts new file mode 100644 index 00000000000..7cf859172c5 --- /dev/null +++ b/apps/files_sharing/src/files_headers/noteToRecipient.ts @@ -0,0 +1,40 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { ComponentPublicInstance, VueConstructor } from 'vue' + +import { Folder, Header, registerFileListHeaders } from '@nextcloud/files' +import Vue from 'vue' + +type IFilesHeaderNoteToRecipient = ComponentPublicInstance & { updateFolder: (folder: Folder) => void } + +/** + * Register the "note to recipient" as a files list header + */ +export default function registerNoteToRecipient() { + let FilesHeaderNoteToRecipient: VueConstructor + let instance: IFilesHeaderNoteToRecipient + + registerFileListHeaders(new Header({ + id: 'note-to-recipient', + order: 0, + // Always if there is a note + enabled: (folder: Folder) => Boolean(folder.attributes.note), + // Update the root folder if needed + updated: (folder: Folder) => { + if (instance) { + instance.updateFolder(folder) + } + }, + // render simply spawns the component + render: async (el: HTMLElement, folder: Folder) => { + if (FilesHeaderNoteToRecipient === undefined) { + const { default: component } = await import('../views/FilesHeaderNoteToRecipient.vue') + FilesHeaderNoteToRecipient = Vue.extend(component) + } + instance = new FilesHeaderNoteToRecipient().$mount(el) as unknown as IFilesHeaderNoteToRecipient + instance.updateFolder(folder) + }, + })) +} diff --git a/apps/files_sharing/src/files_newMenu/newFileRequest.ts b/apps/files_sharing/src/files_newMenu/newFileRequest.ts new file mode 100644 index 00000000000..1d58e3552a2 --- /dev/null +++ b/apps/files_sharing/src/files_newMenu/newFileRequest.ts @@ -0,0 +1,42 @@ +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Entry, Folder, Node } from '@nextcloud/files' + +import { defineAsyncComponent } from 'vue' +import { spawnDialog } from '@nextcloud/dialogs' +import { translate as t } from '@nextcloud/l10n' +import FileUploadSvg from '@mdi/svg/svg/file-upload-outline.svg?raw' + +import Config from '../services/ConfigService' +import { isPublicShare } from '@nextcloud/sharing/public' +const sharingConfig = new Config() + +const NewFileRequestDialogVue = defineAsyncComponent(() => import('../components/NewFileRequestDialog.vue')) + +export const EntryId = 'file-request' + +export const entry = { + id: EntryId, + displayName: t('files_sharing', 'Create file request'), + iconSvgInline: FileUploadSvg, + order: 10, + enabled(): boolean { + // not on public shares + if (isPublicShare()) { + return false + } + if (!sharingConfig.isPublicUploadEnabled) { + return false + } + // We will check for the folder permission on the dialog + return sharingConfig.isPublicShareAllowed + }, + async handler(context: Folder, content: Node[]) { + spawnDialog(NewFileRequestDialogVue, { + context, + content, + }) + }, +} as Entry diff --git a/apps/files_sharing/src/files_sharing.js b/apps/files_sharing/src/files_sharing.js deleted file mode 100644 index 97174542458..00000000000 --- a/apps/files_sharing/src/files_sharing.js +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @copyright Copyright (c) 2016 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -import '../js/app' -import '../js/sharedfilelist' diff --git a/apps/files_sharing/src/files_sharing_tab.js b/apps/files_sharing/src/files_sharing_tab.js index 8858f35570f..6afcfa76717 100644 --- a/apps/files_sharing/src/files_sharing_tab.js +++ b/apps/files_sharing/src/files_sharing_tab.js @@ -1,38 +1,23 @@ /** - * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import Vue from 'vue' -import { translate as t, translatePlural as n } from '@nextcloud/l10n' +import { getCSPNonce } from '@nextcloud/auth' +import { t, n } from '@nextcloud/l10n' -import SharingTab from './views/SharingTab.vue' import ShareSearch from './services/ShareSearch.js' import ExternalLinkActions from './services/ExternalLinkActions.js' import ExternalShareActions from './services/ExternalShareActions.js' import TabSections from './services/TabSections.js' -// eslint-disable-next-line node/no-missing-import, import/no-unresolved +// eslint-disable-next-line n/no-missing-import, import/no-unresolved import ShareVariant from '@mdi/svg/svg/share-variant.svg?raw' +// eslint-disable-next-line camelcase +__webpack_nonce__ = getCSPNonce() + // Init Sharing Tab Service if (!window.OCA.Sharing) { window.OCA.Sharing = {} @@ -46,7 +31,6 @@ Vue.prototype.t = t Vue.prototype.n = n // Init Sharing tab component -const View = Vue.extend(SharingTab) let TabInstance = null window.addEventListener('DOMContentLoaded', function() { @@ -57,6 +41,9 @@ window.addEventListener('DOMContentLoaded', function() { iconSvg: ShareVariant, async mount(el, fileInfo, context) { + const SharingTab = (await import('./views/SharingTab.vue')).default + const View = Vue.extend(SharingTab) + if (TabInstance) { TabInstance.$destroy() } @@ -68,12 +55,16 @@ window.addEventListener('DOMContentLoaded', function() { await TabInstance.update(fileInfo) TabInstance.$mount(el) }, + update(fileInfo) { TabInstance.update(fileInfo) }, + destroy() { - TabInstance.$destroy() - TabInstance = null + if (TabInstance) { + TabInstance.$destroy() + TabInstance = null + } }, })) } diff --git a/apps/files_sharing/src/files_views/publicFileDrop.ts b/apps/files_sharing/src/files_views/publicFileDrop.ts new file mode 100644 index 00000000000..65756e83c74 --- /dev/null +++ b/apps/files_sharing/src/files_views/publicFileDrop.ts @@ -0,0 +1,60 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { VueConstructor } from 'vue' + +import { Folder, Permission, View, getNavigation } from '@nextcloud/files' +import { defaultRemoteURL, defaultRootPath } from '@nextcloud/files/dav' +import { loadState } from '@nextcloud/initial-state' +import { translate as t } from '@nextcloud/l10n' +import svgCloudUpload from '@mdi/svg/svg/cloud-upload.svg?raw' +import Vue from 'vue' + +export default () => { + const foldername = loadState<string>('files_sharing', 'filename') + + let FilesViewFileDropEmptyContent: VueConstructor + let fileDropEmptyContentInstance: Vue + + const view = new View({ + id: 'public-file-drop', + name: t('files_sharing', 'File drop'), + caption: t('files_sharing', 'Upload files to {foldername}', { foldername }), + icon: svgCloudUpload, + order: 1, + + emptyView: async (div: HTMLDivElement) => { + if (FilesViewFileDropEmptyContent === undefined) { + const { default: component } = await import('../views/FilesViewFileDropEmptyContent.vue') + FilesViewFileDropEmptyContent = Vue.extend(component) + } + if (fileDropEmptyContentInstance) { + fileDropEmptyContentInstance.$destroy() + } + fileDropEmptyContentInstance = new FilesViewFileDropEmptyContent({ + propsData: { + foldername, + }, + }) + fileDropEmptyContentInstance.$mount(div) + }, + + getContents: async () => { + return { + contents: [], + // Fake a writeonly folder as root + folder: new Folder({ + id: 0, + source: `${defaultRemoteURL}${defaultRootPath}`, + root: defaultRootPath, + owner: null, + permissions: Permission.CREATE, + }), + } + }, + }) + + const Navigation = getNavigation() + Navigation.register(view) +} diff --git a/apps/files_sharing/src/files_views/publicFileShare.ts b/apps/files_sharing/src/files_views/publicFileShare.ts new file mode 100644 index 00000000000..caa7f862e57 --- /dev/null +++ b/apps/files_sharing/src/files_views/publicFileShare.ts @@ -0,0 +1,66 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { FileStat, ResponseDataDetailed } from 'webdav' +import { Folder, Permission, View, davGetDefaultPropfind, davRemoteURL, davResultToNode, davRootPath, getNavigation } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import { CancelablePromise } from 'cancelable-promise' +import LinkSvg from '@mdi/svg/svg/link.svg?raw' + +import { client } from '../../../files/src/services/WebdavClient' +import logger from '../services/logger' + +export default () => { + const view = new View({ + id: 'public-file-share', + name: t('files_sharing', 'Public file share'), + caption: t('files_sharing', 'Publicly shared file.'), + + emptyTitle: t('files_sharing', 'No file'), + emptyCaption: t('files_sharing', 'The file shared with you will show up here'), + + icon: LinkSvg, + order: 1, + + getContents: () => { + return new CancelablePromise(async (resolve, reject, onCancel) => { + const abort = new AbortController() + onCancel(() => abort.abort()) + try { + const node = await client.stat( + davRootPath, + { + data: davGetDefaultPropfind(), + details: true, + signal: abort.signal, + }, + ) as ResponseDataDetailed<FileStat> + + resolve({ + // We only have one file as the content + contents: [davResultToNode(node.data)], + // Fake a readonly folder as root + folder: new Folder({ + id: 0, + source: `${davRemoteURL}${davRootPath}`, + root: davRootPath, + owner: null, + permissions: Permission.READ, + attributes: { + // Ensure the share note is set on the root + note: node.data.props?.note, + }, + }), + }) + } catch (e) { + logger.error(e as Error) + reject(e as Error) + } + }) + }, + }) + + const Navigation = getNavigation() + Navigation.register(view) +} diff --git a/apps/files_sharing/src/files_views/publicShare.ts b/apps/files_sharing/src/files_views/publicShare.ts new file mode 100644 index 00000000000..4f5526bc829 --- /dev/null +++ b/apps/files_sharing/src/files_views/publicShare.ts @@ -0,0 +1,28 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { translate as t } from '@nextcloud/l10n' +import { View, getNavigation } from '@nextcloud/files' +import LinkSvg from '@mdi/svg/svg/link.svg?raw' + +import { getContents } from '../../../files/src/services/Files' + +export default () => { + const view = new View({ + id: 'public-share', + name: t('files_sharing', 'Public share'), + caption: t('files_sharing', 'Publicly shared files.'), + + emptyTitle: t('files_sharing', 'No files'), + emptyCaption: t('files_sharing', 'Files and folders shared with you will show up here'), + + icon: LinkSvg, + order: 1, + + getContents, + }) + + const Navigation = getNavigation() + Navigation.register(view) +} diff --git a/apps/files_sharing/src/files_views/shares.spec.ts b/apps/files_sharing/src/files_views/shares.spec.ts new file mode 100644 index 00000000000..7e5b59e0ad9 --- /dev/null +++ b/apps/files_sharing/src/files_views/shares.spec.ts @@ -0,0 +1,132 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +/* eslint-disable n/no-extraneous-import */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { OCSResponse } from '@nextcloud/typings/ocs' + +import { beforeEach, describe, expect, test, vi } from 'vitest' +import { Folder, Navigation, View, getNavigation } from '@nextcloud/files' +import * as ncInitialState from '@nextcloud/initial-state' +import axios from '@nextcloud/axios' + +import '../main' +import registerSharingViews from './shares' + +declare global { + interface Window { + _nc_navigation?: Navigation + } +} + +describe('Sharing views definition', () => { + let Navigation + beforeEach(() => { + delete window._nc_navigation + Navigation = getNavigation() + expect(window._nc_navigation).toBeDefined() + }) + + test('Default values', () => { + vi.spyOn(Navigation, 'register') + + expect(Navigation.views.length).toBe(0) + + registerSharingViews() + const shareOverviewView = Navigation.views.find(view => view.id === 'shareoverview') as View + const sharesChildViews = Navigation.views.filter(view => view.parent === 'shareoverview') as View[] + + expect(Navigation.register).toHaveBeenCalledTimes(7) + + // one main view and no children + expect(Navigation.views.length).toBe(7) + expect(shareOverviewView).toBeDefined() + expect(sharesChildViews.length).toBe(6) + + expect(shareOverviewView?.id).toBe('shareoverview') + expect(shareOverviewView?.name).toBe('Shares') + expect(shareOverviewView?.caption).toBe('Overview of shared files.') + expect(shareOverviewView?.icon).toMatch(/<svg.+<\/svg>/i) + expect(shareOverviewView?.order).toBe(20) + expect(shareOverviewView?.columns).toStrictEqual([]) + expect(shareOverviewView?.getContents).toBeDefined() + + const dataProvider = [ + { id: 'sharingin', name: 'Shared with you' }, + { id: 'sharingout', name: 'Shared with others' }, + { id: 'sharinglinks', name: 'Shared by link' }, + { id: 'filerequest', name: 'File requests' }, + { id: 'deletedshares', name: 'Deleted shares' }, + { id: 'pendingshares', name: 'Pending shares' }, + ] + + sharesChildViews.forEach((view, index) => { + expect(view?.id).toBe(dataProvider[index].id) + expect(view?.parent).toBe('shareoverview') + expect(view?.name).toBe(dataProvider[index].name) + expect(view?.caption).toBeDefined() + expect(view?.emptyTitle).toBeDefined() + expect(view?.emptyCaption).toBeDefined() + expect(view?.icon).match(/<svg.+<\/svg>/) + expect(view?.order).toBe(index + 1) + expect(view?.columns).toStrictEqual([]) + expect(view?.getContents).toBeDefined() + }) + }) + + test('Shared with others view is not registered if user has no storage quota', () => { + vi.spyOn(Navigation, 'register') + const spy = vi.spyOn(ncInitialState, 'loadState').mockImplementationOnce(() => ({ quota: 0 })) + + expect(Navigation.views.length).toBe(0) + registerSharingViews() + expect(Navigation.register).toHaveBeenCalledTimes(6) + expect(Navigation.views.length).toBe(6) + + const shareOverviewView = Navigation.views.find(view => view.id === 'shareoverview') as View + const sharesChildViews = Navigation.views.filter(view => view.parent === 'shareoverview') as View[] + expect(shareOverviewView).toBeDefined() + expect(sharesChildViews.length).toBe(5) + + expect(spy).toHaveBeenCalled() + expect(spy).toHaveBeenCalledWith('files', 'storageStats', { quota: -1 }) + + const sharedWithOthersView = Navigation.views.find(view => view.id === 'sharingout') + expect(sharedWithOthersView).toBeUndefined() + }) +}) + +describe('Sharing views contents', () => { + let Navigation + beforeEach(() => { + delete window._nc_navigation + Navigation = getNavigation() + expect(window._nc_navigation).toBeDefined() + }) + + test('Sharing overview get contents', async () => { + vi.spyOn(axios, 'get').mockImplementation(async (): Promise<any> => { + return { + data: { + ocs: { + meta: { + status: 'ok', + statuscode: 200, + message: 'OK', + }, + data: [], + }, + } as OCSResponse<any>, + } + }) + + registerSharingViews() + expect(Navigation.views.length).toBe(7) + Navigation.views.forEach(async (view: View) => { + const content = await view.getContents('/') + expect(content.contents).toStrictEqual([]) + expect(content.folder).toBeInstanceOf(Folder) + }) + }) +}) diff --git a/apps/files_sharing/src/files_views/shares.ts b/apps/files_sharing/src/files_views/shares.ts new file mode 100644 index 00000000000..fd5e908638c --- /dev/null +++ b/apps/files_sharing/src/files_views/shares.ts @@ -0,0 +1,156 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { translate as t } from '@nextcloud/l10n' +import { View, getNavigation } from '@nextcloud/files' +import { ShareType } from '@nextcloud/sharing' +import AccountClockSvg from '@mdi/svg/svg/account-clock.svg?raw' +import AccountGroupSvg from '@mdi/svg/svg/account-group-outline.svg?raw' +import AccountPlusSvg from '@mdi/svg/svg/account-plus-outline.svg?raw' +import AccountSvg from '@mdi/svg/svg/account.svg?raw' +import DeleteSvg from '@mdi/svg/svg/delete.svg?raw' +import FileUploadSvg from '@mdi/svg/svg/file-upload-outline.svg?raw' +import LinkSvg from '@mdi/svg/svg/link.svg?raw' + +import { getContents, isFileRequest } from '../services/SharingService' +import { loadState } from '@nextcloud/initial-state' + +export const sharesViewId = 'shareoverview' +export const sharedWithYouViewId = 'sharingin' +export const sharedWithOthersViewId = 'sharingout' +export const sharingByLinksViewId = 'sharinglinks' +export const deletedSharesViewId = 'deletedshares' +export const pendingSharesViewId = 'pendingshares' +export const fileRequestViewId = 'filerequest' + +export default () => { + const Navigation = getNavigation() + Navigation.register(new View({ + id: sharesViewId, + name: t('files_sharing', 'Shares'), + caption: t('files_sharing', 'Overview of shared files.'), + + emptyTitle: t('files_sharing', 'No shares'), + emptyCaption: t('files_sharing', 'Files and folders you shared or have been shared with you will show up here'), + + icon: AccountPlusSvg, + order: 20, + + columns: [], + + getContents: () => getContents(), + })) + + Navigation.register(new View({ + id: sharedWithYouViewId, + name: t('files_sharing', 'Shared with you'), + caption: t('files_sharing', 'List of files that are shared with you.'), + + emptyTitle: t('files_sharing', 'Nothing shared with you yet'), + emptyCaption: t('files_sharing', 'Files and folders others shared with you will show up here'), + + icon: AccountSvg, + order: 1, + parent: sharesViewId, + + columns: [], + + getContents: () => getContents(true, false, false, false), + })) + + // Don't show this view if the user has no storage quota + const storageStats = loadState('files', 'storageStats', { quota: -1 }) + if (storageStats.quota !== 0) { + Navigation.register(new View({ + id: sharedWithOthersViewId, + name: t('files_sharing', 'Shared with others'), + caption: t('files_sharing', 'List of files that you shared with others.'), + + emptyTitle: t('files_sharing', 'Nothing shared yet'), + emptyCaption: t('files_sharing', 'Files and folders you shared will show up here'), + + icon: AccountGroupSvg, + order: 2, + parent: sharesViewId, + + columns: [], + + getContents: () => getContents(false, true, false, false), + })) + } + + Navigation.register(new View({ + id: sharingByLinksViewId, + name: t('files_sharing', 'Shared by link'), + caption: t('files_sharing', 'List of files that are shared by link.'), + + emptyTitle: t('files_sharing', 'No shared links'), + emptyCaption: t('files_sharing', 'Files and folders you shared by link will show up here'), + + icon: LinkSvg, + order: 3, + parent: sharesViewId, + + columns: [], + + getContents: () => getContents(false, true, false, false, [ShareType.Link]), + })) + + Navigation.register(new View({ + id: fileRequestViewId, + name: t('files_sharing', 'File requests'), + caption: t('files_sharing', 'List of file requests.'), + + emptyTitle: t('files_sharing', 'No file requests'), + emptyCaption: t('files_sharing', 'File requests you have created will show up here'), + + icon: FileUploadSvg, + order: 4, + parent: sharesViewId, + + columns: [], + + getContents: () => getContents(false, true, false, false, [ShareType.Link, ShareType.Email]) + .then(({ folder, contents }) => { + return { + folder, + contents: contents.filter((node) => isFileRequest(node.attributes?.['share-attributes'] || [])), + } + }), + })) + + Navigation.register(new View({ + id: deletedSharesViewId, + name: t('files_sharing', 'Deleted shares'), + caption: t('files_sharing', 'List of shares you left.'), + + emptyTitle: t('files_sharing', 'No deleted shares'), + emptyCaption: t('files_sharing', 'Shares you have left will show up here'), + + icon: DeleteSvg, + order: 5, + parent: sharesViewId, + + columns: [], + + getContents: () => getContents(false, false, false, true), + })) + + Navigation.register(new View({ + id: pendingSharesViewId, + name: t('files_sharing', 'Pending shares'), + caption: t('files_sharing', 'List of unapproved shares.'), + + emptyTitle: t('files_sharing', 'No pending shares'), + emptyCaption: t('files_sharing', 'Shares you have received but not approved will show up here'), + + icon: AccountClockSvg, + order: 6, + parent: sharesViewId, + + columns: [], + + getContents: () => getContents(false, false, true, false), + })) +} diff --git a/apps/files_sharing/src/index.js b/apps/files_sharing/src/index.js deleted file mode 100644 index 9f80c79270e..00000000000 --- a/apps/files_sharing/src/index.js +++ /dev/null @@ -1,38 +0,0 @@ -/** - * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -// register default shares types -Object.assign(OC, { - Share: { - SHARE_TYPE_USER: 0, - SHARE_TYPE_GROUP: 1, - SHARE_TYPE_LINK: 3, - SHARE_TYPE_EMAIL: 4, - SHARE_TYPE_REMOTE: 6, - SHARE_TYPE_CIRCLE: 7, - SHARE_TYPE_GUEST: 8, - SHARE_TYPE_REMOTE_GROUP: 9, - SHARE_TYPE_ROOM: 10, - SHARE_TYPE_DECK: 12, - }, -}) diff --git a/apps/files_sharing/src/init-public.ts b/apps/files_sharing/src/init-public.ts new file mode 100644 index 00000000000..72a3098a0e6 --- /dev/null +++ b/apps/files_sharing/src/init-public.ts @@ -0,0 +1,63 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { ShareAttribute } from './sharing.d.ts' +import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' +import { Folder, getNavigation } from '@nextcloud/files' +import { loadState } from '@nextcloud/initial-state' +import registerFileDropView from './files_views/publicFileDrop.ts' +import registerPublicShareView from './files_views/publicShare.ts' +import registerPublicFileShareView from './files_views/publicFileShare.ts' +import RouterService from '../../files/src/services/RouterService.ts' +import router from './router/index.ts' +import logger from './services/logger.ts' + +registerFileDropView() +registerPublicShareView() +registerPublicFileShareView() + +// Get the current view from state and set it active +const view = loadState<string>('files_sharing', 'view') +const navigation = getNavigation() +navigation.setActive(navigation.views.find(({ id }) => id === view) ?? null) + +// Force our own router +window.OCP.Files = window.OCP.Files ?? {} +window.OCP.Files.Router = new RouterService(router) + +// If this is a single file share, so set the fileid as active in the URL +const fileId = loadState<number|null>('files_sharing', 'fileId', null) +const token = loadState<string>('files_sharing', 'sharingToken') +if (fileId !== null) { + window.OCP.Files.Router.goToRoute( + 'filelist', + { ...window.OCP.Files.Router.params, token, fileid: String(fileId) }, + { ...window.OCP.Files.Router.query, openfile: 'true' }, + ) +} + +// When the file list is loaded we need to apply the "userconfig" setup on the share +subscribe('files:list:updated', loadShareConfig) + +/** + * Event handler to load the view config for the current share. + * This is done on the `files:list:updated` event to ensure the list and especially the config store was correctly initialized. + * + * @param context The event context + * @param context.folder The current folder + */ +function loadShareConfig({ folder }: { folder: Folder }) { + // Only setup config once + unsubscribe('files:list:updated', loadShareConfig) + + // Share attributes (the same) are set on all folders of a share + if (folder.attributes['share-attributes']) { + const shareAttributes = JSON.parse(folder.attributes['share-attributes'] || '[]') as Array<ShareAttribute> + const gridViewAttribute = shareAttributes.find(({ scope, key }: ShareAttribute) => scope === 'config' && key === 'grid_view') + if (gridViewAttribute !== undefined) { + logger.debug('Loading share attributes', { gridViewAttribute }) + emit('files:config:updated', { key: 'grid_view', value: gridViewAttribute.value === true }) + } + } +} diff --git a/apps/files_sharing/src/init.ts b/apps/files_sharing/src/init.ts new file mode 100644 index 00000000000..f275f3beaf7 --- /dev/null +++ b/apps/files_sharing/src/init.ts @@ -0,0 +1,33 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { addNewFileMenuEntry } from '@nextcloud/files' +import { registerDavProperty } from '@nextcloud/files/dav' +import { registerAccountFilter } from './files_filters/AccountFilter' +import { entry as newFileRequest } from './files_newMenu/newFileRequest' + +import registerNoteToRecipient from './files_headers/noteToRecipient' +import registerSharingViews from './files_views/shares' + +import './files_actions/acceptShareAction' +import './files_actions/openInFilesAction' +import './files_actions/rejectShareAction' +import './files_actions/restoreShareAction' +import './files_actions/sharingStatusAction' + +registerSharingViews() + +addNewFileMenuEntry(newFileRequest) + +registerDavProperty('nc:note', { nc: 'http://nextcloud.org/ns' }) +registerDavProperty('nc:sharees', { nc: 'http://nextcloud.org/ns' }) +registerDavProperty('nc:hide-download', { nc: 'http://nextcloud.org/ns' }) +registerDavProperty('nc:share-attributes', { nc: 'http://nextcloud.org/ns' }) +registerDavProperty('oc:share-types', { oc: 'http://owncloud.org/ns' }) +registerDavProperty('ocs:share-permissions', { ocs: 'http://open-collaboration-services.org/ns' }) + +registerAccountFilter() + +// Add "note to recipient" message +registerNoteToRecipient() diff --git a/apps/files_sharing/src/lib/SharePermissionsToolBox.js b/apps/files_sharing/src/lib/SharePermissionsToolBox.js index f5806df70bf..797645ae04d 100644 --- a/apps/files_sharing/src/lib/SharePermissionsToolBox.js +++ b/apps/files_sharing/src/lib/SharePermissionsToolBox.js @@ -1,23 +1,6 @@ /** - * @copyright 2022 Louis Chmn <louis@chmn.me> - * - * @author Louis Chmn <louis@chmn.me> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ export const ATOMIC_PERMISSIONS = { @@ -34,6 +17,7 @@ export const BUNDLED_PERMISSIONS = { UPLOAD_AND_UPDATE: ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.UPDATE | ATOMIC_PERMISSIONS.CREATE | ATOMIC_PERMISSIONS.DELETE, FILE_DROP: ATOMIC_PERMISSIONS.CREATE, ALL: ATOMIC_PERMISSIONS.UPDATE | ATOMIC_PERMISSIONS.CREATE | ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.DELETE | ATOMIC_PERMISSIONS.SHARE, + ALL_FILE: ATOMIC_PERMISSIONS.UPDATE | ATOMIC_PERMISSIONS.READ | ATOMIC_PERMISSIONS.SHARE, } /** diff --git a/apps/files_sharing/src/lib/SharePermissionsToolBox.spec.js b/apps/files_sharing/src/lib/SharePermissionsToolBox.spec.js index 7ae29c7134a..a58552063d8 100644 --- a/apps/files_sharing/src/lib/SharePermissionsToolBox.spec.js +++ b/apps/files_sharing/src/lib/SharePermissionsToolBox.spec.js @@ -1,24 +1,8 @@ /** - * @copyright 2022 Louis Chmn <louis@chmn.me> - * - * @author Louis Chmn <louis@chmn.me> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { describe, expect, test } from 'vitest' import { ATOMIC_PERMISSIONS, @@ -29,7 +13,7 @@ import { permissionsSetIsValid, togglePermissions, canTogglePermissions, -} from '../lib/SharePermissionsToolBox' +} from '../lib/SharePermissionsToolBox.js' describe('SharePermissionsToolBox', () => { test('Adding permissions', () => { diff --git a/apps/files_sharing/src/main.ts b/apps/files_sharing/src/main.ts new file mode 100644 index 00000000000..3170fbc2a7b --- /dev/null +++ b/apps/files_sharing/src/main.ts @@ -0,0 +1,21 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +// register default shares types +Object.assign(window.OC, { + Share: { + SHARE_TYPE_USER: 0, + SHARE_TYPE_GROUP: 1, + SHARE_TYPE_LINK: 3, + SHARE_TYPE_EMAIL: 4, + SHARE_TYPE_REMOTE: 6, + SHARE_TYPE_CIRCLE: 7, + SHARE_TYPE_GUEST: 8, + SHARE_TYPE_REMOTE_GROUP: 9, + SHARE_TYPE_ROOM: 10, + SHARE_TYPE_DECK: 12, + SHARE_TYPE_SCIENCEMESH: 15, + }, +}) diff --git a/apps/files_sharing/src/mixins/ShareDetails.js b/apps/files_sharing/src/mixins/ShareDetails.js new file mode 100644 index 00000000000..6ccdf8d63d0 --- /dev/null +++ b/apps/files_sharing/src/mixins/ShareDetails.js @@ -0,0 +1,82 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import Share from '../models/Share.ts' +import Config from '../services/ConfigService.ts' +import { ATOMIC_PERMISSIONS } from '../lib/SharePermissionsToolBox.js' +import logger from '../services/logger.ts' + +export default { + methods: { + async openSharingDetails(shareRequestObject) { + let share = {} + // handle externalResults from OCA.Sharing.ShareSearch + // TODO : Better name/interface for handler required + // For example `externalAppCreateShareHook` with proper documentation + if (shareRequestObject.handler) { + const handlerInput = {} + if (this.suggestions) { + handlerInput.suggestions = this.suggestions + handlerInput.fileInfo = this.fileInfo + handlerInput.query = this.query + } + const externalShareRequestObject = await shareRequestObject.handler(handlerInput) + share = this.mapShareRequestToShareObject(externalShareRequestObject) + } else { + share = this.mapShareRequestToShareObject(shareRequestObject) + } + + if (this.fileInfo.type !== 'dir') { + const originalPermissions = share.permissions + const strippedPermissions = originalPermissions + & ~ATOMIC_PERMISSIONS.CREATE + & ~ATOMIC_PERMISSIONS.DELETE + + if (originalPermissions !== strippedPermissions) { + logger.debug('Removed create/delete permissions from file share (only valid for folders)') + share.permissions = strippedPermissions + } + } + + const shareDetails = { + fileInfo: this.fileInfo, + share, + } + + this.$emit('open-sharing-details', shareDetails) + }, + openShareDetailsForCustomSettings(share) { + share.setCustomPermissions = true + this.openSharingDetails(share) + }, + mapShareRequestToShareObject(shareRequestObject) { + + if (shareRequestObject.id) { + return shareRequestObject + } + + const share = { + attributes: [ + { + value: true, + key: 'download', + scope: 'permissions', + }, + ], + hideDownload: false, + share_type: shareRequestObject.shareType, + share_with: shareRequestObject.shareWith, + is_no_user: shareRequestObject.isNoUser, + user: shareRequestObject.shareWith, + share_with_displayname: shareRequestObject.displayName, + subtitle: shareRequestObject.subtitle, + permissions: shareRequestObject.permissions ?? new Config().defaultPermissions, + expiration: '', + } + + return new Share(share) + }, + }, +} diff --git a/apps/files_sharing/src/mixins/ShareRequests.js b/apps/files_sharing/src/mixins/ShareRequests.js index 9eaad8c4161..2c33fa3b0c7 100644 --- a/apps/files_sharing/src/mixins/ShareRequests.js +++ b/apps/files_sharing/src/mixins/ShareRequests.js @@ -1,34 +1,17 @@ /** - * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ // TODO: remove when ie not supported import 'url-search-params-polyfill' +import { emit } from '@nextcloud/event-bus' +import { showError } from '@nextcloud/dialogs' import { generateOcsUrl } from '@nextcloud/router' import axios from '@nextcloud/axios' -import Share from '../models/Share' + +import Share from '../models/Share.ts' const shareUrl = generateOcsUrl('apps/files_sharing/api/v1/shares') @@ -41,29 +24,32 @@ export default { * @param {string} data.path path to the file/folder which should be shared * @param {number} data.shareType 0 = user; 1 = group; 3 = public link; 6 = federated cloud share * @param {string} data.shareWith user/group id with which the file should be shared (optional for shareType > 1) - * @param {boolean} [data.publicUpload=false] allow public upload to a public shared folder + * @param {boolean} [data.publicUpload] allow public upload to a public shared folder * @param {string} [data.password] password to protect public link Share with - * @param {number} [data.permissions=31] 1 = read; 2 = update; 4 = create; 8 = delete; 16 = share; 31 = all (default: 31, for public shares: 1) - * @param {boolean} [data.sendPasswordByTalk=false] send the password via a talk conversation - * @param {string} [data.expireDate=''] expire the shareautomatically after - * @param {string} [data.label=''] custom label - * @param {string} [data.attributes=null] Share attributes encoded as json + * @param {number} [data.permissions] 1 = read; 2 = update; 4 = create; 8 = delete; 16 = share; 31 = all (default: 31, for public shares: 1) + * @param {boolean} [data.sendPasswordByTalk] send the password via a talk conversation + * @param {string} [data.expireDate] expire the share automatically after + * @param {string} [data.label] custom label + * @param {string} [data.attributes] Share attributes encoded as json + * @param {string} data.note custom note to recipient * @return {Share} the new share * @throws {Error} */ - async createShare({ path, permissions, shareType, shareWith, publicUpload, password, sendPasswordByTalk, expireDate, label, attributes }) { + async createShare({ path, permissions, shareType, shareWith, publicUpload, password, sendPasswordByTalk, expireDate, label, note, attributes }) { try { - const request = await axios.post(shareUrl, { path, permissions, shareType, shareWith, publicUpload, password, sendPasswordByTalk, expireDate, label, attributes }) + const request = await axios.post(shareUrl, { path, permissions, shareType, shareWith, publicUpload, password, sendPasswordByTalk, expireDate, label, note, attributes }) if (!request?.data?.ocs) { throw request } - return new Share(request.data.ocs.data) + const share = new Share(request.data.ocs.data) + emit('files_sharing:share:created', { share }) + return share } catch (error) { console.error('Error while creating share', error) const errorMessage = error?.response?.data?.ocs?.meta?.message - OC.Notification.showTemporary( + showError( errorMessage ? t('files_sharing', 'Error creating the share: {errorMessage}', { errorMessage }) : t('files_sharing', 'Error creating the share'), - { type: 'error' } + { type: 'error' }, ) throw error } @@ -81,13 +67,14 @@ export default { if (!request?.data?.ocs) { throw request } + emit('files_sharing:share:deleted', { id }) return true } catch (error) { console.error('Error while deleting share', error) const errorMessage = error?.response?.data?.ocs?.meta?.message OC.Notification.showTemporary( errorMessage ? t('files_sharing', 'Error deleting the share: {errorMessage}', { errorMessage }) : t('files_sharing', 'Error deleting the share'), - { type: 'error' } + { type: 'error' }, ) throw error } @@ -102,6 +89,7 @@ export default { async updateShare(id, properties) { try { const request = await axios.put(shareUrl + `/${id}`, properties) + emit('files_sharing:share:updated', { id }) if (!request?.data?.ocs) { throw request } else { @@ -113,7 +101,7 @@ export default { const errorMessage = error?.response?.data?.ocs?.meta?.message OC.Notification.showTemporary( errorMessage ? t('files_sharing', 'Error updating the share: {errorMessage}', { errorMessage }) : t('files_sharing', 'Error updating the share'), - { type: 'error' } + { type: 'error' }, ) } const message = error.response.data.ocs.meta.message diff --git a/apps/files_sharing/src/mixins/ShareTypes.js b/apps/files_sharing/src/mixins/ShareTypes.js deleted file mode 100644 index 8b85f63f456..00000000000 --- a/apps/files_sharing/src/mixins/ShareTypes.js +++ /dev/null @@ -1,32 +0,0 @@ -/** - * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -import { Type as ShareTypes } from '@nextcloud/sharing' - -export default { - data() { - return { - SHARE_TYPES: ShareTypes, - } - }, -} diff --git a/apps/files_sharing/src/mixins/SharesMixin.js b/apps/files_sharing/src/mixins/SharesMixin.js index 2a4637be121..a461da56d85 100644 --- a/apps/files_sharing/src/mixins/SharesMixin.js +++ b/apps/files_sharing/src/mixins/SharesMixin.js @@ -1,48 +1,34 @@ /** - * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Calviño Sánchez <danxuliu@gmail.com> - * @author Gary Kim <gary@garykim.dev> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { showError, showSuccess } from '@nextcloud/dialogs' import { getCurrentUser } from '@nextcloud/auth' -// eslint-disable-next-line import/no-unresolved, node/no-missing-import +import { showError, showSuccess } from '@nextcloud/dialogs' +import { ShareType } from '@nextcloud/sharing' +import { emit } from '@nextcloud/event-bus' + import PQueue from 'p-queue' import debounce from 'debounce' -import Share from '../models/Share.js' +import GeneratePassword from '../utils/GeneratePassword.ts' +import Share from '../models/Share.ts' import SharesRequests from './ShareRequests.js' -import ShareTypes from './ShareTypes.js' -import Config from '../services/ConfigService.js' +import Config from '../services/ConfigService.ts' +import logger from '../services/logger.ts' + +import { + BUNDLED_PERMISSIONS, +} from '../lib/SharePermissionsToolBox.js' +import { fetchNode } from '../../../files/src/services/WebdavClient.ts' export default { - mixins: [SharesRequests, ShareTypes], + mixins: [SharesRequests], props: { fileInfo: { type: Object, - default: () => {}, + default: () => { }, required: true, }, share: { @@ -58,6 +44,8 @@ export default { data() { return { config: new Config(), + node: null, + ShareType, // errors helpers errors: {}, @@ -80,7 +68,9 @@ export default { }, computed: { - + path() { + return (this.fileInfo.path + '/' + this.fileInfo.name).replace('//', '/') + }, /** * Does the current share have a note * @@ -104,10 +94,10 @@ export default { // Datepicker language lang() { const weekdaysShort = window.dayNamesShort - ? window.dayNamesShort // provided by nextcloud + ? window.dayNamesShort // provided by Nextcloud : ['Sun.', 'Mon.', 'Tue.', 'Wed.', 'Thu.', 'Fri.', 'Sat.'] const monthsShort = window.monthNamesShort - ? window.monthNamesShort // provided by nextcloud + ? window.monthNamesShort // provided by Nextcloud : ['Jan.', 'Feb.', 'Mar.', 'Apr.', 'May.', 'Jun.', 'Jul.', 'Aug.', 'Sep.', 'Oct.', 'Nov.', 'Dec.'] const firstDayOfWeek = window.firstDay ? window.firstDay : 0 @@ -121,15 +111,91 @@ export default { monthFormat: 'MMM', } }, - + isNewShare() { + return !this.share.id + }, + isFolder() { + return this.fileInfo.type === 'dir' + }, + isPublicShare() { + const shareType = this.share.shareType ?? this.share.type + return [ShareType.Link, ShareType.Email].includes(shareType) + }, + isRemoteShare() { + return this.share.type === ShareType.RemoteGroup || this.share.type === ShareType.Remote + }, isShareOwner() { return this.share && this.share.owner === getCurrentUser().uid }, - + isExpiryDateEnforced() { + if (this.isPublicShare) { + return this.config.isDefaultExpireDateEnforced + } + if (this.isRemoteShare) { + return this.config.isDefaultRemoteExpireDateEnforced + } + return this.config.isDefaultInternalExpireDateEnforced + }, + hasCustomPermissions() { + const bundledPermissions = [ + BUNDLED_PERMISSIONS.ALL, + BUNDLED_PERMISSIONS.READ_ONLY, + BUNDLED_PERMISSIONS.FILE_DROP, + ] + return !bundledPermissions.includes(this.share.permissions) + }, + maxExpirationDateEnforced() { + if (this.isExpiryDateEnforced) { + if (this.isPublicShare) { + return this.config.defaultExpirationDate + } + if (this.isRemoteShare) { + return this.config.defaultRemoteExpirationDateString + } + // If it get's here then it must be an internal share + return this.config.defaultInternalExpirationDate + } + return null + }, + /** + * Is the current share password protected ? + * + * @return {boolean} + */ + isPasswordProtected: { + get() { + return this.config.enforcePasswordForPublicLink + || this.share.password !== '' + || this.share.newPassword !== undefined + }, + async set(enabled) { + if (enabled) { + this.$set(this.share, 'newPassword', await GeneratePassword(true)) + } else { + this.share.password = '' + this.$delete(this.share, 'newPassword') + } + }, + }, }, methods: { /** + * Fetch WebDAV node + * + * @return {Node} + */ + async getNode() { + const node = { path: this.path } + try { + this.node = await fetchNode(node.path) + logger.info('Fetched node:', { node: this.node }) + } catch (error) { + logger.error('Error:', error) + } + }, + + /** * Check if a share is valid before * firing the request * @@ -152,19 +218,7 @@ export default { }, /** - * @param {string} date a date with YYYY-MM-DD format - * @return {Date} date - */ - parseDateString(date) { - if (!date) { - return - } - const regex = /([0-9]{4}-[0-9]{2}-[0-9]{2})/i - return new Date(date.match(regex)?.pop()) - }, - - /** - * @param {Date} date + * @param {Date} date the date to format * @return {string} date a date with YYYY-MM-DD format */ formatDateToString(date) { @@ -180,19 +234,13 @@ export default { * @param {Date} date */ onExpirationChange(date) { - this.share.expireDate = this.formatDateToString(date) - this.queueUpdate('expireDate') - }, - - /** - * Uncheck expire date - * We need this method because @update:checked - * is ran simultaneously as @uncheck, so - * so we cannot ensure data is up-to-date - */ - onExpirationDisable() { - this.share.expireDate = '' - this.queueUpdate('expireDate') + if (!date) { + this.share.expireDate = null + this.$set(this.share, 'expireDate', null) + return + } + const parsedDate = (date instanceof Date) ? date : new Date(date) + this.share.expireDate = this.formatDateToString(parsedDate) }, /** @@ -224,12 +272,14 @@ export default { this.loading = true this.open = false await this.deleteShare(this.share.id) - console.debug('Share deleted', this.share.id) + logger.debug('Share deleted', { shareId: this.share.id }) const message = this.share.itemType === 'file' ? t('files_sharing', 'File "{path}" has been unshared', { path: this.share.path }) : t('files_sharing', 'Folder "{path}" has been unshared', { path: this.share.path }) showSuccess(message) this.$emit('remove:share', this.share) + await this.getNode() + emit('files:node:updated', this.node) } catch (error) { // re-open menu if error this.open = true @@ -253,22 +303,30 @@ export default { const properties = {} // force value to string because that is what our // share api controller accepts - propertyNames.forEach(name => { - if ((typeof this.share[name]) === 'object') { + for (const name of propertyNames) { + if (name === 'password') { + properties[name] = this.share.newPassword ?? this.share.password + continue + } + + if (this.share[name] === null || this.share[name] === undefined) { + properties[name] = '' + } else if ((typeof this.share[name]) === 'object') { properties[name] = JSON.stringify(this.share[name]) } else { properties[name] = this.share[name].toString() } - }) + } - this.updateQueue.add(async () => { + return this.updateQueue.add(async () => { this.saving = true this.errors = {} try { const updatedShare = await this.updateShare(this.share.id, properties) - if (propertyNames.indexOf('password') >= 0) { + if (propertyNames.includes('password')) { // reset password state after sync + this.share.password = this.share.newPassword ?? '' this.$delete(this.share, 'newPassword') // updates password expiration time after sync @@ -276,18 +334,27 @@ export default { } // clear any previous errors - this.$delete(this.errors, propertyNames[0]) - showSuccess(t('files_sharing', 'Share {propertyName} saved', { propertyName: propertyNames[0] })) - } catch ({ message }) { + for (const property of propertyNames) { + this.$delete(this.errors, property) + } + showSuccess(this.updateSuccessMessage(propertyNames)) + } catch (error) { + logger.error('Could not update share', { error, share: this.share, propertyNames }) + + const { message } = error if (message && message !== '') { - this.onSyncError(propertyNames[0], message) - showError(t('files_sharing', message)) + for (const property of propertyNames) { + this.onSyncError(property, message) + } + showError(message) + } else { + // We do not have information what happened, but we should still inform the user + showError(t('files_sharing', 'Could not update share')) } } finally { this.saving = false } }) - return } // This share does not exists on the server yet @@ -295,12 +362,45 @@ export default { }, /** + * @param {string[]} names Properties changed + */ + updateSuccessMessage(names) { + if (names.length !== 1) { + return t('files_sharing', 'Share saved') + } + + switch (names[0]) { + case 'expireDate': + return t('files_sharing', 'Share expiry date saved') + case 'hideDownload': + return t('files_sharing', 'Share hide-download state saved') + case 'label': + return t('files_sharing', 'Share label saved') + case 'note': + return t('files_sharing', 'Share note for recipient saved') + case 'password': + return t('files_sharing', 'Share password saved') + case 'permissions': + return t('files_sharing', 'Share permissions saved') + default: + return t('files_sharing', 'Share saved') + } + }, + + /** * Manage sync errors * * @param {string} property the errored property, e.g. 'password' * @param {string} message the error message */ onSyncError(property, message) { + if (property === 'password' && this.share.newPassword) { + if (this.share.newPassword === this.share.password) { + this.share.password = '' + } + this.$delete(this.share, 'newPassword') + } + // re-open menu if closed this.open = true switch (property) { @@ -335,7 +435,6 @@ export default { } } }, - /** * Debounce queueUpdate to avoid requests spamming * more importantly for text data diff --git a/apps/files_sharing/src/models/Share.js b/apps/files_sharing/src/models/Share.ts index 9b1535184a0..b0638b29448 100644 --- a/apps/files_sharing/src/models/Share.js +++ b/apps/files_sharing/src/models/Share.ts @@ -1,30 +1,12 @@ /** - * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author Daniel Calviño Sánchez <danxuliu@gmail.com> - * @author Gary Kim <gary@garykim.dev> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ +import type { ShareType } from '@nextcloud/sharing' +import type { ShareAttribute } from '../sharing' +import { isFileRequest } from '../services/SharingService' + export default class Share { _share @@ -39,15 +21,19 @@ export default class Share { ocsData = ocsData.ocs.data[0] } + // string to int + if (typeof ocsData.id === 'string') { + ocsData.id = Number.parseInt(ocsData.id) + } // convert int into boolean ocsData.hide_download = !!ocsData.hide_download ocsData.mail_send = !!ocsData.mail_send - if (ocsData.attributes) { + if (ocsData.attributes && typeof ocsData.attributes === 'string') { try { ocsData.attributes = JSON.parse(ocsData.attributes) } catch (e) { - console.warn('Could not parse share attributes returned by server: "' + ocsData.attributes + '"') + console.warn('Could not parse share attributes returned by server', ocsData.attributes) } } ocsData.attributes = ocsData.attributes ?? [] @@ -64,8 +50,6 @@ export default class Share { * state and make the whole class reactive * * @return {object} the share raw state - * @readonly - * @memberof Sidebar */ get state() { return this._share @@ -73,104 +57,69 @@ export default class Share { /** * get the share id - * - * @return {number} - * @readonly - * @memberof Share */ - get id() { + get id(): number { return this._share.id } /** * Get the share type - * - * @return {number} - * @readonly - * @memberof Share */ - get type() { + get type(): ShareType { return this._share.share_type } /** * Get the share permissions - * See OC.PERMISSION_* variables - * - * @return {number} - * @readonly - * @memberof Share + * See window.OC.PERMISSION_* variables */ - get permissions() { + get permissions(): number { return this._share.permissions } /** * Get the share attributes - * - * @return {Array} - * @readonly - * @memberof Share */ - get attributes() { - return this._share.attributes + get attributes(): Array<ShareAttribute> { + return this._share.attributes || [] } /** * Set the share permissions - * See OC.PERMISSION_* variables - * - * @param {number} permissions valid permission, See OC.PERMISSION_* variables - * @memberof Share + * See window.OC.PERMISSION_* variables */ - set permissions(permissions) { + set permissions(permissions: number) { this._share.permissions = permissions } // SHARE OWNER -------------------------------------------------- /** * Get the share owner uid - * - * @return {string} - * @readonly - * @memberof Share */ - get owner() { + get owner(): string { return this._share.uid_owner } /** * Get the share owner's display name - * - * @return {string} - * @readonly - * @memberof Share */ - get ownerDisplayName() { + get ownerDisplayName(): string { return this._share.displayname_owner } // SHARED WITH -------------------------------------------------- /** * Get the share with entity uid - * - * @return {string} - * @readonly - * @memberof Share */ - get shareWith() { + get shareWith(): string { return this._share.share_with } /** * Get the share with entity display name * fallback to its uid if none - * - * @return {string} - * @readonly - * @memberof Share */ - get shareWithDisplayName() { + get shareWithDisplayName(): string { return this._share.share_with_displayname || this._share.share_with } @@ -178,59 +127,39 @@ export default class Share { /** * Unique display name in case of multiple * duplicates results with the same name. - * - * @return {string} - * @readonly - * @memberof Share */ - get shareWithDisplayNameUnique() { + get shareWithDisplayNameUnique(): string { return this._share.share_with_displayname_unique || this._share.share_with } /** * Get the share with entity link - * - * @return {string} - * @readonly - * @memberof Share */ - get shareWithLink() { + get shareWithLink(): string { return this._share.share_with_link } /** * Get the share with avatar if any - * - * @return {string} - * @readonly - * @memberof Share */ - get shareWithAvatar() { + get shareWithAvatar(): string { return this._share.share_with_avatar } // SHARED FILE OR FOLDER OWNER ---------------------------------- /** * Get the shared item owner uid - * - * @return {string} - * @readonly - * @memberof Share */ - get uidFileOwner() { + get uidFileOwner(): string { return this._share.uid_file_owner } /** * Get the shared item display name * fallback to its uid if none - * - * @return {string} - * @readonly - * @memberof Share */ - get displaynameFileOwner() { + get displaynameFileOwner(): string { return this._share.displayname_file_owner || this._share.uid_file_owner } @@ -238,230 +167,176 @@ export default class Share { // TIME DATA ---------------------------------------------------- /** * Get the share creation timestamp - * - * @return {number} - * @readonly - * @memberof Share */ - get createdTime() { + get createdTime(): number { return this._share.stime } /** * Get the expiration date - * * @return {string} date with YYYY-MM-DD format - * @readonly - * @memberof Share */ - get expireDate() { + get expireDate(): string { return this._share.expiration } /** * Set the expiration date - * * @param {string} date the share expiration date with YYYY-MM-DD format - * @memberof Share */ - set expireDate(date) { + set expireDate(date: string) { this._share.expiration = date } // EXTRA DATA --------------------------------------------------- /** * Get the public share token - * - * @return {string} the token - * @readonly - * @memberof Share */ - get token() { + get token(): string { return this._share.token } /** + * Set the public share token + */ + set token(token: string) { + this._share.token = token + } + + /** * Get the share note if any - * - * @return {string} - * @readonly - * @memberof Share */ - get note() { + get note(): string { return this._share.note } /** * Set the share note if any - * - * @param {string} note the note - * @memberof Share */ - set note(note) { + set note(note: string) { this._share.note = note } /** * Get the share label if any * Should only exist on link shares - * - * @return {string} - * @readonly - * @memberof Share */ - get label() { - return this._share.label + get label(): string { + return this._share.label ?? '' } /** * Set the share label if any * Should only be set on link shares - * - * @param {string} label the label - * @memberof Share */ - set label(label) { + set label(label: string) { this._share.label = label } /** * Have a mail been sent - * - * @return {boolean} - * @readonly - * @memberof Share */ - get mailSend() { + get mailSend(): boolean { return this._share.mail_send === true } /** * Hide the download button on public page - * - * @return {boolean} - * @readonly - * @memberof Share */ - get hideDownload() { + get hideDownload(): boolean { return this._share.hide_download === true + || this.attributes.find?.(({ scope, key, value }) => scope === 'permissions' && key === 'download' && !value) !== undefined } /** * Hide the download button on public page - * - * @param {boolean} state hide the button ? - * @memberof Share */ - set hideDownload(state) { + set hideDownload(state: boolean) { + // disabling hide-download also enables the download permission + // needed for regression in Nextcloud 31.0.0 until (incl.) 31.0.3 + if (!state) { + const attribute = this.attributes.find(({ key, scope }) => key === 'download' && scope === 'permissions') + if (attribute) { + attribute.value = true + } + } + this._share.hide_download = state === true } /** * Password protection of the share - * - * @return {string} - * @readonly - * @memberof Share */ - get password() { + get password():string { return this._share.password } /** * Password protection of the share - * - * @param {string} password the share password - * @memberof Share */ - set password(password) { + set password(password: string) { this._share.password = password } /** * Password expiration time - * - * @return {string} - * @readonly - * @memberof Share + * @return {string} date with YYYY-MM-DD format */ - get passwordExpirationTime() { + get passwordExpirationTime(): string { return this._share.password_expiration_time } /** * Password expiration time - * - * @param {string} password expiration time - * @memberof Share + * @param {string} passwordExpirationTime date with YYYY-MM-DD format */ - set passwordExpirationTime(passwordExpirationTime) { + set passwordExpirationTime(passwordExpirationTime: string) { this._share.password_expiration_time = passwordExpirationTime } /** * Password protection by Talk of the share - * - * @return {boolean} - * @readonly - * @memberof Share */ - get sendPasswordByTalk() { + get sendPasswordByTalk(): boolean { return this._share.send_password_by_talk } /** * Password protection by Talk of the share * - * @param {boolean} sendPasswordByTalk whether to send the password by Talk - * or not - * @memberof Share + * @param {boolean} sendPasswordByTalk whether to send the password by Talk or not */ - set sendPasswordByTalk(sendPasswordByTalk) { + set sendPasswordByTalk(sendPasswordByTalk: boolean) { this._share.send_password_by_talk = sendPasswordByTalk } // SHARED ITEM DATA --------------------------------------------- /** * Get the shared item absolute full path - * - * @return {string} - * @readonly - * @memberof Share */ - get path() { + get path(): string { return this._share.path } /** * Return the item type: file or folder - * - * @return {string} 'folder' or 'file' - * @readonly - * @memberof Share + * @return {string} 'folder' | 'file' */ - get itemType() { + get itemType(): string { return this._share.item_type } /** * Get the shared item mimetype - * - * @return {string} - * @readonly - * @memberof Share */ - get mimetype() { + get mimetype(): string { return this._share.mimetype } /** * Get the shared item id - * - * @return {number} - * @readonly - * @memberof Share */ - get fileSource() { + get fileSource(): number { return this._share.file_source } @@ -469,23 +344,15 @@ export default class Share { * Get the target path on the receiving end * e.g the file /xxx/aaa will be shared in * the receiving root as /aaa, the fileTarget is /aaa - * - * @return {string} - * @readonly - * @memberof Share */ - get fileTarget() { + get fileTarget(): string { return this._share.file_target } /** * Get the parent folder id if any - * - * @return {number} - * @readonly - * @memberof Share */ - get fileParent() { + get fileParent(): number { return this._share.file_parent } @@ -493,93 +360,72 @@ export default class Share { /** * Does this share have READ permissions - * - * @return {boolean} - * @readonly - * @memberof Share */ - get hasReadPermission() { - return !!((this.permissions & OC.PERMISSION_READ)) + get hasReadPermission(): boolean { + return !!((this.permissions & window.OC.PERMISSION_READ)) } /** * Does this share have CREATE permissions - * - * @return {boolean} - * @readonly - * @memberof Share */ - get hasCreatePermission() { - return !!((this.permissions & OC.PERMISSION_CREATE)) + get hasCreatePermission(): boolean { + return !!((this.permissions & window.OC.PERMISSION_CREATE)) } /** * Does this share have DELETE permissions - * - * @return {boolean} - * @readonly - * @memberof Share */ - get hasDeletePermission() { - return !!((this.permissions & OC.PERMISSION_DELETE)) + get hasDeletePermission(): boolean { + return !!((this.permissions & window.OC.PERMISSION_DELETE)) } /** * Does this share have UPDATE permissions - * - * @return {boolean} - * @readonly - * @memberof Share */ - get hasUpdatePermission() { - return !!((this.permissions & OC.PERMISSION_UPDATE)) + get hasUpdatePermission(): boolean { + return !!((this.permissions & window.OC.PERMISSION_UPDATE)) } /** * Does this share have SHARE permissions - * - * @return {boolean} - * @readonly - * @memberof Share */ - get hasSharePermission() { - return !!((this.permissions & OC.PERMISSION_SHARE)) + get hasSharePermission(): boolean { + return !!((this.permissions & window.OC.PERMISSION_SHARE)) } /** * Does this share have download permissions - * - * @return {boolean} - * @readonly - * @memberof Share */ - get hasDownloadPermission() { - for (const i in this._share.attributes) { - const attr = this._share.attributes[i] - if (attr.scope === 'permissions' && attr.key === 'download') { - return attr.enabled - } + get hasDownloadPermission(): boolean { + const hasDisabledDownload = (attribute) => { + return attribute.scope === 'permissions' && attribute.key === 'download' && attribute.value === false } + return this.attributes.some(hasDisabledDownload) + } - return true + /** + * Is this mail share a file request ? + */ + get isFileRequest(): boolean { + return isFileRequest(JSON.stringify(this.attributes)) } set hasDownloadPermission(enabled) { this.setAttribute('permissions', 'download', !!enabled) } - setAttribute(scope, key, enabled) { + setAttribute(scope, key, value) { const attrUpdate = { scope, key, - enabled, + value, } // try and replace existing for (const i in this._share.attributes) { const attr = this._share.attributes[i] if (attr.scope === attrUpdate.scope && attr.key === attrUpdate.key) { - this._share.attributes[i] = attrUpdate + this._share.attributes.splice(i, 1, attrUpdate) return } } @@ -592,45 +438,29 @@ export default class Share { // ! meaning the permissions for the recipient /** * Can the current user EDIT this share ? - * - * @return {boolean} - * @readonly - * @memberof Share */ - get canEdit() { + get canEdit(): boolean { return this._share.can_edit === true } /** * Can the current user DELETE this share ? - * - * @return {boolean} - * @readonly - * @memberof Share */ - get canDelete() { + get canDelete(): boolean { return this._share.can_delete === true } /** * Top level accessible shared folder fileid for the current user - * - * @return {string} - * @readonly - * @memberof Share */ - get viaFileid() { + get viaFileid(): string { return this._share.via_fileid } /** * Top level accessible shared folder path for the current user - * - * @return {string} - * @readonly - * @memberof Share */ - get viaPath() { + get viaPath(): string { return this._share.via_path } @@ -640,15 +470,15 @@ export default class Share { return this._share.parent } - get storageId() { + get storageId(): string { return this._share.storage_id } - get storage() { + get storage(): number { return this._share.storage } - get itemSource() { + get itemSource(): number { return this._share.item_source } @@ -656,4 +486,11 @@ export default class Share { return this._share.status } + /** + * Is the share from a trusted server + */ + get isTrustedServer(): boolean { + return !!this._share.is_trusted_server + } + } diff --git a/apps/files_sharing/src/personal-settings.js b/apps/files_sharing/src/personal-settings.js index afc35dc98dc..e3184f0041e 100644 --- a/apps/files_sharing/src/personal-settings.js +++ b/apps/files_sharing/src/personal-settings.js @@ -1,33 +1,15 @@ /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { getCSPNonce } from '@nextcloud/auth' import Vue from 'vue' -import { getRequestToken } from '@nextcloud/auth' -import PersonalSettings from './components/PersonalSettings' +import PersonalSettings from './components/PersonalSettings.vue' // eslint-disable-next-line camelcase -__webpack_nonce__ = btoa(getRequestToken()) +__webpack_nonce__ = getCSPNonce() Vue.prototype.t = t diff --git a/apps/files_sharing/src/public-nickname-handler.ts b/apps/files_sharing/src/public-nickname-handler.ts new file mode 100644 index 00000000000..02bdc641aaf --- /dev/null +++ b/apps/files_sharing/src/public-nickname-handler.ts @@ -0,0 +1,86 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getBuilder } from '@nextcloud/browser-storage' +import { getGuestNickname, type NextcloudUser } from '@nextcloud/auth' +import { getUploader } from '@nextcloud/upload' +import { loadState } from '@nextcloud/initial-state' +import { showGuestUserPrompt } from '@nextcloud/dialogs' +import { t } from '@nextcloud/l10n' + +import logger from './services/logger' +import { subscribe } from '@nextcloud/event-bus' + +const storage = getBuilder('files_sharing').build() + +// Setup file-request nickname header for the uploader +const registerFileRequestHeader = (nickname: string) => { + const uploader = getUploader() + uploader.setCustomHeader('X-NC-Nickname', encodeURIComponent(nickname)) + logger.debug('Nickname header registered for uploader', { headers: uploader.customHeaders }) +} + +// Callback when a nickname was chosen +const onUserInfoChanged = (guest: NextcloudUser) => { + logger.debug('User info changed', { guest }) + registerFileRequestHeader(guest.displayName ?? '') +} + +// Monitor nickname changes +subscribe('user:info:changed', onUserInfoChanged) + +window.addEventListener('DOMContentLoaded', () => { + const nickname = getGuestNickname() ?? '' + const dialogShown = storage.getItem('public-auth-prompt-shown') !== null + + // Check if a nickname is mandatory + const isFileRequest = loadState('files_sharing', 'isFileRequest', false) + + const owner = loadState('files_sharing', 'owner', '') + const ownerDisplayName = loadState('files_sharing', 'ownerDisplayName', '') + const label = loadState('files_sharing', 'label', '') + const filename = loadState('files_sharing', 'filename', '') + + // If the owner provided a custom label, use it instead of the filename + const folder = label || filename + + const options = { + nickname, + notice: t('files_sharing', 'To upload files to {folder}, you need to provide your name first.', { folder }), + subtitle: undefined as string | undefined, + title: t('files_sharing', 'Upload files to {folder}', { folder }), + } + + // If the guest already has a nickname, we just make them double check + if (nickname) { + options.notice = t('files_sharing', 'Please confirm your name to upload files to {folder}', { folder }) + } + + // If the account owner set their name as public, + // we show it in the subtitle + if (owner) { + options.subtitle = t('files_sharing', '{ownerDisplayName} shared a folder with you.', { ownerDisplayName }) + } + + // If this is a file request, then we need a nickname + if (isFileRequest) { + // If we don't have a nickname or the public auth prompt hasn't been shown yet, show it + // We still show the prompt if the user has a nickname to double check + if (!nickname || !dialogShown) { + logger.debug('Showing public auth prompt.', { nickname }) + showGuestUserPrompt(options) + } + return + } + + if (!dialogShown && !nickname) { + logger.debug('Public auth prompt not shown yet but nickname is not mandatory.', { nickname }) + return + } + + // Else, we just register the nickname header if any. + logger.debug('Public auth prompt already shown.', { nickname }) + registerFileRequestHeader(nickname) +}) diff --git a/apps/files_sharing/src/router/index.ts b/apps/files_sharing/src/router/index.ts new file mode 100644 index 00000000000..fa613dd364f --- /dev/null +++ b/apps/files_sharing/src/router/index.ts @@ -0,0 +1,76 @@ +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { RawLocation, Route } from 'vue-router' + +import { loadState } from '@nextcloud/initial-state' +import { generateUrl } from '@nextcloud/router' +import queryString from 'query-string' +import Router, { isNavigationFailure, NavigationFailureType } from 'vue-router' +import Vue from 'vue' +import logger from '../services/logger' + +const view = loadState<string>('files_sharing', 'view') +const sharingToken = loadState<string>('files_sharing', 'sharingToken') + +Vue.use(Router) + +// Prevent router from throwing errors when we're already on the page we're trying to go to +const originalPush = Router.prototype.push +Router.prototype.push = (function(this: Router, ...args: Parameters<typeof originalPush>) { + if (args.length > 1) { + return originalPush.call(this, ...args) + } + return originalPush.call<Router, [RawLocation], Promise<Route>>(this, args[0]).catch(ignoreDuplicateNavigation) +}) as typeof originalPush + +const originalReplace = Router.prototype.replace +Router.prototype.replace = (function(this: Router, ...args: Parameters<typeof originalReplace>) { + if (args.length > 1) { + return originalReplace.call(this, ...args) + } + return originalReplace.call<Router, [RawLocation], Promise<Route>>(this, args[0]).catch(ignoreDuplicateNavigation) +}) as typeof originalReplace + +/** + * Ignore duplicated-navigation error but forward real exceptions + * @param error The thrown error + */ +function ignoreDuplicateNavigation(error: unknown): void { + if (isNavigationFailure(error, NavigationFailureType.duplicated)) { + logger.debug('Ignoring duplicated navigation from vue-router', { error }) + } else { + throw error + } +} + +const router = new Router({ + mode: 'history', + + // if index.php is in the url AND we got this far, then it's working: + // let's keep using index.php in the url + base: generateUrl('/s'), + linkActiveClass: 'active', + + routes: [ + { + path: '/', + // Pretending we're using the default view + redirect: { name: 'filelist', params: { view, token: sharingToken } }, + }, + { + path: '/:token', + name: 'filelist', + props: true, + }, + ], + + // Custom stringifyQuery to prevent encoding of slashes in the url + stringifyQuery(query) { + const result = queryString.stringify(query).replace(/%2F/gmi, '/') + return result ? ('?' + result) : '' + }, +}) + +export default router diff --git a/apps/files_sharing/src/services/ConfigService.js b/apps/files_sharing/src/services/ConfigService.js deleted file mode 100644 index e3cd6ad8d46..00000000000 --- a/apps/files_sharing/src/services/ConfigService.js +++ /dev/null @@ -1,328 +0,0 @@ -/** - * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -export default class Config { - - /** - * Is public upload allowed on link shares ? - * - * @return {boolean} - * @readonly - * @memberof Config - */ - get isPublicUploadEnabled() { - return document.getElementsByClassName('files-filestable')[0] - && document.getElementsByClassName('files-filestable')[0].dataset.allowPublicUpload === 'yes' - } - - /** - * Are link share allowed ? - * - * @return {boolean} - * @readonly - * @memberof Config - */ - get isShareWithLinkAllowed() { - return document.getElementById('allowShareWithLink') - && document.getElementById('allowShareWithLink').value === 'yes' - } - - /** - * Get the federated sharing documentation link - * - * @return {string} - * @readonly - * @memberof Config - */ - get federatedShareDocLink() { - return OC.appConfig.core.federatedCloudShareDoc - } - - /** - * Get the default link share expiration date - * - * @return {Date|null} - * @readonly - * @memberof Config - */ - get defaultExpirationDate() { - if (this.isDefaultExpireDateEnabled) { - return new Date(new Date().setDate(new Date().getDate() + this.defaultExpireDate)) - } - return null - } - - /** - * Get the default internal expiration date - * - * @return {Date|null} - * @readonly - * @memberof Config - */ - get defaultInternalExpirationDate() { - if (this.isDefaultInternalExpireDateEnabled) { - return new Date(new Date().setDate(new Date().getDate() + this.defaultInternalExpireDate)) - } - return null - } - - /** - * Get the default remote expiration date - * - * @return {Date|null} - * @readonly - * @memberof Config - */ - get defaultRemoteExpirationDateString() { - if (this.isDefaultRemoteExpireDateEnabled) { - return new Date(new Date().setDate(new Date().getDate() + this.defaultRemoteExpireDate)) - } - return null - } - - /** - * Are link shares password-enforced ? - * - * @return {boolean} - * @readonly - * @memberof Config - */ - get enforcePasswordForPublicLink() { - return OC.appConfig.core.enforcePasswordForPublicLink === true - } - - /** - * Is password asked by default on link shares ? - * - * @return {boolean} - * @readonly - * @memberof Config - */ - get enableLinkPasswordByDefault() { - return OC.appConfig.core.enableLinkPasswordByDefault === true - } - - /** - * Is link shares expiration enforced ? - * - * @return {boolean} - * @readonly - * @memberof Config - */ - get isDefaultExpireDateEnforced() { - return OC.appConfig.core.defaultExpireDateEnforced === true - } - - /** - * Is there a default expiration date for new link shares ? - * - * @return {boolean} - * @readonly - * @memberof Config - */ - get isDefaultExpireDateEnabled() { - return OC.appConfig.core.defaultExpireDateEnabled === true - } - - /** - * Is internal shares expiration enforced ? - * - * @return {boolean} - * @readonly - * @memberof Config - */ - get isDefaultInternalExpireDateEnforced() { - return OC.appConfig.core.defaultInternalExpireDateEnforced === true - } - - /** - * Is remote shares expiration enforced ? - * - * @return {boolean} - * @readonly - * @memberof Config - */ - get isDefaultRemoteExpireDateEnforced() { - return OC.appConfig.core.defaultRemoteExpireDateEnforced === true - } - - /** - * Is there a default expiration date for new internal shares ? - * - * @return {boolean} - * @readonly - * @memberof Config - */ - get isDefaultInternalExpireDateEnabled() { - return OC.appConfig.core.defaultInternalExpireDateEnabled === true - } - - /** - * Is there a default expiration date for new remote shares ? - * - * @return {boolean} - * @readonly - * @memberof Config - */ - get isDefaultRemoteExpireDateEnabled() { - return OC.appConfig.core.defaultRemoteExpireDateEnabled === true - } - - /** - * Are users on this server allowed to send shares to other servers ? - * - * @return {boolean} - * @readonly - * @memberof Config - */ - get isRemoteShareAllowed() { - return OC.appConfig.core.remoteShareAllowed === true - } - - /** - * Is sharing my mail (link share) enabled ? - * - * @return {boolean} - * @readonly - * @memberof Config - */ - get isMailShareAllowed() { - const capabilities = OC.getCapabilities() - // eslint-disable-next-line camelcase - return capabilities?.files_sharing?.sharebymail !== undefined - // eslint-disable-next-line camelcase - && capabilities?.files_sharing?.public?.enabled === true - } - - /** - * Get the default days to link shares expiration - * - * @return {number} - * @readonly - * @memberof Config - */ - get defaultExpireDate() { - return OC.appConfig.core.defaultExpireDate - } - - /** - * Get the default days to internal shares expiration - * - * @return {number} - * @readonly - * @memberof Config - */ - get defaultInternalExpireDate() { - return OC.appConfig.core.defaultInternalExpireDate - } - - /** - * Get the default days to remote shares expiration - * - * @return {number} - * @readonly - * @memberof Config - */ - get defaultRemoteExpireDate() { - return OC.appConfig.core.defaultRemoteExpireDate - } - - /** - * Is resharing allowed ? - * - * @return {boolean} - * @readonly - * @memberof Config - */ - get isResharingAllowed() { - return OC.appConfig.core.resharingAllowed === true - } - - /** - * Is password enforced for mail shares ? - * - * @return {boolean} - * @readonly - * @memberof Config - */ - get isPasswordForMailSharesRequired() { - return (OC.getCapabilities().files_sharing.sharebymail === undefined) ? false : OC.getCapabilities().files_sharing.sharebymail.password.enforced - } - - /** - * @return {boolean} - * @readonly - * @memberof Config - */ - get shouldAlwaysShowUnique() { - return (OC.getCapabilities().files_sharing?.sharee?.always_show_unique === true) - } - - /** - * Is sharing with groups allowed ? - * - * @return {boolean} - * @readonly - * @memberof Config - */ - get allowGroupSharing() { - return OC.appConfig.core.allowGroupSharing === true - } - - /** - * Get the maximum results of a share search - * - * @return {number} - * @readonly - * @memberof Config - */ - get maxAutocompleteResults() { - return parseInt(OC.config['sharing.maxAutocompleteResults'], 10) || 25 - } - - /** - * Get the minimal string length - * to initiate a share search - * - * @return {number} - * @readonly - * @memberof Config - */ - get minSearchStringLength() { - return parseInt(OC.config['sharing.minSearchStringLength'], 10) || 0 - } - - /** - * Get the password policy config - * - * @return {object} - * @readonly - * @memberof Config - */ - get passwordPolicy() { - const capabilities = OC.getCapabilities() - return capabilities.password_policy ? capabilities.password_policy : {} - } - -} diff --git a/apps/files_sharing/src/services/ConfigService.ts b/apps/files_sharing/src/services/ConfigService.ts new file mode 100644 index 00000000000..547038f362d --- /dev/null +++ b/apps/files_sharing/src/services/ConfigService.ts @@ -0,0 +1,333 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { getCapabilities } from '@nextcloud/capabilities' +import { loadState } from '@nextcloud/initial-state' + +type PasswordPolicyCapabilities = { + enforceNonCommonPassword: boolean + enforceNumericCharacters: boolean + enforceSpecialCharacters: boolean + enforceUpperLowerCase: boolean + minLength: number +} + +type FileSharingCapabilities = { + api_enabled: boolean, + public: { + enabled: boolean, + password: { + enforced: boolean, + askForOptionalPassword: boolean + }, + expire_date: { + enabled: boolean, + days: number, + enforced: boolean + }, + multiple_links: boolean, + expire_date_internal: { + enabled: boolean + }, + expire_date_remote: { + enabled: boolean + }, + send_mail: boolean, + upload: boolean, + upload_files_drop: boolean, + custom_tokens: boolean, + }, + resharing: boolean, + user: { + send_mail: boolean, + expire_date: { + enabled: boolean + } + }, + group_sharing: boolean, + group: { + enabled: boolean, + expire_date: { + enabled: true + } + }, + default_permissions: number, + federation: { + outgoing: boolean, + incoming: boolean, + expire_date: { + enabled: boolean + }, + expire_date_supported: { + enabled: boolean + } + }, + sharee: { + query_lookup_default: boolean, + always_show_unique: boolean + }, + sharebymail: { + enabled: boolean, + send_password_by_mail: boolean, + upload_files_drop: { + enabled: boolean + }, + password: { + enabled: boolean, + enforced: boolean + }, + expire_date: { + enabled: boolean, + enforced: boolean + } + } +} + +type Capabilities = { + files_sharing: FileSharingCapabilities + password_policy: PasswordPolicyCapabilities +} + +export default class Config { + + _capabilities: Capabilities + + constructor() { + this._capabilities = getCapabilities() as Capabilities + } + + /** + * Get default share permissions, if any + */ + get defaultPermissions(): number { + return this._capabilities.files_sharing?.default_permissions + } + + /** + * Is public upload allowed on link shares ? + * This covers File request and Full upload/edit option. + */ + get isPublicUploadEnabled(): boolean { + return this._capabilities.files_sharing?.public?.upload === true + } + + /** + * Get the federated sharing documentation link + */ + get federatedShareDocLink() { + return window.OC.appConfig.core.federatedCloudShareDoc + } + + /** + * Get the default link share expiration date + */ + get defaultExpirationDate(): Date|null { + if (this.isDefaultExpireDateEnabled && this.defaultExpireDate !== null) { + return new Date(new Date().setDate(new Date().getDate() + this.defaultExpireDate)) + } + return null + } + + /** + * Get the default internal expiration date + */ + get defaultInternalExpirationDate(): Date|null { + if (this.isDefaultInternalExpireDateEnabled && this.defaultInternalExpireDate !== null) { + return new Date(new Date().setDate(new Date().getDate() + this.defaultInternalExpireDate)) + } + return null + } + + /** + * Get the default remote expiration date + */ + get defaultRemoteExpirationDateString(): Date|null { + if (this.isDefaultRemoteExpireDateEnabled && this.defaultRemoteExpireDate !== null) { + return new Date(new Date().setDate(new Date().getDate() + this.defaultRemoteExpireDate)) + } + return null + } + + /** + * Are link shares password-enforced ? + */ + get enforcePasswordForPublicLink(): boolean { + return window.OC.appConfig.core.enforcePasswordForPublicLink === true + } + + /** + * Is password asked by default on link shares ? + */ + get enableLinkPasswordByDefault(): boolean { + return window.OC.appConfig.core.enableLinkPasswordByDefault === true + } + + /** + * Is link shares expiration enforced ? + */ + get isDefaultExpireDateEnforced(): boolean { + return window.OC.appConfig.core.defaultExpireDateEnforced === true + } + + /** + * Is there a default expiration date for new link shares ? + */ + get isDefaultExpireDateEnabled(): boolean { + return window.OC.appConfig.core.defaultExpireDateEnabled === true + } + + /** + * Is internal shares expiration enforced ? + */ + get isDefaultInternalExpireDateEnforced(): boolean { + return window.OC.appConfig.core.defaultInternalExpireDateEnforced === true + } + + /** + * Is there a default expiration date for new internal shares ? + */ + get isDefaultInternalExpireDateEnabled(): boolean { + return window.OC.appConfig.core.defaultInternalExpireDateEnabled === true + } + + /** + * Is remote shares expiration enforced ? + */ + get isDefaultRemoteExpireDateEnforced(): boolean { + return window.OC.appConfig.core.defaultRemoteExpireDateEnforced === true + } + + /** + * Is there a default expiration date for new remote shares ? + */ + get isDefaultRemoteExpireDateEnabled(): boolean { + return window.OC.appConfig.core.defaultRemoteExpireDateEnabled === true + } + + /** + * Are users on this server allowed to send shares to other servers ? + */ + get isRemoteShareAllowed(): boolean { + return window.OC.appConfig.core.remoteShareAllowed === true + } + + /** + * Is federation enabled ? + */ + get isFederationEnabled(): boolean { + return this._capabilities?.files_sharing?.federation?.outgoing === true + } + + /** + * Is public sharing enabled ? + */ + get isPublicShareAllowed(): boolean { + return this._capabilities?.files_sharing?.public?.enabled === true + } + + /** + * Is sharing my mail (link share) enabled ? + */ + get isMailShareAllowed(): boolean { + // eslint-disable-next-line camelcase + return this._capabilities?.files_sharing?.sharebymail?.enabled === true + // eslint-disable-next-line camelcase + && this.isPublicShareAllowed === true + } + + /** + * Get the default days to link shares expiration + */ + get defaultExpireDate(): number|null { + return window.OC.appConfig.core.defaultExpireDate + } + + /** + * Get the default days to internal shares expiration + */ + get defaultInternalExpireDate(): number|null { + return window.OC.appConfig.core.defaultInternalExpireDate + } + + /** + * Get the default days to remote shares expiration + */ + get defaultRemoteExpireDate(): number|null { + return window.OC.appConfig.core.defaultRemoteExpireDate + } + + /** + * Is resharing allowed ? + */ + get isResharingAllowed(): boolean { + return window.OC.appConfig.core.resharingAllowed === true + } + + /** + * Is password enforced for mail shares ? + */ + get isPasswordForMailSharesRequired(): boolean { + return this._capabilities.files_sharing?.sharebymail?.password?.enforced === true + } + + /** + * Always show the email or userid unique sharee label if enabled by the admin + */ + get shouldAlwaysShowUnique(): boolean { + return this._capabilities.files_sharing?.sharee?.always_show_unique === true + } + + /** + * Is sharing with groups allowed ? + */ + get allowGroupSharing(): boolean { + return window.OC.appConfig.core.allowGroupSharing === true + } + + /** + * Get the maximum results of a share search + */ + get maxAutocompleteResults(): number { + return parseInt(window.OC.config['sharing.maxAutocompleteResults'], 10) || 25 + } + + /** + * Get the minimal string length + * to initiate a share search + */ + get minSearchStringLength(): number { + return parseInt(window.OC.config['sharing.minSearchStringLength'], 10) || 0 + } + + /** + * Get the password policy configuration + */ + get passwordPolicy(): PasswordPolicyCapabilities { + return this._capabilities?.password_policy || {} + } + + /** + * Returns true if custom tokens are allowed + */ + get allowCustomTokens(): boolean { + return this._capabilities?.files_sharing?.public?.custom_tokens + } + + /** + * Show federated shares as internal shares + * @return {boolean} + */ + get showFederatedSharesAsInternal(): boolean { + return loadState('files_sharing', 'showFederatedSharesAsInternal', false) + } + + /** + * Show federated shares to trusted servers as internal shares + * @return {boolean} + */ + get showFederatedSharesToTrustedServersAsInternal(): boolean { + return loadState('files_sharing', 'showFederatedSharesToTrustedServersAsInternal', false) + } + +} diff --git a/apps/files_sharing/src/services/ExternalLinkActions.js b/apps/files_sharing/src/services/ExternalLinkActions.js index 06cf97ed255..fe5130fbb49 100644 --- a/apps/files_sharing/src/services/ExternalLinkActions.js +++ b/apps/files_sharing/src/services/ExternalLinkActions.js @@ -1,23 +1,6 @@ /** - * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ export default class ExternalLinkActions { @@ -52,7 +35,7 @@ export default class ExternalLinkActions { * @return {boolean} */ registerAction(action) { - console.warn('OCA.Sharing.ExternalLinkActions is deprecated, use OCA.Sharing.ExternalShareAction instead') + OC.debug && console.warn('OCA.Sharing.ExternalLinkActions is deprecated, use OCA.Sharing.ExternalShareAction instead') if (typeof action === 'object' && action.icon && action.name && action.url) { this._state.actions.push(action) diff --git a/apps/files_sharing/src/services/ExternalShareActions.js b/apps/files_sharing/src/services/ExternalShareActions.js index 6167346699e..6ffd7014fe2 100644 --- a/apps/files_sharing/src/services/ExternalShareActions.js +++ b/apps/files_sharing/src/services/ExternalShareActions.js @@ -1,23 +1,6 @@ /** - * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ export default class ExternalShareActions { @@ -45,12 +28,18 @@ export default class ExternalShareActions { } /** + * @typedef ExternalShareActionData + * @property {import('vue').Component} is Vue component to render, for advanced actions the `async onSave` method of the component will be called when saved + */ + + /** * Register a new option/entry for the a given share type * * @param {object} action new action component to register * @param {string} action.id unique action id - * @param {Function} action.data data to bind the component to + * @param {(data: any) => ExternalShareActionData & Record<string, unknown>} action.data data to bind the component to * @param {Array} action.shareType list of \@nextcloud/sharing.Types.SHARE_XXX to be mounted on + * @param {boolean} action.advanced `true` if the action entry should be rendered within advanced settings * @param {object} action.handlers list of listeners * @return {boolean} */ @@ -59,7 +48,7 @@ export default class ExternalShareActions { if (typeof action !== 'object' || typeof action.id !== 'string' || typeof action.data !== 'function' // () => {disabled: true} - || !Array.isArray(action.shareType) // [\@nextcloud/sharing.Types.SHARE_TYPE_LINK, ...] + || !Array.isArray(action.shareType) // [\@nextcloud/sharing.Types.Link, ...] || typeof action.handlers !== 'object' // {click: () => {}, ...} || !Object.values(action.handlers).every(handler => typeof handler === 'function')) { console.error('Invalid action provided', action) diff --git a/apps/files_sharing/src/services/GuestNameValidity.ts b/apps/files_sharing/src/services/GuestNameValidity.ts new file mode 100644 index 00000000000..0557c5253ca --- /dev/null +++ b/apps/files_sharing/src/services/GuestNameValidity.ts @@ -0,0 +1,45 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { InvalidFilenameError, InvalidFilenameErrorReason, validateFilename } from '@nextcloud/files' +import { t } from '@nextcloud/l10n' + +/** + * Get the validity of a filename (empty if valid). + * This can be used for `setCustomValidity` on input elements + * @param name The filename + * @param escape Escape the matched string in the error (only set when used in HTML) + */ +export function getGuestNameValidity(name: string, escape = false): string { + if (name.trim() === '') { + return t('files', 'Names must not be empty.') + } + + if (name.startsWith('.')) { + return t('files', 'Names must not start with a dot.') + } + + try { + validateFilename(name) + return '' + } catch (error) { + if (!(error instanceof InvalidFilenameError)) { + throw error + } + + switch (error.reason) { + case InvalidFilenameErrorReason.Character: + return t('files', '"{char}" is not allowed inside a name.', { char: error.segment }, undefined, { escape }) + case InvalidFilenameErrorReason.ReservedName: + return t('files', '"{segment}" is a reserved name and not allowed.', { segment: error.segment }, undefined, { escape: false }) + case InvalidFilenameErrorReason.Extension: + if (error.segment.match(/\.[a-z]/i)) { + return t('files', '"{extension}" is not an allowed name.', { extension: error.segment }, undefined, { escape: false }) + } + return t('files', 'Names must not end with "{extension}".', { extension: error.segment }, undefined, { escape: false }) + default: + return t('files', 'Invalid name.') + } + } +} diff --git a/apps/files_sharing/src/services/ShareSearch.js b/apps/files_sharing/src/services/ShareSearch.js index 1a9737cbfba..eff209aad2b 100644 --- a/apps/files_sharing/src/services/ShareSearch.js +++ b/apps/files_sharing/src/services/ShareSearch.js @@ -1,23 +1,6 @@ /** - * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ export default class ShareSearch { diff --git a/apps/files_sharing/src/services/SharingService.spec.ts b/apps/files_sharing/src/services/SharingService.spec.ts new file mode 100644 index 00000000000..936c1afafc4 --- /dev/null +++ b/apps/files_sharing/src/services/SharingService.spec.ts @@ -0,0 +1,516 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { OCSResponse } from '@nextcloud/typings/ocs' + +import { File, Folder } from '@nextcloud/files' +import { ShareType } from '@nextcloud/sharing' +import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest' + +import { getContents } from './SharingService' +import * as auth from '@nextcloud/auth' +import logger from './logger' + +const TAG_FAVORITE = '_$!<Favorite>!$_' + +const axios = vi.hoisted(() => ({ get: vi.fn() })) +vi.mock('@nextcloud/auth') +vi.mock('@nextcloud/axios', () => ({ default: axios })) + +// Mock TAG +beforeAll(() => { + window.OC = { + ...window.OC, + TAG_FAVORITE, + } +}) + +describe('SharingService methods definitions', () => { + beforeEach(() => { + vi.resetAllMocks() + axios.get.mockImplementation(async (): Promise<unknown> => { + return { + data: { + ocs: { + meta: { + status: 'ok', + statuscode: 200, + message: 'OK', + }, + data: [], + }, + } as OCSResponse, + } + }) + }) + + test('Shared with you', async () => { + await getContents(true, false, false, false, []) + + expect(axios.get).toHaveBeenCalledTimes(2) + expect(axios.get).toHaveBeenNthCalledWith(1, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares', { + headers: { + 'Content-Type': 'application/json', + }, + params: { + shared_with_me: true, + include_tags: true, + }, + }) + expect(axios.get).toHaveBeenNthCalledWith(2, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/remote_shares', { + headers: { + 'Content-Type': 'application/json', + }, + params: { + include_tags: true, + }, + }) + }) + + test('Shared with others', async () => { + await getContents(false, true, false, false, []) + + expect(axios.get).toHaveBeenCalledTimes(1) + expect(axios.get).toHaveBeenCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares', { + headers: { + 'Content-Type': 'application/json', + }, + params: { + shared_with_me: false, + include_tags: true, + }, + }) + }) + + test('Pending shares', async () => { + await getContents(false, false, true, false, []) + + expect(axios.get).toHaveBeenCalledTimes(2) + expect(axios.get).toHaveBeenNthCalledWith(1, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares/pending', { + headers: { + 'Content-Type': 'application/json', + }, + params: { + include_tags: true, + }, + }) + expect(axios.get).toHaveBeenNthCalledWith(2, 'http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/remote_shares/pending', { + headers: { + 'Content-Type': 'application/json', + }, + params: { + include_tags: true, + }, + }) + }) + + test('Deleted shares', async () => { + await getContents(false, true, false, false, []) + + expect(axios.get).toHaveBeenCalledTimes(1) + expect(axios.get).toHaveBeenCalledWith('http://nextcloud.local/ocs/v2.php/apps/files_sharing/api/v1/shares', { + headers: { + 'Content-Type': 'application/json', + }, + params: { + shared_with_me: false, + include_tags: true, + }, + }) + }) + + test('Unknown owner', async () => { + vi.spyOn(auth, 'getCurrentUser').mockReturnValue(null) + const results = await getContents(false, true, false, false, []) + + expect(results.folder.owner).toEqual(null) + }) +}) + +describe('SharingService filtering', () => { + beforeEach(() => { + vi.resetAllMocks() + axios.get.mockImplementation(async (): Promise<unknown> => { + return { + data: { + ocs: { + meta: { + status: 'ok', + statuscode: 200, + message: 'OK', + }, + data: [ + { + id: '62', + share_type: ShareType.User, + uid_owner: 'test', + displayname_owner: 'test', + permissions: 31, + stime: 1688666292, + expiration: '2023-07-13 00:00:00', + token: null, + path: '/Collaborators', + item_type: 'folder', + item_permissions: 31, + mimetype: 'httpd/unix-directory', + storage: 224, + item_source: 419413, + file_source: 419413, + file_parent: 419336, + file_target: '/Collaborators', + item_size: 41434, + item_mtime: 1688662980, + }, + ], + }, + }, + } + }) + }) + + test('Shared with others filtering', async () => { + const shares = await getContents(false, true, false, false, [ShareType.User]) + + expect(axios.get).toHaveBeenCalledTimes(1) + expect(shares.contents).toHaveLength(1) + expect(shares.contents[0].fileid).toBe(419413) + expect(shares.contents[0]).toBeInstanceOf(Folder) + }) + + test('Shared with others filtering empty', async () => { + const shares = await getContents(false, true, false, false, [ShareType.Link]) + + expect(axios.get).toHaveBeenCalledTimes(1) + expect(shares.contents).toHaveLength(0) + }) +}) + +describe('SharingService share to Node mapping', () => { + const shareFile = { + id: '66', + share_type: 0, + uid_owner: 'test', + displayname_owner: 'test', + permissions: 19, + can_edit: true, + can_delete: true, + stime: 1688721609, + parent: null, + expiration: '2023-07-14 00:00:00', + token: null, + uid_file_owner: 'test', + note: '', + label: null, + displayname_file_owner: 'test', + path: '/document.md', + item_type: 'file', + item_permissions: 27, + mimetype: 'text/markdown', + has_preview: true, + storage_id: 'home::test', + storage: 224, + item_source: 530936, + file_source: 530936, + file_parent: 419336, + file_target: '/document.md', + item_size: 123, + item_mtime: 1688721600, + share_with: 'user00', + share_with_displayname: 'User00', + share_with_displayname_unique: 'user00@domain.com', + status: { + status: 'away', + message: null, + icon: null, + clearAt: null, + }, + mail_send: 0, + hide_download: 0, + attributes: null, + tags: [], + } + + const shareFolder = { + id: '67', + share_type: 0, + uid_owner: 'test', + displayname_owner: 'test', + permissions: 31, + can_edit: true, + can_delete: true, + stime: 1688721629, + parent: null, + expiration: '2023-07-14 00:00:00', + token: null, + uid_file_owner: 'test', + note: '', + label: null, + displayname_file_owner: 'test', + path: '/Folder', + item_type: 'folder', + item_permissions: 31, + mimetype: 'httpd/unix-directory', + has_preview: false, + storage_id: 'home::test', + storage: 224, + item_source: 531080, + file_source: 531080, + file_parent: 419336, + file_target: '/Folder', + item_size: 0, + item_mtime: 1688721623, + share_with: 'user00', + share_with_displayname: 'User00', + share_with_displayname_unique: 'user00@domain.com', + status: { + status: 'away', + message: null, + icon: null, + clearAt: null, + }, + mail_send: 0, + hide_download: 0, + attributes: null, + tags: [TAG_FAVORITE], + } + + const remoteFileAccepted = { + mimetype: 'text/markdown', + mtime: 1688721600, + permissions: 19, + type: 'file', + file_id: 1234, + id: 4, + share_type: ShareType.User, + parent: null, + remote: 'http://exampe.com', + remote_id: '12345', + share_token: 'share-token', + name: '/test.md', + mountpoint: '/shares/test.md', + owner: 'owner-uid', + user: 'sharee-uid', + accepted: true, + } + + const remoteFilePending = { + mimetype: 'text/markdown', + mtime: 1688721600, + permissions: 19, + type: 'file', + file_id: 1234, + id: 4, + share_type: ShareType.User, + parent: null, + remote: 'http://exampe.com', + remote_id: '12345', + share_token: 'share-token', + name: '/test.md', + mountpoint: '/shares/test.md', + owner: 'owner-uid', + user: 'sharee-uid', + accepted: false, + } + + const tempExternalFile = { + id: 65, + share_type: 0, + parent: -1, + remote: 'http://nextcloud1.local/', + remote_id: '71', + share_token: '9GpiAmTIjayclrE', + name: '/test.md', + owner: 'owner-uid', + user: 'sharee-uid', + mountpoint: '{{TemporaryMountPointName#/test.md}}', + accepted: 0, + } + + beforeEach(() => { vi.resetAllMocks() }) + + test('File', async () => { + axios.get.mockReturnValueOnce(Promise.resolve({ + data: { + ocs: { + data: [shareFile], + }, + }, + })) + + const shares = await getContents(false, true, false, false) + + expect(axios.get).toHaveBeenCalledTimes(1) + expect(shares.contents).toHaveLength(1) + + const file = shares.contents[0] as File + expect(file).toBeInstanceOf(File) + expect(file.fileid).toBe(530936) + expect(file.source).toBe('http://nextcloud.local/remote.php/dav/files/test/document.md') + expect(file.owner).toBe('test') + expect(file.mime).toBe('text/markdown') + expect(file.mtime).toBeInstanceOf(Date) + expect(file.size).toBe(123) + expect(file.permissions).toBe(27) + expect(file.root).toBe('/files/test') + expect(file.attributes).toBeInstanceOf(Object) + expect(file.attributes['has-preview']).toBe(true) + expect(file.attributes.sharees).toEqual({ + sharee: { + id: 'user00', + 'display-name': 'User00', + type: 0, + }, + }) + expect(file.attributes.favorite).toBe(0) + }) + + test('Folder', async () => { + axios.get.mockReturnValueOnce(Promise.resolve({ + data: { + ocs: { + data: [shareFolder], + }, + }, + })) + + const shares = await getContents(false, true, false, false) + + expect(axios.get).toHaveBeenCalledTimes(1) + expect(shares.contents).toHaveLength(1) + + const folder = shares.contents[0] as Folder + expect(folder).toBeInstanceOf(Folder) + expect(folder.fileid).toBe(531080) + expect(folder.source).toBe('http://nextcloud.local/remote.php/dav/files/test/Folder') + expect(folder.owner).toBe('test') + expect(folder.mime).toBe('httpd/unix-directory') + expect(folder.mtime).toBeInstanceOf(Date) + expect(folder.size).toBe(0) + expect(folder.permissions).toBe(31) + expect(folder.root).toBe('/files/test') + expect(folder.attributes).toBeInstanceOf(Object) + expect(folder.attributes['has-preview']).toBe(false) + expect(folder.attributes.previewUrl).toBeUndefined() + expect(folder.attributes.favorite).toBe(1) + }) + + describe('Remote file', () => { + test('Accepted', async () => { + axios.get.mockReturnValueOnce(Promise.resolve({ + data: { + ocs: { + data: [remoteFileAccepted], + }, + }, + })) + + const shares = await getContents(false, true, false, false) + + expect(axios.get).toHaveBeenCalledTimes(1) + expect(shares.contents).toHaveLength(1) + + const file = shares.contents[0] as File + expect(file).toBeInstanceOf(File) + expect(file.fileid).toBe(1234) + expect(file.source).toBe('http://nextcloud.local/remote.php/dav/files/test/shares/test.md') + expect(file.owner).toBe('owner-uid') + expect(file.mime).toBe('text/markdown') + expect(file.mtime?.getTime()).toBe(remoteFileAccepted.mtime * 1000) + // not available for remote shares + expect(file.size).toBe(undefined) + expect(file.permissions).toBe(19) + expect(file.root).toBe('/files/test') + expect(file.attributes).toBeInstanceOf(Object) + expect(file.attributes.favorite).toBe(0) + }) + + test('Pending', async () => { + axios.get.mockReturnValueOnce(Promise.resolve({ + data: { + ocs: { + data: [remoteFilePending], + }, + }, + })) + + const shares = await getContents(false, true, false, false) + + expect(axios.get).toHaveBeenCalledTimes(1) + expect(shares.contents).toHaveLength(1) + + const file = shares.contents[0] as File + expect(file).toBeInstanceOf(File) + expect(file.fileid).toBe(1234) + expect(file.source).toBe('http://nextcloud.local/remote.php/dav/files/test/shares/test.md') + expect(file.owner).toBe('owner-uid') + expect(file.mime).toBe('text/markdown') + expect(file.mtime?.getTime()).toBe(remoteFilePending.mtime * 1000) + // not available for remote shares + expect(file.size).toBe(undefined) + expect(file.permissions).toBe(0) + expect(file.root).toBe('/files/test') + expect(file.attributes).toBeInstanceOf(Object) + expect(file.attributes.favorite).toBe(0) + }) + }) + + test('External temp file', async () => { + axios.get.mockReturnValueOnce(Promise.resolve({ + data: { + ocs: { + data: [tempExternalFile], + }, + }, + })) + + const shares = await getContents(false, true, false, false) + + expect(axios.get).toHaveBeenCalledTimes(1) + expect(shares.contents).toHaveLength(1) + + const file = shares.contents[0] as File + expect(file).toBeInstanceOf(File) + expect(file.fileid).toBe(65) + expect(file.source).toBe('http://nextcloud.local/remote.php/dav/files/test/test.md') + expect(file.owner).toBe('owner-uid') + expect(file.mime).toBe('text/markdown') + expect(file.mtime?.getTime()).toBe(undefined) + // not available for remote shares + expect(file.size).toBe(undefined) + expect(file.permissions).toBe(0) + expect(file.root).toBe('/files/test') + expect(file.attributes).toBeInstanceOf(Object) + expect(file.attributes.favorite).toBe(0) + }) + + test('Empty', async () => { + vi.spyOn(logger, 'error').mockImplementationOnce(() => {}) + axios.get.mockReturnValueOnce(Promise.resolve({ + data: { + ocs: { + data: [], + }, + }, + })) + + const shares = await getContents(false, true, false, false) + expect(shares.contents).toHaveLength(0) + expect(logger.error).toHaveBeenCalledTimes(0) + }) + + test('Error', async () => { + vi.spyOn(logger, 'error').mockImplementationOnce(() => {}) + axios.get.mockReturnValueOnce(Promise.resolve({ + data: { + ocs: { + data: [null], + }, + }, + })) + + const shares = await getContents(false, true, false, false) + expect(shares.contents).toHaveLength(0) + expect(logger.error).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/files_sharing/src/services/SharingService.ts b/apps/files_sharing/src/services/SharingService.ts new file mode 100644 index 00000000000..41c20f9aa73 --- /dev/null +++ b/apps/files_sharing/src/services/SharingService.ts @@ -0,0 +1,244 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +// TODO: Fix this instead of disabling ESLint!!! +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { AxiosPromise } from '@nextcloud/axios' +import type { ContentsWithRoot } from '@nextcloud/files' +import type { OCSResponse } from '@nextcloud/typings/ocs' +import type { ShareAttribute } from '../sharing' + +import { getCurrentUser } from '@nextcloud/auth' +import { Folder, File, Permission, davRemoteURL, davRootPath } from '@nextcloud/files' +import { generateOcsUrl } from '@nextcloud/router' +import axios from '@nextcloud/axios' + +import logger from './logger' + +const headers = { + 'Content-Type': 'application/json', +} + +const ocsEntryToNode = async function(ocsEntry: any): Promise<Folder | File | null> { + try { + // Federated share handling + if (ocsEntry?.remote_id !== undefined) { + if (!ocsEntry.mimetype) { + const mime = (await import('mime')).default + // This won't catch files without an extension, but this is the best we can do + ocsEntry.mimetype = mime.getType(ocsEntry.name) + } + ocsEntry.item_type = ocsEntry.type || (ocsEntry.mimetype ? 'file' : 'folder') + + // different naming for remote shares + ocsEntry.item_mtime = ocsEntry.mtime + ocsEntry.file_target = ocsEntry.file_target || ocsEntry.mountpoint + + if (ocsEntry.file_target.includes('TemporaryMountPointName')) { + ocsEntry.file_target = ocsEntry.name + } + + // If the share is not accepted yet we don't know which permissions it will have + if (!ocsEntry.accepted) { + // Need to set permissions to NONE for federated shares + ocsEntry.item_permissions = Permission.NONE + ocsEntry.permissions = Permission.NONE + } + + ocsEntry.uid_owner = ocsEntry.owner + // TODO: have the real display name stored somewhere + ocsEntry.displayname_owner = ocsEntry.owner + } + + const isFolder = ocsEntry?.item_type === 'folder' + const hasPreview = ocsEntry?.has_preview === true + const Node = isFolder ? Folder : File + + // If this is an external share that is not yet accepted, + // we don't have an id. We can fallback to the row id temporarily + // local shares (this server) use `file_source`, but remote shares (federated) use `file_id` + const fileid = ocsEntry.file_source || ocsEntry.file_id || ocsEntry.id + + // Generate path and strip double slashes + const path = ocsEntry.path || ocsEntry.file_target || ocsEntry.name + const source = `${davRemoteURL}${davRootPath}/${path.replace(/^\/+/, '')}` + + let mtime = ocsEntry.item_mtime ? new Date((ocsEntry.item_mtime) * 1000) : undefined + // Prefer share time if more recent than item mtime + if (ocsEntry?.stime > (ocsEntry?.item_mtime || 0)) { + mtime = new Date((ocsEntry.stime) * 1000) + } + + let sharees: { sharee: object } | undefined + if ('share_with' in ocsEntry) { + sharees = { + sharee: { + id: ocsEntry.share_with, + 'display-name': ocsEntry.share_with_displayname || ocsEntry.share_with, + type: ocsEntry.share_type, + }, + } + } + + return new Node({ + id: fileid, + source, + owner: ocsEntry?.uid_owner, + mime: ocsEntry?.mimetype || 'application/octet-stream', + mtime, + size: ocsEntry?.item_size, + permissions: ocsEntry?.item_permissions || ocsEntry?.permissions, + root: davRootPath, + attributes: { + ...ocsEntry, + 'has-preview': hasPreview, + 'hide-download': ocsEntry?.hide_download === 1, + // Also check the sharingStatusAction.ts code + 'owner-id': ocsEntry?.uid_owner, + 'owner-display-name': ocsEntry?.displayname_owner, + 'share-types': ocsEntry?.share_type, + 'share-attributes': ocsEntry?.attributes || '[]', + sharees, + favorite: ocsEntry?.tags?.includes((window.OC as { TAG_FAVORITE: string }).TAG_FAVORITE) ? 1 : 0, + }, + }) + } catch (error) { + logger.error('Error while parsing OCS entry', { error }) + return null + } +} + +const getShares = function(shareWithMe = false): AxiosPromise<OCSResponse<any>> { + const url = generateOcsUrl('apps/files_sharing/api/v1/shares') + return axios.get(url, { + headers, + params: { + shared_with_me: shareWithMe, + include_tags: true, + }, + }) +} + +const getSharedWithYou = function(): AxiosPromise<OCSResponse<any>> { + return getShares(true) +} + +const getSharedWithOthers = function(): AxiosPromise<OCSResponse<any>> { + return getShares() +} + +const getRemoteShares = function(): AxiosPromise<OCSResponse<any>> { + const url = generateOcsUrl('apps/files_sharing/api/v1/remote_shares') + return axios.get(url, { + headers, + params: { + include_tags: true, + }, + }) +} + +const getPendingShares = function(): AxiosPromise<OCSResponse<any>> { + const url = generateOcsUrl('apps/files_sharing/api/v1/shares/pending') + return axios.get(url, { + headers, + params: { + include_tags: true, + }, + }) +} + +const getRemotePendingShares = function(): AxiosPromise<OCSResponse<any>> { + const url = generateOcsUrl('apps/files_sharing/api/v1/remote_shares/pending') + return axios.get(url, { + headers, + params: { + include_tags: true, + }, + }) +} + +const getDeletedShares = function(): AxiosPromise<OCSResponse<any>> { + const url = generateOcsUrl('apps/files_sharing/api/v1/deletedshares') + return axios.get(url, { + headers, + params: { + include_tags: true, + }, + }) +} + +/** + * Check if a file request is enabled + * @param attributes the share attributes json-encoded array + */ +export const isFileRequest = (attributes = '[]'): boolean => { + const isFileRequest = (attribute) => { + return attribute.scope === 'fileRequest' && attribute.key === 'enabled' && attribute.value === true + } + + try { + const attributesArray = JSON.parse(attributes) as Array<ShareAttribute> + return attributesArray.some(isFileRequest) + } catch (error) { + logger.error('Error while parsing share attributes', { error }) + return false + } +} + +/** + * Group an array of objects (here Nodes) by a key + * and return an array of arrays of them. + * @param nodes Nodes to group + * @param key The attribute to group by + */ +const groupBy = function(nodes: (Folder | File)[], key: string) { + return Object.values(nodes.reduce(function(acc, curr) { + (acc[curr[key]] = acc[curr[key]] || []).push(curr) + return acc + }, {})) as (Folder | File)[][] +} + +export const getContents = async (sharedWithYou = true, sharedWithOthers = true, pendingShares = false, deletedshares = false, filterTypes: number[] = []): Promise<ContentsWithRoot> => { + const promises = [] as AxiosPromise<OCSResponse<any>>[] + + if (sharedWithYou) { + promises.push(getSharedWithYou(), getRemoteShares()) + } + if (sharedWithOthers) { + promises.push(getSharedWithOthers()) + } + if (pendingShares) { + promises.push(getPendingShares(), getRemotePendingShares()) + } + if (deletedshares) { + promises.push(getDeletedShares()) + } + + const responses = await Promise.all(promises) + const data = responses.map((response) => response.data.ocs.data).flat() + let contents = (await Promise.all(data.map(ocsEntryToNode))) + .filter((node) => node !== null) as (Folder | File)[] + + if (filterTypes.length > 0) { + contents = contents.filter((node) => filterTypes.includes(node.attributes?.share_type)) + } + + // Merge duplicate shares and group their attributes + // Also check the sharingStatusAction.ts code + contents = groupBy(contents, 'source').map((nodes) => { + const node = nodes[0] + node.attributes['share-types'] = nodes.map(node => node.attributes['share-types']) + return node + }) + + return { + folder: new Folder({ + id: 0, + source: `${davRemoteURL}${davRootPath}`, + owner: getCurrentUser()?.uid || null, + }), + contents, + } +} diff --git a/apps/files_sharing/src/services/TabSections.js b/apps/files_sharing/src/services/TabSections.js index d266909b6cc..ab1237e7044 100644 --- a/apps/files_sharing/src/services/TabSections.js +++ b/apps/files_sharing/src/services/TabSections.js @@ -1,23 +1,14 @@ /** - * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> - * - * @author Julius Härtl <jus@bitgrid.net> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Callback to render a section in the sharing tab. * + * @callback registerSectionCallback + * @param {undefined} el - Deprecated and will always be undefined (formerly the root element) + * @param {object} fileInfo - File info object */ export default class TabSections { diff --git a/apps/files_sharing/src/services/TokenService.ts b/apps/files_sharing/src/services/TokenService.ts new file mode 100644 index 00000000000..c497531dfdb --- /dev/null +++ b/apps/files_sharing/src/services/TokenService.ts @@ -0,0 +1,20 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import axios from '@nextcloud/axios' +import { generateOcsUrl } from '@nextcloud/router' + +interface TokenData { + ocs: { + data: { + token: string, + } + } +} + +export const generateToken = async (): Promise<string> => { + const { data } = await axios.get<TokenData>(generateOcsUrl('/apps/files_sharing/api/v1/token')) + return data.ocs.data.token +} diff --git a/apps/files_sharing/src/services/logger.ts b/apps/files_sharing/src/services/logger.ts new file mode 100644 index 00000000000..ea582deee91 --- /dev/null +++ b/apps/files_sharing/src/services/logger.ts @@ -0,0 +1,10 @@ +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { getLoggerBuilder } from '@nextcloud/logger' + +export default getLoggerBuilder() + .setApp('files_sharing') + .detectUser() + .build() diff --git a/apps/files_sharing/src/share.js b/apps/files_sharing/src/share.js index be003d51fa4..cdc3c917dfa 100644 --- a/apps/files_sharing/src/share.js +++ b/apps/files_sharing/src/share.js @@ -1,41 +1,13 @@ /** - * Copyright (c) 2014 - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Calviño Sánchez <danxuliu@gmail.com> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Maxence Lange <maxence@nextcloud.com> - * @author Michael Jobst <mjobst+github@tecratech.de> - * @author Michael Jobst <mjobst@necls.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Samuel <faust64@gmail.com> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2011-2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-or-later */ /* eslint-disable */ import escapeHTML from 'escape-html' -import { Type as ShareTypes } from '@nextcloud/sharing' +import { ShareType } from '@nextcloud/sharing' import { getCapabilities } from '@nextcloud/capabilities' (function() { @@ -94,7 +66,7 @@ import { getCapabilities } from '@nextcloud/capabilities' } if (_.isFunction(fileData.canDownload) && !fileData.canDownload()) { delete fileActions.actions.all.Download - if (fileData.permissions & OC.PERMISSION_UPDATE === 0) { + if ((fileData.permissions & OC.PERMISSION_UPDATE) === 0) { // neither move nor copy is allowed, remove the action completely delete fileActions.actions.all.MoveCopy } @@ -183,23 +155,23 @@ import { getCapabilities } from '@nextcloud/capabilities' var hasShares = false _.each(shareTypesStr.split(',') || [], function(shareTypeStr) { let shareType = parseInt(shareTypeStr, 10) - if (shareType === ShareTypes.SHARE_TYPE_LINK) { + if (shareType === ShareType.Link) { hasLink = true - } else if (shareType === ShareTypes.SHARE_TYPE_EMAIL) { + } else if (shareType === ShareType.Email) { hasLink = true - } else if (shareType === ShareTypes.SHARE_TYPE_USER) { + } else if (shareType === ShareType.User) { hasShares = true - } else if (shareType === ShareTypes.SHARE_TYPE_GROUP) { + } else if (shareType === ShareType.Group) { hasShares = true - } else if (shareType === ShareTypes.SHARE_TYPE_REMOTE) { + } else if (shareType === ShareType.Remote) { hasShares = true - } else if (shareType === ShareTypes.SHARE_TYPE_REMOTE_GROUP) { + } else if (shareType === ShareType.RemoteGroup) { hasShares = true - } else if (shareType === ShareTypes.SHARE_TYPE_CIRCLE) { + } else if (shareType === ShareType.Team) { hasShares = true - } else if (shareType === ShareTypes.SHARE_TYPE_ROOM) { + } else if (shareType === ShareType.Room) { hasShares = true - } else if (shareType === ShareTypes.SHARE_TYPE_DECK) { + } else if (shareType === ShareType.Deck) { hasShares = true } }) @@ -230,8 +202,8 @@ import { getCapabilities } from '@nextcloud/capabilities' permissions: OC.PERMISSION_ALL, iconClass: function(fileName, context) { var shareType = parseInt(context.$file.data('share-types'), 10) - if (shareType === ShareTypes.SHARE_TYPE_EMAIL - || shareType === ShareTypes.SHARE_TYPE_LINK) { + if (shareType === ShareType.Email + || shareType === ShareType.Link) { return 'icon-public' } return 'icon-shared' @@ -330,7 +302,11 @@ import { getCapabilities } from '@nextcloud/capabilities' var iconClass = 'icon-shared' action.removeClass('shared-style') // update folder icon - if (type === 'dir' && (hasShares || hasLink || ownerId)) { + var isEncrypted = $tr.attr('data-e2eencrypted') + if (type === 'dir' && isEncrypted === 'true') { + shareFolderIcon = OC.MimeType.getIconUrl('dir-encrypted') + $tr.attr('data-icon', shareFolderIcon) + } else if (type === 'dir' && (hasShares || hasLink || ownerId)) { if (typeof mountType !== 'undefined' && mountType !== 'shared-root' && mountType !== 'shared') { shareFolderIcon = OC.MimeType.getIconUrl('dir-' + mountType) } else if (hasLink) { @@ -341,13 +317,9 @@ import { getCapabilities } from '@nextcloud/capabilities' $tr.find('.filename .thumbnail').css('background-image', 'url(' + shareFolderIcon + ')') $tr.attr('data-icon', shareFolderIcon) } else if (type === 'dir') { - var isEncrypted = $tr.attr('data-e2eencrypted') // FIXME: duplicate of FileList._createRow logic for external folder, // need to refactor the icon logic into a single code path eventually - if (isEncrypted === 'true') { - shareFolderIcon = OC.MimeType.getIconUrl('dir-encrypted') - $tr.attr('data-icon', shareFolderIcon) - } else if (mountType && mountType.indexOf('external') === 0) { + if (mountType && mountType.indexOf('external') === 0) { shareFolderIcon = OC.MimeType.getIconUrl('dir-external') $tr.attr('data-icon', shareFolderIcon) } else { diff --git a/apps/files_sharing/src/sharebreadcrumbview.js b/apps/files_sharing/src/sharebreadcrumbview.js index ef04c9c029d..68ea75d4df9 100644 --- a/apps/files_sharing/src/sharebreadcrumbview.js +++ b/apps/files_sharing/src/sharebreadcrumbview.js @@ -1,28 +1,9 @@ /** - * @copyright 2016 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { Type as ShareTypes } from '@nextcloud/sharing' +import { ShareType } from '@nextcloud/sharing' (function() { 'use strict' @@ -42,7 +23,7 @@ import { Type as ShareTypes } from '@nextcloud/sharing' this.$el.removeClass('shared icon-public icon-shared') if (isShared) { this.$el.addClass('shared') - if (data.dirInfo.shareTypes.indexOf(ShareTypes.SHARE_TYPE_LINK) !== -1) { + if (data.dirInfo.shareTypes.indexOf(ShareType.Link) !== -1) { this.$el.addClass('icon-public') } else { this.$el.addClass('icon-shared') diff --git a/apps/files_sharing/src/sharing.d.ts b/apps/files_sharing/src/sharing.d.ts new file mode 100644 index 00000000000..5c1a211f346 --- /dev/null +++ b/apps/files_sharing/src/sharing.d.ts @@ -0,0 +1,10 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export type ShareAttribute = { + value: boolean|string|number|null|object|Array<unknown> + key: string + scope: string +} diff --git a/apps/files_sharing/src/style/sharebreadcrumb.scss b/apps/files_sharing/src/style/sharebreadcrumb.scss index f3096f45013..6ee05c45306 100644 --- a/apps/files_sharing/src/style/sharebreadcrumb.scss +++ b/apps/files_sharing/src/style/sharebreadcrumb.scss @@ -1,34 +1,17 @@ -/** - * @copyright 2016 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2016 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * +/*! + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -div.crumb span.icon-shared, -div.crumb span.icon-public { +li.crumb span.icon-shared, +li.crumb span.icon-public { display: inline-block; cursor: pointer; opacity: 0.2; - margin-right: 6px; + margin-inline-end: 6px; } -div.crumb span.icon-shared.shared, -div.crumb span.icon-public.shared { +li.crumb span.icon-shared.shared, +li.crumb span.icon-public.shared { opacity: 0.7; } diff --git a/apps/files_sharing/src/utils/AccountIcon.spec.ts b/apps/files_sharing/src/utils/AccountIcon.spec.ts new file mode 100644 index 00000000000..bbc7f031774 --- /dev/null +++ b/apps/files_sharing/src/utils/AccountIcon.spec.ts @@ -0,0 +1,40 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { describe, expect, it, afterEach } from 'vitest' +import { generateAvatarSvg } from './AccountIcon' +describe('AccountIcon', () => { + + afterEach(() => { + delete document.body.dataset.themes + }) + + it('should generate regular account avatar svg', () => { + const svg = generateAvatarSvg('admin') + expect(svg).toContain('/avatar/admin/32') + expect(svg).not.toContain('dark') + expect(svg).toContain('?guestFallback=true') + }) + + it('should generate guest account avatar svg', () => { + const svg = generateAvatarSvg('admin', true) + expect(svg).toContain('/avatar/guest/admin/32') + expect(svg).not.toContain('dark') + expect(svg).not.toContain('?guestFallback=true') + }) + + it('should generate dark mode account avatar svg', () => { + document.body.dataset.themes = 'dark' + const svg = generateAvatarSvg('admin') + expect(svg).toContain('/avatar/admin/32/dark') + expect(svg).toContain('?guestFallback=true') + }) + + it('should generate dark mode guest account avatar svg', () => { + document.body.dataset.themes = 'dark' + const svg = generateAvatarSvg('admin', true) + expect(svg).toContain('/avatar/guest/admin/32/dark') + expect(svg).not.toContain('?guestFallback=true') + }) +}) diff --git a/apps/files_sharing/src/utils/AccountIcon.ts b/apps/files_sharing/src/utils/AccountIcon.ts new file mode 100644 index 00000000000..21732f08f68 --- /dev/null +++ b/apps/files_sharing/src/utils/AccountIcon.ts @@ -0,0 +1,28 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { generateUrl } from '@nextcloud/router' + +const isDarkMode = () => { + return window?.matchMedia?.('(prefers-color-scheme: dark)')?.matches === true + || document.querySelector('[data-themes*=dark]') !== null +} + +export const generateAvatarSvg = (userId: string, isGuest = false) => { + // normal avatar url: /avatar/{userId}/32?guestFallback=true + // dark avatar url: /avatar/{userId}/32/dark?guestFallback=true + // guest avatar url: /avatar/guest/{userId}/32 + // guest dark avatar url: /avatar/guest/{userId}/32/dark + const basePath = isGuest ? `/avatar/guest/${userId}` : `/avatar/${userId}` + const darkModePath = isDarkMode() ? '/dark' : '' + const guestFallback = isGuest ? '' : '?guestFallback=true' + + const url = `${basePath}/32${darkModePath}${guestFallback}` + const avatarUrl = generateUrl(url, { userId }) + + return `<svg width="32" height="32" viewBox="0 0 32 32" + xmlns="http://www.w3.org/2000/svg" class="sharing-status__avatar"> + <image href="${avatarUrl}" height="32" width="32" /> + </svg>` +} diff --git a/apps/files_sharing/src/utils/GeneratePassword.js b/apps/files_sharing/src/utils/GeneratePassword.js deleted file mode 100644 index 63cc68983a1..00000000000 --- a/apps/files_sharing/src/utils/GeneratePassword.js +++ /dev/null @@ -1,61 +0,0 @@ -/** - * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * - */ - -import axios from '@nextcloud/axios' -import Config from '../services/ConfigService' -import { showError, showSuccess } from '@nextcloud/dialogs' - -const config = new Config() -// note: some chars removed on purpose to make them human friendly when read out -const passwordSet = 'abcdefgijkmnopqrstwxyzABCDEFGHJKLMNPQRSTWXYZ23456789' - -/** - * Generate a valid policy password or - * request a valid password if password_policy - * is enabled - * - * @return {string} a valid password - */ -export default async function() { - // password policy is enabled, let's request a pass - if (config.passwordPolicy.api && config.passwordPolicy.api.generate) { - try { - const request = await axios.get(config.passwordPolicy.api.generate) - if (request.data.ocs.data.password) { - showSuccess(t('files_sharing', 'Password created successfully')) - return request.data.ocs.data.password - } - } catch (error) { - console.info('Error generating password from password_policy', error) - showError(t('files_sharing', 'Error generating password from password policy')) - } - } - - const array = new Uint8Array(10) - const ratio = passwordSet.length / 255 - self.crypto.getRandomValues(array) - let password = '' - for (let i = 0; i < array.length; i++) { - password += passwordSet.charAt(array[i] * ratio) - } - return password -} diff --git a/apps/files_sharing/src/utils/GeneratePassword.ts b/apps/files_sharing/src/utils/GeneratePassword.ts new file mode 100644 index 00000000000..82efaaa69d4 --- /dev/null +++ b/apps/files_sharing/src/utils/GeneratePassword.ts @@ -0,0 +1,66 @@ +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import axios from '@nextcloud/axios' +import Config from '../services/ConfigService.ts' +import { showError, showSuccess } from '@nextcloud/dialogs' +import { translate as t } from '@nextcloud/l10n' + +const config = new Config() +// note: some chars removed on purpose to make them human friendly when read out +const passwordSet = 'abcdefgijkmnopqrstwxyzABCDEFGHJKLMNPQRSTWXYZ23456789' + +/** + * Generate a valid policy password or request a valid password if password_policy is enabled + * + * @param {boolean} verbose If enabled the the status is shown to the user via toast + */ +export default async function(verbose = false): Promise<string> { + // password policy is enabled, let's request a pass + if (config.passwordPolicy.api && config.passwordPolicy.api.generate) { + try { + const request = await axios.get(config.passwordPolicy.api.generate) + if (request.data.ocs.data.password) { + if (verbose) { + showSuccess(t('files_sharing', 'Password created successfully')) + } + return request.data.ocs.data.password + } + } catch (error) { + console.info('Error generating password from password_policy', error) + if (verbose) { + showError(t('files_sharing', 'Error generating password from password policy')) + } + } + } + + const array = new Uint8Array(10) + const ratio = passwordSet.length / 255 + getRandomValues(array) + let password = '' + for (let i = 0; i < array.length; i++) { + password += passwordSet.charAt(array[i] * ratio) + } + return password +} + +/** + * Fills the given array with cryptographically secure random values. + * If the crypto API is not available, it falls back to less secure Math.random(). + * Crypto API is available in modern browsers on secure contexts (HTTPS). + * + * @param {Uint8Array} array - The array to fill with random values. + */ +function getRandomValues(array: Uint8Array): void { + if (self?.crypto?.getRandomValues) { + self.crypto.getRandomValues(array) + return + } + + let len = array.length + while (len--) { + array[len] = Math.floor(Math.random() * 256) + } +} diff --git a/apps/files_sharing/src/utils/NodeShareUtils.ts b/apps/files_sharing/src/utils/NodeShareUtils.ts new file mode 100644 index 00000000000..f14f981e2ad --- /dev/null +++ b/apps/files_sharing/src/utils/NodeShareUtils.ts @@ -0,0 +1,58 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getCurrentUser } from '@nextcloud/auth' +import type { Node } from '@nextcloud/files' +import { ShareType } from '@nextcloud/sharing' + +type Share = { + /** The recipient display name */ + 'display-name': string + /** The recipient user id */ + id: string + /** The share type */ + type: ShareType +} + +const getSharesAttribute = function(node: Node) { + return Object.values(node.attributes.sharees).flat() as Share[] +} + +export const isNodeSharedWithMe = function(node: Node) { + const uid = getCurrentUser()?.uid + const shares = getSharesAttribute(node) + + // If you're the owner, you can't share with yourself + if (node.owner === uid) { + return false + } + + return shares.length > 0 && ( + // If some shares are shared with you as a direct user share + shares.some(share => share.id === uid && share.type === ShareType.User) + // Or of the file is shared with a group you're in + // (if it's returned by the backend, we assume you're in it) + || shares.some(share => share.type === ShareType.Group) + ) +} + +export const isNodeSharedWithOthers = function(node: Node) { + const uid = getCurrentUser()?.uid + const shares = getSharesAttribute(node) + + // If you're NOT the owner, you can't share with yourself + if (node.owner === uid) { + return false + } + + return shares.length > 0 + // If some shares are shared with you as a direct user share + && shares.some(share => share.id !== uid && share.type !== ShareType.Group) +} + +export const isNodeShared = function(node: Node) { + const shares = getSharesAttribute(node) + return shares.length > 0 +} diff --git a/apps/files_sharing/src/utils/SharedWithMe.js b/apps/files_sharing/src/utils/SharedWithMe.js index bd39c765221..2f63932bfbe 100644 --- a/apps/files_sharing/src/utils/SharedWithMe.js +++ b/apps/files_sharing/src/utils/SharedWithMe.js @@ -1,30 +1,12 @@ /** - * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - * - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * - * @license AGPL-3.0-or-later - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { Type as ShareTypes } from '@nextcloud/sharing' +import { ShareType } from '@nextcloud/sharing' const shareWithTitle = function(share) { - if (share.type === ShareTypes.SHARE_TYPE_GROUP) { + if (share.type === ShareType.Group) { return t( 'files_sharing', 'Shared with you and the group {group} by {owner}', @@ -33,9 +15,9 @@ const shareWithTitle = function(share) { owner: share.ownerDisplayName, }, undefined, - { escape: false } + { escape: false }, ) - } else if (share.type === ShareTypes.SHARE_TYPE_CIRCLE) { + } else if (share.type === ShareType.Team) { return t( 'files_sharing', 'Shared with you and {circle} by {owner}', @@ -44,9 +26,9 @@ const shareWithTitle = function(share) { owner: share.ownerDisplayName, }, undefined, - { escape: false } + { escape: false }, ) - } else if (share.type === ShareTypes.SHARE_TYPE_ROOM) { + } else if (share.type === ShareType.Room) { if (share.shareWithDisplayName) { return t( 'files_sharing', @@ -56,7 +38,7 @@ const shareWithTitle = function(share) { owner: share.ownerDisplayName, }, undefined, - { escape: false } + { escape: false }, ) } else { return t( @@ -66,7 +48,7 @@ const shareWithTitle = function(share) { owner: share.ownerDisplayName, }, undefined, - { escape: false } + { escape: false }, ) } } else { @@ -75,7 +57,7 @@ const shareWithTitle = function(share) { 'Shared with you by {owner}', { owner: share.ownerDisplayName }, undefined, - { escape: false } + { escape: false }, ) } } diff --git a/apps/files_sharing/src/views/CollaborationView.vue b/apps/files_sharing/src/views/CollaborationView.vue deleted file mode 100644 index a3249f8b5c7..00000000000 --- a/apps/files_sharing/src/views/CollaborationView.vue +++ /dev/null @@ -1,53 +0,0 @@ -<!-- - - @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net> - - - - @author Julius Härtl <jus@bitgrid.net> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> - -<template> - <CollectionList v-if="fileId" - :id="fileId" - type="file" - :name="filename" /> -</template> - -<script> -import { CollectionList } from 'nextcloud-vue-collections' - -export default { - name: 'CollaborationView', - components: { - CollectionList, - }, - computed: { - fileId() { - if (this.$root.model && this.$root.model.id) { - return '' + this.$root.model.id - } - return null - }, - filename() { - if (this.$root.model && this.$root.model.name) { - return '' + this.$root.model.name - } - return '' - }, - }, -} -</script> diff --git a/apps/files_sharing/src/views/FilesHeaderNoteToRecipient.vue b/apps/files_sharing/src/views/FilesHeaderNoteToRecipient.vue new file mode 100644 index 00000000000..ec6348606fb --- /dev/null +++ b/apps/files_sharing/src/views/FilesHeaderNoteToRecipient.vue @@ -0,0 +1,73 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <NcNoteCard v-if="note.length > 0" + class="note-to-recipient" + type="info"> + <p v-if="displayName" class="note-to-recipient__heading"> + {{ t('files_sharing', 'Note from') }} + <NcUserBubble :user="user.id" :display-name="user.displayName" /> + </p> + <p v-else class="note-to-recipient__heading"> + {{ t('files_sharing', 'Note:') }} + </p> + <p class="note-to-recipient__text" v-text="note" /> + </NcNoteCard> +</template> + +<script setup lang="ts"> +import type { Folder } from '@nextcloud/files' +import { getCurrentUser } from '@nextcloud/auth' +import { t } from '@nextcloud/l10n' +import { computed, ref } from 'vue' + +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' +import NcUserBubble from '@nextcloud/vue/components/NcUserBubble' + +const folder = ref<Folder>() +const note = computed<string>(() => folder.value?.attributes.note ?? '') +const displayName = computed<string>(() => folder.value?.attributes['owner-display-name'] ?? '') +const user = computed(() => { + const id = folder.value?.owner + if (id !== getCurrentUser()?.uid) { + return { + id, + displayName: displayName.value, + } + } + return null +}) + +/** + * Update the current folder + * @param newFolder the new folder to show note for + */ +function updateFolder(newFolder: Folder) { + folder.value = newFolder +} + +defineExpose({ updateFolder }) +</script> + +<style scoped> +.note-to-recipient { + margin-inline: var(--row-height) +} + +.note-to-recipient__text { + /* respect new lines */ + white-space: pre-line; +} + +.note-to-recipient__heading { + font-weight: bold; +} + +@media screen and (max-width: 512px) { + .note-to-recipient { + margin-inline: var(--default-grid-baseline); + } +} +</style> diff --git a/apps/files_sharing/src/views/FilesViewFileDropEmptyContent.vue b/apps/files_sharing/src/views/FilesViewFileDropEmptyContent.vue new file mode 100644 index 00000000000..dac22748d8a --- /dev/null +++ b/apps/files_sharing/src/views/FilesViewFileDropEmptyContent.vue @@ -0,0 +1,136 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <NcEmptyContent class="file-drop-empty-content" + data-cy-files-sharing-file-drop + :name="name"> + <template #icon> + <NcIconSvgWrapper :svg="svgCloudUpload" /> + </template> + <template #description> + <p> + {{ shareNote || t('files_sharing', 'Upload files to {foldername}.', { foldername }) }} + </p> + <p v-if="disclaimer"> + {{ t('files_sharing', 'By uploading files, you agree to the terms of service.') }} + </p> + <NcNoteCard v-if="getSortedUploads().length" + class="file-drop-empty-content__note-card" + type="success"> + <h2 id="file-drop-empty-content__heading"> + {{ t('files_sharing', 'Successfully uploaded files') }} + </h2> + <ul aria-labelledby="file-drop-empty-content__heading" class="file-drop-empty-content__list"> + <li v-for="file in getSortedUploads()" :key="file"> + {{ file }} + </li> + </ul> + </NcNoteCard> + </template> + <template #action> + <template v-if="disclaimer"> + <!-- Terms of service if enabled --> + <NcButton type="primary" @click="showDialog = true"> + {{ t('files_sharing', 'View terms of service') }} + </NcButton> + <NcDialog close-on-click-outside + content-classes="terms-of-service-dialog" + :open.sync="showDialog" + :name="t('files_sharing', 'Terms of service')" + :message="disclaimer" /> + </template> + <UploadPicker allow-folders + :content="() => []" + no-menu + :destination="uploadDestination" + multiple /> + </template> + </NcEmptyContent> +</template> + +<script lang="ts"> +/* eslint-disable import/first */ + +// We need this on module level rather than on the instance as view will be refreshed by the files app after uploading +const uploads = new Set<string>() +</script> + +<script setup lang="ts"> +import { loadState } from '@nextcloud/initial-state' +import { translate as t } from '@nextcloud/l10n' +import { getUploader, UploadPicker, UploadStatus } from '@nextcloud/upload' +import { ref } from 'vue' + +import NcButton from '@nextcloud/vue/components/NcButton' +import NcDialog from '@nextcloud/vue/components/NcDialog' +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' +import svgCloudUpload from '@mdi/svg/svg/cloud-upload-outline.svg?raw' + +defineProps<{ + foldername: string +}>() + +const disclaimer = loadState<string>('files_sharing', 'disclaimer', '') +const shareLabel = loadState<string>('files_sharing', 'label', '') +const shareNote = loadState<string>('files_sharing', 'note', '') + +const name = shareLabel || t('files_sharing', 'File drop') + +const showDialog = ref(false) +const uploadDestination = getUploader().destination + +getUploader() + .addNotifier((upload) => { + if (upload.status === UploadStatus.FINISHED && upload.file.name) { + // if a upload is finished and is not a meta upload (name is set) + // then we add the upload to the list of finished uploads to be shown to the user + uploads.add(upload.file.name) + } + }) + +/** + * Get the previous uploads as sorted list + */ +function getSortedUploads() { + return [...uploads].sort((a, b) => a.localeCompare(b)) +} +</script> + +<style scoped lang="scss"> +.file-drop-empty-content { + margin: auto; + max-width: max(50vw, 300px); + + .file-drop-empty-content__note-card { + width: fit-content; + margin-inline: auto; + } + + #file-drop-empty-content__heading { + margin-block: 0 10px; + font-weight: bold; + font-size: 20px; + } + + .file-drop-empty-content__list { + list-style: inside; + max-height: min(350px, 33vh); + overflow-y: scroll; + padding-inline-end: calc(2 * var(--default-grid-baseline)); + } + + :deep(.terms-of-service-dialog) { + min-height: min(100px, 20vh); + } + + /* TODO fix in library */ + :deep(.empty-content__action) { + display: flex; + gap: var(--default-grid-baseline); + } +} +</style> diff --git a/apps/files_sharing/src/views/SharingDetailsTab.vue b/apps/files_sharing/src/views/SharingDetailsTab.vue new file mode 100644 index 00000000000..b3a3b95d92e --- /dev/null +++ b/apps/files_sharing/src/views/SharingDetailsTab.vue @@ -0,0 +1,1310 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <div class="sharingTabDetailsView"> + <div class="sharingTabDetailsView__header"> + <span> + <NcAvatar v-if="isUserShare" + class="sharing-entry__avatar" + :is-no-user="share.shareType !== ShareType.User" + :user="share.shareWith" + :display-name="share.shareWithDisplayName" + :menu-position="'left'" + :url="share.shareWithAvatar" /> + <component :is="getShareTypeIcon(share.type)" :size="32" /> + </span> + <span> + <h1>{{ title }}</h1> + </span> + </div> + <div class="sharingTabDetailsView__wrapper"> + <div ref="quickPermissions" class="sharingTabDetailsView__quick-permissions"> + <div> + <NcCheckboxRadioSwitch :button-variant="true" + data-cy-files-sharing-share-permissions-bundle="read-only" + :checked.sync="sharingPermission" + :value="bundledPermissions.READ_ONLY.toString()" + name="sharing_permission_radio" + type="radio" + button-variant-grouped="vertical" + @update:checked="toggleCustomPermissions"> + {{ t('files_sharing', 'View only') }} + <template #icon> + <ViewIcon :size="20" /> + </template> + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch :button-variant="true" + data-cy-files-sharing-share-permissions-bundle="upload-edit" + :checked.sync="sharingPermission" + :value="allPermissions" + name="sharing_permission_radio" + type="radio" + button-variant-grouped="vertical" + @update:checked="toggleCustomPermissions"> + <template v-if="allowsFileDrop"> + {{ t('files_sharing', 'Allow upload and editing') }} + </template> + <template v-else> + {{ t('files_sharing', 'Allow editing') }} + </template> + <template #icon> + <EditIcon :size="20" /> + </template> + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch v-if="allowsFileDrop" + data-cy-files-sharing-share-permissions-bundle="file-drop" + :button-variant="true" + :checked.sync="sharingPermission" + :value="bundledPermissions.FILE_DROP.toString()" + name="sharing_permission_radio" + type="radio" + button-variant-grouped="vertical" + @update:checked="toggleCustomPermissions"> + {{ t('files_sharing', 'File request') }} + <small class="subline">{{ t('files_sharing', 'Upload only') }}</small> + <template #icon> + <UploadIcon :size="20" /> + </template> + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch :button-variant="true" + data-cy-files-sharing-share-permissions-bundle="custom" + :checked.sync="sharingPermission" + :value="'custom'" + name="sharing_permission_radio" + type="radio" + button-variant-grouped="vertical" + @update:checked="expandCustomPermissions"> + {{ t('files_sharing', 'Custom permissions') }} + <small class="subline">{{ customPermissionsList }}</small> + <template #icon> + <DotsHorizontalIcon :size="20" /> + </template> + </NcCheckboxRadioSwitch> + </div> + </div> + <div class="sharingTabDetailsView__advanced-control"> + <NcButton id="advancedSectionAccordionAdvancedControl" + type="tertiary" + alignment="end-reverse" + aria-controls="advancedSectionAccordionAdvanced" + :aria-expanded="advancedControlExpandedValue" + @click="advancedSectionAccordionExpanded = !advancedSectionAccordionExpanded"> + {{ t('files_sharing', 'Advanced settings') }} + <template #icon> + <MenuDownIcon v-if="!advancedSectionAccordionExpanded" /> + <MenuUpIcon v-else /> + </template> + </NcButton> + </div> + <div v-if="advancedSectionAccordionExpanded" + id="advancedSectionAccordionAdvanced" + class="sharingTabDetailsView__advanced" + aria-labelledby="advancedSectionAccordionAdvancedControl" + role="region"> + <section> + <NcInputField v-if="isPublicShare" + class="sharingTabDetailsView__label" + autocomplete="off" + :label="t('files_sharing', 'Share label')" + :value.sync="share.label" /> + <NcInputField v-if="config.allowCustomTokens && isPublicShare && !isNewShare" + autocomplete="off" + :label="t('files_sharing', 'Share link token')" + :helper-text="t('files_sharing', 'Set the public share link token to something easy to remember or generate a new token. It is not recommended to use a guessable token for shares which contain sensitive information.')" + show-trailing-button + :trailing-button-label="loadingToken ? t('files_sharing', 'Generating…') : t('files_sharing', 'Generate new token')" + :value.sync="share.token" + @trailing-button-click="generateNewToken"> + <template #trailing-button-icon> + <NcLoadingIcon v-if="loadingToken" /> + <Refresh v-else :size="20" /> + </template> + </NcInputField> + <template v-if="isPublicShare"> + <NcCheckboxRadioSwitch :checked.sync="isPasswordProtected" :disabled="isPasswordEnforced"> + {{ t('files_sharing', 'Set password') }} + </NcCheckboxRadioSwitch> + <NcPasswordField v-if="isPasswordProtected" + autocomplete="new-password" + :value="share.newPassword ?? ''" + :error="passwordError" + :helper-text="errorPasswordLabel || passwordHint" + :required="isPasswordEnforced && isNewShare" + :label="t('files_sharing', 'Password')" + @update:value="onPasswordChange" /> + + <!-- Migrate icons and remote -> icon="icon-info"--> + <span v-if="isEmailShareType && passwordExpirationTime" icon="icon-info"> + {{ t('files_sharing', 'Password expires {passwordExpirationTime}', { passwordExpirationTime }) }} + </span> + <span v-else-if="isEmailShareType && passwordExpirationTime !== null" icon="icon-error"> + {{ t('files_sharing', 'Password expired') }} + </span> + </template> + <NcCheckboxRadioSwitch v-if="canTogglePasswordProtectedByTalkAvailable" + :checked.sync="isPasswordProtectedByTalk" + @update:checked="onPasswordProtectedByTalkChange"> + {{ t('files_sharing', 'Video verification') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch :checked.sync="hasExpirationDate" :disabled="isExpiryDateEnforced"> + {{ isExpiryDateEnforced + ? t('files_sharing', 'Expiration date (enforced)') + : t('files_sharing', 'Set expiration date') }} + </NcCheckboxRadioSwitch> + <NcDateTimePickerNative v-if="hasExpirationDate" + id="share-date-picker" + :value="new Date(share.expireDate ?? dateTomorrow)" + :min="dateTomorrow" + :max="maxExpirationDateEnforced" + hide-label + :label="t('files_sharing', 'Expiration date')" + :placeholder="t('files_sharing', 'Expiration date')" + type="date" + @input="onExpirationChange" /> + <NcCheckboxRadioSwitch v-if="isPublicShare" + :disabled="canChangeHideDownload" + :checked.sync="share.hideDownload" + @update:checked="queueUpdate('hideDownload')"> + {{ t('files_sharing', 'Hide download') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch v-else + :disabled="!canSetDownload" + :checked.sync="canDownload" + data-cy-files-sharing-share-permissions-checkbox="download"> + {{ t('files_sharing', 'Allow download and sync') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch :checked.sync="writeNoteToRecipientIsChecked"> + {{ t('files_sharing', 'Note to recipient') }} + </NcCheckboxRadioSwitch> + <template v-if="writeNoteToRecipientIsChecked"> + <NcTextArea :label="t('files_sharing', 'Note to recipient')" + :placeholder="t('files_sharing', 'Enter a note for the share recipient')" + :value.sync="share.note" /> + </template> + <NcCheckboxRadioSwitch v-if="isPublicShare && isFolder" + :checked.sync="showInGridView"> + {{ t('files_sharing', 'Show files in grid view') }} + </NcCheckboxRadioSwitch> + <ExternalShareAction v-for="action in externalLinkActions" + :id="action.id" + ref="externalLinkActions" + :key="action.id" + :action="action" + :file-info="fileInfo" + :share="share" /> + <NcCheckboxRadioSwitch :checked.sync="setCustomPermissions"> + {{ t('files_sharing', 'Custom permissions') }} + </NcCheckboxRadioSwitch> + <section v-if="setCustomPermissions" class="custom-permissions-group"> + <NcCheckboxRadioSwitch :disabled="!canRemoveReadPermission" + :checked.sync="hasRead" + data-cy-files-sharing-share-permissions-checkbox="read"> + {{ t('files_sharing', 'Read') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch v-if="isFolder" + :disabled="!canSetCreate" + :checked.sync="canCreate" + data-cy-files-sharing-share-permissions-checkbox="create"> + {{ t('files_sharing', 'Create') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch :disabled="!canSetEdit" + :checked.sync="canEdit" + data-cy-files-sharing-share-permissions-checkbox="update"> + {{ t('files_sharing', 'Edit') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch v-if="resharingIsPossible" + :disabled="!canSetReshare" + :checked.sync="canReshare" + data-cy-files-sharing-share-permissions-checkbox="share"> + {{ t('files_sharing', 'Share') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch :disabled="!canSetDelete" + :checked.sync="canDelete" + data-cy-files-sharing-share-permissions-checkbox="delete"> + {{ t('files_sharing', 'Delete') }} + </NcCheckboxRadioSwitch> + </section> + </section> + </div> + </div> + + <div class="sharingTabDetailsView__footer"> + <div class="button-group"> + <NcButton data-cy-files-sharing-share-editor-action="cancel" + @click="cancel"> + {{ t('files_sharing', 'Cancel') }} + </NcButton> + <div class="sharingTabDetailsView__delete"> + <NcButton v-if="!isNewShare" + :aria-label="t('files_sharing', 'Delete share')" + :disabled="false" + :readonly="false" + variant="tertiary" + @click.prevent="removeShare"> + <template #icon> + <CloseIcon :size="20" /> + </template> + {{ t('files_sharing', 'Delete share') }} + </NcButton> + </div> + <NcButton type="primary" + data-cy-files-sharing-share-editor-action="save" + :disabled="creating" + @click="saveShare"> + {{ shareButtonText }} + <template v-if="creating" #icon> + <NcLoadingIcon /> + </template> + </NcButton> + </div> + </div> + </div> +</template> + +<script> +import { emit } from '@nextcloud/event-bus' +import { getLanguage } from '@nextcloud/l10n' +import { ShareType } from '@nextcloud/sharing' +import { showError } from '@nextcloud/dialogs' +import moment from '@nextcloud/moment' + +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' +import NcDateTimePickerNative from '@nextcloud/vue/components/NcDateTimePickerNative' +import NcInputField from '@nextcloud/vue/components/NcInputField' +import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' +import NcPasswordField from '@nextcloud/vue/components/NcPasswordField' +import NcTextArea from '@nextcloud/vue/components/NcTextArea' + +import CircleIcon from 'vue-material-design-icons/CircleOutline.vue' +import CloseIcon from 'vue-material-design-icons/Close.vue' +import EditIcon from 'vue-material-design-icons/PencilOutline.vue' +import EmailIcon from 'vue-material-design-icons/Email.vue' +import LinkIcon from 'vue-material-design-icons/Link.vue' +import GroupIcon from 'vue-material-design-icons/AccountGroup.vue' +import ShareIcon from 'vue-material-design-icons/ShareCircle.vue' +import UserIcon from 'vue-material-design-icons/AccountCircleOutline.vue' +import ViewIcon from 'vue-material-design-icons/Eye.vue' +import UploadIcon from 'vue-material-design-icons/Upload.vue' +import MenuDownIcon from 'vue-material-design-icons/MenuDown.vue' +import MenuUpIcon from 'vue-material-design-icons/MenuUp.vue' +import DotsHorizontalIcon from 'vue-material-design-icons/DotsHorizontal.vue' +import Refresh from 'vue-material-design-icons/Refresh.vue' + +import ExternalShareAction from '../components/ExternalShareAction.vue' + +import GeneratePassword from '../utils/GeneratePassword.ts' +import Share from '../models/Share.ts' +import ShareRequests from '../mixins/ShareRequests.js' +import SharesMixin from '../mixins/SharesMixin.js' +import { generateToken } from '../services/TokenService.ts' +import logger from '../services/logger.ts' + +import { + ATOMIC_PERMISSIONS, + BUNDLED_PERMISSIONS, + hasPermissions, +} from '../lib/SharePermissionsToolBox.js' + +export default { + name: 'SharingDetailsTab', + components: { + NcAvatar, + NcButton, + NcCheckboxRadioSwitch, + NcDateTimePickerNative, + NcInputField, + NcLoadingIcon, + NcPasswordField, + NcTextArea, + CloseIcon, + CircleIcon, + EditIcon, + ExternalShareAction, + LinkIcon, + GroupIcon, + ShareIcon, + UserIcon, + UploadIcon, + ViewIcon, + MenuDownIcon, + MenuUpIcon, + DotsHorizontalIcon, + Refresh, + }, + mixins: [ShareRequests, SharesMixin], + props: { + shareRequestValue: { + type: Object, + required: false, + }, + fileInfo: { + type: Object, + required: true, + }, + share: { + type: Object, + required: true, + }, + }, + data() { + return { + writeNoteToRecipientIsChecked: false, + sharingPermission: BUNDLED_PERMISSIONS.ALL.toString(), + revertSharingPermission: BUNDLED_PERMISSIONS.ALL.toString(), + setCustomPermissions: false, + passwordError: false, + advancedSectionAccordionExpanded: false, + bundledPermissions: BUNDLED_PERMISSIONS, + isFirstComponentLoad: true, + test: false, + creating: false, + initialToken: this.share.token, + loadingToken: false, + + ExternalShareActions: OCA.Sharing.ExternalShareActions.state, + } + }, + + computed: { + title() { + switch (this.share.type) { + case ShareType.User: + return t('files_sharing', 'Share with {user}', { user: this.share.shareWithDisplayName }) + case ShareType.Email: + return t('files_sharing', 'Share with email {email}', { email: this.share.shareWith }) + case ShareType.Link: + return t('files_sharing', 'Share link') + case ShareType.Group: + return t('files_sharing', 'Share with group') + case ShareType.Room: + return t('files_sharing', 'Share in conversation') + case ShareType.Remote: { + const [user, server] = this.share.shareWith.split('@') + if (this.config.showFederatedSharesAsInternal) { + return t('files_sharing', 'Share with {user}', { user }) + } + return t('files_sharing', 'Share with {user} on remote server {server}', { user, server }) + } + case ShareType.RemoteGroup: + return t('files_sharing', 'Share with remote group') + case ShareType.Guest: + return t('files_sharing', 'Share with guest') + default: { + if (this.share.id) { + // Share already exists + return t('files_sharing', 'Update share') + } else { + return t('files_sharing', 'Create share') + } + } + } + }, + allPermissions() { + return this.isFolder ? this.bundledPermissions.ALL.toString() : this.bundledPermissions.ALL_FILE.toString() + }, + /** + * Can the sharee edit the shared file ? + */ + canEdit: { + get() { + return this.share.hasUpdatePermission + }, + set(checked) { + this.updateAtomicPermissions({ isEditChecked: checked }) + }, + }, + /** + * Can the sharee create the shared file ? + */ + canCreate: { + get() { + return this.share.hasCreatePermission + }, + set(checked) { + this.updateAtomicPermissions({ isCreateChecked: checked }) + }, + }, + /** + * Can the sharee delete the shared file ? + */ + canDelete: { + get() { + return this.share.hasDeletePermission + }, + set(checked) { + this.updateAtomicPermissions({ isDeleteChecked: checked }) + }, + }, + /** + * Can the sharee reshare the file ? + */ + canReshare: { + get() { + return this.share.hasSharePermission + }, + set(checked) { + this.updateAtomicPermissions({ isReshareChecked: checked }) + }, + }, + + /** + * Change the default view for public shares from "list" to "grid" + */ + showInGridView: { + get() { + return this.getShareAttribute('config', 'grid_view', false) + }, + /** @param {boolean} value If the default view should be changed to "grid" */ + set(value) { + this.setShareAttribute('config', 'grid_view', value) + }, + }, + + /** + * Can the sharee download files or only view them ? + */ + canDownload: { + get() { + return this.getShareAttribute('permissions', 'download', true) + }, + set(checked) { + this.setShareAttribute('permissions', 'download', checked) + }, + }, + /** + * Is this share readable + * Needed for some federated shares that might have been added from file requests links + */ + hasRead: { + get() { + return this.share.hasReadPermission + }, + set(checked) { + this.updateAtomicPermissions({ isReadChecked: checked }) + }, + }, + /** + * Does the current share have an expiration date + * + * @return {boolean} + */ + hasExpirationDate: { + get() { + return this.isValidShareAttribute(this.share.expireDate) + }, + set(enabled) { + this.share.expireDate = enabled + ? this.formatDateToString(this.defaultExpiryDate) + : '' + }, + }, + /** + * Is the current share a folder ? + * + * @return {boolean} + */ + isFolder() { + return this.fileInfo.type === 'dir' + }, + /** + * @return {boolean} + */ + isSetDownloadButtonVisible() { + const allowedMimetypes = [ + // Office documents + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.oasis.opendocument.text', + 'application/vnd.oasis.opendocument.spreadsheet', + 'application/vnd.oasis.opendocument.presentation', + ] + + return this.isFolder || allowedMimetypes.includes(this.fileInfo.mimetype) + }, + isPasswordEnforced() { + return this.isPublicShare && this.config.enforcePasswordForPublicLink + }, + defaultExpiryDate() { + if ((this.isGroupShare || this.isUserShare) && this.config.isDefaultInternalExpireDateEnabled) { + return new Date(this.config.defaultInternalExpirationDate) + } else if (this.isRemoteShare && this.config.isDefaultRemoteExpireDateEnabled) { + return new Date(this.config.defaultRemoteExpireDateEnabled) + } else if (this.isPublicShare && this.config.isDefaultExpireDateEnabled) { + return new Date(this.config.defaultExpirationDate) + } + return new Date(new Date().setDate(new Date().getDate() + 1)) + }, + isUserShare() { + return this.share.type === ShareType.User + }, + isGroupShare() { + return this.share.type === ShareType.Group + }, + allowsFileDrop() { + if (this.isFolder && this.config.isPublicUploadEnabled) { + if (this.share.type === ShareType.Link || this.share.type === ShareType.Email) { + return true + } + } + return false + }, + hasFileDropPermissions() { + return this.share.permissions === this.bundledPermissions.FILE_DROP + }, + shareButtonText() { + if (this.isNewShare) { + return t('files_sharing', 'Save share') + } + return t('files_sharing', 'Update share') + + }, + resharingIsPossible() { + return this.config.isResharingAllowed && this.share.type !== ShareType.Link && this.share.type !== ShareType.Email + }, + /** + * Can the sharer set whether the sharee can edit the file ? + * + * @return {boolean} + */ + canSetEdit() { + // If the owner revoked the permission after the resharer granted it + // the share still has the permission, and the resharer is still + // allowed to revoke it too (but not to grant it again). + return (this.fileInfo.sharePermissions & OC.PERMISSION_UPDATE) || this.canEdit + }, + + /** + * Can the sharer set whether the sharee can create the file ? + * + * @return {boolean} + */ + canSetCreate() { + // If the owner revoked the permission after the resharer granted it + // the share still has the permission, and the resharer is still + // allowed to revoke it too (but not to grant it again). + return (this.fileInfo.sharePermissions & OC.PERMISSION_CREATE) || this.canCreate + }, + + /** + * Can the sharer set whether the sharee can delete the file ? + * + * @return {boolean} + */ + canSetDelete() { + // If the owner revoked the permission after the resharer granted it + // the share still has the permission, and the resharer is still + // allowed to revoke it too (but not to grant it again). + return (this.fileInfo.sharePermissions & OC.PERMISSION_DELETE) || this.canDelete + }, + /** + * Can the sharer set whether the sharee can reshare the file ? + * + * @return {boolean} + */ + canSetReshare() { + // If the owner revoked the permission after the resharer granted it + // the share still has the permission, and the resharer is still + // allowed to revoke it too (but not to grant it again). + return (this.fileInfo.sharePermissions & OC.PERMISSION_SHARE) || this.canReshare + }, + /** + * Can the sharer set whether the sharee can download the file ? + * + * @return {boolean} + */ + canSetDownload() { + // If the owner revoked the permission after the resharer granted it + // the share still has the permission, and the resharer is still + // allowed to revoke it too (but not to grant it again). + return (this.fileInfo.canDownload() || this.canDownload) + }, + canRemoveReadPermission() { + return this.allowsFileDrop && ( + this.share.type === ShareType.Link + || this.share.type === ShareType.Email + ) + }, + // if newPassword exists, but is empty, it means + // the user deleted the original password + hasUnsavedPassword() { + return this.share.newPassword !== undefined + }, + passwordExpirationTime() { + if (!this.isValidShareAttribute(this.share.passwordExpirationTime)) { + return null + } + + const expirationTime = moment(this.share.passwordExpirationTime) + + if (expirationTime.diff(moment()) < 0) { + return false + } + + return expirationTime.fromNow() + }, + + /** + * Is Talk enabled? + * + * @return {boolean} + */ + isTalkEnabled() { + return OC.appswebroots.spreed !== undefined + }, + + /** + * Is it possible to protect the password by Talk? + * + * @return {boolean} + */ + isPasswordProtectedByTalkAvailable() { + return this.isPasswordProtected && this.isTalkEnabled + }, + /** + * Is the current share password protected by Talk? + * + * @return {boolean} + */ + isPasswordProtectedByTalk: { + get() { + return this.share.sendPasswordByTalk + }, + async set(enabled) { + this.share.sendPasswordByTalk = enabled + }, + }, + /** + * Is the current share an email share ? + * + * @return {boolean} + */ + isEmailShareType() { + return this.share + ? this.share.type === ShareType.Email + : false + }, + canTogglePasswordProtectedByTalkAvailable() { + if (!this.isPublicShare || !this.isPasswordProtected) { + // Makes no sense + return false + } else if (this.isEmailShareType && !this.hasUnsavedPassword) { + // For email shares we need a new password in order to enable or + // disable + return false + } + + // Is Talk enabled? + return OC.appswebroots.spreed !== undefined + }, + canChangeHideDownload() { + const hasDisabledDownload = (shareAttribute) => shareAttribute.key === 'download' && shareAttribute.scope === 'permissions' && shareAttribute.value === false + return this.fileInfo.shareAttributes.some(hasDisabledDownload) + }, + customPermissionsList() { + // Key order will be different, because ATOMIC_PERMISSIONS are numbers + const translatedPermissions = { + [ATOMIC_PERMISSIONS.READ]: this.t('files_sharing', 'Read'), + [ATOMIC_PERMISSIONS.CREATE]: this.t('files_sharing', 'Create'), + [ATOMIC_PERMISSIONS.UPDATE]: this.t('files_sharing', 'Edit'), + [ATOMIC_PERMISSIONS.SHARE]: this.t('files_sharing', 'Share'), + [ATOMIC_PERMISSIONS.DELETE]: this.t('files_sharing', 'Delete'), + } + + const permissionsList = [ + ATOMIC_PERMISSIONS.READ, + ...(this.isFolder ? [ATOMIC_PERMISSIONS.CREATE] : []), + ATOMIC_PERMISSIONS.UPDATE, + ...(this.resharingIsPossible ? [ATOMIC_PERMISSIONS.SHARE] : []), + ...(this.isFolder ? [ATOMIC_PERMISSIONS.DELETE] : []), + ] + + return permissionsList.filter((permission) => hasPermissions(this.share.permissions, permission)) + .map((permission, index) => index === 0 + ? translatedPermissions[permission] + : translatedPermissions[permission].toLocaleLowerCase(getLanguage())) + .join(', ') + }, + advancedControlExpandedValue() { + return this.advancedSectionAccordionExpanded ? 'true' : 'false' + }, + errorPasswordLabel() { + if (this.passwordError) { + return t('files_sharing', 'Password field cannot be empty') + } + return undefined + }, + + passwordHint() { + if (this.isNewShare || this.hasUnsavedPassword) { + return undefined + } + return t('files_sharing', 'Replace current password') + }, + + /** + * Additional actions for the menu + * + * @return {Array} + */ + externalLinkActions() { + const filterValidAction = (action) => (action.shareType.includes(ShareType.Link) || action.shareType.includes(ShareType.Email)) && action.advanced + // filter only the advanced registered actions for said link + return this.ExternalShareActions.actions + .filter(filterValidAction) + }, + }, + watch: { + setCustomPermissions(isChecked) { + if (isChecked) { + this.sharingPermission = 'custom' + } else { + this.sharingPermission = this.revertSharingPermission + } + }, + }, + beforeMount() { + this.initializePermissions() + this.initializeAttributes() + logger.debug('Share object received', { share: this.share }) + logger.debug('Configuration object received', { config: this.config }) + }, + + mounted() { + this.$refs.quickPermissions?.querySelector('input:checked')?.focus() + }, + + methods: { + /** + * Set a share attribute on the current share + * @param {string} scope The attribute scope + * @param {string} key The attribute key + * @param {boolean} value The value + */ + setShareAttribute(scope, key, value) { + if (!this.share.attributes) { + this.$set(this.share, 'attributes', []) + } + + const attribute = this.share.attributes + .find((attr) => attr.scope === scope || attr.key === key) + + if (attribute) { + attribute.value = value + } else { + this.share.attributes.push({ + scope, + key, + value, + }) + } + }, + + /** + * Get the value of a share attribute + * @param {string} scope The attribute scope + * @param {string} key The attribute key + * @param {undefined|boolean} fallback The fallback to return if not found + */ + getShareAttribute(scope, key, fallback = undefined) { + const attribute = this.share.attributes?.find((attr) => attr.scope === scope && attr.key === key) + return attribute?.value ?? fallback + }, + + async generateNewToken() { + if (this.loadingToken) { + return + } + this.loadingToken = true + try { + this.share.token = await generateToken() + } catch (error) { + showError(t('files_sharing', 'Failed to generate a new token')) + } + this.loadingToken = false + }, + + cancel() { + this.share.token = this.initialToken + this.$emit('close-sharing-details') + }, + + updateAtomicPermissions({ + isReadChecked = this.hasRead, + isEditChecked = this.canEdit, + isCreateChecked = this.canCreate, + isDeleteChecked = this.canDelete, + isReshareChecked = this.canReshare, + } = {}) { + // calc permissions if checked + + if (!this.isFolder && (isCreateChecked || isDeleteChecked)) { + logger.debug('Ignoring create/delete permissions for file share — only available for folders') + isCreateChecked = false + isDeleteChecked = false + } + + const permissions = 0 + | (isReadChecked ? ATOMIC_PERMISSIONS.READ : 0) + | (isCreateChecked ? ATOMIC_PERMISSIONS.CREATE : 0) + | (isDeleteChecked ? ATOMIC_PERMISSIONS.DELETE : 0) + | (isEditChecked ? ATOMIC_PERMISSIONS.UPDATE : 0) + | (isReshareChecked ? ATOMIC_PERMISSIONS.SHARE : 0) + this.share.permissions = permissions + }, + expandCustomPermissions() { + if (!this.advancedSectionAccordionExpanded) { + this.advancedSectionAccordionExpanded = true + } + this.toggleCustomPermissions() + }, + toggleCustomPermissions(selectedPermission) { + const isCustomPermissions = this.sharingPermission === 'custom' + this.revertSharingPermission = !isCustomPermissions ? selectedPermission : 'custom' + this.setCustomPermissions = isCustomPermissions + }, + async initializeAttributes() { + + if (this.isNewShare) { + if ((this.config.enableLinkPasswordByDefault || this.isPasswordEnforced) && this.isPublicShare) { + this.$set(this.share, 'newPassword', await GeneratePassword(true)) + this.advancedSectionAccordionExpanded = true + } + /* Set default expiration dates if configured */ + if (this.isPublicShare && this.config.isDefaultExpireDateEnabled) { + this.share.expireDate = this.config.defaultExpirationDate.toDateString() + } else if (this.isRemoteShare && this.config.isDefaultRemoteExpireDateEnabled) { + this.share.expireDate = this.config.defaultRemoteExpirationDateString.toDateString() + } else if (this.config.isDefaultInternalExpireDateEnabled) { + this.share.expireDate = this.config.defaultInternalExpirationDate.toDateString() + } + + if (this.isValidShareAttribute(this.share.expireDate)) { + this.advancedSectionAccordionExpanded = true + } + + return + } + + // If there is an enforced expiry date, then existing shares created before enforcement + // have no expiry date, hence we set it here. + if (!this.isValidShareAttribute(this.share.expireDate) && this.isExpiryDateEnforced) { + this.hasExpirationDate = true + } + + if ( + this.isValidShareAttribute(this.share.password) + || this.isValidShareAttribute(this.share.expireDate) + || this.isValidShareAttribute(this.share.label) + ) { + this.advancedSectionAccordionExpanded = true + } + + if (this.isValidShareAttribute(this.share.note)) { + this.writeNoteToRecipientIsChecked = true + this.advancedSectionAccordionExpanded = true + } + + }, + handleShareType() { + if ('shareType' in this.share) { + this.share.type = this.share.shareType + } else if (this.share.share_type) { + this.share.type = this.share.share_type + } + }, + handleDefaultPermissions() { + if (this.isNewShare) { + const defaultPermissions = this.config.defaultPermissions + if (defaultPermissions === BUNDLED_PERMISSIONS.READ_ONLY || defaultPermissions === BUNDLED_PERMISSIONS.ALL) { + this.sharingPermission = defaultPermissions.toString() + } else { + this.sharingPermission = 'custom' + this.share.permissions = defaultPermissions + this.advancedSectionAccordionExpanded = true + this.setCustomPermissions = true + } + } + // Read permission required for share creation + if (!this.canRemoveReadPermission) { + this.hasRead = true + } + }, + handleCustomPermissions() { + if (!this.isNewShare && (this.hasCustomPermissions || this.share.setCustomPermissions)) { + this.sharingPermission = 'custom' + this.advancedSectionAccordionExpanded = true + this.setCustomPermissions = true + } else if (this.share.permissions) { + this.sharingPermission = this.share.permissions.toString() + } + }, + initializePermissions() { + this.handleShareType() + this.handleDefaultPermissions() + this.handleCustomPermissions() + }, + async saveShare() { + const permissionsAndAttributes = ['permissions', 'attributes', 'note', 'expireDate'] + const publicShareAttributes = ['label', 'password', 'hideDownload'] + if (this.config.allowCustomTokens) { + publicShareAttributes.push('token') + } + if (this.isPublicShare) { + permissionsAndAttributes.push(...publicShareAttributes) + } + const sharePermissionsSet = parseInt(this.sharingPermission) + if (this.setCustomPermissions) { + this.updateAtomicPermissions() + } else { + this.share.permissions = sharePermissionsSet + } + + if (!this.isFolder && this.share.permissions === BUNDLED_PERMISSIONS.ALL) { + // It's not possible to create an existing file. + this.share.permissions = BUNDLED_PERMISSIONS.ALL_FILE + } + if (!this.writeNoteToRecipientIsChecked) { + this.share.note = '' + } + if (this.isPasswordProtected) { + if (this.isPasswordEnforced && this.isNewShare && !this.isValidShareAttribute(this.share.password)) { + this.passwordError = true + } + } else { + this.share.password = '' + } + + if (!this.hasExpirationDate) { + this.share.expireDate = '' + } + + if (this.isNewShare) { + const incomingShare = { + permissions: this.share.permissions, + shareType: this.share.type, + shareWith: this.share.shareWith, + attributes: this.share.attributes, + note: this.share.note, + fileInfo: this.fileInfo, + } + + incomingShare.expireDate = this.hasExpirationDate ? this.share.expireDate : '' + + if (this.isPasswordProtected) { + incomingShare.password = this.share.newPassword + } + + let share + try { + this.creating = true + share = await this.addShare(incomingShare) + } catch (error) { + this.creating = false + // Error is already handled by ShareRequests mixin + return + } + + // ugly hack to make code work - we need the id to be set but at the same time we need to keep values we want to update + this.share._share.id = share.id + await this.queueUpdate(...permissionsAndAttributes) + // Also a ugly hack to update the updated permissions + for (const prop of permissionsAndAttributes) { + if (prop in share && prop in this.share) { + try { + share[prop] = this.share[prop] + } catch { + share._share[prop] = this.share[prop] + } + } + } + + this.share = share + this.creating = false + this.$emit('add:share', this.share) + } else { + // Let's update after creation as some attrs are only available after creation + await this.queueUpdate(...permissionsAndAttributes) + this.$emit('update:share', this.share) + } + + await this.getNode() + emit('files:node:updated', this.node) + + if (this.$refs.externalLinkActions?.length > 0) { + await Promise.allSettled(this.$refs.externalLinkActions.map((action) => { + if (typeof action.$children.at(0)?.onSave !== 'function') { + return Promise.resolve() + } + return action.$children.at(0)?.onSave?.() + })) + } + + this.$emit('close-sharing-details') + }, + /** + * Process the new share request + * + * @param {Share} share incoming share object + */ + async addShare(share) { + logger.debug('Adding a new share from the input for', { share }) + const path = this.path + try { + const resultingShare = await this.createShare({ + path, + shareType: share.shareType, + shareWith: share.shareWith, + permissions: share.permissions, + expireDate: share.expireDate, + attributes: JSON.stringify(share.attributes), + ...(share.note ? { note: share.note } : {}), + ...(share.password ? { password: share.password } : {}), + }) + return resultingShare + } catch (error) { + logger.error('Error while adding new share', { error }) + } finally { + // this.loading = false // No loader here yet + } + }, + async removeShare() { + await this.onDelete() + await this.getNode() + emit('files:node:updated', this.node) + this.$emit('close-sharing-details') + }, + /** + * Update newPassword values + * of share. If password is set but not newPassword + * then the user did not changed the password + * If both co-exists, the password have changed and + * we show it in plain text. + * Then on submit (or menu close), we sync it. + * + * @param {string} password the changed password + */ + onPasswordChange(password) { + if (password === '') { + this.$delete(this.share, 'newPassword') + this.passwordError = this.isNewShare && this.isPasswordEnforced + return + } + this.passwordError = !this.isValidShareAttribute(password) + this.$set(this.share, 'newPassword', password) + }, + /** + * Update the password along with "sendPasswordByTalk". + * + * If the password was modified the new password is sent; otherwise + * updating a mail share would fail, as in that case it is required that + * a new password is set when enabling or disabling + * "sendPasswordByTalk". + */ + onPasswordProtectedByTalkChange() { + this.queueUpdate('sendPasswordByTalk', 'password') + }, + isValidShareAttribute(value) { + if ([null, undefined].includes(value)) { + return false + } + + if (!(value.trim().length > 0)) { + return false + } + + return true + }, + getShareTypeIcon(type) { + switch (type) { + case ShareType.Link: + return LinkIcon + case ShareType.Guest: + return UserIcon + case ShareType.RemoteGroup: + case ShareType.Group: + return GroupIcon + case ShareType.Email: + return EmailIcon + case ShareType.Team: + return CircleIcon + case ShareType.Room: + return ShareIcon + case ShareType.Deck: + return ShareIcon + case ShareType.ScienceMesh: + return ShareIcon + default: + return null // Or a default icon component if needed + } + }, + }, +} +</script> + +<style lang="scss" scoped> +.sharingTabDetailsView { + display: flex; + flex-direction: column; + width: 100%; + margin: 0 auto; + position: relative; + height: 100%; + overflow: hidden; + + &__header { + display: flex; + align-items: center; + box-sizing: border-box; + margin: 0.2em; + + span { + display: flex; + align-items: center; + + h1 { + font-size: 15px; + padding-inline-start: 0.3em; + } + + } + } + + &__wrapper { + position: relative; + overflow: scroll; + flex-shrink: 1; + padding: 4px; + padding-inline-end: 12px; + } + + &__quick-permissions { + display: flex; + justify-content: center; + width: 100%; + margin: 0 auto; + border-radius: 0; + + div { + width: 100%; + + span { + width: 100%; + + span:nth-child(1) { + align-items: center; + justify-content: center; + padding: 0.1em; + } + + :deep(label span) { + display: flex; + flex-direction: column; + } + + /* Target component based style in NcCheckboxRadioSwitch slot content*/ + :deep(span.checkbox-content__text.checkbox-radio-switch__text) { + flex-wrap: wrap; + + .subline { + display: block; + flex-basis: 100%; + } + } + } + + } + } + + &__advanced-control { + width: 100%; + + button { + margin-top: 0.5em; + } + + } + + &__advanced { + width: 100%; + margin-bottom: 0.5em; + text-align: start; + padding-inline-start: 0; + + section { + + textarea, + div.mx-datepicker { + width: 100%; + } + + textarea { + height: 80px; + margin: 0; + } + + /* + The following style is applied out of the component's scope + to remove padding from the label.checkbox-radio-switch__label, + which is used to group radio checkbox items. The use of ::v-deep + ensures that the padding is modified without being affected by + the component's scoping. + Without this achieving left alignment for the checkboxes would not + be possible. + */ + span :deep(label) { + padding-inline-start: 0 !important; + background-color: initial !important; + border: none !important; + } + + section.custom-permissions-group { + padding-inline-start: 1.5em; + } + } + } + + &__label { + padding-block-end: 6px; + } + + &__delete { + > button:first-child { + color: rgb(223, 7, 7); + } + } + + &__footer { + width: 100%; + display: flex; + position: sticky; + bottom: 0; + flex-direction: column; + justify-content: space-between; + align-items: flex-start; + background: linear-gradient(to bottom, rgba(255, 255, 255, 0), var(--color-main-background)); + + .button-group { + display: flex; + justify-content: space-between; + width: 100%; + margin-top: 16px; + + button { + margin-inline-start: 16px; + + &:first-child { + margin-inline-start: 0; + } + } + } + } +} +</style> diff --git a/apps/files_sharing/src/views/SharingInherited.vue b/apps/files_sharing/src/views/SharingInherited.vue index b570b47e257..809de522d93 100644 --- a/apps/files_sharing/src/views/SharingInherited.vue +++ b/apps/files_sharing/src/views/SharingInherited.vue @@ -1,27 +1,10 @@ <!-- - - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <ul id="sharing-inherited-shares"> + <ul v-if="shares.length" id="sharing-inherited-shares"> <!-- Main collapsible entry --> <SharingEntrySimple class="sharing-entry__inherited" :title="mainTitle" @@ -47,12 +30,12 @@ <script> import { generateOcsUrl } from '@nextcloud/router' -import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' import axios from '@nextcloud/axios' -import Share from '../models/Share' -import SharingEntryInherited from '../components/SharingEntryInherited' -import SharingEntrySimple from '../components/SharingEntrySimple' +import Share from '../models/Share.ts' +import SharingEntryInherited from '../components/SharingEntryInherited.vue' +import SharingEntrySimple from '../components/SharingEntrySimple.vue' export default { name: 'SharingInherited', @@ -94,7 +77,7 @@ export default { }, subTitle() { return (this.showInheritedShares && this.shares.length === 0) - ? t('files_sharing', 'No other users with access found') + ? t('files_sharing', 'No other accounts with access found') : '' }, toggleTooltip() { diff --git a/apps/files_sharing/src/views/SharingLinkList.vue b/apps/files_sharing/src/views/SharingLinkList.vue index ee7418c00d5..c3d9a7f83dc 100644 --- a/apps/files_sharing/src/views/SharingLinkList.vue +++ b/apps/files_sharing/src/views/SharingLinkList.vue @@ -1,33 +1,12 @@ <!-- - - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <ul v-if="canLinkShare" class="sharing-link-list"> - <!-- If no link shares, show the add link default entry --> - <SharingEntryLink v-if="!hasLinkShares && canReshare" - :can-reshare="canReshare" - :file-info="fileInfo" - @add:share="addShare" /> - + <ul v-if="canLinkShare" + :aria-label="t('files_sharing', 'Link shares')" + class="sharing-link-list"> <!-- Else we display the list --> <template v-if="hasShares"> <!-- using shares[index] to work with .sync --> @@ -39,16 +18,27 @@ :file-info="fileInfo" @add:share="addShare(...arguments)" @update:share="awaitForShare(...arguments)" - @remove:share="removeShare" /> + @remove:share="removeShare" + @open-sharing-details="openSharingDetails(share)" /> </template> + + <!-- If no link shares, show the add link default entry --> + <SharingEntryLink v-if="!hasLinkShares && canReshare" + :can-reshare="canReshare" + :file-info="fileInfo" + @add:share="addShare" /> </ul> </template> <script> -// eslint-disable-next-line no-unused-vars -import Share from '../models/Share' -import ShareTypes from '../mixins/ShareTypes' -import SharingEntryLink from '../components/SharingEntryLink' +import { getCapabilities } from '@nextcloud/capabilities' + +import { t } from '@nextcloud/l10n' + +import Share from '../models/Share.js' +import SharingEntryLink from '../components/SharingEntryLink.vue' +import ShareDetails from '../mixins/ShareDetails.js' +import { ShareType } from '@nextcloud/sharing' export default { name: 'SharingLinkList', @@ -57,7 +47,7 @@ export default { SharingEntryLink, }, - mixins: [ShareTypes], + mixins: [ShareDetails], props: { fileInfo: { @@ -78,7 +68,7 @@ export default { data() { return { - canLinkShare: OC.getCapabilities().files_sharing.public.enabled, + canLinkShare: getCapabilities().files_sharing.public.enabled, } }, @@ -91,7 +81,7 @@ export default { * @return {Array} */ hasLinkShares() { - return this.shares.filter(share => share.type === this.SHARE_TYPES.SHARE_TYPE_LINK).length > 0 + return this.shares.filter(share => share.type === ShareType.Link).length > 0 }, /** @@ -105,6 +95,8 @@ export default { }, methods: { + t, + /** * Add a new share into the link shares list * and return the newly created share component @@ -114,7 +106,7 @@ export default { */ addShare(share, resolve) { // eslint-disable-next-line vue/no-mutating-props - this.shares.unshift(share) + this.shares.push(share) this.awaitForShare(share, resolve) }, diff --git a/apps/files_sharing/src/views/SharingList.vue b/apps/files_sharing/src/views/SharingList.vue index 0635ad27635..2167059772e 100644 --- a/apps/files_sharing/src/views/SharingList.vue +++ b/apps/files_sharing/src/views/SharingList.vue @@ -1,41 +1,24 @@ <!-- - - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <ul class="sharing-sharee-list"> + <ul class="sharing-sharee-list" :aria-label="t('files_sharing', 'Shares')"> <SharingEntry v-for="share in shares" :key="share.id" :file-info="fileInfo" :share="share" :is-unique="isUnique(share)" - @remove:share="removeShare" /> + @open-sharing-details="openSharingDetails(share)" /> </ul> </template> <script> -// eslint-disable-next-line no-unused-vars -import Share from '../models/Share' -import SharingEntry from '../components/SharingEntry' -import ShareTypes from '../mixins/ShareTypes' +import { t } from '@nextcloud/l10n' +import SharingEntry from '../components/SharingEntry.vue' +import ShareDetails from '../mixins/ShareDetails.js' +import { ShareType } from '@nextcloud/sharing' export default { name: 'SharingList', @@ -44,12 +27,12 @@ export default { SharingEntry, }, - mixins: [ShareTypes], + mixins: [ShareDetails], props: { fileInfo: { type: Object, - default: () => {}, + default: () => { }, required: true, }, shares: { @@ -59,6 +42,11 @@ export default { }, }, + setup() { + return { + t, + } + }, computed: { hasShares() { return this.shares.length === 0 @@ -66,23 +54,10 @@ export default { isUnique() { return (share) => { return [...this.shares].filter((item) => { - return share.type === this.SHARE_TYPES.SHARE_TYPE_USER && share.shareWithDisplayName === item.shareWithDisplayName + return share.type === ShareType.User && share.shareWithDisplayName === item.shareWithDisplayName }).length <= 1 } }, }, - - methods: { - /** - * Remove a share from the shares list - * - * @param {Share} share the share to remove - */ - removeShare(share) { - const index = this.shares.findIndex(item => item === share) - // eslint-disable-next-line vue/no-mutating-props - this.shares.splice(index, 1) - }, - }, } </script> diff --git a/apps/files_sharing/src/views/SharingTab.vue b/apps/files_sharing/src/views/SharingTab.vue index f7920346981..2ed44a4b5ad 100644 --- a/apps/files_sharing/src/views/SharingTab.vue +++ b/apps/files_sharing/src/views/SharingTab.vue @@ -1,27 +1,10 @@ <!-- - - @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com> - - - - @author John Molakvoæ <skjnldsv@protonmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - - --> + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <div :class="{ 'icon-loading': loading }"> + <div class="sharingTab" :class="{ 'icon-loading': loading }"> <!-- error message --> <div v-if="error" class="emptycontent" :class="{ emptyContentWithSections: sections.length > 0 }"> <div class="icon icon-error" /> @@ -29,100 +12,204 @@ </div> <!-- shares content --> - <div v-else class="sharingTab__content"> + <div v-show="!showSharingDetailsView" + class="sharingTab__content"> <!-- shared with me information --> - <SharingEntrySimple v-if="isSharedWithMe" v-bind="sharedWithMe" class="sharing-entry__reshare"> - <template #avatar> - <NcAvatar :user="sharedWithMe.user" - :title="sharedWithMe.displayName" - class="sharing-entry__avatar" /> - </template> - </SharingEntrySimple> - - <!-- add new share input --> - <SharingInput v-if="!loading" - :can-reshare="canReshare" - :file-info="fileInfo" - :link-shares="linkShares" - :reshare="reshare" - :shares="shares" - @add:share="addShare" /> - - <!-- link shares list --> - <SharingLinkList v-if="!loading" - ref="linkShareList" - :can-reshare="canReshare" - :file-info="fileInfo" - :shares="linkShares" /> - - <!-- other shares list --> - <SharingList v-if="!loading" - ref="shareList" - :shares="shares" - :file-info="fileInfo" /> - - <!-- inherited shares --> - <SharingInherited v-if="canReshare && !loading" :file-info="fileInfo" /> - - <!-- internal link copy --> - <SharingEntryInternal :file-info="fileInfo" /> - - <!-- projects --> - <CollectionList v-if="projectsEnabled && fileInfo" - :id="`${fileInfo.id}`" - type="file" - :name="fileInfo.name" /> + <ul v-if="isSharedWithMe"> + <SharingEntrySimple v-bind="sharedWithMe" class="sharing-entry__reshare"> + <template #avatar> + <NcAvatar :user="sharedWithMe.user" + :display-name="sharedWithMe.displayName" + class="sharing-entry__avatar" /> + </template> + </SharingEntrySimple> + </ul> + + <section> + <div class="section-header"> + <h4>{{ t('files_sharing', 'Internal shares') }}</h4> + <NcPopover popup-role="dialog"> + <template #trigger> + <NcButton class="hint-icon" + type="tertiary-no-background" + :aria-label="t('files_sharing', 'Internal shares explanation')"> + <template #icon> + <InfoIcon :size="20" /> + </template> + </NcButton> + </template> + <p class="hint-body"> + {{ internalSharesHelpText }} + </p> + </NcPopover> + </div> + <!-- add new share input --> + <SharingInput v-if="!loading" + :can-reshare="canReshare" + :file-info="fileInfo" + :link-shares="linkShares" + :reshare="reshare" + :shares="shares" + :placeholder="internalShareInputPlaceholder" + @open-sharing-details="toggleShareDetailsView" /> + + <!-- other shares list --> + <SharingList v-if="!loading" + ref="shareList" + :shares="shares" + :file-info="fileInfo" + @open-sharing-details="toggleShareDetailsView" /> + + <!-- inherited shares --> + <SharingInherited v-if="canReshare && !loading" :file-info="fileInfo" /> + + <!-- internal link copy --> + <SharingEntryInternal :file-info="fileInfo" /> + </section> + + <section> + <div class="section-header"> + <h4>{{ t('files_sharing', 'External shares') }}</h4> + <NcPopover popup-role="dialog"> + <template #trigger> + <NcButton class="hint-icon" + type="tertiary-no-background" + :aria-label="t('files_sharing', 'External shares explanation')"> + <template #icon> + <InfoIcon :size="20" /> + </template> + </NcButton> + </template> + <p class="hint-body"> + {{ externalSharesHelpText }} + </p> + </NcPopover> + </div> + <SharingInput v-if="!loading" + :can-reshare="canReshare" + :file-info="fileInfo" + :link-shares="linkShares" + :is-external="true" + :placeholder="externalShareInputPlaceholder" + :reshare="reshare" + :shares="shares" + @open-sharing-details="toggleShareDetailsView" /> + <!-- Non link external shares list --> + <SharingList v-if="!loading" + :shares="externalShares" + :file-info="fileInfo" + @open-sharing-details="toggleShareDetailsView" /> + <!-- link shares list --> + <SharingLinkList v-if="!loading && isLinkSharingAllowed" + ref="linkShareList" + :can-reshare="canReshare" + :file-info="fileInfo" + :shares="linkShares" + @open-sharing-details="toggleShareDetailsView" /> + </section> + + <section v-if="sections.length > 0 && !showSharingDetailsView"> + <div class="section-header"> + <h4>{{ t('files_sharing', 'Additional shares') }}</h4> + <NcPopover popup-role="dialog"> + <template #trigger> + <NcButton class="hint-icon" + type="tertiary-no-background" + :aria-label="t('files_sharing', 'Additional shares explanation')"> + <template #icon> + <InfoIcon :size="20" /> + </template> + </NcButton> + </template> + <p class="hint-body"> + {{ additionalSharesHelpText }} + </p> + </NcPopover> + </div> + <!-- additional entries, use it with cautious --> + <div v-for="(component, index) in sectionComponents" + :key="index" + class="sharingTab__additionalContent"> + <component :is="component" :file-info="fileInfo" /> + </div> + + <!-- projects (deprecated as of NC25 (replaced by related_resources) - see instance config "projects.enabled" ; ignore this / remove it / move into own section) --> + <div v-if="projectsEnabled" + v-show="!showSharingDetailsView && fileInfo" + class="sharingTab__additionalContent"> + <NcCollectionList :id="`${fileInfo.id}`" + type="file" + :name="fileInfo.name" /> + </div> + </section> </div> - <!-- additional entries, use it with cautious --> - <div v-for="(section, index) in sections" - :ref="'section-' + index" - :key="index" - class="sharingTab__additionalContent"> - <component :is="section($refs['section-'+index], fileInfo)" :file-info="fileInfo" /> - </div> + <!-- share details --> + <SharingDetailsTab v-if="showSharingDetailsView" + :file-info="shareDetailsData.fileInfo" + :share="shareDetailsData.share" + @close-sharing-details="toggleShareDetailsView" + @add:share="addShare" + @remove:share="removeShare" /> </div> </template> <script> -import { CollectionList } from 'nextcloud-vue-collections' +import { getCurrentUser } from '@nextcloud/auth' +import { getCapabilities } from '@nextcloud/capabilities' +import { orderBy } from '@nextcloud/files' +import { loadState } from '@nextcloud/initial-state' import { generateOcsUrl } from '@nextcloud/router' -import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar' +import { ShareType } from '@nextcloud/sharing' + +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcCollectionList from '@nextcloud/vue/components/NcCollectionList' +import NcPopover from '@nextcloud/vue/components/NcPopover' +import InfoIcon from 'vue-material-design-icons/InformationOutline.vue' + import axios from '@nextcloud/axios' -import { loadState } from '@nextcloud/initial-state' +import moment from '@nextcloud/moment' -import Config from '../services/ConfigService' -import { shareWithTitle } from '../utils/SharedWithMe' -import Share from '../models/Share' -import ShareTypes from '../mixins/ShareTypes' -import SharingEntryInternal from '../components/SharingEntryInternal' -import SharingEntrySimple from '../components/SharingEntrySimple' -import SharingInput from '../components/SharingInput' +import { shareWithTitle } from '../utils/SharedWithMe.js' -import SharingInherited from './SharingInherited' -import SharingLinkList from './SharingLinkList' -import SharingList from './SharingList' +import Config from '../services/ConfigService.ts' +import Share from '../models/Share.ts' +import SharingEntryInternal from '../components/SharingEntryInternal.vue' +import SharingEntrySimple from '../components/SharingEntrySimple.vue' +import SharingInput from '../components/SharingInput.vue' + +import SharingInherited from './SharingInherited.vue' +import SharingLinkList from './SharingLinkList.vue' +import SharingList from './SharingList.vue' +import SharingDetailsTab from './SharingDetailsTab.vue' + +import ShareDetails from '../mixins/ShareDetails.js' +import logger from '../services/logger.ts' export default { name: 'SharingTab', components: { + InfoIcon, NcAvatar, - CollectionList, + NcButton, + NcCollectionList, + NcPopover, SharingEntryInternal, SharingEntrySimple, SharingInherited, SharingInput, SharingLinkList, SharingList, + SharingDetailsTab, }, - - mixins: [ShareTypes], + mixins: [ShareDetails], data() { return { config: new Config(), - + deleteEvent: null, error: '', expirationInterval: null, loading: true, @@ -134,9 +221,17 @@ export default { sharedWithMe: {}, shares: [], linkShares: [], + externalShares: [], sections: OCA.Sharing.ShareTabSections.getSections(), projectsEnabled: loadState('core', 'projects_enabled', false), + showSharingDetailsView: false, + shareDetailsData: {}, + returnFocusElement: null, + + internalSharesHelpText: t('files_sharing', 'Share files within your organization. Recipients who can already view the file can also use this link for easy access.'), + externalSharesHelpText: t('files_sharing', 'Share files with others outside your organization via public links and email addresses. You can also share to Nextcloud accounts on other instances using their federated cloud ID.'), + additionalSharesHelpText: t('files_sharing', 'Shares from apps or other sources which are not included in internal or external shares.'), } }, @@ -147,15 +242,54 @@ export default { * @return {boolean} */ isSharedWithMe() { - return Object.keys(this.sharedWithMe).length > 0 + return !!this.sharedWithMe?.user + }, + + /** + * Is link sharing allowed for the current user? + * + * @return {boolean} + */ + isLinkSharingAllowed() { + const currentUser = getCurrentUser() + if (!currentUser) { + return false + } + + const capabilities = getCapabilities() + const publicSharing = capabilities.files_sharing?.public || {} + return publicSharing.enabled === true }, canReshare() { return !!(this.fileInfo.permissions & OC.PERMISSION_SHARE) || !!(this.reshare && this.reshare.hasSharePermission && this.config.isResharingAllowed) }, - }, + internalShareInputPlaceholder() { + return this.config.showFederatedSharesAsInternal && this.config.isFederationEnabled + // TRANSLATORS: Type as in with a keyboard + ? t('files_sharing', 'Type names, teams, federated cloud IDs') + // TRANSLATORS: Type as in with a keyboard + : t('files_sharing', 'Type names or teams') + }, + + externalShareInputPlaceholder() { + if (!this.isLinkSharingAllowed) { + // TRANSLATORS: Type as in with a keyboard + return this.config.isFederationEnabled ? t('files_sharing', 'Type a federated cloud ID') : '' + } + return !this.config.showFederatedSharesAsInternal && !this.config.isFederationEnabled + // TRANSLATORS: Type as in with a keyboard + ? t('files_sharing', 'Type an email') + // TRANSLATORS: Type as in with a keyboard + : t('files_sharing', 'Type an email or federated cloud ID') + }, + + sectionComponents() { + return this.sections.map((section) => section(undefined, this.fileInfo)) + }, + }, methods: { /** * Update current fileInfo and fetch new data @@ -167,7 +301,6 @@ export default { this.resetState() this.getShares() }, - /** * Get the existing shares infos */ @@ -205,7 +338,7 @@ export default { this.processSharedWithMe(sharedWithMe) this.processShares(shares) } catch (error) { - if (error.response.data?.ocs?.meta?.message) { + if (error?.response?.data?.ocs?.meta?.message) { this.error = error.response.data.ocs.meta.message } else { this.error = t('files_sharing', 'Unable to load the shares list') @@ -225,6 +358,8 @@ export default { this.sharedWithMe = {} this.shares = [] this.linkShares = [] + this.showSharingDetailsView = false + this.shareDetailsData = {} }, /** @@ -236,7 +371,7 @@ export default { updateExpirationSubtitle(share) { const expiration = moment(share.expireDate).unix() this.$set(this.sharedWithMe, 'subtitle', t('files_sharing', 'Expires {relativetime}', { - relativetime: OC.Util.relativeModifiedDate(expiration * 1000), + relativetime: moment(expiration * 1000).fromNow(), })) // share have expired @@ -256,16 +391,41 @@ export default { */ processShares({ data }) { if (data.ocs && data.ocs.data && data.ocs.data.length > 0) { - // create Share objects and sort by newest - const shares = data.ocs.data - .map(share => new Share(share)) - .sort((a, b) => b.createdTime - a.createdTime) - - this.linkShares = shares.filter(share => share.type === this.SHARE_TYPES.SHARE_TYPE_LINK || share.type === this.SHARE_TYPES.SHARE_TYPE_EMAIL) - this.shares = shares.filter(share => share.type !== this.SHARE_TYPES.SHARE_TYPE_LINK && share.type !== this.SHARE_TYPES.SHARE_TYPE_EMAIL) + const shares = orderBy( + data.ocs.data.map(share => new Share(share)), + [ + // First order by the "share with" label + (share) => share.shareWithDisplayName, + // Then by the label + (share) => share.label, + // And last resort order by createdTime + (share) => share.createdTime, + ], + ) + + for (const share of shares) { + if ([ShareType.Link, ShareType.Email].includes(share.type)) { + this.linkShares.push(share) + } else if ([ShareType.Remote, ShareType.RemoteGroup].includes(share.type)) { + if (this.config.showFederatedSharesToTrustedServersAsInternal) { + if (share.isTrustedServer) { + this.shares.push(share) + } else { + this.externalShares.push(share) + } + } else if (this.config.showFederatedSharesAsInternal) { + this.shares.push(share) + } else { + this.externalShares.push(share) + } + } else { + this.shares.push(share) + } + } - console.debug('Processed', this.linkShares.length, 'link share(s)') - console.debug('Processed', this.shares.length, 'share(s)') + logger.debug(`Processed ${this.linkShares.length} link share(s)`) + logger.debug(`Processed ${this.shares.length} share(s)`) + logger.debug(`Processed ${this.externalShares.length} external share(s)`) } }, @@ -298,7 +458,7 @@ export default { // interval update this.expirationInterval = setInterval(this.updateExpirationSubtitle, 10000, share) } - } else if (this.fileInfo && this.fileInfo.shareOwnerId !== undefined ? this.fileInfo.shareOwnerId !== OC.currentUser : false) { + } else if (this.fileInfo && this.fileInfo.shareOwnerId !== undefined ? this.fileInfo.shareOwnerId !== getCurrentUser().uid : false) { // Fallback to compare owner and current user. this.sharedWithMe = { displayName: this.fileInfo.shareOwner, @@ -307,7 +467,7 @@ export default { 'Shared with you by {owner}', { owner: this.fileInfo.shareOwner }, undefined, - { escape: false } + { escape: false }, ), user: this.fileInfo.shareOwnerId, } @@ -321,17 +481,43 @@ export default { * @param {Share} share the share to add to the array * @param {Function} [resolve] a function to run after the share is added and its component initialized */ - addShare(share, resolve = () => {}) { + addShare(share, resolve = () => { }) { // only catching share type MAIL as link shares are added differently // meaning: not from the ShareInput - if (share.type === this.SHARE_TYPES.SHARE_TYPE_EMAIL) { + if (share.type === ShareType.Email) { this.linkShares.unshift(share) + } else if ([ShareType.Remote, ShareType.RemoteGroup].includes(share.type)) { + if (this.config.showFederatedSharesAsInternal) { + this.shares.unshift(share) + } if (this.config.showFederatedSharesToTrustedServersAsInternal) { + if (share.isTrustedServer) { + this.shares.unshift(share) + } + } else { + this.externalShares.unshift(share) + } } else { this.shares.unshift(share) } this.awaitForShare(share, resolve) }, - + /** + * Remove a share from the shares list + * + * @param {Share} share the share to remove + */ + removeShare(share) { + // Get reference for this.linkShares or this.shares + const shareList + = share.type === ShareType.Email + || share.type === ShareType.Link + ? this.linkShares + : this.shares + const index = shareList.findIndex(item => item.id === share.id) + if (index !== -1) { + shareList.splice(index, 1) + } + }, /** * Await for next tick and render after the list updated * Then resolve with the matched vue component of the @@ -341,20 +527,45 @@ export default { * @param {Function} resolve a function to execute after */ awaitForShare(share, resolve) { - let listComponent = this.$refs.shareList - // Only mail shares comes from the input, link shares - // are managed internally in the SharingLinkList component - if (share.type === this.SHARE_TYPES.SHARE_TYPE_EMAIL) { - listComponent = this.$refs.linkShareList - } - this.$nextTick(() => { + let listComponent = this.$refs.shareList + // Only mail shares comes from the input, link shares + // are managed internally in the SharingLinkList component + if (share.type === ShareType.Email) { + listComponent = this.$refs.linkShareList + } const newShare = listComponent.$children.find(component => component.share === share) if (newShare) { resolve(newShare) } }) }, + + toggleShareDetailsView(eventData) { + if (!this.showSharingDetailsView) { + const isAction = Array.from(document.activeElement.classList) + .some(className => className.startsWith('action-')) + if (isAction) { + const menuId = document.activeElement.closest('[role="menu"]')?.id + this.returnFocusElement = document.querySelector(`[aria-controls="${menuId}"]`) + } else { + this.returnFocusElement = document.activeElement + } + } + + if (eventData) { + this.shareDetailsData = eventData + } + + this.showSharingDetailsView = !this.showSharingDetailsView + + if (!this.showSharingDetailsView) { + this.$nextTick(() => { // Wait for next tick as the element must be visible to be focused + this.returnFocusElement?.focus() + this.returnFocusElement = null + }) + } + }, }, } </script> @@ -365,11 +576,52 @@ export default { } .sharingTab { + position: relative; + height: 100%; + &__content { padding: 0 6px; + + section { + padding-bottom: 16px; + + .section-header { + margin-top: 2px; + margin-bottom: 2px; + display: flex; + align-items: center; + padding-bottom: 4px; + + h4 { + margin: 0; + font-size: 16px; + } + + .visually-hidden { + display: none; + } + + .hint-icon { + color: var(--color-primary-element); + } + + } + + } + + & > section:not(:last-child) { + border-bottom: 2px solid var(--color-border); + } + } + &__additionalContent { margin: 44px 0; } } + +.hint-body { + max-width: 300px; + padding: var(--border-radius-element); +} </style> |