diff options
author | fenn-cs <fenn25.fn@gmail.com> | 2023-07-19 02:11:27 +0100 |
---|---|---|
committer | Louis Chemineau <louis@chmn.me> | 2023-08-30 18:12:49 +0200 |
commit | 8b42fb033fdcd3775b4850de6faf6091c8dcc716 (patch) | |
tree | 7ca9ccb33d95090ae4a34e24ef650d0eede8732d /apps/files_sharing/src/views | |
parent | 191e20d7f48338ca336fd0091301653251fc0667 (diff) | |
download | nextcloud-server-8b42fb033fdcd3775b4850de6faf6091c8dcc716.tar.gz nextcloud-server-8b42fb033fdcd3775b4850de6faf6091c8dcc716.zip |
Improve sharing flow
This commit introduces the following changes:
- Does not create new share once user is selected for internal shares
- Adds a `SharingDetails` view for share configurations
- Adds a quick share select to enable fast changes in share permisions.
Resolves: https://github.com/nextcloud/server/issues/26691
Signed-off-by: fenn-cs <fenn25.fn@gmail.com>
Signed-off-by: Louis Chemineau <louis@chmn.me>
Diffstat (limited to 'apps/files_sharing/src/views')
-rw-r--r-- | apps/files_sharing/src/views/SharingDetailsTab.vue | 1053 | ||||
-rw-r--r-- | apps/files_sharing/src/views/SharingLinkList.vue | 6 | ||||
-rw-r--r-- | apps/files_sharing/src/views/SharingList.vue | 22 | ||||
-rw-r--r-- | apps/files_sharing/src/views/SharingTab.vue | 49 |
4 files changed, 1102 insertions, 28 deletions
diff --git a/apps/files_sharing/src/views/SharingDetailsTab.vue b/apps/files_sharing/src/views/SharingDetailsTab.vue new file mode 100644 index 00000000000..dbc8c1508e4 --- /dev/null +++ b/apps/files_sharing/src/views/SharingDetailsTab.vue @@ -0,0 +1,1053 @@ +<template> + <div class="sharingTabDetailsView"> + <div class="sharingTabDetailsView__header"> + <span> + <NcAvatar v-if="isUserShare" + class="sharing-entry__avatar" + :is-no-user="share.shareType !== SHARE_TYPES.SHARE_TYPE_USER" + :user="share.shareWith" + :display-name="share.shareWithDisplayName" + :menu-position="'left'" + :url="share.shareWithAvatar" /> + <component :is="getShareTypeIcon(share.type)" :size="32" /> + </span> + <span> + <h1>{{ title }}</h1> + </span> + </div> + <div class="sharingTabDetailsView__quick-permissions"> + <div> + <NcCheckboxRadioSwitch :button-variant="true" + :checked.sync="sharingPermission" + :value="bundledPermissions.READ_ONLY.toString()" + name="sharing_permission_radio" + type="radio" + button-variant-grouped="vertical" + @update:checked="toggleCustomPermissions"> + {{ t('files_sharing', 'View only') }} + <template #icon> + <ViewIcon :size="20" /> + </template> + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch :button-variant="true" + :checked.sync="sharingPermission" + :value="bundledPermissions.ALL.toString()" + name="sharing_permission_radio" + type="radio" + button-variant-grouped="vertical" + @update:checked="toggleCustomPermissions"> + {{ t('files_sharing', 'Allow upload and editing') }} + <template #icon> + <EditIcon :size="20" /> + </template> + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch v-if="allowsFileDrop" + :button-variant="true" + :checked.sync="sharingPermission" + :value="bundledPermissions.FILE_DROP.toString()" + name="sharing_permission_radio" + type="radio" + button-variant-grouped="vertical" + @update:checked="toggleCustomPermissions"> + {{ t('files_sharing', 'File drop') }} + <small>{{ t('files_sharing', 'Upload only') }}</small> + <template #icon> + <UploadIcon :size="20" /> + </template> + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch :button-variant="true" + :checked.sync="sharingPermission" + :value="'custom'" + name="sharing_permission_radio" + type="radio" + button-variant-grouped="vertical" + @update:checked="toggleCustomPermissions"> + {{ t('files_sharing', 'Custom permissions') }} + <small>{{ t('files_sharing', customPermissionsList) }}</small> + <template #icon> + <DotsHorizontalIcon :size="20" /> + </template> + </NcCheckboxRadioSwitch> + </div> + </div> + <div class="sharingTabDetailsView__advanced-control"> + <NcButton type="tertiary" + alignment="end-reverse" + @click="advancedSectionAccordionExpanded = !advancedSectionAccordionExpanded"> + {{ t('files_sharing', 'Advanced settings') }} + <template #icon> + <MenuDownIcon /> + </template> + </NcButton> + </div> + <div v-if="advancedSectionAccordionExpanded" class="sharingTabDetailsView__advanced"> + <section> + <NcInputField v-if="isPublicShare" + :value.sync="share.label" + type="text" + :label="t('file_sharing', 'Share label')" /> + <template v-if="isPublicShare"> + <NcCheckboxRadioSwitch :checked.sync="isPasswordProtected" :disabled="isPasswordEnforced"> + {{ t('file_sharing', 'Set password') }} + </NcCheckboxRadioSwitch> + <NcInputField v-if="isPasswordProtected" + :type="hasUnsavedPassword ? 'text' : 'password'" + :value="hasUnsavedPassword ? share.newPassword : '***************'" + :error="passwordError" + :required="isPasswordEnforced" + :label="t('file_sharing', 'Password')" + @update:value="onPasswordChange" /> + + <!-- Migrate icons and remote -> icon="icon-info"--> + <span v-if="isEmailShareType && passwordExpirationTime" icon="icon-info"> + {{ t('files_sharing', 'Password expires {passwordExpirationTime}', { passwordExpirationTime }) }} + </span> + <span v-else-if="isEmailShareType && passwordExpirationTime !== null" icon="icon-error"> + {{ t('files_sharing', 'Password expired') }} + </span> + </template> + <NcCheckboxRadioSwitch :checked.sync="hasExpirationDate" :disabled="isExpiryDateEnforced"> + {{ isExpiryDateEnforced + ? t('files_sharing', 'Expiration date (enforced)') + : t('files_sharing', 'Set expiration date') }} + </NcCheckboxRadioSwitch> + <NcDateTimePickerNative v-if="hasExpirationDate" + id="share-date-picker" + :value="new Date(share.expireDate)" + :min="dateTomorrow" + :max="dateMaxEnforced" + :hide-label="true" + :disabled="isExpiryDateEnforced" + :placeholder="t('file_sharing', 'Expiration date')" + type="date" + @input="onExpirationChange" /> + <NcCheckboxRadioSwitch v-if="isPublicShare" + :disabled="canChangeHideDownload" + :checked.sync="share.hideDownload" + @update:checked="queueUpdate('hideDownload')"> + {{ t('file_sharing', 'Hide download') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch v-if="canTogglePasswordProtectedByTalkAvailable" + :checked.sync="isPasswordProtectedByTalk" + @update:checked="onPasswordProtectedByTalkChange"> + {{ t('file_sharing', 'Video verification') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch :checked.sync="writeNoteToRecipientIsChecked"> + {{ t('file_sharing', 'Note to recipient') }} + </NcCheckboxRadioSwitch> + <template v-if="writeNoteToRecipientIsChecked"> + <textarea :value="share.note" @input="share.note = $event.target.value" /> + </template> + <NcCheckboxRadioSwitch :checked.sync="setCustomPermissions"> + {{ t('file_sharing', 'Custom permissions') }} + </NcCheckboxRadioSwitch> + <section v-if="setCustomPermissions" class="custom-permissions-group"> + <NcCheckboxRadioSwitch :disabled="!allowsFileDrop && share.type === SHARE_TYPES.SHARE_TYPE_LINK" :checked.sync="hasRead"> + {{ t('file_sharing', 'Read') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch v-if="isFolder" :disabled="!canSetCreate" :checked.sync="canCreate"> + {{ t('file_sharing', 'Create') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch :disabled="!canSetEdit" :checked.sync="canEdit"> + {{ t('file_sharing', 'Update') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch v-if="config.isResharingAllowed && share.type !== SHARE_TYPES.SHARE_TYPE_LINK" :disabled="!canSetReshare" :checked.sync="canReshare"> + {{ t('file_sharing', 'Share') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch v-if="!isPublicShare" :disabled="!canSetDownload" :checked.sync="canDownload"> + {{ t('file_sharing', 'Download') }} + </NcCheckboxRadioSwitch> + <NcCheckboxRadioSwitch :disabled="!canSetDelete" :checked.sync="canDelete"> + {{ t('file_sharing', 'Delete') }} + </NcCheckboxRadioSwitch> + </section> + </section> + </div> + + <div class="sharingTabDetailsView__footer"> + <NcButton v-if="!isNewShare" + :aria-label="t('files_sharing', 'Delete share')" + :disabled="false" + :readonly="false" + type="tertiary" + @click.prevent="removeShare"> + <template #icon> + <CloseIcon :size="16" /> + </template> + {{ t('files_sharing', 'Delete share') }} + </NcButton> + <div class="button-group"> + <NcButton @click="$emit('close-sharing-details')"> + {{ t('file_sharing', 'Cancel') }} + </NcButton> + <NcButton type="primary" @click="saveShare"> + {{ shareButtonText }} + </NcButton> + </div> + </div> + </div> +</template> + +<script> +import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' +import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js' +import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js' +import NcDatetimePicker from '@nextcloud/vue/dist/Components/NcDatetimePicker.js' +import NcDateTimePickerNative from '@nextcloud/vue/dist/Components/NcDateTimePickerNative.js' +import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' +import CircleIcon from 'vue-material-design-icons/CircleOutline.vue' +import CloseIcon from 'vue-material-design-icons/Close.vue' +import EditIcon from 'vue-material-design-icons/Pencil.vue' +import EmailIcon from 'vue-material-design-icons/Email.vue' +import LinkIcon from 'vue-material-design-icons/Link.vue' +import GroupIcon from 'vue-material-design-icons/AccountGroup.vue' +import ShareIcon from 'vue-material-design-icons/ShareCircle.vue' +import UserIcon from 'vue-material-design-icons/AccountCircleOutline.vue' +import ViewIcon from 'vue-material-design-icons/Eye.vue' +import UploadIcon from 'vue-material-design-icons/Upload.vue' +import MenuDownIcon from 'vue-material-design-icons/MenuDown.vue' +import DotsHorizontalIcon from 'vue-material-design-icons/DotsHorizontal.vue' + +import GeneratePassword from '../utils/GeneratePassword.js' +import Share from '../models/Share.js' +import ShareRequests from '../mixins/ShareRequests.js' +import ShareTypes from '../mixins/ShareTypes.js' +import SharesMixin from '../mixins/SharesMixin.js' + +import { + ATOMIC_PERMISSIONS, + BUNDLED_PERMISSIONS, + hasPermissions, +} from '../lib/SharePermissionsToolBox.js' + +export default { + name: 'SharingDetailsTab', + components: { + NcAvatar, + NcButton, + NcInputField, + NcDatetimePicker, + NcDateTimePickerNative, + NcCheckboxRadioSwitch, + CloseIcon, + CircleIcon, + EditIcon, + LinkIcon, + GroupIcon, + ShareIcon, + UserIcon, + UploadIcon, + ViewIcon, + MenuDownIcon, + DotsHorizontalIcon, + }, + mixins: [ShareTypes, ShareRequests, SharesMixin], + props: { + shareRequestValue: { + type: Object, + required: false, + }, + fileInfo: { + type: Object, + required: true, + }, + share: { + type: Object, + required: true, + }, + }, + data() { + return { + writeNoteToRecipientIsChecked: false, + sharingPermission: BUNDLED_PERMISSIONS.ALL.toString(), + revertSharingPermission: null, + setCustomPermissions: false, + passwordError: false, + advancedSectionAccordionExpanded: false, + bundledPermissions: BUNDLED_PERMISSIONS, + isFirstComponentLoad: true, + test: false, + } + }, + + computed: { + title() { + let title = t('files_sharing', 'Share with ') + if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_USER) { + title = title + this.share.shareWithDisplayName + } else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_LINK) { + title = t('files_sharing', 'Share link') + } else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_GROUP) { + title += ` (${t('files_sharing', 'group')})` + } else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_ROOM) { + title += ` (${t('files_sharing', 'conversation')})` + } else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_REMOTE) { + title += ` (${t('files_sharing', 'remote')})` + } else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP) { + title += ` (${t('files_sharing', 'remote group')})` + } else if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_GUEST) { + title += ` (${t('files_sharing', 'guest')})` + } + + return title + }, + /** + * Can the sharee edit the shared file ? + */ + canEdit: { + get() { + return this.share.hasUpdatePermission + }, + set(checked) { + this.updateAtomicPermissions({ isEditChecked: checked }) + }, + }, + /** + * Can the sharee create the shared file ? + */ + canCreate: { + get() { + return this.share.hasCreatePermission + }, + set(checked) { + this.updateAtomicPermissions({ isCreateChecked: checked }) + }, + }, + /** + * Can the sharee delete the shared file ? + */ + canDelete: { + get() { + return this.share.hasDeletePermission + }, + set(checked) { + this.updateAtomicPermissions({ isDeleteChecked: checked }) + }, + }, + /** + * Can the sharee reshare the file ? + */ + canReshare: { + get() { + return this.share.hasSharePermission + }, + set(checked) { + this.updateAtomicPermissions({ isReshareChecked: checked }) + }, + }, + /** + * Can the sharee download files or only view them ? + */ + canDownload: { + get() { + return this.share.hasDownloadPermission + }, + set(checked) { + this.updateAtomicPermissions({ isDownloadChecked: checked }) + }, + }, + /** + * Is this share readable + * Needed for some federated shares that might have been added from file drop links + */ + hasRead: { + get() { + return this.share.hasReadPermission + }, + set(checked) { + this.updateAtomicPermissions({ isReadChecked: checked }) + }, + }, + /** + * Does the current share have an expiration date + * + * @return {boolean} + */ + hasExpirationDate: { + get() { + return !!this.share.expireDate || this.config.isDefaultInternalExpireDateEnforced + }, + set(enabled) { + this.share.expireDate = enabled + ? this.formatDateToString(this.defaultExpiryDate) + : '' + }, + }, + /** + * Is the current share password protected ? + * + * @return {boolean} + */ + isPasswordProtected: { + get() { + return this.config.enforcePasswordForPublicLink + || !!this.share.password + }, + async set(enabled) { + // TODO: directly save after generation to make sure the share is always protected + this.share.password = enabled ? await GeneratePassword() : '' + this.$set(this.share, 'newPassword', this.share.password) + }, + }, + /** + * Is the current share a folder ? + * + * @return {boolean} + */ + isFolder() { + return this.fileInfo.type === 'dir' + }, + dateMaxEnforced() { + if (!this.isRemote && this.config.isDefaultInternalExpireDateEnforced) { + return new Date(new Date().setDate(new Date().getDate() + 1 + this.config.defaultInternalExpireDate)) + } else if (this.config.isDefaultRemoteExpireDateEnforced) { + return new Date(new Date().setDate(new Date().getDate() + 1 + this.config.defaultRemoteExpireDate)) + } + return null + }, + /** + * @return {boolean} + */ + isSetDownloadButtonVisible() { + // TODO: Implement download permission for circle shares instead of hiding the option. + // https://github.com/nextcloud/server/issues/39161 + if (this.share && this.share.type === this.SHARE_TYPES.SHARE_TYPE_CIRCLE) { + return false + } + + const allowedMimetypes = [ + // Office documents + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.oasis.opendocument.text', + 'application/vnd.oasis.opendocument.spreadsheet', + 'application/vnd.oasis.opendocument.presentation', + ] + + return this.isFolder || allowedMimetypes.includes(this.fileInfo.mimetype) + }, + isPasswordEnforced() { + return this.isPublicShare && this.config.enforcePasswordForPublicLink + }, + isExpiryDateEnforced() { + return this.config.isDefaultInternalExpireDateEnforced + }, + defaultExpiryDate() { + if ((this.isGroupShare || this.isUserShare) && this.config.isDefaultInternalExpireDateEnabled) { + return new Date(this.config.defaultInternalExpirationDate) + } else if (this.isRemoteShare && this.config.isDefaultRemoteExpireDateEnabled) { + return new Date(this.config.defaultRemoteExpireDateEnabled) + } else if (this.isPublicShare && this.config.isDefaultExpireDateEnabled) { + return new Date(this.config.defaultExpirationDate) + } + return new Date(new Date().setDate(new Date().getDate() + 1)) + }, + isUserShare() { + return this.share.type === this.SHARE_TYPES.SHARE_TYPE_USER + }, + isGroupShare() { + return this.share.type === this.SHARE_TYPES.SHARE_TYPE_GROUP + }, + isRemoteShare() { + return this.share.type === this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP || this.share.type === this.SHARE_TYPES.SHARE_TYPE_REMOTE + }, + isNewShare() { + return this.share.id === null || this.share.id === undefined + }, + allowsFileDrop() { + if (this.isFolder) { + if (this.share.type === this.SHARE_TYPES.SHARE_TYPE_LINK || this.share.type === this.SHARE_TYPES.SHARE_TYPE_EMAIL) { + return true + } + } + return false + }, + hasFileDropPermissions() { + return this.share.permissions === this.bundledPermissions.FILE_DROP + }, + shareButtonText() { + if (this.isNewShare) { + return t('file_sharing', 'Save share') + } + return t('file_sharing', 'Update share') + + }, + /** + * Can the sharer set whether the sharee can edit the file ? + * + * @return {boolean} + */ + canSetEdit() { + // If the owner revoked the permission after the resharer granted it + // the share still has the permission, and the resharer is still + // allowed to revoke it too (but not to grant it again). + return (this.fileInfo.sharePermissions & OC.PERMISSION_UPDATE) || this.canEdit + }, + + /** + * Can the sharer set whether the sharee can create the file ? + * + * @return {boolean} + */ + canSetCreate() { + // If the owner revoked the permission after the resharer granted it + // the share still has the permission, and the resharer is still + // allowed to revoke it too (but not to grant it again). + return (this.fileInfo.sharePermissions & OC.PERMISSION_CREATE) || this.canCreate + }, + + /** + * Can the sharer set whether the sharee can delete the file ? + * + * @return {boolean} + */ + canSetDelete() { + // If the owner revoked the permission after the resharer granted it + // the share still has the permission, and the resharer is still + // allowed to revoke it too (but not to grant it again). + return (this.fileInfo.sharePermissions & OC.PERMISSION_DELETE) || this.canDelete + }, + /** + * Can the sharer set whether the sharee can reshare the file ? + * + * @return {boolean} + */ + canSetReshare() { + // If the owner revoked the permission after the resharer granted it + // the share still has the permission, and the resharer is still + // allowed to revoke it too (but not to grant it again). + return (this.fileInfo.sharePermissions & OC.PERMISSION_SHARE) || this.canReshare + }, + /** + * Can the sharer set whether the sharee can download the file ? + * + * @return {boolean} + */ + canSetDownload() { + // If the owner revoked the permission after the resharer granted it + // the share still has the permission, and the resharer is still + // allowed to revoke it too (but not to grant it again). + return (this.fileInfo.canDownload() || this.canDownload) + }, + // if newPassword exists, but is empty, it means + // the user deleted the original password + hasUnsavedPassword() { + return this.share.newPassword !== undefined + }, + 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 === this.SHARE_TYPES.SHARE_TYPE_EMAIL + : false + }, + canTogglePasswordProtectedByTalkAvailable() { + if (!this.isPublicShare || !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 + }, + canChangeHideDownload() { + const hasDisabledDownload = (shareAttribute) => shareAttribute.key === 'download' && shareAttribute.scope === 'permissions' && shareAttribute.enabled === false + return this.fileInfo.shareAttributes.some(hasDisabledDownload) + }, + customPermissionsList() { + const perms = [] + if (hasPermissions(this.share.permissions, ATOMIC_PERMISSIONS.READ)) { + perms.push('read') + } + if (hasPermissions(this.share.permissions, ATOMIC_PERMISSIONS.CREATE)) { + perms.push('create') + } + if (hasPermissions(this.share.permissions, ATOMIC_PERMISSIONS.UPDATE)) { + perms.push('update') + } + if (hasPermissions(this.share.permissions, ATOMIC_PERMISSIONS.DELETE)) { + perms.push('delete') + } + if (hasPermissions(this.share.permissions, ATOMIC_PERMISSIONS.SHARE)) { + perms.push('share') + } + if (this.share.hasDownloadPermission) { + perms.push('download') + } + const capitalizeFirstAndJoin = array => array.map((item, index) => index === 0 ? item[0].toUpperCase() + item.substring(1) : item).join(', ') + + return capitalizeFirstAndJoin(perms) + + }, + }, + watch: { + setCustomPermissions(isChecked) { + if (isChecked) { + this.sharingPermission = 'custom' + } else { + this.sharingPermission = this.revertSharingPermission + } + }, + }, + beforeMount() { + this.initializePermissions() + this.initializeAttributes() + console.debug('shareSentIn', this.share) + console.debug('config', this.config) + }, + + methods: { + updateAtomicPermissions({ + isReadChecked = this.hasRead, + isEditChecked = this.canEdit, + isCreateChecked = this.canCreate, + isDeleteChecked = this.canDelete, + isReshareChecked = this.canReshare, + isDownloadChecked = this.canDownload, + } = {}) { + // calc permissions if checked + const permissions = 0 + | (isReadChecked ? ATOMIC_PERMISSIONS.READ : 0) + | (isCreateChecked ? ATOMIC_PERMISSIONS.CREATE : 0) + | (isDeleteChecked ? ATOMIC_PERMISSIONS.DELETE : 0) + | (isEditChecked ? ATOMIC_PERMISSIONS.UPDATE : 0) + | (isReshareChecked ? ATOMIC_PERMISSIONS.SHARE : 0) + this.share.permissions = permissions + if (this.share.hasDownloadPermission !== isDownloadChecked) { + this.$set(this.share, 'hasDownloadPermission', isDownloadChecked) + } + }, + + toggleCustomPermissions(selectedPermission) { + if (this.sharingPermission === 'custom') { + this.advancedSectionAccordionExpanded = true + this.setCustomPermissions = true + } else { + this.advancedSectionAccordionExpanded = false + this.revertSharingPermission = selectedPermission + this.setCustomPermissions = false + } + }, + initializeAttributes() { + + if (this.isNewShare) return + + let hasAdvancedAttributes = false + if (this.isValidShareAttribute(this.share.note)) { + this.writeNoteToRecipientIsChecked = true + hasAdvancedAttributes = true + } + + if (this.isValidShareAttribute(this.share.password)) { + hasAdvancedAttributes = true + } + + if (this.isValidShareAttribute(this.share.expireDate)) { + hasAdvancedAttributes = true + } + + if (this.isValidShareAttribute(this.share.label)) { + hasAdvancedAttributes = true + } + + if (hasAdvancedAttributes) { + this.advancedSectionAccordionExpanded = true + } + + }, + initializePermissions() { + if (this.share.share_type) { + this.share.type = this.share.share_type + } + // shareType 0 (USER_SHARE) would evaluate to zero + // Hence the use of hasOwnProperty + if ('shareType' in this.share) { + this.share.type = this.share.shareType + } + if (this.isNewShare) { + if (this.isPublicShare) { + this.sharingPermission = BUNDLED_PERMISSIONS.READ_ONLY.toString() + } else { + this.sharingPermission = BUNDLED_PERMISSIONS.ALL.toString() + } + + } else { + if (this.hasCustomPermissions || this.share.setCustomPermissions) { + this.sharingPermission = 'custom' + this.advancedSectionAccordionExpanded = true + this.setCustomPermissions = true + } else { + this.sharingPermission = this.share.permissions.toString() + } + } + }, + async saveShare() { + const permissionsAndAttributes = ['permissions', 'attributes', 'note', 'expireDate'] + const publicShareAttributes = ['label', 'password', 'hideDownload'] + if (this.isPublicShare) { + permissionsAndAttributes.push(...publicShareAttributes) + } + const sharePermissionsSet = parseInt(this.sharingPermission) + if (this.setCustomPermissions) { + this.updateAtomicPermissions() + } else { + this.share.permissions = sharePermissionsSet + } + + if (!this.isFolder && this.share.permissions === BUNDLED_PERMISSIONS.ALL) { + // It's not possible to create an existing file. + this.share.permissions = BUNDLED_PERMISSIONS.ALL_FILE + } + if (!this.writeNoteToRecipientIsChecked) { + this.share.note = '' + } + + if (this.isPasswordProtected) { + if (this.isValidShareAttribute(this.share.newPassword)) { + this.share.password = this.share.newPassword + this.$delete(this.share, 'newPassword') + } else { + if (this.isPasswordEnforced) { + this.passwordError = true + return + } + } + } else { + this.share.password = '' + } + + if (!this.hasExpirationDate) { + this.share.expireDate = '' + } + + if (this.isNewShare) { + const incomingShare = { + permissions: this.share.permissions, + shareType: this.share.type, + shareWith: this.share.shareWith, + attributes: this.share.attributes, + note: this.share.note, + } + + if (this.hasExpirationDate) { + incomingShare.expireDate = this.share.expireDate + } + + if (this.isPasswordProtected) { + incomingShare.password = this.share.password + } + + const share = await this.addShare(incomingShare, this.fileInfo, this.config) + this.share = share + this.$emit('add:share', this.share) + } else { + this.queueUpdate(...permissionsAndAttributes) + } + + this.$emit('close-sharing-details') + }, + /** + * Process the new share request + * + * @param {object} value the multiselect option + * @param {object} fileInfo file data + * @param {Config} config instance configs + */ + async addShare(value, fileInfo, config) { + // Clear the displayed selection + this.value = null + + // handle externalResults from OCA.Sharing.ShareSearch + if (value.handler) { + const share = await value.handler(this) + this.$emit('add:share', new Share(share)) + return true + } + + // this.loading = true // Are we adding loaders the new share flow? + console.debug('Adding a new share from the input for', value) + try { + const path = (fileInfo.path + '/' + fileInfo.name).replace('//', '/') + const share = await this.createShare({ + path, + shareType: value.shareType, + shareWith: value.shareWith, + permissions: value.permissions, + attributes: JSON.stringify(fileInfo.shareAttributes), + ...(value.note ? { note: value.note } : {}), + ...(value.password ? { password: value.password } : {}), + ...(value.expireDate ? { expireDate: value.expireDate } : {}), + }) + return share + } catch (error) { + console.error('Error while adding new share', error) + } finally { + // this.loading = false // No loader here yet + } + }, + async removeShare() { + await this.onDelete() + this.$emit('close-sharing-details') + }, + /** + * 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.passwordError = !this.isValidShareAttribute(password) + this.$set(this.share, 'newPassword', 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.password = this.share.newPassword.trim() + } + + this.queueUpdate('sendPasswordByTalk', 'password') + }, + isValidShareAttribute(value) { + if ([null, undefined].includes(value)) { + return false + } + + if (!(value.trim().length > 0)) { + return false + } + + return true + }, + getShareTypeIcon(type) { + switch (type) { + case this.SHARE_TYPES.SHARE_TYPE_LINK: + return LinkIcon + case this.SHARE_TYPES.SHARE_TYPE_GUEST: + return UserIcon + case this.SHARE_TYPES.SHARE_TYPE_REMOTE_GROUP: + case this.SHARE_TYPES.SHARE_TYPE_GROUP: + return GroupIcon + case this.SHARE_TYPES.SHARE_TYPE_EMAIL: + return EmailIcon + case this.SHARE_TYPES.SHARE_TYPE_CIRCLE: + return CircleIcon + case this.SHARE_TYPES.SHARE_TYPE_ROOM: + return ShareIcon + case this.SHARE_TYPES.SHARE_TYPE_DECK: + return ShareIcon + case this.SHARE_TYPES.SHARE_TYPE_SCIENCEMESH: + return ShareIcon + default: + return null // Or a default icon component if needed + } + }, + }, +} +</script> + +<style lang="scss" scoped> +.sharingTabDetailsView { + display: flex; + flex-direction: column; + align-items: flex-start; + width: 96%; + margin: 0 auto; + + &__header { + display: flex; + align-items: center; + box-sizing: border-box; + margin: 0.2em; + + span { + display: flex; + align-items: center; + + h1 { + font-size: 15px; + padding-left: 0.3em; + } + + } + } + + &__quick-permissions { + display: flex; + justify-content: center; + margin-bottom: 0.2em; + width: 100%; + margin: 0 auto; + border-radius: 0; + + div { + width: 100%; + + span { + width: 100%; + + span:nth-child(1) { + align-items: center; + justify-content: center; + color: var(--color-primary-element); + padding: 0.1em; + } + + ::v-deep label { + + span { + display: flex; + flex-direction: column; + } + } + } + + } + } + + &__advanced-control { + width: 100%; + + button { + margin-top: 0.5em; + } + + } + + &__advanced { + width: 100%; + margin-bottom: 0.5em; + text-align: left; + padding-left: 0; + + section { + + textarea, + div.mx-datepicker { + width: 100%; + } + + textarea { + height: 80px; + } + + /* + The following style is applied out of the component's scope + to remove padding from the label.checkbox-radio-switch__label, + which is used to group radio checkbox items. The use of ::v-deep + ensures that the padding is modified without being affected by + the component's scoping. + Without this achieving left alignment for the checkboxes would not + be possible. + */ + span { + ::v-deep label { + padding-left: 0 !important; + background-color: initial !important; + border: none !important; + } + } + + section.custom-permissions-group { + padding-left: 1.5em; + } + } + } + + &__footer { + width: 100%; + display: flex; + position: sticky; + bottom: 0; + flex-direction: column; + justify-content: space-between; + align-items: flex-start; + + >button:first-child { + font-size: 12px; + color: rgb(223, 7, 7); + background-color: #f5f5f5; + } + + .button-group { + display: flex; + justify-content: space-between; + width: 100%; + margin-top: 16px; + + button { + margin-left: 16px; + + &:first-child { + margin-left: 0; + } + } + } + } +} +</style> diff --git a/apps/files_sharing/src/views/SharingLinkList.vue b/apps/files_sharing/src/views/SharingLinkList.vue index c3f1425cb70..899424be1d8 100644 --- a/apps/files_sharing/src/views/SharingLinkList.vue +++ b/apps/files_sharing/src/views/SharingLinkList.vue @@ -39,7 +39,8 @@ :file-info="fileInfo" @add:share="addShare(...arguments)" @update:share="awaitForShare(...arguments)" - @remove:share="removeShare" /> + @remove:share="removeShare" + @open-sharing-details="openSharingDetails(share)" /> </template> </ul> </template> @@ -49,6 +50,7 @@ import Share from '../models/Share.js' import ShareTypes from '../mixins/ShareTypes.js' import SharingEntryLink from '../components/SharingEntryLink.vue' +import ShareDetails from '../mixins/ShareDetails.js' export default { name: 'SharingLinkList', @@ -57,7 +59,7 @@ export default { SharingEntryLink, }, - mixins: [ShareTypes], + mixins: [ShareTypes, ShareDetails], props: { fileInfo: { diff --git a/apps/files_sharing/src/views/SharingList.vue b/apps/files_sharing/src/views/SharingList.vue index 05dc87d9b07..6da48c2eece 100644 --- a/apps/files_sharing/src/views/SharingList.vue +++ b/apps/files_sharing/src/views/SharingList.vue @@ -27,15 +27,15 @@ :file-info="fileInfo" :share="share" :is-unique="isUnique(share)" - @remove:share="removeShare" /> + @open-sharing-details="openSharingDetails(share)" /> </ul> </template> <script> // eslint-disable-next-line no-unused-vars -import Share from '../models/Share.js' import SharingEntry from '../components/SharingEntry.vue' import ShareTypes from '../mixins/ShareTypes.js' +import ShareDetails from '../mixins/ShareDetails.js' export default { name: 'SharingList', @@ -44,12 +44,12 @@ export default { SharingEntry, }, - mixins: [ShareTypes], + mixins: [ShareTypes, ShareDetails], props: { fileInfo: { type: Object, - default: () => {}, + default: () => { }, required: true, }, shares: { @@ -58,7 +58,6 @@ export default { required: true, }, }, - computed: { hasShares() { return this.shares.length === 0 @@ -71,18 +70,5 @@ export default { } }, }, - - methods: { - /** - * Remove a share from the shares list - * - * @param {Share} share the share to remove - */ - removeShare(share) { - const index = this.shares.findIndex(item => item === share) - // eslint-disable-next-line vue/no-mutating-props - this.shares.splice(index, 1) - }, - }, } </script> diff --git a/apps/files_sharing/src/views/SharingTab.vue b/apps/files_sharing/src/views/SharingTab.vue index bfaf8a766ee..e5d26156750 100644 --- a/apps/files_sharing/src/views/SharingTab.vue +++ b/apps/files_sharing/src/views/SharingTab.vue @@ -29,7 +29,7 @@ </div> <!-- shares content --> - <div v-else class="sharingTab__content"> + <div v-if="!showSharingDetailsView" class="sharingTab__content"> <!-- shared with me information --> <SharingEntrySimple v-if="isSharedWithMe" v-bind="sharedWithMe" class="sharing-entry__reshare"> <template #avatar> @@ -46,20 +46,22 @@ :link-shares="linkShares" :reshare="reshare" :shares="shares" - @add:share="addShare" /> + @open-sharing-details="toggleShareDetailsView" /> <!-- link shares list --> <SharingLinkList v-if="!loading" ref="linkShareList" :can-reshare="canReshare" :file-info="fileInfo" - :shares="linkShares" /> + :shares="linkShares" + @open-sharing-details="toggleShareDetailsView" /> <!-- other shares list --> <SharingList v-if="!loading" ref="shareList" :shares="shares" - :file-info="fileInfo" /> + :file-info="fileInfo" + @open-sharing-details="toggleShareDetailsView" /> <!-- inherited shares --> <SharingInherited v-if="canReshare && !loading" :file-info="fileInfo" /> @@ -74,6 +76,15 @@ :name="fileInfo.name" /> </div> + <!-- share details --> + <div v-else> + <SharingDetailsTab :file-info="shareDetailsData.fileInfo" + :share="shareDetailsData.share" + @close-sharing-details="toggleShareDetailsView" + @add:share="addShare" + @remove:share="removeShare" /> + </div> + <!-- additional entries, use it with cautious --> <div v-for="(section, index) in sections" :ref="'section-' + index" @@ -102,6 +113,7 @@ import SharingInput from '../components/SharingInput.vue' import SharingInherited from './SharingInherited.vue' import SharingLinkList from './SharingLinkList.vue' import SharingList from './SharingList.vue' +import SharingDetailsTab from './SharingDetailsTab.vue' export default { name: 'SharingTab', @@ -115,6 +127,7 @@ export default { SharingInput, SharingLinkList, SharingList, + SharingDetailsTab, }, mixins: [ShareTypes], @@ -122,7 +135,7 @@ export default { data() { return { config: new Config(), - + deleteEvent: null, error: '', expirationInterval: null, loading: true, @@ -137,6 +150,8 @@ export default { sections: OCA.Sharing.ShareTabSections.getSections(), projectsEnabled: loadState('core', 'projects_enabled', false), + showSharingDetailsView: false, + shareDetailsData: {}, } }, @@ -225,6 +240,8 @@ export default { this.sharedWithMe = {} this.shares = [] this.linkShares = [] + this.showSharingDetailsView = false + this.shareDetailsData = {} }, /** @@ -307,7 +324,7 @@ export default { 'Shared with you by {owner}', { owner: this.fileInfo.shareOwner }, undefined, - { escape: false } + { escape: false }, ), user: this.fileInfo.shareOwnerId, } @@ -321,7 +338,7 @@ export default { * @param {Share} share the share to add to the array * @param {Function} [resolve] a function to run after the share is added and its component initialized */ - addShare(share, resolve = () => {}) { + addShare(share, resolve = () => { }) { // only catching share type MAIL as link shares are added differently // meaning: not from the ShareInput if (share.type === this.SHARE_TYPES.SHARE_TYPE_EMAIL) { @@ -331,7 +348,16 @@ export default { } this.awaitForShare(share, resolve) }, - + /** + * Remove a share from the shares list + * + * @param {Share} share the share to remove + */ + removeShare(share) { + const index = this.shares.findIndex(item => item.id === share.id) + // eslint-disable-next-line vue/no-mutating-props + this.shares.splice(index, 1) + }, /** * Await for next tick and render after the list updated * Then resolve with the matched vue component of the @@ -355,6 +381,12 @@ export default { } }) }, + toggleShareDetailsView(eventData) { + if (eventData) { + this.shareDetailsData = eventData + } + this.showSharingDetailsView = !this.showSharingDetailsView + }, }, } </script> @@ -368,6 +400,7 @@ export default { &__content { padding: 0 6px; } + &__additionalContent { margin: 44px 0; } |