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.vue46
-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.vue68
-rw-r--r--apps/files_sharing/src/components/SelectShareFolderDialogue.vue113
-rw-r--r--apps/files_sharing/src/components/ShareExpiryTime.vue91
-rw-r--r--apps/files_sharing/src/components/SharingEntry.vue176
-rw-r--r--apps/files_sharing/src/components/SharingEntryInherited.vue98
-rw-r--r--apps/files_sharing/src/components/SharingEntryInternal.vue133
-rw-r--r--apps/files_sharing/src/components/SharingEntryLink.vue987
-rw-r--r--apps/files_sharing/src/components/SharingEntryQuickShareSelect.vue206
-rw-r--r--apps/files_sharing/src/components/SharingEntrySimple.vue92
-rw-r--r--apps/files_sharing/src/components/SharingInput.vue530
16 files changed, 3806 insertions, 0 deletions
diff --git a/apps/files_sharing/src/components/ExternalShareAction.vue b/apps/files_sharing/src/components/ExternalShareAction.vue
new file mode 100644
index 00000000000..c2c86cc8679
--- /dev/null
+++ b/apps/files_sharing/src/components/ExternalShareAction.vue
@@ -0,0 +1,46 @@
+<!--
+ - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <Component :is="data.is"
+ v-bind="data"
+ v-on="action.handlers">
+ {{ data.text }}
+ </Component>
+</template>
+
+<script>
+import Share from '../models/Share.ts'
+
+export default {
+ name: 'ExternalShareAction',
+
+ props: {
+ id: {
+ type: String,
+ required: true,
+ },
+ action: {
+ type: Object,
+ default: () => ({}),
+ },
+ fileInfo: {
+ type: Object,
+ default: () => {},
+ required: true,
+ },
+ share: {
+ type: Share,
+ default: null,
+ },
+ },
+
+ computed: {
+ data() {
+ return this.action.data(this)
+ },
+ },
+}
+</script>
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
new file mode 100644
index 00000000000..19c9c2aec87
--- /dev/null
+++ b/apps/files_sharing/src/components/PersonalSettings.vue
@@ -0,0 +1,68 @@
+<!--
+ - 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">
+ <h2>{{ t('files_sharing', 'Sharing') }}</h2>
+ <p v-if="!enforceAcceptShares">
+ <input id="files-sharing-personal-settings-accept"
+ v-model="accepting"
+ class="checkbox"
+ type="checkbox"
+ @change="toggleEnabled">
+ <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 />
+ </p>
+ </div>
+</template>
+
+<script>
+import { generateUrl } from '@nextcloud/router'
+import { loadState } from '@nextcloud/initial-state'
+import { showError } from '@nextcloud/dialogs'
+import axios from '@nextcloud/axios'
+
+import SelectShareFolderDialogue from './SelectShareFolderDialogue.vue'
+
+export default {
+ name: 'PersonalSettings',
+ components: {
+ SelectShareFolderDialogue,
+ },
+
+ data() {
+ return {
+ // Share acceptance config
+ accepting: loadState('files_sharing', 'accept_default'),
+ enforceAcceptShares: loadState('files_sharing', 'enforce_accept'),
+
+ // Receiving share folder config
+ allowCustomDirectory: loadState('files_sharing', 'allow_custom_share_folder'),
+ }
+ },
+
+ methods: {
+ async toggleEnabled() {
+ try {
+ await axios.put(generateUrl('/apps/files_sharing/settings/defaultAccept'), {
+ accept: this.accepting,
+ })
+ } catch (error) {
+ showError(t('files_sharing', 'Error while toggling options'))
+ console.error(error)
+ }
+ },
+ },
+}
+</script>
+
+<style scoped lang="scss">
+p {
+ margin-top: 12px;
+ margin-bottom: 12px;
+}
+</style>
diff --git a/apps/files_sharing/src/components/SelectShareFolderDialogue.vue b/apps/files_sharing/src/components/SelectShareFolderDialogue.vue
new file mode 100644
index 00000000000..959fecaa4a4
--- /dev/null
+++ b/apps/files_sharing/src/components/SelectShareFolderDialogue.vue
@@ -0,0 +1,113 @@
+<!--
+ - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <div class="share-folder">
+ <!-- Folder picking form -->
+ <form class="share-folder__form" @reset.prevent.stop="resetFolder">
+ <NcTextField class="share-folder__picker"
+ type="text"
+ :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"
+ class="share-folder__reset"
+ type="reset"
+ :value="t('files_sharing', 'Reset')"
+ :aria-label="t('files_sharing', 'Reset folder to system default')">
+ </form>
+ </div>
+</template>
+
+<script>
+import axios from '@nextcloud/axios'
+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,
+ defaultDirectory,
+ }
+ },
+ computed: {
+ readableDirectory() {
+ if (!this.directory) {
+ return '/'
+ }
+ return this.directory
+ },
+ },
+ methods: {
+ async pickFolder() {
+
+ // Setup file picker
+ const picker = getFilePickerBuilder(t('files_sharing', 'Choose a default folder for accepted shares'))
+ .startAt(this.readableDirectory)
+ .setMultiSelect(false)
+ .setType(1)
+ .setMimeTypeFilter(['httpd/unix-directory'])
+ .allowDirectories()
+ .build()
+
+ try {
+ // Init user folder picking
+ const dir = await picker.pick() || '/'
+ if (!dir.startsWith('/')) {
+ throw new Error(t('files_sharing', 'Invalid path selected'))
+ }
+
+ // Fix potential path issues and save results
+ this.directory = path.normalize(dir)
+ await axios.put(generateUrl('/apps/files_sharing/settings/shareFolder'), {
+ shareFolder: this.directory,
+ })
+ } catch (error) {
+ showError(error.message || t('files_sharing', 'Unknown error'))
+ }
+ },
+
+ resetFolder() {
+ this.directory = this.defaultDirectory
+ axios.delete(generateUrl('/apps/files_sharing/settings/shareFolder'))
+ },
+ },
+}
+</script>
+
+<style scoped lang="scss">
+.share-folder {
+ &__form {
+ display: flex;
+ }
+
+ &__picker {
+ cursor: pointer;
+ max-width: 300px;
+ }
+
+ // Make the reset button looks like text
+ &__reset {
+ background-color: transparent;
+ border: none;
+ font-weight: normal;
+ text-decoration: underline;
+ font-size: inherit;
+ }
+}
+</style>
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/SharingEntry.vue b/apps/files_sharing/src/components/SharingEntry.vue
new file mode 100644
index 00000000000..342b40ce384
--- /dev/null
+++ b/apps/files_sharing/src/components/SharingEntry.vue
@@ -0,0 +1,176 @@
+<!--
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <li class="sharing-entry">
+ <NcAvatar class="sharing-entry__avatar"
+ :is-no-user="share.type !== ShareType.User"
+ :user="share.shareWith"
+ :display-name="share.shareWithDisplayName"
+ :menu-position="'left'"
+ :url="share.shareWithAvatar" />
+
+ <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>
+ </NcButton>
+ </li>
+</template>
+
+<script>
+import { ShareType } from '@nextcloud/sharing'
+
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcSelect from '@nextcloud/vue/components/NcSelect'
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import DotsHorizontalIcon from 'vue-material-design-icons/DotsHorizontal.vue'
+
+import ShareExpiryTime from './ShareExpiryTime.vue'
+import SharingEntryQuickShareSelect from './SharingEntryQuickShareSelect.vue'
+
+import SharesMixin from '../mixins/SharesMixin.js'
+import ShareDetails from '../mixins/ShareDetails.js'
+
+export default {
+ name: 'SharingEntry',
+
+ components: {
+ NcButton,
+ NcAvatar,
+ DotsHorizontalIcon,
+ NcSelect,
+ ShareExpiryTime,
+ SharingEntryQuickShareSelect,
+ },
+
+ mixins: [SharesMixin, ShareDetails],
+
+ computed: {
+ title() {
+ let title = this.share.shareWithDisplayName
+
+ 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 === ShareType.Room) {
+ title += ` (${t('files_sharing', 'conversation')})`
+ } else if (this.share.type === ShareType.Remote && !showAsInternal) {
+ title += ` (${t('files_sharing', 'remote')})`
+ } else if (this.share.type === ShareType.RemoteGroup) {
+ title += ` (${t('files_sharing', 'remote group')})`
+ } 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 = {
+ // todo: strong or italic?
+ // but the t function escape any html from the data :/
+ user: this.share.shareWithDisplayName,
+ owner: this.share.ownerDisplayName,
+ }
+ if (this.share.type === ShareType.Group) {
+ return t('files_sharing', 'Shared with the group {user} by {owner}', data)
+ } else if (this.share.type === ShareType.Room) {
+ return t('files_sharing', 'Shared with the conversation {user} by {owner}', data)
+ }
+
+ return t('files_sharing', 'Shared with {user} by {owner}', data)
+ }
+ return null
+ },
+
+ /**
+ * @return {boolean}
+ */
+ hasStatus() {
+ if (this.share.type !== ShareType.User) {
+ return false
+ }
+
+ return (typeof this.share.status === 'object' && !Array.isArray(this.share.status))
+ },
+ },
+
+ methods: {
+ /**
+ * Save potential changed data on menu close
+ */
+ onMenuClose() {
+ this.onNoteSubmit()
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+.sharing-entry {
+ display: flex;
+ align-items: center;
+ height: 44px;
+ &__summary {
+ padding: 8px;
+ padding-inline-start: 10px;
+ display: flex;
+ flex-direction: column;
+ 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);
+ }
+ }
+ }
+
+}
+</style>
diff --git a/apps/files_sharing/src/components/SharingEntryInherited.vue b/apps/files_sharing/src/components/SharingEntryInherited.vue
new file mode 100644
index 00000000000..e7dfffd5776
--- /dev/null
+++ b/apps/files_sharing/src/components/SharingEntryInherited.vue
@@ -0,0 +1,98 @@
+<!--
+ - 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>
+ <NcAvatar :user="share.shareWith"
+ :display-name="share.shareWithDisplayName"
+ class="sharing-entry__avatar" />
+ </template>
+ <NcActionText icon="icon-user">
+ {{ t('files_sharing', 'Added by {initiator}', { initiator: share.ownerDisplayName }) }}
+ </NcActionText>
+ <NcActionLink v-if="share.viaPath && share.viaFileid"
+ icon="icon-folder"
+ :href="viaFileTargetUrl">
+ {{ t('files_sharing', 'Via “{folder}”', {folder: viaFolderName} ) }}
+ </NcActionLink>
+ <NcActionButton v-if="share.canDelete"
+ icon="icon-close"
+ @click.prevent="onDelete">
+ {{ t('files_sharing', 'Unshare') }}
+ </NcActionButton>
+ </SharingEntrySimple>
+</template>
+
+<script>
+import { generateUrl } from '@nextcloud/router'
+import { basename } from '@nextcloud/paths'
+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.js'
+import SharesMixin from '../mixins/SharesMixin.js'
+import SharingEntrySimple from '../components/SharingEntrySimple.vue'
+
+export default {
+ name: 'SharingEntryInherited',
+
+ components: {
+ NcActionButton,
+ NcActionLink,
+ NcActionText,
+ NcAvatar,
+ SharingEntrySimple,
+ },
+
+ mixins: [SharesMixin],
+
+ props: {
+ share: {
+ type: Share,
+ required: true,
+ },
+ },
+
+ computed: {
+ viaFileTargetUrl() {
+ return generateUrl('/f/{fileid}', {
+ fileid: this.share.viaFileid,
+ })
+ },
+
+ viaFolderName() {
+ return basename(this.share.viaPath)
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+.sharing-entry {
+ display: flex;
+ align-items: center;
+ height: 44px;
+ &__desc {
+ display: flex;
+ 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-inline-start: auto;
+ }
+}
+</style>
diff --git a/apps/files_sharing/src/components/SharingEntryInternal.vue b/apps/files_sharing/src/components/SharingEntryInternal.vue
new file mode 100644
index 00000000000..027d2a3d5c3
--- /dev/null
+++ b/apps/files_sharing/src/components/SharingEntryInternal.vue
@@ -0,0 +1,133 @@
+<!--
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<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>
+
+ <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 { 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: {
+ NcActionButton,
+ SharingEntrySimple,
+ CheckIcon,
+ ClipboardIcon,
+ },
+
+ props: {
+ fileInfo: {
+ type: Object,
+ default: () => {},
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ copied: false,
+ copySuccess: false,
+ }
+ },
+
+ computed: {
+ /**
+ * Get the internal link to this file id
+ *
+ * @return {string}
+ */
+ internalLink() {
+ return window.location.protocol + '//' + window.location.host + generateUrl('/f/') + this.fileInfo.id
+ },
+
+ /**
+ * Tooltip message
+ *
+ * @return {string}
+ */
+ copyLinkTooltip() {
+ if (this.copied) {
+ if (this.copySuccess) {
+ return ''
+ }
+ return t('files_sharing', 'Cannot copy, please copy the link manually')
+ }
+ return t('files_sharing', 'Copy internal link')
+ },
+
+ internalLinkSubtitle() {
+ return t('files_sharing', 'For people who already have access')
+ },
+ },
+
+ methods: {
+ async copyLink() {
+ try {
+ 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) {
+ this.copySuccess = false
+ this.copied = true
+ console.error(error)
+ } finally {
+ setTimeout(() => {
+ this.copySuccess = false
+ this.copied = false
+ }, 4000)
+ }
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+.sharing-entry__internal {
+ .avatar-external {
+ width: 32px;
+ height: 32px;
+ line-height: 32px;
+ font-size: 18px;
+ background-color: var(--color-text-maxcontrast);
+ border-radius: 50%;
+ flex-shrink: 0;
+ }
+ .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
new file mode 100644
index 00000000000..6865af1b864
--- /dev/null
+++ b/apps/files_sharing/src/components/SharingEntryLink.vue
@@ -0,0 +1,987 @@
+<!--
+ - 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">
+ <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__summary">
+ <div class="sharing-entry__desc">
+ <span class="sharing-entry__title" :title="title">
+ {{ title }}
+ </span>
+ <p v-if="subtitle">
+ {{ subtitle }}
+ </p>
+ <SharingEntryQuickShareSelect v-if="share && share.permissions !== undefined"
+ :share="share"
+ :file-info="fileInfo"
+ @open-sharing-details="openShareDetailsForCustomSettings(share)" />
+ </div>
+
+ <div class="sharing-entry__actions">
+ <ShareExpiryTime v-if="share && share.expireDate" :share="share" />
+
+ <!-- clipboard -->
+ <div>
+ <NcActions v-if="share && (!isEmailShareType || isFileRequest) && share.token" ref="copyButton" class="sharing-entry__copy">
+ <NcActionButton :aria-label="copyLinkTooltip"
+ :title="copyLinkTooltip"
+ :href="shareLink"
+ @click.prevent="copyLink">
+ <template #icon>
+ <CheckIcon v-if="copied && copySuccess"
+ :size="20"
+ class="icon-checkmark-color" />
+ <ClipboardIcon v-else :size="20" />
+ </template>
+ </NcActionButton>
+ </NcActions>
+ </div>
+ </div>
+ </div>
+
+ <!-- pending actions -->
+ <NcActions v-if="!pending && pendingDataIsMissing"
+ class="sharing-entry__actions"
+ :aria-label="actionsTooltip"
+ menu-align="right"
+ :open.sync="open"
+ @close="onCancel">
+ <!-- pending data menu -->
+ <NcActionText v-if="errors.pending"
+ class="error">
+ <template #icon>
+ <ErrorIcon :size="20" />
+ </template>
+ {{ errors.pending }}
+ </NcActionText>
+ <NcActionText v-else icon="icon-info">
+ {{ t('files_sharing', 'Please enter the following required information before creating the share') }}
+ </NcActionText>
+
+ <!-- password -->
+ <NcActionCheckbox v-if="pendingPassword"
+ :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 protection') }}
+ </NcActionCheckbox>
+
+ <NcActionInput v-if="pendingEnforcedPassword || isPasswordProtected"
+ class="share-link-password"
+ :label="t('files_sharing', 'Enter a password')"
+ :value.sync="share.newPassword"
+ :disabled="saving"
+ :required="config.enableLinkPasswordByDefault || config.enforcePasswordForPublicLink"
+ :minlength="isPasswordPolicyEnabled && config.passwordPolicy.minLength"
+ autocomplete="new-password"
+ @submit="onNewLinkShare(true)">
+ <template #icon>
+ <LockIcon :size="20" />
+ </template>
+ </NcActionInput>
+
+ <NcActionCheckbox v-if="pendingDefaultExpirationDate"
+ :checked.sync="defaultExpirationDateEnabled"
+ :disabled="pendingEnforcedExpirationDate || saving"
+ class="share-link-expiration-date-checkbox"
+ @update:model-value="onExpirationDateToggleUpdate">
+ {{ config.isDefaultExpireDateEnforced ? t('files_sharing', 'Enable link expiration (enforced)') : t('files_sharing', 'Enable link expiration') }}
+ </NcActionCheckbox>
+
+ <!-- expiration date -->
+ <NcActionInput v-if="(pendingDefaultExpirationDate || pendingEnforcedExpirationDate) && defaultExpirationDateEnabled"
+ data-cy-files-sharing-expiration-date-input
+ class="share-link-expire-date"
+ :label="pendingEnforcedExpirationDate ? t('files_sharing', 'Enter expiration date (enforced)') : t('files_sharing', 'Enter expiration date')"
+ :disabled="saving"
+ :is-native-picker="true"
+ :hide-label="true"
+ :value="new Date(share.expireDate)"
+ type="date"
+ :min="dateTomorrow"
+ :max="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') }}
+ </NcActionButton>
+ <NcActionButton @click.prevent.stop="onCancel">
+ <template #icon>
+ <CloseIcon :size="20" />
+ </template>
+ {{ t('files_sharing', 'Cancel') }}
+ </NcActionButton>
+ </NcActions>
+
+ <!-- actions -->
+ <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">
+ <NcActionButton :disabled="saving"
+ :close-after-click="true"
+ @click.prevent="openSharingDetails">
+ <template #icon>
+ <Tune :size="20" />
+ </template>
+ {{ t('files_sharing', 'Customize link') }}
+ </NcActionButton>
+ </template>
+
+ <NcActionButton :close-after-click="true"
+ @click.prevent="showQRCode = true">
+ <template #icon>
+ <IconQr :size="20" />
+ </template>
+ {{ t('files_sharing', 'Generate QR code') }}
+ </NcActionButton>
+
+ <NcActionSeparator />
+
+ <!-- external actions -->
+ <ExternalShareAction v-for="action in externalLinkActions"
+ :id="action.id"
+ :key="action.id"
+ :action="action"
+ :file-info="fileInfo"
+ :share="share" />
+
+ <!-- external legacy sharing via url (social...) -->
+ <NcActionLink v-for="({ icon, url, name }, actionIndex) in externalLegacyLinkActions"
+ :key="actionIndex"
+ :href="url(shareLink)"
+ :icon="icon"
+ target="_blank">
+ {{ name }}
+ </NcActionLink>
+
+ <NcActionButton v-if="!isEmailShareType && canReshare"
+ class="new-share-link"
+ @click.prevent.stop="onNewLinkShare">
+ <template #icon>
+ <PlusIcon :size="20" />
+ </template>
+ {{ t('files_sharing', 'Add another link') }}
+ </NcActionButton>
+
+ <NcActionButton v-if="share.canDelete"
+ :disabled="saving"
+ @click.prevent="onDelete">
+ <template #icon>
+ <CloseIcon :size="20" />
+ </template>
+ {{ t('files_sharing', 'Unshare') }}
+ </NcActionButton>
+ </template>
+
+ <!-- Create new share -->
+ <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" />
+ </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 { 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: {
+ ExternalShareAction,
+ NcActions,
+ NcActionButton,
+ NcActionCheckbox,
+ NcActionInput,
+ NcActionLink,
+ NcActionText,
+ NcActionSeparator,
+ NcAvatar,
+ NcDialog,
+ VueQrcode,
+ Tune,
+ IconCalendarBlank,
+ IconQr,
+ ErrorIcon,
+ LockIcon,
+ CheckIcon,
+ ClipboardIcon,
+ CloseIcon,
+ PlusIcon,
+ SharingEntryQuickShareSelect,
+ ShareExpiryTime,
+ },
+
+ 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,
+ }
+ },
+
+ computed: {
+ /**
+ * Link share label
+ *
+ * @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) {
+ if (this.isEmailShareType) {
+ 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', 'Create public link')
+ },
+
+ /**
+ * Show the email on a second line if a label is set for mail shares
+ *
+ * @return {string}
+ */
+ subtitle() {
+ if (this.isEmailShareType
+ && this.title !== this.share.shareWith) {
+ return this.share.shareWith
+ }
+ return null
+ },
+
+ passwordExpirationTime() {
+ if (this.share.passwordExpirationTime === null) {
+ return null
+ }
+
+ const expirationTime = moment(this.share.passwordExpirationTime)
+
+ if (expirationTime.diff(moment()) < 0) {
+ return false
+ }
+
+ return expirationTime.fromNow()
+ },
+
+ /**
+ * Is Talk enabled?
+ *
+ * @return {boolean}
+ */
+ isTalkEnabled() {
+ return OC.appswebroots.spreed !== undefined
+ },
+
+ /**
+ * Is it possible to protect the password by Talk?
+ *
+ * @return {boolean}
+ */
+ isPasswordProtectedByTalkAvailable() {
+ return this.isPasswordProtected && this.isTalkEnabled
+ },
+
+ /**
+ * Is the current share password protected by Talk?
+ *
+ * @return {boolean}
+ */
+ isPasswordProtectedByTalk: {
+ get() {
+ return this.share.sendPasswordByTalk
+ },
+ async set(enabled) {
+ this.share.sendPasswordByTalk = enabled
+ },
+ },
+
+ /**
+ * Is the current share an email share ?
+ *
+ * @return {boolean}
+ */
+ isEmailShareType() {
+ return this.share
+ ? this.share.type === ShareType.Email
+ : false
+ },
+
+ canTogglePasswordProtectedByTalkAvailable() {
+ if (!this.isPasswordProtected) {
+ // Makes no sense
+ return false
+ } else if (this.isEmailShareType && !this.hasUnsavedPassword) {
+ // For email shares we need a new password in order to enable or
+ // disable
+ return false
+ }
+
+ // Anything else should be fine
+ return true
+ },
+
+ /**
+ * Pending data.
+ * If the share still doesn't have an id, it is not synced
+ * Therefore this is still not valid and requires user input
+ *
+ * @return {boolean}
+ */
+ pendingDataIsMissing() {
+ return this.pendingPassword || this.pendingEnforcedPassword || this.pendingDefaultExpirationDate || this.pendingEnforcedExpirationDate
+ },
+ pendingPassword() {
+ return this.config.enableLinkPasswordByDefault && this.isPendingShare
+ },
+ pendingEnforcedPassword() {
+ return this.config.enforcePasswordForPublicLink && this.isPendingShare
+ },
+ pendingEnforcedExpirationDate() {
+ return this.config.isDefaultExpireDateEnforced && this.isPendingShare
+ },
+ 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() {
+ return this.share.newPassword !== undefined
+ },
+
+ /**
+ * Return the public share link
+ *
+ * @return {string}
+ */
+ shareLink() {
+ return generateUrl('/s/{token}', { token: this.share.token }, { baseURL: getBaseUrl() })
+ },
+
+ /**
+ * Tooltip message for actions button
+ *
+ * @return {string}
+ */
+ actionsTooltip() {
+ return t('files_sharing', 'Actions for "{title}"', { title: this.title })
+ },
+
+ /**
+ * Tooltip message for copy button
+ *
+ * @return {string}
+ */
+ copyLinkTooltip() {
+ if (this.copied) {
+ if (this.copySuccess) {
+ return ''
+ }
+ return t('files_sharing', 'Cannot copy, please copy the link manually')
+ }
+ return t('files_sharing', 'Copy public link of "{title}"', { title: this.title })
+ },
+
+ /**
+ * External additionnai actions for the menu
+ *
+ * @deprecated use OCA.Sharing.ExternalShareActions
+ * @return {Array}
+ */
+ externalLegacyLinkActions() {
+ return this.ExternalLegacyLinkActions.actions
+ },
+
+ /**
+ * Additional actions for the menu
+ *
+ * @return {Array}
+ */
+ externalLinkActions() {
+ const filterValidAction = (action) => (action.shareType.includes(ShareType.Link) || action.shareType.includes(ShareType.Email)) && !action.advanced
+ // filter only the registered actions for said link
+ return this.ExternalShareActions.actions
+ .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(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: ShareType.Link,
+ }
+ if (this.config.isDefaultExpireDateEnforced) {
+ // default is empty string if not set
+ // expiration is the share object key, not expireDate
+ shareDefaults.expiration = this.formatDateToString(this.config.defaultExpirationDate)
+ }
+
+ 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
+
+ logger.info('Share policy requires a review or has mandated properties (password, expirationDate)...')
+
+ // ELSE, show the pending popovermenu
+ // if password default or enforced, pre-fill with random one
+ if (this.config.enableLinkPasswordByDefault || this.config.enforcePasswordForPublicLink) {
+ shareDefaults.password = await GeneratePassword(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)
+ })
+
+ // open the menu on the
+ // freshly created share component
+ this.open = false
+ this.pending = false
+ component.open = true
+
+ // 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
+ }
+ },
+
+ /**
+ * Push a new link share to the server
+ * And update or append to the list
+ * accordingly
+ *
+ * @param {Share} share the new share
+ * @param {boolean} [update] do we update the current share ?
+ */
+ async pushNewLinkShare(share, update) {
+ try {
+ // do nothing if we're already pending creation
+ if (this.loading) {
+ return true
+ }
+
+ this.loading = true
+ this.errors = {}
+
+ const path = (this.fileInfo.path + '/' + this.fileInfo.name).replace('//', '/')
+ const options = {
+ path,
+ shareType: ShareType.Link,
+ password: share.password,
+ 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 requests
+ // (currently not supported on create, only update)
+ }
+
+ 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) {
+ component = await new Promise(resolve => {
+ this.$emit('update:share', newShare, resolve)
+ })
+ } else {
+ // adding new share to the array and copying link to clipboard
+ // using promise so that we can copy link in the same click function
+ // and avoid firefox copy permissions issue
+ component = await new Promise(resolve => {
+ this.$emit('add:share', newShare, resolve)
+ })
+ }
+
+ await this.getNode()
+ emit('files:node:updated', this.node)
+
+ // Execute the copy link method
+ // freshly created share component
+ // ! somehow does not works on firefox !
+ if (!this.config.enforcePasswordForPublicLink) {
+ // Only copy the link when the password was not forced,
+ // 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
+ }
+
+ if (message.match(/password/i)) {
+ this.onSyncError('password', message)
+ } else if (message.match(/date/i)) {
+ this.onSyncError('expireDate', message)
+ } else {
+ this.onSyncError('pending', message)
+ }
+ throw data
+
+ } finally {
+ this.loading = false
+ this.shareCreationComplete = true
+ }
+ },
+ async copyLink() {
+ try {
+ 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
+ this.copied = true
+ } catch (error) {
+ this.copySuccess = false
+ this.copied = true
+ console.error(error)
+ } finally {
+ setTimeout(() => {
+ this.copySuccess = false
+ this.copied = false
+ }, 4000)
+ }
+ },
+
+ /**
+ * Update newPassword values
+ * of share. If password is set but not newPassword
+ * then the user did not changed the password
+ * If both co-exists, the password have changed and
+ * we show it in plain text.
+ * Then on submit (or menu close), we sync it.
+ *
+ * @param {string} password the changed password
+ */
+ onPasswordChange(password) {
+ this.$set(this.share, 'newPassword', password)
+ },
+
+ /**
+ * Uncheck password protection
+ * We need this method because @update:checked
+ * is ran simultaneously as @uncheck, so we
+ * cannot ensure data is up-to-date
+ */
+ onPasswordDisable() {
+ this.share.password = ''
+
+ // reset password state after sync
+ this.$delete(this.share, 'newPassword')
+
+ // only update if valid share.
+ if (this.share.id) {
+ this.queueUpdate('password')
+ }
+ },
+
+ /**
+ * 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
+ * password.
+ * expireDate is saved on datepicker pick
+ * or close.
+ */
+ onPasswordSubmit() {
+ if (this.hasUnsavedPassword) {
+ this.share.newPassword = this.share.newPassword.trim()
+ this.queueUpdate('password')
+ }
+ },
+
+ /**
+ * Update the password along with "sendPasswordByTalk".
+ *
+ * If the password was modified the new password is sent; otherwise
+ * updating a mail share would fail, as in that case it is required that
+ * a new password is set when enabling or disabling
+ * "sendPasswordByTalk".
+ */
+ onPasswordProtectedByTalkChange() {
+ if (this.hasUnsavedPassword) {
+ this.share.newPassword = this.share.newPassword.trim()
+ }
+
+ this.queueUpdate('sendPasswordByTalk', 'password')
+ },
+
+ /**
+ * Save potential changed data on menu close
+ */
+ onMenuClose() {
+ this.onPasswordSubmit()
+ this.onNoteSubmit()
+ },
+
+ /**
+ * @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
+ */
+ onCancel() {
+ // this.share already exists at this point,
+ // but is incomplete as not pushed to server
+ // YET. We can safely delete the share :)
+ if (!this.shareCreationComplete) {
+ this.$emit('remove:share', this.share)
+ }
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+.sharing-entry {
+ display: flex;
+ align-items: center;
+ min-height: 44px;
+
+ &__summary {
+ padding: 8px;
+ padding-inline-start: 10px;
+ display: flex;
+ justify-content: space-between;
+ flex: 1 0;
+ min-width: 0;
+ }
+
+ &__desc {
+ display: flex;
+ flex-direction: column;
+ line-height: 1.2em;
+
+ p {
+ color: var(--color-text-maxcontrast);
+ }
+
+ &__title {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ }
+ }
+
+ &__actions {
+ display: flex;
+ align-items: center;
+ margin-inline-start: auto;
+ }
+
+ &:not(.sharing-entry--share) &__actions {
+ .new-share-link {
+ border-top: 1px solid var(--color-border);
+ }
+ }
+
+ :deep(.avatar-link-share) {
+ background-color: var(--color-primary-element);
+ }
+
+ .sharing-entry__action--public-upload {
+ border-bottom: 1px solid var(--color-border);
+ }
+
+ &__loading {
+ width: 44px;
+ height: 44px;
+ margin: 0;
+ padding: 14px;
+ margin-inline-start: auto;
+ }
+
+ // put menus to the left
+ // but only the first one
+ .action-item {
+
+ ~.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
new file mode 100644
index 00000000000..a00333ba0ce
--- /dev/null
+++ b/apps/files_sharing/src/components/SharingEntrySimple.vue
@@ -0,0 +1,92 @@
+<!--
+ - 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 class="sharing-entry__desc">
+ <span class="sharing-entry__title">{{ title }}</span>
+ <p v-if="subtitle">
+ {{ subtitle }}
+ </p>
+ </div>
+ <NcActions v-if="$slots['default']"
+ ref="actionsComponent"
+ class="sharing-entry__actions"
+ menu-align="right"
+ :aria-expanded="ariaExpandedValue">
+ <slot />
+ </NcActions>
+ </li>
+</template>
+
+<script>
+import NcActions from '@nextcloud/vue/components/NcActions'
+
+export default {
+ name: 'SharingEntrySimple',
+
+ components: {
+ NcActions,
+ },
+
+ props: {
+ title: {
+ type: String,
+ default: '',
+ required: true,
+ },
+ subtitle: {
+ type: String,
+ default: '',
+ },
+ isUnique: {
+ type: Boolean,
+ default: true,
+ },
+ ariaExpanded: {
+ type: Boolean,
+ default: null,
+ },
+ },
+
+ computed: {
+ ariaExpandedValue() {
+ if (this.ariaExpanded === null) {
+ return this.ariaExpanded
+ }
+ return this.ariaExpanded ? 'true' : 'false'
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+.sharing-entry {
+ display: flex;
+ align-items: center;
+ min-height: 44px;
+ &__desc {
+ padding: 8px;
+ padding-inline-start: 10px;
+ line-height: 1.2em;
+ position: relative;
+ flex: 1 1;
+ min-width: 0;
+ p {
+ color: var(--color-text-maxcontrast);
+ }
+ }
+ &__title {
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ max-width: inherit;
+ }
+ &__actions {
+ margin-inline-start: auto !important;
+ }
+}
+</style>
diff --git a/apps/files_sharing/src/components/SharingInput.vue b/apps/files_sharing/src/components/SharingInput.vue
new file mode 100644
index 00000000000..6fb33aba6b2
--- /dev/null
+++ b/apps/files_sharing/src/components/SharingInput.vue
@@ -0,0 +1,530 @@
+<!--
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <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 NcSelect from '@nextcloud/vue/components/NcSelect'
+
+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: {
+ NcSelect,
+ },
+
+ mixins: [ShareRequests, ShareDetails],
+
+ props: {
+ shares: {
+ type: Array,
+ default: () => [],
+ required: true,
+ },
+ linkShares: {
+ type: Array,
+ default: () => [],
+ required: true,
+ },
+ fileInfo: {
+ type: Object,
+ default: () => {},
+ required: true,
+ },
+ reshare: {
+ type: Share,
+ default: null,
+ },
+ canReshare: {
+ 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() {
+ return {
+ config: new Config(),
+ loading: false,
+ query: '',
+ recommendations: [],
+ ShareSearch: OCA.Sharing.ShareSearch.state,
+ suggestions: [],
+ value: null,
+ }
+ },
+
+ computed: {
+ /**
+ * Implement ShareSearch
+ * allows external appas to inject new
+ * results into the autocomplete dropdown
+ * Used for the guests app
+ *
+ * @return {Array}
+ */
+ externalResults() {
+ return this.ShareSearch.results
+ },
+ inputPlaceholder() {
+ const allowRemoteSharing = this.config.isRemoteShareAllowed
+
+ 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 …')
+ }
+
+ return t('files_sharing', 'Name, email, or Federated Cloud ID …')
+ },
+
+ isValidQuery() {
+ return this.query && this.query.trim() !== '' && this.query.length > this.config.minSearchStringLength
+ },
+
+ options() {
+ if (this.isValidQuery) {
+ return this.suggestions
+ }
+ return this.recommendations
+ },
+
+ noResultText() {
+ if (this.loading) {
+ return t('files_sharing', 'Searching …')
+ }
+ return t('files_sharing', 'No elements found.')
+ },
+ },
+
+ mounted() {
+ if (!this.isExternal) {
+ // We can only recommend users, groups etc for internal shares
+ this.getRecommendations()
+ }
+ },
+
+ methods: {
+ onSelected(option) {
+ this.value = null // Reset selected option
+ this.openSharingDetails(option)
+ },
+
+ async asyncFind(query) {
+ // save current query to check if we display
+ // recommendations or search results
+ this.query = query.trim()
+ if (this.isValidQuery) {
+ // start loading now to have proper ux feedback
+ // during the debounce
+ this.loading = true
+ await this.debounceGetSuggestions(query)
+ }
+ },
+
+ /**
+ * Get suggestions
+ *
+ * @param {string} search the search query
+ * @param {boolean} [lookup] search on lookup server
+ */
+ async getSuggestions(search, lookup = false) {
+ this.loading = true
+
+ if (getCapabilities().files_sharing.sharee.query_lookup_default === true) {
+ lookup = true
+ }
+
+ 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
+ try {
+ request = await axios.get(generateOcsUrl('apps/files_sharing/api/v1/sharees'), {
+ params: {
+ format: 'json',
+ itemType: this.fileInfo.type === 'dir' ? 'folder' : 'file',
+ search,
+ lookup,
+ perPage: this.config.maxAutocompleteResults,
+ shareType,
+ },
+ })
+ } catch (error) {
+ console.error('Error fetching suggestions', error)
+ return
+ }
+
+ const { exact, ...data } = request.data.ocs.data
+ // flatten array of arrays
+ 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)
+ .map(share => this.formatForMultiselect(share))
+ // sort by type so we can get user&groups first...
+ .sort((a, b) => a.shareType - b.shareType)
+ const suggestions = this.filterOutExistingShares(rawSuggestions)
+ .map(share => this.formatForMultiselect(share))
+ // sort by type so we can get user&groups first...
+ .sort((a, b) => a.shareType - b.shareType)
+
+ // lookup clickable entry
+ // show if enabled and not already requested
+ const lookupEntry = []
+ if (data.lookupEnabled && !lookup) {
+ lookupEntry.push({
+ id: 'global-lookup',
+ isNoUser: true,
+ displayName: t('files_sharing', 'Search everywhere'),
+ lookup: true,
+ })
+ }
+
+ // if there is a condition specified, filter it
+ const externalResults = this.externalResults.filter(result => !result.condition || result.condition(this))
+
+ const allSuggestions = exactSuggestions.concat(suggestions).concat(externalResults).concat(lookupEntry)
+
+ // 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
+ }
+ if (!nameCounts[result.displayName]) {
+ nameCounts[result.displayName] = 0
+ }
+ nameCounts[result.displayName]++
+ return nameCounts
+ }, {})
+
+ this.suggestions = allSuggestions.map(item => {
+ // Make sure that items with duplicate displayName get the shareWith applied as a description
+ if (nameCounts[item.displayName] > 1 && !item.desc) {
+ return { ...item, desc: item.shareWithDisplayNameUnique }
+ }
+ return item
+ })
+
+ this.loading = false
+ console.info('suggestions', this.suggestions)
+ },
+
+ /**
+ * Debounce getSuggestions
+ *
+ * @param {...*} args the arguments
+ */
+ debounceGetSuggestions: debounce(function(...args) {
+ this.getSuggestions(...args)
+ }, 300),
+
+ /**
+ * Get the sharing recommendations
+ */
+ async getRecommendations() {
+ this.loading = true
+
+ let request = null
+ try {
+ request = await axios.get(generateOcsUrl('apps/files_sharing/api/v1/sharees_recommended'), {
+ params: {
+ format: 'json',
+ itemType: this.fileInfo.type,
+ },
+ })
+ } catch (error) {
+ console.error('Error fetching recommendations', error)
+ return
+ }
+
+ // Add external results from the OCA.Sharing.ShareSearch api
+ const externalResults = this.externalResults.filter(result => !result.condition || result.condition(this))
+
+ // flatten array of arrays
+ const rawRecommendations = Object.values(request.data.ocs.data.exact)
+ .reduce((arr, elem) => arr.concat(elem), [])
+
+ // remove invalid data and format to user-select layout
+ this.recommendations = this.filterOutExistingShares(rawRecommendations)
+ .map(share => this.formatForMultiselect(share))
+ .concat(externalResults)
+
+ this.loading = false
+ console.info('recommendations', this.recommendations)
+ },
+
+ /**
+ * Filter out existing shares from
+ * the provided shares search results
+ *
+ * @param {object[]} shares the array of shares object
+ * @return {object[]}
+ */
+ filterOutExistingShares(shares) {
+ return shares.reduce((arr, share) => {
+ // only check proper objects
+ if (typeof share !== 'object') {
+ return arr
+ }
+ try {
+ if (share.value.shareType === ShareType.User) {
+ // filter out current user
+ if (share.value.shareWith === getCurrentUser().uid) {
+ return arr
+ }
+
+ // filter out the owner of the share
+ if (this.reshare && share.value.shareWith === this.reshare.owner) {
+ return arr
+ }
+ }
+
+ // filter out existing mail shares
+ 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
+ }
+ } else { // filter out existing shares
+ // creating an object of uid => type
+ const sharesObj = this.shares.reduce((obj, elem) => {
+ obj[elem.shareWith] = elem.type
+ return obj
+ }, {})
+
+ // if shareWith is the same and the share type too, ignore it
+ const key = share.value.shareWith.trim()
+ if (key in sharesObj
+ && sharesObj[key] === share.value.shareType) {
+ return arr
+ }
+ }
+
+ // ALL GOOD
+ // let's add the suggestion
+ arr.push(share)
+ } catch {
+ return arr
+ }
+ return arr
+ }, [])
+ },
+
+ /**
+ * Get the icon based on the share type
+ *
+ * @param {number} type the share type
+ * @return {string} the icon class
+ */
+ shareTypeToIcon(type) {
+ switch (type) {
+ 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 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 {}
+ }
+ },
+
+ /**
+ * Format shares for the multiselect options
+ *
+ * @param {object} result select entry item
+ * @return {object}
+ */
+ formatForMultiselect(result) {
+ 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 {
+ subname = result.shareWithDescription ?? ''
+ }
+
+ return {
+ shareWith: result.value.shareWith,
+ shareType: result.value.shareType,
+ user: result.uuid || result.value.shareWith,
+ isNoUser: result.value.shareType !== ShareType.User,
+ displayName,
+ subname,
+ shareWithDisplayNameUnique: result.shareWithDisplayNameUnique || '',
+ ...this.shareTypeToIcon(result.value.shareType),
+ }
+ },
+ },
+}
+</script>
+
+<style lang="scss">
+.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
+ 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;
+ }
+ }
+ }
+}
+</style>