aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files_sharing/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'apps/files_sharing/src/components')
-rw-r--r--apps/files_sharing/src/components/ExternalShareAction.vue25
-rw-r--r--apps/files_sharing/src/components/FileListFilterAccount.vue138
-rw-r--r--apps/files_sharing/src/components/NewFileRequestDialog.vue468
-rw-r--r--apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogDatePassword.vue258
-rw-r--r--apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogFinish.vue236
-rw-r--r--apps/files_sharing/src/components/NewFileRequestDialog/NewFileRequestDialogIntro.vue166
-rw-r--r--apps/files_sharing/src/components/PersonalSettings.vue29
-rw-r--r--apps/files_sharing/src/components/SelectShareFolderDialogue.vue42
-rw-r--r--apps/files_sharing/src/components/ShareExpiryTime.vue91
-rw-r--r--apps/files_sharing/src/components/SharePermissionsEditor.vue291
-rw-r--r--apps/files_sharing/src/components/SharingEntry.vue436
-rw-r--r--apps/files_sharing/src/components/SharingEntryInherited.vue65
-rw-r--r--apps/files_sharing/src/components/SharingEntryInternal.vue77
-rw-r--r--apps/files_sharing/src/components/SharingEntryLink.vue885
-rw-r--r--apps/files_sharing/src/components/SharingEntryQuickShareSelect.vue206
-rw-r--r--apps/files_sharing/src/components/SharingEntrySimple.vue75
-rw-r--r--apps/files_sharing/src/components/SharingInput.vue396
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;
}
}
}