Browse Source
feat(files_sharing): add `new file request` option
feat(files_sharing): add `new file request` option
Signed-off-by: skjnldsv <skjnldsv@protonmail.com>pull/46007/head
committed by
John Molakvoæ
12 changed files with 1047 additions and 14 deletions
-
49apps/files_sharing/lib/Controller/ShareAPIController.php
-
330apps/files_sharing/src/components/NewFileRequestDialog.vue
-
227apps/files_sharing/src/components/NewFileRequestDialog/FileRequestDatePassword.vue
-
217apps/files_sharing/src/components/NewFileRequestDialog/FileRequestFinish.vue
-
153apps/files_sharing/src/components/NewFileRequestDialog/FileRequestIntro.vue
-
6apps/files_sharing/src/components/SharingEntryLink.vue
-
3apps/files_sharing/src/components/SharingInput.vue
-
5apps/files_sharing/src/init.ts
-
50apps/files_sharing/src/new/newFileRequest.ts
-
13apps/files_sharing/src/utils/GeneratePassword.ts
-
6apps/files_sharing/src/views/SharingDetailsTab.vue
-
2apps/sharebymail/lib/ShareByMailProvider.php
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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 |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue