diff options
Diffstat (limited to 'apps/files_sharing/src/components')
17 files changed, 2455 insertions, 1429 deletions
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 9c0a2378af8..00000000000 --- a/apps/files_sharing/src/components/SharePermissionsEditor.vue +++ /dev/null @@ -1,291 +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> - <span> - <!-- file --> - <ActionCheckbox v-if="!isFolder" - :checked="shareHasPermissions(atomicPermissions.UPDATE)" - :disabled="saving" - @update:checked="toggleSharePermissions(atomicPermissions.UPDATE)"> - {{ t('files_sharing', 'Allow editing') }} - </ActionCheckbox> - - <!-- folder --> - <template v-if="isFolder && fileHasCreatePermission && config.isPublicUploadEnabled"> - <template v-if="!showCustomPermissionsForm"> - <ActionRadio :checked="sharePermissionEqual(bundledPermissions.READ_ONLY)" - :value="bundledPermissions.READ_ONLY" - :name="randomFormName" - :disabled="saving" - @change="setSharePermissions(bundledPermissions.READ_ONLY)"> - {{ t('files_sharing', 'Read only') }} - </ActionRadio> - - <ActionRadio :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') }} - </ActionRadio> - <ActionRadio :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)') }} - </ActionRadio> - - <!-- custom permissions button --> - <ActionButton :title="t('files_sharing', 'Custom permissions')" - @click="showCustomPermissionsForm = true"> - <template #icon> - <Tune /> - </template> - {{ sharePermissionsIsBundle ? "" : sharePermissionsSummary }} - </ActionButton> - </template> - - <!-- custom permissions --> - <span v-else :class="{error: !sharePermissionsSetIsValid}"> - <ActionCheckbox :checked="shareHasPermissions(atomicPermissions.READ)" - :disabled="saving || !canToggleSharePermissions(atomicPermissions.READ)" - @update:checked="toggleSharePermissions(atomicPermissions.READ)"> - {{ t('files_sharing', 'Read') }} - </ActionCheckbox> - <ActionCheckbox :checked="shareHasPermissions(atomicPermissions.CREATE)" - :disabled="saving || !canToggleSharePermissions(atomicPermissions.CREATE)" - @update:checked="toggleSharePermissions(atomicPermissions.CREATE)"> - {{ t('files_sharing', 'Upload') }} - </ActionCheckbox> - <ActionCheckbox :checked="shareHasPermissions(atomicPermissions.UPDATE)" - :disabled="saving || !canToggleSharePermissions(atomicPermissions.UPDATE)" - @update:checked="toggleSharePermissions(atomicPermissions.UPDATE)"> - {{ t('files_sharing', 'Edit') }} - </ActionCheckbox> - <ActionCheckbox :checked="shareHasPermissions(atomicPermissions.DELETE)" - :disabled="saving || !canToggleSharePermissions(atomicPermissions.DELETE)" - @update:checked="toggleSharePermissions(atomicPermissions.DELETE)"> - {{ t('files_sharing', 'Delete') }} - </ActionCheckbox> - - <ActionButton @click="showCustomPermissionsForm = false"> - <template #icon> - <ChevronLeft /> - </template> - {{ t('files_sharing', 'Bundled permissions') }} - </ActionButton> - </span> - </template> - </span> -</template> - -<script> -import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' -import ActionRadio from '@nextcloud/vue/dist/Components/ActionRadio' -import ActionCheckbox from '@nextcloud/vue/dist/Components/ActionCheckbox' - -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: { - ActionButton, - ActionCheckbox, - ActionRadio, - 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 '' - } - }) - .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 2d29aaf1dc6..342b40ce384 100644 --- a/apps/files_sharing/src/components/SharingEntry.vue +++ b/apps/files_sharing/src/components/SharingEntry.vue @@ -1,199 +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"> - <Avatar class="sharing-entry__avatar" - :is-no-user="share.type !== SHARE_TYPES.SHARE_TYPE_USER" + <NcAvatar class="sharing-entry__avatar" + :is-no-user="share.type !== ShareType.User" :user="share.shareWith" :display-name="share.shareWithDisplayName" - :tooltip-message="share.type === SHARE_TYPES.SHARE_TYPE_USER ? share.shareWith : ''" :menu-position="'left'" :url="share.shareWithAvatar" /> - <component :is="share.shareWithLink ? 'a' : 'div'" - v-tooltip.auto="tooltip" - :href="share.shareWithLink" - class="sharing-entry__desc"> - <h5>{{ title }}<span v-if="!isUnique" class="sharing-entry__desc-unique"> ({{ share.shareWithDisplayNameUnique }})</span></h5> - <p v-if="hasStatus"> - <span>{{ share.status.icon || '' }}</span> - <span>{{ share.status.message || '' }}</span> - </p> - </component> - <Actions menu-align="right" - class="sharing-entry__actions" - @close="onMenuClose"> - <template v-if="share.canEdit"> - <!-- edit permission --> - <ActionCheckbox ref="canEdit" - :checked.sync="canEdit" - :value="permissionsEdit" - :disabled="saving || !canSetEdit"> - {{ t('files_sharing', 'Allow editing') }} - </ActionCheckbox> - <!-- create permission --> - <ActionCheckbox v-if="isFolder" - ref="canCreate" - :checked.sync="canCreate" - :value="permissionsCreate" - :disabled="saving || !canSetCreate"> - {{ t('files_sharing', 'Allow creating') }} - </ActionCheckbox> - - <!-- delete permission --> - <ActionCheckbox v-if="isFolder" - ref="canDelete" - :checked.sync="canDelete" - :value="permissionsDelete" - :disabled="saving || !canSetDelete"> - {{ t('files_sharing', 'Allow deleting') }} - </ActionCheckbox> - - <!-- reshare permission --> - <ActionCheckbox v-if="config.isResharingAllowed" - ref="canReshare" - :checked.sync="canReshare" - :value="permissionsShare" - :disabled="saving || !canSetReshare"> - {{ t('files_sharing', 'Allow resharing') }} - </ActionCheckbox> - - <!-- expiration date --> - <ActionCheckbox :checked.sync="hasExpirationDate" - :disabled="config.isDefaultInternalExpireDateEnforced || saving" - @uncheck="onExpirationDisable"> - {{ config.isDefaultInternalExpireDateEnforced - ? t('files_sharing', 'Expiration date enforced') - : t('files_sharing', 'Set expiration date') }} - </ActionCheckbox> - <ActionInput v-if="hasExpirationDate" - ref="expireDate" - v-tooltip.auto="{ - content: errors.expireDate, - show: errors.expireDate, - trigger: 'manual' - }" - :class="{ error: errors.expireDate}" - :disabled="saving" - :lang="lang" - :value="share.expireDate" - value-type="format" - icon="icon-calendar-dark" - type="date" - :disabled-date="disabledDate" - @update:value="onExpirationChange"> - {{ t('files_sharing', 'Enter a date') }} - </ActionInput> - - <!-- note --> - <template v-if="canHaveNote"> - <ActionCheckbox :checked.sync="hasNote" - :disabled="saving" - @uncheck="queueUpdate('note')"> - {{ t('files_sharing', 'Note to recipient') }} - </ActionCheckbox> - <ActionTextEditable v-if="hasNote" - ref="note" - v-tooltip.auto="{ - content: errors.note, - show: errors.note, - trigger: 'manual' - }" - :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> - - <ActionButton v-if="share.canDelete" - icon="icon-close" - :disabled="saving" - @click.prevent="onDelete"> - {{ t('files_sharing', 'Unshare') }} - </ActionButton> - </Actions> + </NcButton> </li> </template> <script> -import Avatar from '@nextcloud/vue/dist/Components/Avatar' -import Actions from '@nextcloud/vue/dist/Components/Actions' -import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' -import ActionCheckbox from '@nextcloud/vue/dist/Components/ActionCheckbox' -import ActionInput from '@nextcloud/vue/dist/Components/ActionInput' -import ActionTextEditable from '@nextcloud/vue/dist/Components/ActionTextEditable' -import Tooltip from '@nextcloud/vue/dist/Directives/Tooltip' +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 SharesMixin from '../mixins/SharesMixin' +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: { - Actions, - ActionButton, - ActionCheckbox, - ActionInput, - ActionTextEditable, - Avatar, - }, - - directives: { - Tooltip, + 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 = { @@ -202,10 +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) } @@ -214,185 +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 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 }) - }, - }, - - /** - * 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) { - this.share.expireDate = enabled - ? this.config.defaultInternalExpirationDateString !== '' - ? this.config.defaultInternalExpirationDateString - : moment().format('YYYY-MM-DD') - : '' - }, - }, - - dateMaxEnforced() { - if (!this.isRemote) { - return this.config.isDefaultInternalExpireDateEnforced - && moment().add(1 + this.config.defaultInternalExpireDate, 'days') - } else { - return this.config.isDefaultRemoteExpireDateEnforced - && moment().add(1 + this.config.defaultRemoteExpireDate, 'days') - } - }, - /** * @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)) }, - }, methods: { - updatePermissions({ isEditChecked = this.canEdit, isCreateChecked = this.canCreate, isDeleteChecked = this.canDelete, isReshareChecked = this.canReshare } = {}) { - // 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 - this.queueUpdate('permissions') - }, - /** * Save potential changed data on menu close */ @@ -408,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 72a513cfb1f..e7dfffd5776 100644 --- a/apps/files_sharing/src/components/SharingEntryInherited.vue +++ b/apps/files_sharing/src/components/SharingEntryInherited.vue @@ -1,72 +1,54 @@ <!-- - - @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" class="sharing-entry__inherited" :title="share.shareWithDisplayName"> <template #avatar> - <Avatar :user="share.shareWith" + <NcAvatar :user="share.shareWith" :display-name="share.shareWithDisplayName" - class="sharing-entry__avatar" - tooltip-message="" /> + class="sharing-entry__avatar" /> </template> - <ActionText icon="icon-user"> + <NcActionText icon="icon-user"> {{ t('files_sharing', 'Added by {initiator}', { initiator: share.ownerDisplayName }) }} - </ActionText> - <ActionLink v-if="share.viaPath && share.viaFileid" + </NcActionText> + <NcActionLink v-if="share.viaPath && share.viaFileid" icon="icon-folder" :href="viaFileTargetUrl"> {{ t('files_sharing', 'Via “{folder}”', {folder: viaFolderName} ) }} - </ActionLink> - <ActionButton v-if="share.canDelete" + </NcActionLink> + <NcActionButton v-if="share.canDelete" icon="icon-close" @click.prevent="onDelete"> {{ t('files_sharing', 'Unshare') }} - </actionbutton> + </NcActionButton> </SharingEntrySimple> </template> <script> import { generateUrl } from '@nextcloud/router' import { basename } from '@nextcloud/paths' -import Avatar from '@nextcloud/vue/dist/Components/Avatar' -import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' -import ActionLink from '@nextcloud/vue/dist/Components/ActionLink' -import ActionText from '@nextcloud/vue/dist/Components/ActionText' +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', components: { - ActionButton, - ActionLink, - ActionText, - Avatar, + NcActionButton, + NcActionLink, + NcActionText, + NcAvatar, SharingEntrySimple, }, @@ -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 73b86d26180..027d2a3d5c3 100644 --- a/apps/files_sharing/src/components/SharingEntryInternal.vue +++ b/apps/files_sharing/src/components/SharingEntryInternal.vue @@ -1,33 +1,49 @@ - +<!-- + - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <SharingEntrySimple class="sharing-entry__internal" - :title="t('files_sharing', 'Internal link')" - :subtitle="internalLinkSubtitle"> - <template #avatar> - <div class="avatar-external icon-external-white" /> - </template> + <ul> + <SharingEntrySimple ref="shareEntrySimple" + class="sharing-entry__internal" + :title="t('files_sharing', 'Internal link')" + :subtitle="internalLinkSubtitle"> + <template #avatar> + <div class="avatar-external icon-external-white" /> + </template> - <ActionLink ref="copyButton" - :href="internalLink" - target="_blank" - :icon="copied && copySuccess ? 'icon-checkmark-color' : 'icon-clippy'" - @click.prevent="copyLink"> - {{ clipboardTooltip }} - </ActionLink> - </SharingEntrySimple> + <NcActionButton :title="copyLinkTooltip" + :aria-label="copyLinkTooltip" + @click="copyLink"> + <template #icon> + <CheckIcon v-if="copied && copySuccess" + :size="20" + class="icon-checkmark-color" /> + <ClipboardIcon v-else :size="20" /> + </template> + </NcActionButton> + </SharingEntrySimple> + </ul> </template> <script> import { generateUrl } from '@nextcloud/router' -import ActionLink from '@nextcloud/vue/dist/Components/ActionLink' -import SharingEntrySimple from './SharingEntrySimple' +import { showSuccess } from '@nextcloud/dialogs' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' + +import CheckIcon from 'vue-material-design-icons/Check.vue' +import ClipboardIcon from 'vue-material-design-icons/ContentCopy.vue' + +import SharingEntrySimple from './SharingEntrySimple.vue' export default { name: 'SharingEntryInternal', components: { - ActionLink, + NcActionButton, SharingEntrySimple, + CheckIcon, + ClipboardIcon, }, props: { @@ -56,33 +72,31 @@ export default { }, /** - * Clipboard v-tooltip message + * Tooltip message * * @return {string} */ - clipboardTooltip() { + copyLinkTooltip() { if (this.copied) { - return this.copySuccess - ? t('files_sharing', 'Link copied') - : t('files_sharing', 'Cannot copy, please copy the link manually') + if (this.copySuccess) { + return '' + } + return t('files_sharing', 'Cannot copy, please copy the link manually') } - return t('files_sharing', 'Copy 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') }, }, methods: { async copyLink() { try { - await this.$copyText(this.internalLink) - // focus and show the tooltip - this.$refs.copyButton.$el.focus() + await navigator.clipboard.writeText(this.internalLink) + showSuccess(t('files_sharing', 'Link copied')) + this.$refs.shareEntrySimple.$refs.actionsComponent.$el.focus() this.copySuccess = true this.copied = true } catch (error) { @@ -113,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 ee7e8d4b930..6865af1b864 100644 --- a/apps/files_sharing/src/components/SharingEntryLink.vue +++ b/apps/files_sharing/src/components/SharingEntryLink.vue @@ -1,261 +1,163 @@ <!-- - - @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"> - <Avatar :is-no-user="true" + <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"> - <h5 :title="title"> - {{ title }} - </h5> - <p v-if="subtitle"> - {{ subtitle }} - </p> - </div> - <!-- clipboard --> - <Actions v-if="share && !isEmailShareType && share.token" - ref="copyButton" - class="sharing-entry__copy"> - <ActionLink :href="shareLink" - target="_blank" - :icon="copied && copySuccess ? 'icon-checkmark-color' : 'icon-clippy'" - @click.stop.prevent="copyLink"> - {{ clipboardTooltip }} - </ActionLink> - </Actions> + <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 --> - <Actions 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 --> - <ActionText v-if="errors.pending" - icon="icon-error" - :class="{ error: errors.pending}"> + <NcActionText v-if="errors.pending" + class="error"> + <template #icon> + <ErrorIcon :size="20" /> + </template> {{ errors.pending }} - </ActionText> - <ActionText v-else icon="icon-info"> + </NcActionText> + <NcActionText v-else icon="icon-info"> {{ t('files_sharing', 'Please enter the following required information before creating the share') }} - </ActionText> + </NcActionText> <!-- password --> - <ActionText v-if="pendingPassword" icon="icon-password"> - {{ t('files_sharing', 'Password protection (enforced)') }} - </ActionText> - <ActionCheckbox 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') }} - </ActionCheckbox> - <ActionInput v-if="pendingPassword || share.password" - v-tooltip.auto="{ - content: errors.password, - show: errors.password, - trigger: 'manual', - defaultContainer: '#app-sidebar' - }" + {{ config.enforcePasswordForPublicLink ? t('files_sharing', 'Password protection (enforced)') : t('files_sharing', 'Password protection') }} + </NcActionCheckbox> + + <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') }} - </ActionInput> + @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 --> - <ActionText v-if="pendingExpirationDate" icon="icon-calendar-dark"> - {{ t('files_sharing', 'Expiration date (enforced)') }} - </ActionText> - <ActionInput v-if="pendingExpirationDate" - v-model="share.expireDate" - v-tooltip.auto="{ - content: errors.expireDate, - show: errors.expireDate, - trigger: 'manual', - defaultContainer: '#app-sidebar' - }" + <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" - - :lang="lang" - icon="" + :is-native-picker="true" + :hide-label="true" + :value="new Date(share.expireDate)" type="date" - value-type="format" - :disabled-date="disabledDate"> - <!-- let's not submit when picked, the user - might want to still edit or copy the password --> - {{ t('files_sharing', 'Enter a date') }} - </ActionInput> - - <ActionButton icon="icon-checkmark" @click.prevent.stop="onNewLinkShare"> + :min="dateTomorrow" + :max="maxExpirationDateEnforced" + @update:model-value="onExpirationChange" + @change="expirationDateChanged"> + <template #icon> + <IconCalendarBlank :size="20" /> + </template> + </NcActionInput> + + <NcActionButton :disabled="pendingEnforcedPassword && !share.newPassword" + @click.prevent.stop="onNewLinkShare(true)"> + <template #icon> + <CheckIcon :size="20" /> + </template> {{ t('files_sharing', 'Create share') }} - </ActionButton> - <ActionButton icon="icon-close" @click.prevent.stop="onCancel"> + </NcActionButton> + <NcActionButton @click.prevent.stop="onCancel"> + <template #icon> + <CloseIcon :size="20" /> + </template> {{ t('files_sharing', 'Cancel') }} - </ActionButton> - </Actions> + </NcActionButton> + </NcActions> <!-- actions --> - <Actions v-else-if="!loading" + <NcActions v-else-if="!loading" class="sharing-entry__actions" + :aria-label="actionsTooltip" menu-align="right" :open.sync="open" @close="onMenuClose"> <template v-if="share"> <template v-if="share.canEdit && canReshare"> - <!-- Custom Label --> - <ActionInput ref="label" - v-tooltip.auto="{ - content: errors.label, - show: errors.label, - trigger: 'manual', - defaultContainer: '.app-sidebar' - }" - :class="{ error: errors.label }" - :disabled="saving" - :aria-label="t('files_sharing', 'Share label')" - :value="share.newLabel !== undefined ? share.newLabel : share.label" - icon="icon-edit" - maxlength="255" - @update:value="onLabelChange" - @submit="onLabelSubmit"> - {{ t('files_sharing', 'Share label') }} - </ActionInput> - - <SharePermissionsEditor :can-reshare="canReshare" - :share.sync="share" - :file-info="fileInfo" /> - - <ActionSeparator /> - - <ActionCheckbox :checked.sync="share.hideDownload" - :disabled="saving" - @change="queueUpdate('hideDownload')"> - {{ t('files_sharing', 'Hide download') }} - </ActionCheckbox> - - <!-- password --> - <ActionCheckbox :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') }} - </ActionCheckbox> - <ActionInput v-if="isPasswordProtected" - ref="password" - v-tooltip.auto="{ - content: errors.password, - show: errors.password, - trigger: 'manual', - defaultContainer: '#app-sidebar' - }" - 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') }} - </ActionInput> - - <!-- password protected by Talk --> - <ActionCheckbox v-if="isPasswordProtectedByTalkAvailable" - :checked.sync="isPasswordProtectedByTalk" - :disabled="!canTogglePasswordProtectedByTalkAvailable || saving" - class="share-link-password-talk-checkbox" - @change="onPasswordProtectedByTalkChange"> - {{ t('files_sharing', 'Video verification') }} - </ActionCheckbox> - - <!-- expiration date --> - <ActionCheckbox :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') }} - </ActionCheckbox> - <ActionInput v-if="hasExpirationDate" - ref="expireDate" - v-tooltip.auto="{ - content: errors.expireDate, - show: errors.expireDate, - trigger: 'manual', - defaultContainer: '#app-sidebar' - }" - class="share-link-expire-date" - :class="{ error: errors.expireDate}" - :disabled="saving" - :lang="lang" - :value="share.expireDate" - value-type="format" - icon="icon-calendar-dark" - type="date" - :disabled-date="disabledDate" - @update:value="onExpirationChange"> - {{ t('files_sharing', 'Enter a date') }} - </ActionInput> - - <!-- note --> - <ActionCheckbox :checked.sync="hasNote" - :disabled="saving" - @uncheck="queueUpdate('note')"> - {{ t('files_sharing', 'Note to recipient') }} - </ActionCheckbox> - <ActionTextEditable v-if="hasNote" - ref="note" - v-tooltip.auto="{ - content: errors.note, - show: errors.note, - trigger: 'manual', - defaultContainer: '#app-sidebar' - }" - :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> - <ActionSeparator /> + <NcActionButton :close-after-click="true" + @click.prevent="showQRCode = true"> + <template #icon> + <IconQr :size="20" /> + </template> + {{ t('files_sharing', 'Generate QR code') }} + </NcActionButton> + + <NcActionSeparator /> <!-- external actions --> <ExternalShareAction v-for="action in externalLinkActions" @@ -266,104 +168,156 @@ :share="share" /> <!-- external legacy sharing via url (social...) --> - <ActionLink 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 }} - </ActionLink> + </NcActionLink> - <ActionButton v-if="share.canDelete" - icon="icon-close" - :disabled="saving" - @click.prevent="onDelete"> - {{ t('files_sharing', 'Unshare') }} - </ActionButton> - <ActionButton v-if="!isEmailShareType && canReshare" + <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') }} - </ActionButton> + </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 --> - <ActionButton v-else-if="canReshare" + <NcActionButton v-else-if="canReshare" class="new-share-link" + :title="t('files_sharing', 'Create a new share link')" + :aria-label="t('files_sharing', 'Create a new share link')" :icon="loading ? 'icon-loading-small' : 'icon-add'" - @click.prevent.stop="onNewLinkShare"> - {{ t('files_sharing', 'Create a new share link') }} - </ActionButton> - </Actions> + @click.prevent.stop="onNewLinkShare" /> + </NcActions> <!-- loading indicator to replace the menu --> <div v-else class="icon-loading-small sharing-entry__loading" /> + + <!-- Modal to open whenever we have a QR code --> + <NcDialog v-if="showQRCode" + size="normal" + :open.sync="showQRCode" + :name="title" + :close-on-click-outside="true" + @close="showQRCode = false"> + <div class="qr-code-dialog"> + <VueQrcode tag="img" + :value="shareLink" + class="qr-code-dialog__img" /> + </div> + </NcDialog> </li> </template> <script> -import { generateUrl } from '@nextcloud/router' -import { Type as ShareTypes } from '@nextcloud/sharing' -import Vue from 'vue' - -import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' -import ActionCheckbox from '@nextcloud/vue/dist/Components/ActionCheckbox' -import ActionInput from '@nextcloud/vue/dist/Components/ActionInput' -import ActionLink from '@nextcloud/vue/dist/Components/ActionLink' -import ActionText from '@nextcloud/vue/dist/Components/ActionText' -import ActionSeparator from '@nextcloud/vue/dist/Components/ActionSeparator' -import ActionTextEditable from '@nextcloud/vue/dist/Components/ActionTextEditable' -import Actions from '@nextcloud/vue/dist/Components/Actions' -import Avatar from '@nextcloud/vue/dist/Components/Avatar' -import Tooltip from '@nextcloud/vue/dist/Directives/Tooltip' - -import ExternalShareAction from './ExternalShareAction' -import SharePermissionsEditor from './SharePermissionsEditor' -import GeneratePassword from '../utils/GeneratePassword' -import Share from '../models/Share' -import SharesMixin from '../mixins/SharesMixin' +import { showError, showSuccess } from '@nextcloud/dialogs' +import { emit } from '@nextcloud/event-bus' +import { t } from '@nextcloud/l10n' +import moment from '@nextcloud/moment' +import { generateUrl, getBaseUrl } from '@nextcloud/router' +import { ShareType } from '@nextcloud/sharing' + +import VueQrcode from '@chenfengyuan/vue-qrcode' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcActionCheckbox from '@nextcloud/vue/components/NcActionCheckbox' +import NcActionInput from '@nextcloud/vue/components/NcActionInput' +import NcActionLink from '@nextcloud/vue/components/NcActionLink' +import NcActionText from '@nextcloud/vue/components/NcActionText' +import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcDialog from '@nextcloud/vue/components/NcDialog' + +import Tune from 'vue-material-design-icons/Tune.vue' +import IconCalendarBlank from 'vue-material-design-icons/CalendarBlankOutline.vue' +import IconQr from 'vue-material-design-icons/Qrcode.vue' +import ErrorIcon from 'vue-material-design-icons/Exclamation.vue' +import LockIcon from 'vue-material-design-icons/LockOutline.vue' +import CheckIcon from 'vue-material-design-icons/CheckBold.vue' +import ClipboardIcon from 'vue-material-design-icons/ContentCopy.vue' +import CloseIcon from 'vue-material-design-icons/Close.vue' +import PlusIcon from 'vue-material-design-icons/Plus.vue' + +import SharingEntryQuickShareSelect from './SharingEntryQuickShareSelect.vue' +import ShareExpiryTime from './ShareExpiryTime.vue' + +import ExternalShareAction from './ExternalShareAction.vue' +import GeneratePassword from '../utils/GeneratePassword.ts' +import Share from '../models/Share.ts' +import SharesMixin from '../mixins/SharesMixin.js' +import ShareDetails from '../mixins/ShareDetails.js' +import logger from '../services/logger.ts' export default { name: 'SharingEntryLink', components: { - Actions, - ActionButton, - ActionCheckbox, - ActionInput, - ActionLink, - ActionText, - ActionTextEditable, - ActionSeparator, - Avatar, ExternalShareAction, - SharePermissionsEditor, - }, - - directives: { - Tooltip, + NcActions, + NcActionButton, + NcActionCheckbox, + NcActionInput, + NcActionLink, + NcActionText, + NcActionSeparator, + NcAvatar, + NcDialog, + VueQrcode, + Tune, + IconCalendarBlank, + IconQr, + ErrorIcon, + LockIcon, + CheckIcon, + ClipboardIcon, + CloseIcon, + PlusIcon, + SharingEntryQuickShareSelect, + ShareExpiryTime, }, - mixins: [SharesMixin], + mixins: [SharesMixin, ShareDetails], props: { canReshare: { type: Boolean, default: true, }, + index: { + type: Number, + default: null, + }, }, data() { return { + shareCreationComplete: false, copySuccess: true, copied: false, + defaultExpirationDateEnabled: false, // Are we waiting for password/expiration date pending: false, ExternalLegacyLinkActions: OCA.Sharing.ExternalLinkActions.state, ExternalShareActions: OCA.Sharing.ExternalShareActions.state, + + // tracks whether modal should be opened or not + showQRCode: false, } }, @@ -374,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) { @@ -381,27 +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) { + return t('files_sharing', 'Share link ({index})', { index: this.index }) } - return t('files_sharing', 'Share link') + + return t('files_sharing', 'Create public link') }, /** @@ -417,48 +392,18 @@ 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) { - let dateString = moment(this.config.defaultExpirationDateString) - if (!dateString.isValid()) { - dateString = moment() - } - this.share.state.expiration = enabled - ? dateString.format('YYYY-MM-DD') - : '' - console.debug('Expiration date status', enabled, this.share.expireDate) - }, - }, + passwordExpirationTime() { + if (this.share.passwordExpirationTime === null) { + return null + } - dateMaxEnforced() { - return this.config.isDefaultExpireDateEnforced - && moment().add(1 + this.config.defaultExpireDate, 'days') - }, + const expirationTime = moment(this.share.passwordExpirationTime) - /** - * 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) - }, + if (expirationTime.diff(moment()) < 0) { + return false + } + + return expirationTime.fromNow() }, /** @@ -500,7 +445,7 @@ export default { */ isEmailShareType() { return this.share - ? this.share.type === this.SHARE_TYPES.SHARE_TYPE_EMAIL + ? this.share.type === ShareType.Email : false }, @@ -525,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() { @@ -544,21 +526,31 @@ 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() }) }, /** - * Clipboard v-tooltip message + * Tooltip message for actions button * * @return {string} */ - clipboardTooltip() { + actionsTooltip() { + return t('files_sharing', 'Actions for "{title}"', { title: this.title }) + }, + + /** + * Tooltip message for copy button + * + * @return {string} + */ + copyLinkTooltip() { if (this.copied) { - return this.copySuccess - ? t('files_sharing', 'Link copied') - : t('files_sharing', 'Cannot copy, please copy the link manually') + if (this.copySuccess) { + return '' + } + return t('files_sharing', 'Cannot copy, please copy the link manually') } - return t('files_sharing', 'Copy to clipboard') + return t('files_sharing', 'Copy public link of "{title}"', { title: this.title }) }, /** @@ -577,64 +569,85 @@ 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() { return typeof this.config.passwordPolicy === 'object' }, + + canChangeHideDownload() { + const hasDisabledDownload = (shareAttribute) => shareAttribute.scope === 'permissions' && shareAttribute.key === 'download' && shareAttribute.value === false + return this.fileInfo.shareAttributes.some(hasDisabledDownload) + }, + + isFileRequest() { + return this.share.isFileRequest + }, + }, + mounted() { + this.defaultExpirationDateEnabled = this.config.defaultExpirationDate instanceof Date + if (this.share && this.isNewShare) { + this.share.expireDate = this.defaultExpirationDateEnabled ? this.formatDateToString(this.config.defaultExpirationDate) : '' + } }, methods: { /** + * Check if the share requires review + * + * @param {boolean} shareReviewComplete if the share was reviewed + * @return {boolean} + */ + shareRequiresReview(shareReviewComplete) { + // If a user clicks 'Create share' it means they have reviewed the share + if (shareReviewComplete) { + return false + } + return this.defaultExpirationDateEnabled || this.config.enableLinkPasswordByDefault + }, + /** * Create a new share link and append it to the list + * @param {boolean} shareReviewComplete if the share was reviewed */ - async onNewLinkShare() { + 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.config.defaultExpirationDateString - } - if (this.config.enableLinkPasswordByDefault) { - shareDefaults.password = await GeneratePassword() + shareDefaults.expiration = this.formatDateToString(this.config.defaultExpirationDate) } - // 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)) { - await this.pushNewLinkShare(this.share, true) - 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) }) @@ -645,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 } }, @@ -658,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 { @@ -671,22 +708,25 @@ export default { this.errors = {} const path = (this.fileInfo.path + '/' + this.fileInfo.name).replace('//', '/') - const newShare = await this.createShare({ + 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) - }) + } - this.open = false + console.debug('Creating link share with options', options) + const newShare = await this.createShare(options) + this.open = false + this.shareCreationComplete = true console.debug('Link share created', newShare) - // if share already exists, copy link directly on next tick let component if (update) { @@ -702,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 ! @@ -710,9 +753,16 @@ export default { // otherwise the user needs to copy/paste the password before finishing the share. component.copyLink() } + showSuccess(t('files_sharing', 'Link share created')) + + } catch (data) { + const message = data?.response?.data?.ocs?.meta?.message + if (!message) { + showError(t('files_sharing', 'Error while creating the share')) + console.error(data) + return + } - } catch ({ response }) { - const message = response.data.ocs.meta.message if (message.match(/password/i)) { this.onSyncError('password', message) } else if (message.match(/date/i)) { @@ -720,33 +770,17 @@ export default { } else { 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() { try { - await this.$copyText(this.shareLink) + await navigator.clipboard.writeText(this.shareLink) + showSuccess(t('files_sharing', 'Link copied')) // focus and show the tooltip this.$refs.copyButton.$el.focus() this.copySuccess = true @@ -796,7 +830,7 @@ export default { }, /** - * Menu have been closed or password has been submited. + * Menu have been closed or password has been submitted. * The only property that does not get * synced automatically is the password * So let's check if we have an unsaved @@ -806,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') } }, @@ -821,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') @@ -836,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 */ @@ -843,10 +890,11 @@ 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) + } }, }, - } </script> @@ -855,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); + } - h5 { - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; + &__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; } - } &:not(.sharing-entry--share) &__actions { .new-share-link { @@ -879,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 { @@ -892,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 de545a497a8..a00333ba0ce 100644 --- a/apps/files_sharing/src/components/SharingEntrySimple.vue +++ b/apps/files_sharing/src/components/SharingEntrySimple.vue @@ -1,53 +1,35 @@ <!-- - - @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"> <slot name="avatar" /> - <div v-tooltip="tooltip" class="sharing-entry__desc"> - <h5>{{ title }}</h5> + <div class="sharing-entry__desc"> + <span class="sharing-entry__title">{{ title }}</span> <p v-if="subtitle"> {{ subtitle }} </p> </div> - <Actions v-if="$slots['default']" menu-align="right" class="sharing-entry__actions"> + <NcActions v-if="$slots['default']" + ref="actionsComponent" + class="sharing-entry__actions" + menu-align="right" + :aria-expanded="ariaExpandedValue"> <slot /> - </Actions> + </NcActions> </li> </template> <script> -import Actions from '@nextcloud/vue/dist/Components/Actions' -import Tooltip from '@nextcloud/vue/dist/Directives/Tooltip' +import NcActions from '@nextcloud/vue/components/NcActions' export default { name: 'SharingEntrySimple', components: { - Actions, - }, - - directives: { - Tooltip, + NcActions, }, props: { @@ -56,10 +38,6 @@ export default { default: '', required: true, }, - tooltip: { - type: String, - default: '', - }, subtitle: { type: String, default: '', @@ -68,8 +46,20 @@ export default { type: Boolean, default: true, }, + ariaExpanded: { + type: Boolean, + default: null, + }, }, + computed: { + ariaExpandedValue() { + if (this.ariaExpanded === null) { + return this.ariaExpanded + } + return this.ariaExpanded ? 'true' : 'false' + }, + }, } </script> @@ -80,22 +70,23 @@ export default { min-height: 44px; &__desc { padding: 8px; + padding-inline-start: 10px; line-height: 1.2em; position: relative; flex: 1 1; min-width: 0; - h5 { - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - max-width: inherit; - } p { color: var(--color-text-maxcontrast); } } + &__title { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + 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 02c1f27f173..6fb33aba6b2 100644 --- a/apps/files_sharing/src/components/SharingInput.vue +++ b/apps/files_sharing/src/components/SharingInput.vue @@ -1,74 +1,57 @@ <!-- - - @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> - <Multiselect ref="multiselect" - class="sharing-input" - :clear-on-select="true" - :disabled="!canReshare" - :hide-selected="true" - :internal-search="false" - :loading="loading" - :options="options" - :placeholder="inputPlaceholder" - :preselect-first="true" - :preserve-search="true" - :searchable="true" - :user-select="true" - open-direction="below" - label="displayName" - track-by="id" - @search-change="asyncFind" - @select="addShare"> - <template #noOptions> - {{ t('files_sharing', 'No recommendations. Start typing.') }} - </template> - <template #noResult> - {{ noResultText }} - </template> - </Multiselect> + <div class="sharing-search"> + <label class="hidden-visually" :for="shareInputId"> + {{ isExternal ? t('files_sharing', 'Enter external recipients') + : t('files_sharing', 'Search for internal recipients') }} + </label> + <NcSelect ref="select" + v-model="value" + :input-id="shareInputId" + class="sharing-search__input" + :disabled="!canReshare" + :loading="loading" + :filterable="false" + :placeholder="inputPlaceholder" + :clear-search-on-blur="() => false" + :user-select="true" + :options="options" + :label-outside="true" + @search="asyncFind" + @option:selected="onSelected"> + <template #no-options="{ search }"> + {{ search ? noResultText : placeholder }} + </template> + </NcSelect> + </div> </template> <script> import { generateOcsUrl } from '@nextcloud/router' import { getCurrentUser } from '@nextcloud/auth' +import { getCapabilities } from '@nextcloud/capabilities' import axios from '@nextcloud/axios' import debounce from 'debounce' -import Multiselect from '@nextcloud/vue/dist/Components/Multiselect' +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', components: { - Multiselect, + NcSelect, }, - mixins: [ShareTypes, ShareRequests], + mixins: [ShareRequests, ShareDetails], props: { shares: { @@ -94,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() { @@ -104,6 +101,7 @@ export default { recommendations: [], ShareSearch: OCA.Sharing.ShareSearch.state, suggestions: [], + value: null, } }, @@ -125,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 …') @@ -153,11 +155,19 @@ export default { }, mounted() { - this.getRecommendations() + if (!this.isExternal) { + // We can only recommend users, groups etc for internal shares + this.getRecommendations() + } }, methods: { - async asyncFind(query, id) { + onSelected(option) { + this.value = null // Reset selected option + this.openSharingDetails(option) + }, + + async asyncFind(query) { // save current query to check if we display // recommendations or search results this.query = query.trim() @@ -173,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 @@ -214,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) @@ -239,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, }) } @@ -249,7 +274,7 @@ export default { const allSuggestions = exactSuggestions.concat(suggestions).concat(externalResults).concat(lookupEntry) - // Count occurances of display names in order to provide a distinguishable description if needed + // Count occurrences of display names in order to provide a distinguishable description if needed const nameCounts = allSuggestions.reduce((nameCounts, result) => { if (!result.displayName) { return nameCounts @@ -331,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 @@ -344,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 @@ -382,26 +412,48 @@ export default { */ shareTypeToIcon(type) { switch (type) { - case this.SHARE_TYPES.SHARE_TYPE_GUEST: - // default is a user, other icons are here to differenciate + 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: - return 'icon-user' - case this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP: - case this.SHARE_TYPES.SHARE_TYPE_GROUP: - return 'icon-group' - case this.SHARE_TYPES.SHARE_TYPE_EMAIL: - return 'icon-mail' - case this.SHARE_TYPES.SHARE_TYPE_CIRCLE: - return 'icon-circle' - case this.SHARE_TYPES.SHARE_TYPE_ROOM: - return 'icon-room' - case this.SHARE_TYPES.SHARE_TYPE_DECK: - return 'icon-deck' - + // case ShareType.Remote: + // case ShareType.User: + return { + icon: 'icon-user', + iconTitle: t('files_sharing', 'Guest'), + } + case ShareType.RemoteGroup: + case ShareType.Group: + return { + icon: 'icon-group', + iconTitle: t('files_sharing', 'Group'), + } + case ShareType.Email: + return { + icon: 'icon-mail', + iconTitle: t('files_sharing', 'Email'), + } + case ShareType.Team: + return { + icon: 'icon-teams', + iconTitle: t('files_sharing', 'Team'), + } + case ShareType.Room: + return { + icon: 'icon-room', + iconTitle: t('files_sharing', 'Talk conversation'), + } + case ShareType.Deck: + return { + icon: 'icon-deck', + iconTitle: t('files_sharing', 'Deck board'), + } + case ShareType.Sciencemesh: + return { + icon: 'icon-sciencemesh', + iconTitle: t('files_sharing', 'ScienceMesh'), + } default: - return '' + return {} } }, @@ -412,107 +464,33 @@ 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 || '', - icon: this.shareTypeToIcon(result.value.shareType), - } - }, - - /** - * Process the new share request - * - * @param {object} value the multiselect option - */ - async addShare(value) { - if (value.lookup) { - await this.getSuggestions(this.query, true) - - // focus the input again - this.$nextTick(() => { - this.$refs.multiselect.$el.querySelector('.multiselect__input').focus() - }) - 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, - }) - - // 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) - } - - // reset the search string when done - // FIXME: https://github.com/shentao/vue-multiselect/issues/633 - if (this.$refs.multiselect?.$refs?.VueMultiselect?.search) { - this.$refs.multiselect.$refs.VueMultiselect.search = '' - } - - await this.getRecommendations() - } catch (error) { - // focus back if any error - const input = this.$refs.multiselect.$el.querySelector('input') - if (input) { - input.focus() - } - this.query = value.shareWith - console.error('Error while adding new share', error) - } finally { - this.loading = false + ...this.shareTypeToIcon(result.value.shareType), } }, }, @@ -520,21 +498,31 @@ export default { </script> <style lang="scss"> -.sharing-input { - width: 100%; - margin: 10px 0; +.sharing-search { + display: flex; + flex-direction: column; + margin-bottom: 4px; + + label[for="sharing-search-input"] { + margin-bottom: 2px; + } + &__input { + width: 100%; + margin: 10px 0; + } +} + +.vs__dropdown-menu { // properly style the lookup entry - .multiselect__option { - span[lookup] { - .avatardiv { - background-image: var(--icon-search-fff); - background-repeat: no-repeat; - background-position: center; - background-color: var(--color-text-maxcontrast) !important; - div { - display: none; - } + span[lookup] { + .avatardiv { + background-image: var(--icon-search-white); + background-repeat: no-repeat; + background-position: center; + background-color: var(--color-text-maxcontrast) !important; + .avatardiv__initials-wrapper { + display: none; } } } |