diff --git a/packages/design-system/src/components/OcDatepicker/OcDatepicker.vue b/packages/design-system/src/components/OcDatepicker/OcDatepicker.vue index c0bf6951134..b0f386119b2 100644 --- a/packages/design-system/src/components/OcDatepicker/OcDatepicker.vue +++ b/packages/design-system/src/components/OcDatepicker/OcDatepicker.vue @@ -5,6 +5,7 @@ :label="label" type="date" :min="minDate?.toISODate()" + :max="maxDate?.toISODate()" :fix-message-line="true" :error-message="errorMessage" :clear-button-enabled="isClearable" @@ -25,7 +26,8 @@ export default defineComponent({ label: { type: String, required: true }, isClearable: { type: Boolean, default: true }, currentDate: { type: Object as PropType<DateTime>, required: false, default: null }, - minDate: { type: Object as PropType<DateTime>, required: false, default: null } + minDate: { type: Object as PropType<DateTime>, required: false, default: null }, + maxDate: { type: Object as PropType<DateTime>, required: false, default: null } }, emits: ['dateChanged'], setup(props, { emit }) { @@ -44,6 +46,13 @@ export default defineComponent({ return unref(date) < props.minDate }) + const isMaxDateExceeded = computed(() => { + if (!props.maxDate || !unref(date)) { + return false + } + return unref(date) > props.maxDate + }) + const errorMessage = computed(() => { if (unref(isMinDateUndercut)) { return $gettext('The date must be after %{date}', { @@ -53,6 +62,14 @@ export default defineComponent({ .toLocaleString(DateTime.DATE_SHORT) }) } + if (unref(isMaxDateExceeded)) { + return $gettext('The date must be before %{date}', { + date: props.maxDate + .plus({ day: 1 }) + .setLocale(current) + .toLocaleString(DateTime.DATE_SHORT) + }) + } return '' }) @@ -73,6 +90,7 @@ export default defineComponent({ date, () => { emit('dateChanged', { date: unref(date), error: unref(isMinDateUndercut) }) + emit('dateChanged', { date: unref(date), error: unref(isMaxDateExceeded) }) }, { deep: true @@ -97,7 +115,7 @@ export default defineComponent({ ```js <template> <div> - <oc-datepicker :current-date="currentDate" :min-date="minDate" label="Enter or pick a date" + <oc-datepicker :current-date="currentDate" :min-date="minDate" :max-date="maxDate" label="Enter or pick a date" @date-changed="onDateChanged"/> <p v-if="selectedDate" v-text="selectedDate"/> </div> @@ -107,7 +125,7 @@ export default defineComponent({ export default { data: () => ({ - minDate: DateTime.now(), currentDate: DateTime.now(), selectedDate: '' + minDate: DateTime.now(), currentDate: DateTime.now(), selectedDate: '', maxDate: null }), methods: { onDateChanged({date}) { diff --git a/packages/web-app-files/src/components/Modals/DatePickerModal.vue b/packages/web-app-files/src/components/Modals/DatePickerModal.vue index 4ace3be2e6f..10257d13369 100644 --- a/packages/web-app-files/src/components/Modals/DatePickerModal.vue +++ b/packages/web-app-files/src/components/Modals/DatePickerModal.vue @@ -3,6 +3,7 @@ :label="$gettext('Expiration date')" type="date" :min-date="minDate" + :max-date="maxDate" :current-date="currentDate" :is-clearable="isClearable" @date-changed="onDateChanged" @@ -38,6 +39,7 @@ export default defineComponent({ modal: { type: Object as PropType<Modal>, required: true }, currentDate: { type: Object as PropType<DateTime>, required: false, default: null }, minDate: { type: Object as PropType<DateTime>, required: false, default: null }, + maxDate: { type: Object as PropType<DateTime>, required: false, default: null }, isClearable: { type: Boolean, default: true } }, emits: ['confirm', 'cancel'], diff --git a/packages/web-app-files/src/components/SideBar/Shares/FileLinks.vue b/packages/web-app-files/src/components/SideBar/Shares/FileLinks.vue index b0fb6018cfd..d0eb2314c89 100644 --- a/packages/web-app-files/src/components/SideBar/Shares/FileLinks.vue +++ b/packages/web-app-files/src/components/SideBar/Shares/FileLinks.vue @@ -92,7 +92,8 @@ import { useResourcesStore, useLinkTypes, useCanShare, - UpdateLinkOptions + UpdateLinkOptions, + useCapabilityStore } from '@ownclouders/web-pkg' import { shareViaLinkHelp, shareViaIndirectLinkHelp } from '../../../helpers/contextualHelpers' import { isSpaceResource, LinkShare } from '@ownclouders/web-client' @@ -102,11 +103,14 @@ import { isLocationSharesActive, useSharesStore } from '@ownclouders/web-pkg' import { useGettext } from 'vue3-gettext' import { storeToRefs } from 'pinia' import { SharingLinkType } from '@ownclouders/web-client/graph/generated' +import { DateTime, Duration } from 'luxon' export default defineComponent({ name: 'FileLinks', components: { ListItem }, setup() { + const { sharingPublicExpireDateDefaultRWFolders, sharingPublicExpireDateMaxRWFolders } = + useCapabilityStore() const { showMessage, showErrorMessage } = useMessages() const { $gettext } = useGettext() const ability = useAbility() @@ -124,12 +128,15 @@ export default defineComponent({ return canShare({ space: unref(space), resource: unref(resource) }) }) + const language = useGettext() + const sharesStore = useSharesStore() const { updateLink, deleteLink } = sharesStore const { linkShares } = storeToRefs(sharesStore) const configStore = useConfigStore() const { options: configOptions } = storeToRefs(configStore) + const alertRwFolders = configStore.options.alertRwFolders const { actions: createLinkActions } = useFileActionsCreateLink() const createLinkAction = computed<FileAction>(() => @@ -192,14 +199,62 @@ export default defineComponent({ options: UpdateLinkOptions['options'] }) => { try { + if ( + options.type === 'edit' && + unref(resource).isFolder && + !linkShare.expirationDateTime && + sharingPublicExpireDateDefaultRWFolders + ) { + Object.assign(options, { + ...options, + expirationDateTime: DateTime.now() + .plus(sharingPublicExpireDateDefaultRWFolders) + .endOf('day') + .toISO() + }) + } + if ( + unref(resource).isFolder && + linkShare.expirationDateTime && + sharingPublicExpireDateDefaultRWFolders && + sharingPublicExpireDateMaxRWFolders + ) { + if ( + DateTime.fromISO(linkShare.expirationDateTime).diff(DateTime.now(), 'days').as('days') > + Duration.fromObject(sharingPublicExpireDateMaxRWFolders).as('days') + ) { + Object.assign(options, { + ...options, + expirationDateTime: DateTime.now() + .plus(sharingPublicExpireDateDefaultRWFolders) + .endOf('day') + .toISO() + }) + } + } await updateLink({ clientService, space: unref(space), resource: unref(resource), linkShare, options + }).then(() => { + showMessage({ title: $gettext('Link was updated successfully') }) }) - showMessage({ title: $gettext('Link was updated successfully') }) + if (options.type === 'edit' && unref(resource).isFolder && alertRwFolders) { + if (!document.getElementById('files-file-link-warning')) { + const warningMessage = document.createElement('div') + warningMessage.className = 'oc-mb-m oc-p-s oc-background-secondary oc-rounded' + warningMessage.id = 'files-file-link-warning' + warningMessage.innerHTML = $gettext( + alertRwFolders[language.current] ?? alertRwFolders[Object.keys(alertRwFolders)[0]] + ) + document.getElementById('files-links-list').parentElement.prepend(warningMessage) + setTimeout(() => { + warningMessage.remove() + }, 10000) + } + } } catch (e) { console.error(e) showErrorMessage({ @@ -349,4 +404,9 @@ export default defineComponent({ margin-top: var(--oc-space-medium); } } +#files-file-link-warning { + color: var(--oc-color-swatch-danger-default); + text-align: center; + border: solid 1px var(--oc-color-swatch-danger-muted); +} </style> diff --git a/packages/web-app-files/src/components/SideBar/Shares/Links/EditDropdown.vue b/packages/web-app-files/src/components/SideBar/Shares/Links/EditDropdown.vue index d4378ea62e1..541bcd79da1 100644 --- a/packages/web-app-files/src/components/SideBar/Shares/Links/EditDropdown.vue +++ b/packages/web-app-files/src/components/SideBar/Shares/Links/EditDropdown.vue @@ -46,6 +46,7 @@ import { DateTime } from 'luxon' import { createLocationSpaces, + useCapabilityStore, useGetMatchingSpace, useModals, useResourcesStore @@ -97,6 +98,7 @@ export default defineComponent({ const { $gettext } = useGettext() const { getMatchingSpace } = useGetMatchingSpace() const resourcesStore = useResourcesStore() + const { sharingPublicExpireDateMaxRWFolders } = useCapabilityStore() const editPublicLinkDropdown = useTemplateRef<typeof OcDrop>('editPublicLinkDropdown') const resource = inject<Ref<Resource>>('resource') @@ -110,7 +112,13 @@ export default defineComponent({ customComponent: DatePickerModal, customComponentAttrs: () => ({ currentDate: currentDate.isValid ? currentDate : null, - minDate: DateTime.now() + minDate: DateTime.now(), + maxDate: + resource.value.isFolder && + props.linkShare.type === SharingLinkType.Edit && + sharingPublicExpireDateMaxRWFolders + ? DateTime.now().plus(sharingPublicExpireDateMaxRWFolders).endOf('day') + : null }), onConfirm: (expirationDateTime: DateTime) => { emit('updateLink', { @@ -214,18 +222,27 @@ export default defineComponent({ method: showDatePickerModal }) - result.push({ - id: 'remove-expiration', - title: $gettext('Remove expiration date'), - icon: 'calendar-close', - method: () => { - emit('updateLink', { - linkShare: { ...props.linkShare }, - options: { expirationDateTime: null } - }) - unref(editPublicLinkDropdown).hide() - } - }) + // only if it isn't a edit folder link + if ( + !( + props.linkShare.type === SharingLinkType.Edit && + resource.value.isFolder && + sharingPublicExpireDateMaxRWFolders + ) + ) { + result.push({ + id: 'remove-expiration', + title: $gettext('Remove expiration date'), + icon: 'calendar-close', + method: () => { + emit('updateLink', { + linkShare: { ...props.linkShare }, + options: { expirationDateTime: null } + }) + unref(editPublicLinkDropdown).hide() + } + }) + } } else if (!unref(isInternalLink)) { result.push({ id: 'add-expiration', diff --git a/packages/web-client/src/ocs/capabilities.ts b/packages/web-client/src/ocs/capabilities.ts index 57408cbef90..e1588311020 100644 --- a/packages/web-client/src/ocs/capabilities.ts +++ b/packages/web-client/src/ocs/capabilities.ts @@ -145,6 +145,19 @@ export interface Capabilities { send_mail?: boolean supports_upload_only?: boolean upload?: boolean + expire_date?: { + enabled?: boolean + default_rw_folders?: { + years?: number + months?: number + days?: number + } + max_rw_folders?: { + years?: number + months?: number + days?: number + } + } } search_min_length?: number user?: { diff --git a/packages/web-pkg/src/components/CreateLinkModal.vue b/packages/web-pkg/src/components/CreateLinkModal.vue index eab55ee9aef..6c01302f0a1 100644 --- a/packages/web-pkg/src/components/CreateLinkModal.vue +++ b/packages/web-pkg/src/components/CreateLinkModal.vue @@ -48,6 +48,11 @@ v-if="isAdvancedMode" class="oc-mt-s" :min-date="DateTime.now()" + :max-date=" + isFolder && selectedType === 'edit' && sharingPublicExpireDateMaxRWFolders + ? DateTime.now().plus(sharingPublicExpireDateMaxRWFolders).endOf('day') + : null + " :label="$gettext('Expiry date')" @date-changed="onExpiryDateChanged" /> @@ -69,7 +74,7 @@ appearance="filled" variation="primary" :disabled="confirmButtonDisabled" - @click="$emit('confirm')" + @click="$emit('confirm', { isRW: selectedType === 'edit', isFolder })" >{{ confirmButtonText }} </oc-button> <oc-button @@ -94,7 +99,13 @@ <oc-button class="oc-modal-body-actions-confirm-password action-menu-item" appearance="raw" - @click="$emit('confirm', { copyPassword: true })" + @click=" + $emit('confirm', { + copyPassword: true, + isRW: selectedType === 'edit', + isFolder + }) + " >{{ $gettext('Copy link and password') }} </oc-button> </li> @@ -125,7 +136,8 @@ import { useLinkTypes, Modal, useSharesStore, - useClientService + useClientService, + useCapabilityStore } from '../composables' import { LinkShare, SpaceResource } from '@ownclouders/web-client' import { Resource } from '@ownclouders/web-client' @@ -169,6 +181,7 @@ export default defineComponent({ const { addLink } = useSharesStore() const isAdvancedMode = ref(false) const isInvalidExpiryDate = ref(false) + const { sharingPublicExpireDateMaxRWFolders } = useCapabilityStore() const isFolder = computed(() => props.resources.every(({ isFolder }) => isFolder)) @@ -297,6 +310,15 @@ export default defineComponent({ const updateSelectedLinkType = (type: SharingLinkType) => { selectedType.value = type + onExpiryDateChanged({ + date: unref(selectedExpiry), + error: + sharingPublicExpireDateMaxRWFolders && + unref(selectedExpiry)?.toISO() > + DateTime.now().plus(sharingPublicExpireDateMaxRWFolders).endOf('day').toISO() && + isFolder && + unref(selectedType) === 'edit' + }) } onMounted(() => { @@ -332,7 +354,9 @@ export default defineComponent({ setAdvancedMode, onExpiryDateChanged, confirmButtonDisabled, + isFolder, DateTime, + sharingPublicExpireDateMaxRWFolders, // unit tests onConfirm diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsCreateLink.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsCreateLink.ts index 1dc5e33932a..7ac5a6558ab 100644 --- a/packages/web-pkg/src/composables/actions/files/useFileActionsCreateLink.ts +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsCreateLink.ts @@ -11,7 +11,8 @@ import { useModals, useUserStore, useCapabilityStore, - useSharesStore + useSharesStore, + useConfigStore } from '../../piniaStores' import { useClipboard } from '../../clipboard' import { useClientService } from '../../clientService' @@ -32,6 +33,7 @@ export const useFileActionsCreateLink = ({ const { addLink } = useSharesStore() const { dispatchModal } = useModals() const { copyToClipboard } = useClipboard() + const configStore = useConfigStore() const proceedResult = async ({ result, @@ -40,7 +42,7 @@ export const useFileActionsCreateLink = ({ }: { result: PromiseSettledResult<LinkShare>[] password?: string - options?: { copyPassword?: boolean } + options?: { isRW?: boolean; isFolder?: boolean; copyPassword?: boolean } }) => { const succeeded = result.filter( (val): val is PromiseFulfilledResult<LinkShare> => val.status === 'fulfilled' @@ -70,6 +72,20 @@ export const useFileActionsCreateLink = ({ } } + const language = useGettext() + const alertRwFolders = configStore.options.alertRwFolders + + if (options.isRW && options.isFolder && alertRwFolders) { + dispatchModal({ + variation: 'warning', + title: 'Default expiration date', + message: $gettext( + alertRwFolders[language.current] ?? alertRwFolders[Object.keys(alertRwFolders)[0]] + ), + confirmText: 'Got it' + }) + } + showMessage({ title: $ngettext(successMessage, 'Links have been created successfully.', succeeded.length) }) diff --git a/packages/web-pkg/src/composables/piniaStores/capabilities.ts b/packages/web-pkg/src/composables/piniaStores/capabilities.ts index 9aab623ef61..0ba4db19c62 100644 --- a/packages/web-pkg/src/composables/piniaStores/capabilities.ts +++ b/packages/web-pkg/src/composables/piniaStores/capabilities.ts @@ -35,6 +35,11 @@ const defaultValues = { enabled: true, password: { enforced_for: { read_only: false, upload_only: false, read_write: false } + }, + expire_date: { + enabled: true, + default_rw_folders: null, + max_rw_folders: null } } }, @@ -118,6 +123,13 @@ export const useCapabilityStore = defineStore('capabilities', () => { const sharingPublicPasswordEnforcedFor = computed( () => unref(capabilities).files_sharing.public?.password.enforced_for ) + const sharingPublicExpireDateDefaultRWFolders = computed( + () => unref(capabilities).files_sharing.public?.expire_date.default_rw_folders + ) + const sharingPublicExpireDateMaxRWFolders = computed( + () => unref(capabilities).files_sharing.public?.expire_date.max_rw_folders + ) + const sharingSearchMinLength = computed(() => unref(capabilities).files_sharing.search_min_length) const sharingUserProfilePicture = computed( () => unref(capabilities).files_sharing.user?.profile_picture @@ -175,6 +187,8 @@ export const useCapabilityStore = defineStore('capabilities', () => { sharingPublicAlias, sharingPublicDefaultPermissions, sharingPublicPasswordEnforcedFor, + sharingPublicExpireDateDefaultRWFolders, + sharingPublicExpireDateMaxRWFolders, sharingSearchMinLength, sharingUserProfilePicture, tusMaxChunkSize, diff --git a/packages/web-pkg/src/composables/piniaStores/config/config.ts b/packages/web-pkg/src/composables/piniaStores/config/config.ts index 5511545eb9a..dced5c611d6 100644 --- a/packages/web-pkg/src/composables/piniaStores/config/config.ts +++ b/packages/web-pkg/src/composables/piniaStores/config/config.ts @@ -37,7 +37,8 @@ const defaultOptions = { runningOnEos: false, tokenStorageLocal: true, userListRequiresFilter: false, - hideLogo: false + hideLogo: false, + alertRwFolders: {} } satisfies Partial<OptionsConfig> export const useConfigStore = defineStore('config', () => { diff --git a/packages/web-pkg/src/composables/piniaStores/config/types.ts b/packages/web-pkg/src/composables/piniaStores/config/types.ts index 8e8fc808c00..c7f5e33025b 100644 --- a/packages/web-pkg/src/composables/piniaStores/config/types.ts +++ b/packages/web-pkg/src/composables/piniaStores/config/types.ts @@ -126,7 +126,8 @@ const OptionsConfigSchema = z.object({ hideAppSwitcher: z.boolean().optional(), hideAccountMenu: z.boolean().optional(), hideNavigation: z.boolean().optional(), - defaultLanguage: z.string().optional() + defaultLanguage: z.string().optional(), + alertRwFolders: z.object({}).optional() }) export type OptionsConfig = z.infer<typeof OptionsConfigSchema>