diff options
author | skjnldsv <skjnldsv@protonmail.com> | 2024-06-20 14:02:53 +0200 |
---|---|---|
committer | John Molakvoæ <skjnldsv@users.noreply.github.com> | 2024-07-12 20:14:30 +0200 |
commit | 443c48aefb70983afefce48652880a1bcaad4d53 (patch) | |
tree | 791143357ce720357653a03489b81534d03f3699 /apps | |
parent | b6f635f6f6dd79b073107bde7f7e378e00ceaccf (diff) | |
download | nextcloud-server-443c48aefb70983afefce48652880a1bcaad4d53.tar.gz nextcloud-server-443c48aefb70983afefce48652880a1bcaad4d53.zip |
feat(files_sharing): add `new file request` option
Signed-off-by: skjnldsv <skjnldsv@protonmail.com>
Diffstat (limited to 'apps')
-rw-r--r-- | apps/files_sharing/lib/Controller/ShareAPIController.php | 49 | ||||
-rw-r--r-- | apps/files_sharing/src/components/NewFileRequestDialog.vue | 330 | ||||
-rw-r--r-- | apps/files_sharing/src/components/NewFileRequestDialog/FileRequestDatePassword.vue | 227 | ||||
-rw-r--r-- | apps/files_sharing/src/components/NewFileRequestDialog/FileRequestFinish.vue | 217 | ||||
-rw-r--r-- | apps/files_sharing/src/components/NewFileRequestDialog/FileRequestIntro.vue | 153 | ||||
-rw-r--r-- | apps/files_sharing/src/components/SharingEntryLink.vue | 6 | ||||
-rw-r--r-- | apps/files_sharing/src/components/SharingInput.vue | 3 | ||||
-rw-r--r-- | apps/files_sharing/src/init.ts | 5 | ||||
-rw-r--r-- | apps/files_sharing/src/new/newFileRequest.ts | 50 | ||||
-rw-r--r-- | apps/files_sharing/src/utils/GeneratePassword.ts (renamed from apps/files_sharing/src/utils/GeneratePassword.js) | 13 | ||||
-rw-r--r-- | apps/files_sharing/src/views/SharingDetailsTab.vue | 6 | ||||
-rw-r--r-- | apps/sharebymail/lib/ShareByMailProvider.php | 2 |
12 files changed, 1047 insertions, 14 deletions
diff --git a/apps/files_sharing/lib/Controller/ShareAPIController.php b/apps/files_sharing/lib/Controller/ShareAPIController.php index dd2df96bd19..5a939e88914 100644 --- a/apps/files_sharing/lib/Controller/ShareAPIController.php +++ b/apps/files_sharing/lib/Controller/ShareAPIController.php @@ -12,14 +12,17 @@ namespace OCA\Files_Sharing\Controller; use Exception; use OC\Files\FileInfo; use OC\Files\Storage\Wrapper\Wrapper; +use OC\Share20\Exception\ProviderException; use OCA\Files\Helper; use OCA\Files_Sharing\Exceptions\SharingRightsException; use OCA\Files_Sharing\External\Storage; use OCA\Files_Sharing\ResponseDefinitions; use OCA\Files_Sharing\SharedStorage; +use OCA\ShareByMail\ShareByMailProvider; use OCP\App\IAppManager; use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\OCS\OCSBadRequestException; use OCP\AppFramework\OCS\OCSException; use OCP\AppFramework\OCS\OCSForbiddenException; @@ -46,6 +49,7 @@ use OCP\Server; use OCP\Share\Exceptions\GenericShareException; use OCP\Share\Exceptions\ShareNotFound; use OCP\Share\IManager; +use OCP\Share\IProviderFactory; use OCP\Share\IShare; use OCP\UserStatus\IManager as IUserStatusManager; use Psr\Container\ContainerExceptionInterface; @@ -81,6 +85,7 @@ class ShareAPIController extends OCSController { private IPreview $previewManager, private IDateTimeZone $dateTimeZone, private LoggerInterface $logger, + private IProviderFactory $factory, ?string $userId = null ) { parent::__construct($appName, $request); @@ -2025,6 +2030,50 @@ class ShareAPIController extends OCSController { } } } + } + + public function sendShareEmail(int $shareId, $emails = []) { + try { + $share = $this->shareManager->getShareById($shareId); + + // Only mail and link shares are supported + if ($share->getShareType() !== IShare::TYPE_EMAIL + && $share->getShareType() !== IShare::TYPE_LINK) { + throw new OCSBadRequestException('Only email and link shares are supported'); + } + + // Allow sending the mail again if the share is an email share + if ($share->getShareType() === IShare::TYPE_EMAIL && count($emails) !== 0) { + throw new OCSBadRequestException('Emails should not be provided for email shares'); + } + + // Allow sending a mail if the share is a link share AND a list of emails is provided + if ($share->getShareType() === IShare::TYPE_LINK && count($emails) === 0) { + throw new OCSBadRequestException('Emails should be provided for link shares'); + } + + $link = $this->urlGenerator->linkToRouteAbsolute('files_sharing.sharecontroller.showShare', + ['token' => $share->getToken()]); + try { + /** @var ShareByMailProvider */ + $provider = $this->factory->getProviderForType(IShare::TYPE_EMAIL); + $provider->sendMailNotification( + $share->getNode()->getName(), + $link, + $share->getSharedBy(), + $share->getSharedWith(), + $share->getExpirationDate(), + $share->getNote() + ); + return new JSONResponse(['message' => 'ok']); + } catch (ProviderException $e) { + throw new OCSBadRequestException($this->l->t('Sending mail notification is not enabled')); + } catch (Exception $e) { + throw new OCSException($this->l->t('Error while sending mail notification')); + } + } catch (ShareNotFound $e) { + throw new OCSNotFoundException($this->l->t('Wrong share ID, share does not exist')); + } } } diff --git a/apps/files_sharing/src/components/NewFileRequestDialog.vue b/apps/files_sharing/src/components/NewFileRequestDialog.vue new file mode 100644 index 00000000000..53b4408b263 --- /dev/null +++ b/apps/files_sharing/src/components/NewFileRequestDialog.vue @@ -0,0 +1,330 @@ +<!-- + - 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 don\'t 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-labelledby="file-request-dialog-description" + data-cy-file-request-dialog-form + @submit.prevent.stop="onSubmit"> + <FileRequestIntro v-if="currentStep === STEP.FIRST" + :context="context" + :destination.sync="destination" + :disabled="loading" + :label.sync="label" + :note.sync="note" /> + + <FileRequestDatePassword v-else-if="currentStep === STEP.SECOND" + :deadline.sync="deadline" + :disabled="loading" + :password.sync="password" /> + + <FileRequestFinish v-else-if="share" + :emails="emails" + :share="share" + @add-email="email => emails.push(email)" + @remove-email="onRemoveEmail" /> + </form> + + <!-- Controls --> + <template #actions> + <!-- Cancel the creation --> + <NcButton :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> + + <!-- Align right --> + <span class="dialog__actions-separator" /> + + <!-- 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') }} + </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> + {{ continueButtonLabel }} + </NcButton> + + <!-- Finish --> + <NcButton v-else + :aria-label="t('files_sharing', 'Close the creation dialog')" + data-cy-file-request-dialog-controls="finish" + type="primary" + @click="$emit('close')"> + <template #icon> + <IconCheck :size="20" /> + </template> + {{ finishButtonLabel }} + </NcButton> + </template> + </NcDialog> +</template> + +<script lang="ts"> +import type { AxiosError } from '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 { showError } from '@nextcloud/dialogs' +import { translate, translatePlural } from '@nextcloud/l10n' +import { Type } from '@nextcloud/sharing' +import axios from '@nextcloud/axios' + +import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' +import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js' +import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' +import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js' + +import IconCheck from 'vue-material-design-icons/Check.vue' +import IconNext from 'vue-material-design-icons/ArrowRight.vue' + +import FileRequestDatePassword from './NewFileRequestDialog/FileRequestDatePassword.vue' +import FileRequestFinish from './NewFileRequestDialog/FileRequestFinish.vue' +import FileRequestIntro from './NewFileRequestDialog/FileRequestIntro.vue' +import Share from '../models/Share' +import logger from '../services/logger' + +enum STEP { + FIRST = 0, + SECOND = 1, + LAST = 2, +} + +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 { + n: translatePlural, + t: translate, + STEP, + } + }, + + data() { + return { + currentStep: STEP.FIRST, + loading: false, + + destination: this.context.path || '/', + label: '', + note: '', + + deadline: null as Date | null, + password: null as string | null, + + share: null as Share | null, + emails: [] as string[], + } + }, + + computed: { + continueButtonLabel() { + if (this.currentStep === STEP.LAST) { + return this.t('files_sharing', 'Close') + } + return this.t('files_sharing', 'Continue') + }, + + finishButtonLabel() { + if (this.emails.length === 0) { + return this.t('files_sharing', 'Close') + } + return this.n('files_sharing', 'Close and send email', 'Close and send {count} emails', this.emails.length, { count: this.emails.length }) + }, + }, + + methods: { + onPageNext() { + const form = this.$refs.form as HTMLFormElement + if (!form.checkValidity()) { + form.reportValidity() + } + + // custom destination validation + // cannot share root + if (this.destination === '/' || this.destination === '') { + const destinationInput = form.querySelector('input[name="destination"]') as HTMLInputElement + destinationInput?.setCustomValidity(this.t('files_sharing', 'Please select a folder, you cannot share the root directory.')) + 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') + }, + + onSubmit() { + this.$emit('submit') + }, + + async createShare() { + this.loading = true + + const shareUrl = generateOcsUrl('apps/files_sharing/api/v1/shares') + // Format must be YYYY-MM-DD + const expireDate = this.deadline ? this.deadline.toISOString().split('T')[0] : undefined + try { + const request = await axios.post(shareUrl, { + path: this.destination, + shareType: Type.SHARE_TYPE_LINK, + publicUpload: 'true', + password: this.password || undefined, + expireDate, + label: this.label, + attributes: JSON.stringify({ is_file_request: true }) + }) + + // 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 + ? this.t('files_sharing', 'Error creating the share: {errorMessage}', { errorMessage }) + : this.t('files_sharing', 'Error creating the share'), + ) + logger.error('Error while creating share', { error, errorMessage }) + throw error + } finally { + this.loading = false + } + }, + }, +}) +</script> + +<style scoped lang="scss"> +.file-request-dialog { + --margin: 36px; + --secondary-margin: 18px; + + &__header { + margin: 0 var(--margin); + } + + &__form { + position: relative; + overflow: auto; + padding: var(--secondary-margin) var(--margin); + // overlap header bottom padding + margin-top: calc(-1 * var(--secondary-margin)); + } + + :deep(fieldset) { + display: flex; + flex-direction: column; + width: 100%; + margin-top: var(--secondary-margin); + + :deep(legend) { + display: flex; + align-items: center; + width: 100%; + } + } + + :deep(.dialog__actions) { + width: auto; + margin-inline: 12px; + // align left and remove margin + margin-left: 0; + span.dialog__actions-separator { + margin-left: auto; + } + } + + :deep(.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/FileRequestDatePassword.vue b/apps/files_sharing/src/components/NewFileRequestDialog/FileRequestDatePassword.vue new file mode 100644 index 00000000000..6da342da0f1 --- /dev/null +++ b/apps/files_sharing/src/components/NewFileRequestDialog/FileRequestDatePassword.vue @@ -0,0 +1,227 @@ +<!-- + - 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"> + <NcNoteCard v-if="defaultExpireDateEnforced" type="info"> + {{ t('files_sharing', 'Your administrator has enforced a default expiration date with a maximum {days} days.', { days: defaultExpireDate }) }} + </NcNoteCard> + + <!-- Enable expiration --> + <legend>{{ t('files_sharing', 'When should the request expire ?') }}</legend> + <NcCheckboxRadioSwitch v-show="!defaultExpireDateEnforced" + :checked="defaultExpireDateEnforced || deadline !== null" + :disabled="disabled || defaultExpireDateEnforced" + @update:checked="onToggleDeadline"> + {{ t('files_sharing', 'Set a submission deadline') }} + </NcCheckboxRadioSwitch> + + <!-- Date picker --> + <NcDateTimePickerNative v-if="deadline !== null" + id="file-request-dialog-deadline" + :disabled="disabled" + :hide-label="true" + :max="maxDate" + :min="minDate" + :placeholder="t('files_sharing', 'Select a date')" + :required="defaultExpireDateEnforced" + :value="deadline" + name="deadline" + type="date" + @update:value="$emit('update:deadline', $event)"/> + </fieldset> + + <!-- Password --> + <fieldset class="file-request-dialog__password" data-cy-file-request-dialog-fieldset="password"> + <NcNoteCard v-if="enforcePasswordForPublicLink" type="info"> + {{ t('files_sharing', 'Your administrator has enforced a password protection.') }} + </NcNoteCard> + + <!-- Enable password --> + <legend>{{ t('files_sharing', 'What password should be used for the request ?') }}</legend> + <NcCheckboxRadioSwitch v-show="!enforcePasswordForPublicLink" + :checked="enforcePasswordForPublicLink || password !== null" + :disabled="disabled || enforcePasswordForPublicLink" + @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-outside="true" + :placeholder="t('files_sharing', 'Enter a valid password')" + :required="false" + :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="generatePassword(); showPassword()"> + <template #icon> + <IconPasswordGen :size="20" /> + </template> + </NcButton> + </div> + </fieldset> + </div> +</template> + +<script lang="ts"> +import { defineComponent, type PropType } from 'vue' +import { translate } from '@nextcloud/l10n' + +import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' +import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' +import NcDateTimePickerNative from '@nextcloud/vue/dist/Components/NcDateTimePickerNative.js' +import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js' +import NcPasswordField from '@nextcloud/vue/dist/Components/NcPasswordField.js' + +import IconPasswordGen from 'vue-material-design-icons/AutoFix.vue' + +import GeneratePassword from '../../utils/GeneratePassword' + +export default defineComponent({ + name: 'FileRequestDatePassword', + + components: { + IconPasswordGen, + NcButton, + NcCheckboxRadioSwitch, + NcDateTimePickerNative, + NcNoteCard, + NcPasswordField, + }, + + props: { + disabled: { + type: Boolean, + required: false, + default: false, + }, + deadline: { + type: Date as PropType<Date | null>, + required: false, + default: null, + }, + password: { + type: String as PropType<string | null>, + required: false, + default: null, + }, + }, + + emits: [ + 'update:deadline', + 'update:password', + ], + + setup() { + return { + t: translate, + + // Default expiration date if defaultExpireDateEnabled is true + defaultExpireDate: window.OC.appConfig.core.defaultExpireDate as number, + // Default expiration date is enabled for public links (can be disabled) + defaultExpireDateEnabled: window.OC.appConfig.core.defaultExpireDateEnabled === true, + // Default expiration date is enforced for public links (can't be disabled) + defaultExpireDateEnforced: window.OC.appConfig.core.defaultExpireDateEnforced === true, + + // Default password protection is enabled for public links (can be disabled) + enableLinkPasswordByDefault: window.OC.appConfig.core.enableLinkPasswordByDefault === true, + // Password protection is enforced for public links (can't be disabled) + enforcePasswordForPublicLink: window.OC.appConfig.core.enforcePasswordForPublicLink === true, + } + }, + + data() { + return { + maxDate: null as Date | null, + minDate: new Date(new Date().setDate(new Date().getDate() + 1)), + } + }, + + computed: { + passwordAndExpirationSummary(): string { + if (this.deadline && this.password) { + return this.t('files_sharing', 'The request will expire on {date} at midnight and will be password protected.', { + date: this.deadline.toLocaleDateString(), + }) + } + + if (this.deadline) { + return this.t('files_sharing', 'The request will expire on {date} at midnight.', { + date: this.deadline.toLocaleDateString(), + }) + } + + if (this.password) { + return this.t('files_sharing', 'The request will be password protected.') + } + + return '' + }, + }, + + mounted() { + // If defined, we set the default expiration date + if (this.defaultExpireDate > 0) { + this.$emit('update:deadline', new Date(new Date().setDate(new Date().getDate() + this.defaultExpireDate))) + } + + // If enforced, we cannot set a date before the default expiration days (see admin settings) + if (this.defaultExpireDateEnforced) { + this.maxDate = new Date(new Date().setDate(new Date().getDate() + this.defaultExpireDate)) + } + + // If enabled by default, we generate a valid password + if (this.enableLinkPasswordByDefault) { + this.generatePassword() + } + }, + + methods: { + onToggleDeadline(checked: boolean) { + this.$emit('update:deadline', checked ? new Date() : null) + }, + + async onTogglePassword(checked: boolean) { + if (checked) { + this.generatePassword() + return + } + this.$emit('update:password', null) + }, + + generatePassword() { + 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; +} +</style> diff --git a/apps/files_sharing/src/components/NewFileRequestDialog/FileRequestFinish.vue b/apps/files_sharing/src/components/NewFileRequestDialog/FileRequestFinish.vue new file mode 100644 index 00000000000..1162414e049 --- /dev/null +++ b/apps/files_sharing/src/components/NewFileRequestDialog/FileRequestFinish.vue @@ -0,0 +1,217 @@ +<!-- + - 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 others 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 to clipboard')" + @click="copyShareLink" + @click-trailing-button="copyShareLink"> + <template #trailing-button-icon> + <IconCheck v-if="isCopied" :size="20" @click="isCopied = false" /> + <IconClipboard v-else :size="20" @click="copyShareLink" /> + </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')" + type="email" + @keypress.enter.stop="addNewEmail" + @paste.stop.prevent="onPasteEmails" /> + + <!-- 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" + :is-guest="true" + :size="24" + :user="mail" /> + </template> + </NcChip> + </div> + </template> + </div> +</template> + +<script lang="ts"> +import type { PropType } from 'vue' +import Share from '../../models/Share' + +import { defineComponent } from 'vue' +import { generateUrl } from '@nextcloud/router' +import { showError, showSuccess } from '@nextcloud/dialogs' +import { translate, translatePlural } from '@nextcloud/l10n' + +import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js' +import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js' +import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js' +import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' +import NcChip from '@nextcloud/vue/dist/Components/NcChip.js' + +import IconCheck from 'vue-material-design-icons/Check.vue' +import IconClipboard from 'vue-material-design-icons/Clipboard.vue' +import { getCapabilities } from '@nextcloud/capabilities' + +export default defineComponent({ + name: 'FileRequestFinish', + + 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, + }, + }, + + emits: ['add-email', 'remove-email'], + + setup() { + return { + n: translatePlural, + t: translate, + isShareByMailEnabled: getCapabilities()?.files_sharing?.sharebymail?.enabled === true, + } + }, + + data() { + return { + isCopied: false, + email: '', + } + }, + + computed: { + shareLink() { + return window.location.protocol + '//' + window.location.host + generateUrl('/s/') + this.share.token + }, + }, + + methods: { + async copyShareLink(event: MouseEvent) { + if (!navigator.clipboard) { + // Clipboard API not available + showError(this.t('files_sharing', 'Clipboard is not available')) + return + } + + await navigator.clipboard.writeText(this.shareLink) + + showSuccess(this.t('files_sharing', 'Link copied to clipboard')) + this.isCopied = true + event.target?.select?.() + + setTimeout(() => { + this.isCopied = false + }, 3000) + }, + + addNewEmail(e: KeyboardEvent) { + if (e.target instanceof HTMLInputElement) { + 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(this.t('files_sharing', 'Email already added')) + e.target.reportValidity() + return + } + + if (!this.isValidEmail(this.email.trim())) { + e.target.setCustomValidity(this.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(this.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(this.n('files_sharing', '1 email address already added', '{count} email addresses already added', duplicateEmails.length, { count: duplicateEmails.length })) + } + + if (validEmails.length > 0) { + showSuccess(this.n('files_sharing', '1 email address added', '{count} email addresses added', validEmails.length, { count: validEmails.length })) + } + + this.email = '' + }, + + isValidEmail(email) { + const regExpEmail = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ + return regExpEmail.test(email) + }, + }, +}) +</script> +<style scoped> +.input-field, +.file-request-dialog__emails { + margin-top: var(--secondary-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/FileRequestIntro.vue b/apps/files_sharing/src/components/NewFileRequestDialog/FileRequestIntro.vue new file mode 100644 index 00000000000..766fbc3fc22 --- /dev/null +++ b/apps/files_sharing/src/components/NewFileRequestDialog/FileRequestIntro.vue @@ -0,0 +1,153 @@ +<!-- + - 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-outside="true" + :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" + :helper-text="t('files_sharing', 'The uploaded files are visible only to you unless you choose to share them.')" + :label-outside="true" + :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> + </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-outside="true" + :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)" /> + </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 { translate } from '@nextcloud/l10n' + +import IconFolder from 'vue-material-design-icons/Folder.vue' +import NcTextArea from '@nextcloud/vue/dist/Components/NcTextArea.js' +import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' + +export default defineComponent({ + name: 'FileRequestIntro', + + components: { + IconFolder, + 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: translate, + } + }, + + methods: { + onPickDestination() { + const filepicker = getFilePickerBuilder(this.t('files_sharing', 'Select a destination')) + .addMimeTypeFilter('httpd/unix-directory') + .allowDirectories(true) + .addButton({ + label: this.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/SharingEntryLink.vue b/apps/files_sharing/src/components/SharingEntryLink.vue index f93e6a9c953..9410f980a54 100644 --- a/apps/files_sharing/src/components/SharingEntryLink.vue +++ b/apps/files_sharing/src/components/SharingEntryLink.vue @@ -240,7 +240,7 @@ import PlusIcon from 'vue-material-design-icons/Plus.vue' import SharingEntryQuickShareSelect from './SharingEntryQuickShareSelect.vue' import ExternalShareAction from './ExternalShareAction.vue' -import GeneratePassword from '../utils/GeneratePassword.js' +import GeneratePassword from '../utils/GeneratePassword.ts' import Share from '../models/Share.js' import SharesMixin from '../mixins/SharesMixin.js' import ShareDetails from '../mixins/ShareDetails.js' @@ -369,7 +369,7 @@ export default { }, 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, 'password', enabled ? await GeneratePassword(true) : '') Vue.set(this.share, 'newPassword', this.share.password) }, }, @@ -590,7 +590,7 @@ export default { // ELSE, show the pending popovermenu // if password default or enforced, pre-fill with random one if (this.config.enableLinkPasswordByDefault || this.config.enforcePasswordForPublicLink) { - shareDefaults.password = await GeneratePassword() + shareDefaults.password = await GeneratePassword(true) } // create share & close menu diff --git a/apps/files_sharing/src/components/SharingInput.vue b/apps/files_sharing/src/components/SharingInput.vue index f2b2f573700..e986ff4491f 100644 --- a/apps/files_sharing/src/components/SharingInput.vue +++ b/apps/files_sharing/src/components/SharingInput.vue @@ -27,14 +27,15 @@ </template> <script> +import { generateOcsUrl } from '@nextcloud/router' import { getCurrentUser } from '@nextcloud/auth' import { getCapabilities } from '@nextcloud/capabilities' -import { generateOcsUrl } from '@nextcloud/router' import axios from '@nextcloud/axios' import debounce from 'debounce' import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' import Config from '../services/ConfigService.js' +import GeneratePassword from '../utils/GeneratePassword.ts' import Share from '../models/Share.js' import ShareRequests from '../mixins/ShareRequests.js' import ShareTypes from '../mixins/ShareTypes.js' diff --git a/apps/files_sharing/src/init.ts b/apps/files_sharing/src/init.ts index 0c8cd349f1b..4652026861d 100644 --- a/apps/files_sharing/src/init.ts +++ b/apps/files_sharing/src/init.ts @@ -2,9 +2,10 @@ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { registerDavProperty } from '@nextcloud/files' +import { addNewFileMenuEntry, registerDavProperty } from '@nextcloud/files' import registerSharingViews from './views/shares' +import { entry as newFileRequest } from './new/newFileRequest' import './actions/acceptShareAction' import './actions/openInFilesAction' import './actions/rejectShareAction' @@ -13,6 +14,8 @@ import './actions/sharingStatusAction' registerSharingViews() +addNewFileMenuEntry(newFileRequest) + registerDavProperty('nc:share-attributes', { nc: 'http://nextcloud.org/ns' }) registerDavProperty('oc:share-types', { oc: 'http://owncloud.org/ns' }) registerDavProperty('ocs:share-permissions', { ocs: 'http://open-collaboration-services.org/ns' }) diff --git a/apps/files_sharing/src/new/newFileRequest.ts b/apps/files_sharing/src/new/newFileRequest.ts new file mode 100644 index 00000000000..07adacd67d3 --- /dev/null +++ b/apps/files_sharing/src/new/newFileRequest.ts @@ -0,0 +1,50 @@ +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import type { Entry, Folder, Node } from '@nextcloud/files' + +import { translate as t } from '@nextcloud/l10n' +import Vue, { defineAsyncComponent } from 'vue' +import FileUploadSvg from '@mdi/svg/svg/file-upload.svg?raw' + +const NewFileRequestDialogVue = defineAsyncComponent(() => import('../components/NewFileRequestDialog.vue')) + +export const entry = { + id: 'file-request', + displayName: t('files', 'Create new file request'), + iconSvgInline: FileUploadSvg, + order: 30, + enabled(): boolean { + // determine requirements + // 1. user can share the root folder + // 2. OR user can create subfolders ? + return true + }, + async handler(context: Folder, content: Node[]) { + // Create document root + const mountingPoint = document.createElement('div') + mountingPoint.id = 'file-request-dialog' + document.body.appendChild(mountingPoint) + + // Init vue app + const NewFileRequestDialog = new Vue({ + name: 'NewFileRequestDialogRoot', + render: (h) => h( + NewFileRequestDialogVue, + { + props: { + context, + content, + }, + on: { + close: () => { + NewFileRequestDialog.$destroy() + }, + }, + }, + ), + el: mountingPoint, + }) + }, +} as Entry diff --git a/apps/files_sharing/src/utils/GeneratePassword.js b/apps/files_sharing/src/utils/GeneratePassword.ts index e40b12c53d1..c7839fe6302 100644 --- a/apps/files_sharing/src/utils/GeneratePassword.js +++ b/apps/files_sharing/src/utils/GeneratePassword.ts @@ -6,6 +6,7 @@ import axios from '@nextcloud/axios' import Config from '../services/ConfigService.js' import { showError, showSuccess } from '@nextcloud/dialogs' +import { translate as t } from '@nextcloud/l10n' const config = new Config() // note: some chars removed on purpose to make them human friendly when read out @@ -15,21 +16,23 @@ const passwordSet = 'abcdefgijkmnopqrstwxyzABCDEFGHJKLMNPQRSTWXYZ23456789' * Generate a valid policy password or * request a valid password if password_policy * is enabled - * - * @return {string} a valid password */ -export default async function() { +export default async function(verbose = false): Promise<string> { // password policy is enabled, let's request a pass if (config.passwordPolicy.api && config.passwordPolicy.api.generate) { try { const request = await axios.get(config.passwordPolicy.api.generate) if (request.data.ocs.data.password) { - showSuccess(t('files_sharing', 'Password created successfully')) + if (verbose) { + showSuccess(t('files_sharing', 'Password created successfully')) + } return request.data.ocs.data.password } } catch (error) { console.info('Error generating password from password_policy', error) - showError(t('files_sharing', 'Error generating password from password policy')) + if (verbose) { + showError(t('files_sharing', 'Error generating password from password policy')) + } } } diff --git a/apps/files_sharing/src/views/SharingDetailsTab.vue b/apps/files_sharing/src/views/SharingDetailsTab.vue index 6141d92e33e..a9ade99f556 100644 --- a/apps/files_sharing/src/views/SharingDetailsTab.vue +++ b/apps/files_sharing/src/views/SharingDetailsTab.vue @@ -272,7 +272,7 @@ import DotsHorizontalIcon from 'vue-material-design-icons/DotsHorizontal.vue' import ExternalShareAction from '../components/ExternalShareAction.vue' -import GeneratePassword from '../utils/GeneratePassword.js' +import GeneratePassword from '../utils/GeneratePassword.ts' import Share from '../models/Share.js' import ShareRequests from '../mixins/ShareRequests.js' import ShareTypes from '../mixins/ShareTypes.js' @@ -470,7 +470,7 @@ export default { }, async set(enabled) { if (enabled) { - this.share.password = await GeneratePassword() + this.share.password = await GeneratePassword(true) this.$set(this.share, 'newPassword', this.share.password) } else { this.share.password = '' @@ -772,7 +772,7 @@ export default { if (this.isNewShare) { if (this.isPasswordEnforced && this.isPublicShare) { - this.$set(this.share, 'newPassword', await GeneratePassword()) + this.$set(this.share, 'newPassword', await GeneratePassword(true)) this.advancedSectionAccordionExpanded = true } /* Set default expiration dates if configured */ diff --git a/apps/sharebymail/lib/ShareByMailProvider.php b/apps/sharebymail/lib/ShareByMailProvider.php index 6b37f99df79..4b3162c1982 100644 --- a/apps/sharebymail/lib/ShareByMailProvider.php +++ b/apps/sharebymail/lib/ShareByMailProvider.php @@ -277,7 +277,7 @@ class ShareByMailProvider implements IShareProvider { /** * @throws \Exception If mail couldn't be sent */ - protected function sendMailNotification( + public function sendMailNotification( string $filename, string $link, string $initiator, |